feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree

readUnion etait N requetes ancrees plus une politique de lot : une lecture
qui echoue avalee en reponse vide, un document dont on ne detient pas la cle
ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le
filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et
son annotation citait sparql_query -- une citation exacte attachee a une
affirmation fausse. Une composition ne s'aligne pas sur une fonction.

Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un
document, qui va deja chercher la cle servie par le magasin public -- donc
une reference nue suffit toujours pour lire un document public, sans rien
changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois
par document, et la tolerance a une lecture qui echoue revient a l'appelant,
la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne
detient pas rejette.

Cote src, l'unique lecture interne restante devient read-document.ts : un
document, une requete ancree, precedee de l'ouverture du depot -- barriere
que index-deposit consulte pour distinguer une absence d'un depot non
synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch
visible au point d'appel.

Version majeure : 1.0.0-dev.8.
This commit is contained in:
Sylvain Duchesne
2026-09-21 14:08:05 +02:00
parent 05a0f7b148
commit 0c21adebf2
18 changed files with 659 additions and 516 deletions
@@ -40,8 +40,8 @@ import { insertObject } from "../src/surface/orm";
import type { InsertObjectLike } from "../src/model/types";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlQuery, sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
/**
@@ -242,7 +242,7 @@ function makeFakeNg() {
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
// The anchored per-document read (`SELECT ?s ?p ?o`) — the document's content.
return {
results: {
bindings: quads
@@ -281,12 +281,25 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
/**
* The values `p` carries in `doc`, as the current holder reads them.
*
* ONE document, one anchored query - what `readUnion` did per entry before it was
* removed in `1.0.0-dev.8`. The difference that shows up all over this file: a document
* the holder may NOT reach used to be dropped from the batch and read as `[]`, and now
* the read is REFUSED. Both say "you cannot read this"; only the second says it out
* loud, which is why {@link expectRefused} exists rather than another `toEqual([])`.
*/
async function readValues(doc: Nuri, p: string): Promise<string[]> {
const subjects = await readDocumentSubjects(doc);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/** Assert the current holder is REFUSED `doc`: the answer to "I hold no cap for this". */
async function expectRefused(doc: Nuri, p: string): Promise<void> {
await expect(readValues(doc, p)).rejects.toThrow(/does not hold this document/i);
}
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI.
@@ -326,13 +339,13 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
// store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
const refs = await readValues(pubDoc, REFERS_TO);
const ref = referenceFoundIn(refs);
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(hasCap(ref)).toBe(false);
expect(await readValues([ref], SECRET)).toEqual([]);
await expectRefused(ref, SECRET);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
@@ -348,10 +361,10 @@ test("Charlie: same public document, same reference — and he reads through it"
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
expect(ref).toBe(protDoc);
expect(hasCap(ref)).toBe(true);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
@@ -363,13 +376,18 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
await share(protDoc, "charlie");
setCurrentUser("bob");
const bobSees = await readValues([protDoc], SECRET);
// Obtained under BOB, so what is compared below is each holder's own answer to the
// same question. His is a refusal; hers is the content.
const bobIsRefused = await readValues(protDoc, SECRET).then(
() => false,
() => true,
);
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
const charlieSees = await readValues(protDoc, SECRET);
expect(bobSees).toEqual([]);
expect(bobIsRefused).toBe(true);
expect(charlieSees).toEqual(["the-protected-content"]);
});
@@ -381,10 +399,10 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
// Before: named, unreadable.
expect(await readValues([ref], SECRET)).toEqual([]);
await expectRefused(ref, SECRET);
// A reader that re-reads whenever what it holds changes — this is exactly what
// `watchShape` wires internally, played here on an ad-hoc read.
@@ -392,7 +410,13 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
let latest: string[] = [];
const unsub = getCaps().onChange(() => {
reread += 1;
void readValues([ref], SECRET).then((v) => (latest = v));
// While the cap is still missing the read is REFUSED rather than empty, and a reader
// keeps what it had - which is what any real view does, and what makes `latest`
// meaningful when the cap finally lands.
void readValues(ref, SECRET).then(
(v) => (latest = v),
() => {},
);
});
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
@@ -410,7 +434,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
// …and the read that was empty now yields the content.
expect(hasCap(ref)).toBe(true);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
unsub();
});
@@ -420,14 +444,27 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
// (a bare reference) yields a different outcome depending on the store, and never
// because a key travelled.
//
// It is also the property `readUnion`'s removal rests on. `readUnion([doc])` was how a
// stranger reached a public index from its reference alone; what actually did the work
// was the anchored `docs.sparqlQuery` underneath, which fetches the public store's key
// at its own door (`surface/docs.ts`). This test exercises exactly that path with no
// batch above it, which is why the key fetch is asserted here rather than assumed.
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob has been given nothing but the two NURIs.
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
expect(await readValues([protDoc], SECRET)).toEqual([]);
// Bob has been given nothing but the two NURIs — no key, for either of them.
expect(getCaps().isEnforcing()).toBe(true);
expect(hasCap(pubDoc)).toBe(false);
expect(hasCap(protDoc)).toBe(false);
expect((await readValues(pubDoc, REFERS_TO)).length).toBe(1);
// …and the key he now holds for it came from the DOCUMENT, on the way through the
// anchored read. Nothing in this file handed it to him.
expect(hasCap(pubDoc)).toBe(true);
await expectRefused(protDoc, SECRET);
// And what he obtained for the public one is a READ grant, not a write right: a
// public store serves its read cap, no store hands out the write cap.
@@ -449,7 +486,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
await connectedUser();
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
// cap, then re-arm the emulation so the boundary is actually in force again.
@@ -468,7 +505,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(hasCap(protDoc)).toBe(true);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
});
test("connecting a user that does not exist provisions nothing", async () => {
@@ -494,7 +531,7 @@ test("a deposit is addressed to an inbox, never to a document", async () => {
await expect(post(protDoc, { payload: { x: 1 }, ts: 1 })).rejects.toThrow(/not an inbox/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
});
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
@@ -538,10 +575,10 @@ test("the owner writes to her own public note, even after opening it from its re
setCurrentUser("bob");
await createEntityDoc("bob", "private"); // re-arms the emulation
setCurrentUser("alice");
await readValues([pubDoc], SECRET); // this is what files the served cap
await readValues(pubDoc, SECRET); // this is what files the served cap
await write(pubDoc, SECRET, "v2"); // must not throw
expect((await readValues([pubDoc], SECRET)).includes("v2")).toBe(true);
expect((await readValues(pubDoc, SECRET)).includes("v2")).toBe(true);
});
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
@@ -557,11 +594,11 @@ test("a cap received in an inbox reads, and does NOT write", async () => {
setCurrentUser("bob");
await readInbox(BOB_INBOX);
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // he reads it
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // he reads it
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/WRITE cap/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
@@ -726,18 +763,37 @@ test("concurrent opens by a NON-owner are all refused, and leave no residue", as
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
test("the inbox address is machinery: a SUBJECT beside the data, never mixed into it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
// Both halves are asserted, because `readUnion`'s removal changed which one an
// APPLICATION sees. The compartment is a distinct reserved SUBJECT, so it is never
// mixed into the entity's properties whichever read is used - that is the property
// that protects the data, and it is structural.
const subjects = await readDocumentSubjects(doc);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
// The filter that DROPS it, however, lives in this library's internal read. A raw
// anchored `docs.sparqlQuery` - which is what an application composes now that
// `readUnion` is gone - returns the Header-branch rows too. That is not a gain of
// rights: the two registers an entity document carries (`inboxAddress`,
// `exposedReadCap`) are the compartment meant for what any reader may see, and the
// cap-bearing `inboxCap` register lives in its owner's PRIVATE store, not here.
const raw = await sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"test",
);
const rawSubjects = (raw as { results: { bindings: Array<{ s: { value: string } }> } })
.results.bindings.map((b) => b.s.value);
expect(rawSubjects).toContain(`${SHIM}:headerBranch`);
});
// CONNECTING APPLIES WHAT WAS DEPOSITED — and the honest scope of that claim.
@@ -775,7 +831,7 @@ test("connecting applies the Links waiting for me, and leaves consumer deposits
await connectedUser();
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
expect(await readValues(protDoc, SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
});
@@ -47,7 +47,7 @@ import {
} from "../src/emulated-verifier/inbox-processor";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { share } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
@@ -282,9 +282,10 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in `docs`, as the CURRENT holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
/** The values `p` carries in `doc`, as the CURRENT holder reads them - one document,
* one anchored query, which is all `readUnion` ever did per entry. */
async function readValues(doc: Nuri, p: string): Promise<string[]> {
const subjects = await readDocumentSubjects(doc);
return subjects.flatMap((s) => s.props[p] ?? []);
}
@@ -335,7 +336,10 @@ test("a deposit is drained for the inbox's OWNER, while the depositor still hold
// connected, Alice would now hold a capability nobody gave her.
expect(getCurrentUser()).toBe("alice");
expect(caps.capForHolder("alice", carolDoc)).toBeUndefined();
expect(await readValues([carolDoc], SECRET)).toEqual([]);
// …and the read says so out loud. It used to answer `[]`, because `readUnion` dropped a
// document it could not attempt; the anchored read refuses it instead, which asserts the
// same fact more strongly - Alice is not merely shown nothing, she is told no.
await expect(readValues(carolDoc, SECRET)).rejects.toThrow(/does not hold this document/i);
expect(await linksFiledFor("alice")).toEqual([]);
// …and both Links landed in Bob's space instead.
@@ -371,7 +375,7 @@ test("what the drain filed for the owner is DURABLE: he restores it with his inb
expect(getCaps().capFor(aliceDoc)).toBeUndefined(); // he holds nothing yet
await connectedUser();
expect(getCaps().capFor(aliceDoc)).toBeDefined();
expect(await readValues([aliceDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(aliceDoc, SECRET)).toEqual(["the-protected-content"]);
});
// --- coalescing --------------------------------------------------------------
@@ -525,7 +529,6 @@ test("nothing about the deferred service reaches the published surface", () => {
"initNg",
"insertObject",
"ng",
"readUnion",
"storeRegistry",
"subscribeDoc",
"subscribeDocs",
@@ -41,7 +41,7 @@ import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifi
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/emulated-verifier/index-deposit";
import { postToDocument } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
@@ -261,7 +261,7 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
/** Every value the index holds for `object` — one per entry written. */
async function entryValuesFor(index: Nuri, object: Nuri): Promise<string[]> {
const subjects = await readUnion([index]);
const subjects = await readDocumentSubjects(index);
return subjects.filter((s) => s.subject === object).flatMap((s) => s.props[ENTRY_VALUE] ?? []);
}
@@ -340,9 +340,10 @@ test("a reference nobody can resolve is reported, and costs the index nothing",
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
// A reference this holder cannot even attempt — no cap, so `readUnion` drops it before
// issuing a query — and a resolvable one BEHIND it, which is what makes the question
// answerable at all.
// A reference this holder cannot reach — no cap, so the anchored read REFUSES it (until
// `1.0.0-dev.8` `readUnion` dropped it before issuing a query, which reached this arm as
// an empty answer instead of a failure) — and a resolvable one BEHIND it, which is what
// makes the question answerable at all.
await bobDepositsInto(index, "did:ng:o:nowhere", 1);
await bobDepositsInto(index, object, 2);
@@ -560,9 +561,9 @@ test("an EMPTY but readable object is absent — reported, and the queue behind
// A document Alice really holds and that really reads — it just has no triples. This is
// the genuine clean-empty read, and until 2026-08-26 NOTHING exercised it: the only test
// of this branch pointed at `did:ng:o:nowhere`, which `readUnion`'s possession gate drops
// before any query is issued. So the branch that decides "absent vs unreadable" on a real
// read was uncovered, which is how two opposite misclassifications shipped through it.
// of this branch pointed at `did:ng:o:nowhere`, which the possession gate refuses before
// any rows exist. So the branch that decides "absent vs unreadable" on a real read was
// uncovered, which is how two opposite misclassifications shipped through it.
setCurrentUser("alice");
const empty = await createEntityDoc("alice", "public");
setCurrentUser(null);
@@ -29,7 +29,7 @@
*/
import { test, expect, describe, mock, beforeEach } from "bun:test";
import { configure, storeRegistry, readUnion } from "../src/index";
import { configure, storeRegistry, docs } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
@@ -44,6 +44,25 @@ const KEY_READ = `<${SHIM}:readCap>`;
const TITLE = "urn:test:title";
/**
* The titles `doc` carries, read the way an APPLICATION reads one document since
* `1.0.0-dev.8`: the published anchored `docs.sparqlQuery`, naming the document. It
* replaces `readUnion([doc])`, which was the same anchored query with a batch around it.
*/
async function titlesIn(doc: Nuri): Promise<string[]> {
const res = await docs.sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"readDoc",
);
const rows = (res as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
return rows.filter((r) => r["p"]?.value === TITLE).map((r) => r["o"]!.value);
}
/**
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
@@ -121,8 +140,9 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
await signIn("alice");
expect(await listMyEntityDocs("alice", scope)).toContain(note);
const subjects = await readUnion([note]);
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
// Read the way an application reads one document now that `readUnion` is gone: the
// published anchored query, naming the document it was just handed.
expect(await titlesIn(note)).toEqual(["the note"]);
});
}
+11 -8
View File
@@ -13,13 +13,16 @@
* - doc_subscribe is a mock that records calls, fires the callback once
* (simulating the initial State push), then returns an unsubscribe fn.
*
* We test ensureRepoOpen via readUnion (from read-model) because that is the
* production caller — it gates on ensureReposOpen internally.
* We test ensureRepoOpen via `readDocumentSubjects` (from `surface/read-document`)
* because that is the production caller — it opens the document before its anchored
* read, and `emulated-verifier/index-deposit.ts` depends on that ordering to tell an
* absence from an unsynced repo. It was `readUnion` until `1.0.0-dev.8`; the integration
* path is the same one, minus the batch.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { readUnion } from "../src/surface/read-model";
import { readDocumentSubjects } from "../src/surface/read-document";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -115,7 +118,7 @@ describe("ensureRepoOpen", () => {
expect(ng.subscribeCallOrder[0]).toBe("did:ng:o:a");
// sparql_query was called AFTER subscribe (ensureRepoOpen guarantees ordering)
const result = await readUnion(["did:ng:o:a"]);
const result = await readDocumentSubjects("did:ng:o:a");
expect(result.length).toBe(1);
expect(result[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
});
@@ -127,7 +130,7 @@ describe("ensureRepoOpen", () => {
inject(ng);
// Do NOT call ensureRepoOpen — subscribed Set remains empty
// Query directly (bypass readUnion which calls ensureReposOpen internally)
// Query directly (bypass `readDocumentSubjects`, which opens the repo internally)
const result = await ng.sparql_query("sid-or", "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, "did:ng:o:a");
const bindings = (result as any).results.bindings;
expect(bindings.length).toBe(0); // not subscribed → 0 rows (confirms fake design)
@@ -203,15 +206,15 @@ describe("ensureReposOpen", () => {
expect(ng.doc_subscribe).toHaveBeenCalledTimes(0);
});
it("readUnion triggers doc_subscribe then returns data (integration path)", async () => {
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
});
inject(ng);
const subjects = await readUnion(["did:ng:o:p"]);
const subjects = await readDocumentSubjects("did:ng:o:p");
// doc_subscribe was called as part of ensureReposOpen inside readUnion
// doc_subscribe was called as part of the `ensureRepoOpen` inside the read
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(subjects.length).toBe(1);
expect(subjects[0]!.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
@@ -1,24 +1,53 @@
/**
* read-document.test.ts - the internal single-document read (`src/surface/read-document.ts`).
*
* This file was `read-model.test.ts` until `1.0.0-dev.8`, when the published `readUnion`
* went. Three of its cases tested the BATCH POLICY rather than the read, and a batch no
* longer exists, so each was re-pointed at the property that replaced it rather than
* dropped:
*
* - "a doc that fails to read is SKIPPED, not aborting the batch" -> a read that fails
* THROWS. With no other document to carry on for, swallowing a failure would leave
* "it failed" and "it is empty" as the same answer, which is the family of defect
* this package has closed repeatedly.
* - "returns [] for an empty doc SET (no query)" -> a document that holds nothing is
* read, answers, and returns `[]`. That is the surviving distinction: an empty ANSWER.
* - "the same subject IRI in two documents stays two entries, told apart by `graph`" ->
* two reads, each reporting its own document. Nothing can merge across documents any
* more, because nothing shares a call; `graph` went with the batch, since the caller
* names exactly one document and already holds its reference.
*
* One BEHAVIOUR CHANGE is pinned below rather than left to be discovered: a document the
* holder has no capability for used to be dropped silently from the batch, and now makes
* the read THROW - the refusal belongs to `docs.sparqlQuery`, which is the published door
* this read goes through and which has always refused out loud.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { readUnion } from "../src/surface/read-model";
import { readDocumentSubjects } from "../src/surface/read-document";
import type { Nuri } from "../src/model/types";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps } from "../src/shared-wallet/bootstrap";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
// The cap registry is process-wide, so each inject() starts from an empty one:
// once ANY cap exists the possession gate is in force for every reader, and a
// suite that never declares caps must not inherit another suite's.
afterAll(() => {
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
});
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
// NO anchorless union scan: each doc is read independently by its own anchor. Each
// entity subject IRI IS its own document NURI (writeEntity convention), so the
// fixture keys triples by the doc NURI and returns them for the matching anchor.
// NO anchorless union scan: a document is read by its own anchor. Each entity subject
// IRI IS its own document NURI (writeEntity convention), so the fixture keys triples by
// the doc NURI and returns them for the matching anchor.
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
return {
doc_create: mock(async () => "did:ng:o:new"),
@@ -26,7 +55,7 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
// Every read is ANCHORED to one doc NURI — never anchorless.
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
throw new Error("read-document must NEVER run an anchorless (union) query");
}
const doc = anchor as string;
const triples = triplesByDoc[doc];
@@ -44,10 +73,12 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const ng = fakeNgWith(triplesByDoc);
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
return ng;
@@ -56,29 +87,27 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => {
test("reads the document with its OWN anchored query (never anchorless)", async () => {
const ng = inject({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
const a = await readDocumentSubjects("did:ng:o:a");
const b = await readDocumentSubjects("did:ng:o:b");
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
// One anchored query per read = 2 sparql_query calls, each anchored (c[3] set).
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
expect(anchored.length).toBe(2);
// The anchors are exactly the requested doc NURIs.
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
new Set(["did:ng:o:a", "did:ng:o:b"]),
);
// The anchors are exactly the documents that were named.
expect(anchored.map((c: unknown[]) => c[3])).toEqual(["did:ng:o:a", "did:ng:o:b"]);
expect(subjects.length).toBe(2);
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
expect(a.props[`${FP}title`]).toEqual(["A"]);
expect(a.graph).toBe("did:ng:o:a");
expect(a.map((s) => s.subject)).toEqual(["did:ng:o:a"]);
expect(a[0]!.props[`${FP}title`]).toEqual(["A"]);
expect(b[0]!.props[`${FP}title`]).toEqual(["B"]);
});
test("readUnion groups predicates per subject", async () => {
test("groups predicates per subject", async () => {
inject({
"did:ng:o:p": [
[TYPE, `${FP}Participation`],
@@ -86,20 +115,22 @@ test("readUnion groups predicates per subject", async () => {
[`${FP}user`, "urn:festipod:user:x"],
],
});
const s = (await readUnion(["did:ng:o:p"]))[0]!;
const s = (await readDocumentSubjects("did:ng:o:p"))[0]!;
expect(s.subject).toBe("did:ng:o:p");
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
});
test("readUnion returns [] for an empty doc set (no query)", async () => {
test("a document that holds nothing is READ, answers, and returns []", async () => {
// The surviving half of the old "empty doc set" case, and the one that matters: an
// empty ANSWER is not a failure, and the query really was issued to obtain it. A caller
// deciding "absent versus unreadable" depends on exactly that difference.
const ng = inject({});
const subjects = await readUnion([]);
expect(subjects).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
expect(await readDocumentSubjects("did:ng:o:empty")).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
test("a doc that fails to read is skipped, not aborting the batch", async () => {
test("a read that FAILS throws, rather than reading as an empty document", async () => {
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
const orig = ng.sparql_query;
// Make the anchored read throw for the bad doc only.
@@ -107,49 +138,62 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
return orig(sid, query, base, anchor);
}) as any;
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
// The bad doc failed its read but the good one still lists.
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
// The batch used to swallow this one and carry on with the good document. There is no
// "carry on" left, so the failure reaches the caller as a failure — which is the ONLY
// thing that keeps it distinguishable from a document that is genuinely empty.
await expect(readDocumentSubjects("did:ng:o:bad")).rejects.toThrow(/RepoNotFound/);
// The neighbour is unaffected: nothing is shared between two reads.
expect((await readDocumentSubjects("did:ng:o:ok")).map((s) => s.subject)).toEqual([
"did:ng:o:ok",
]);
});
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
// whose cap is not in what the current holder holds is dropped — however well its
// NURI resolves. Before the first cap the gate is inert (no regression).
test("readUnion drops a doc whose cap the holder does not hold", async () => {
// The possession gate: once ANY cap exists, a document whose cap is not in what the
// current holder holds is REFUSED — however well its NURI resolves. Before the first cap
// the gate is inert (no regression).
test("a document whose cap the holder does not hold is refused, out loud", async () => {
inject({
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
});
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
const mine = "did:ng:o:mine" as Nuri;
const theirs = "did:ng:o:theirs" as Nuri;
// Inert: no cap issued yet → everything flows through.
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
expect((await readDocumentSubjects(theirs)).map((s) => s.subject)).toEqual([theirs]);
// One cap issued → possession is now the rule for every document.
// One cap issued → possession is now the rule for every document. BEHAVIOUR CHANGE:
// the batch dropped the unheld document silently and answered with the rest; a single
// read has no rest, so `docs.sparqlQuery` refuses it the way it always has — loudly.
setCurrentUser("alice");
getCaps().mint("did:ng:o:mine");
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
getCaps().mint(mine);
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
await expect(readDocumentSubjects(theirs)).rejects.toThrow(/does not hold this document/i);
// …and for every holder: bob holds nothing, so bob reads nothing.
setCurrentUser("bob");
expect(await readUnion(both)).toEqual([]);
await expect(readDocumentSubjects(mine)).rejects.toThrow(/does not hold this document/i);
});
test("readUnion tolerates holes in the list, and refuses a malformed reference", async () => {
// Two different things that must not be conflated, and conflating them broke a whole
// reconnect run: an EMPTY entry is absence — a scope index can carry one, and a caller
// assembling a list from optional values should not have to compact it — while a
// non-reference is a caller mistake worth a loud error. Validating before filtering
// turned the first into the second.
test("a malformed reference is refused, loudly", async () => {
// A non-reference is a caller mistake worth a loud error, never an empty read: swallowing
// it is how a mistake becomes an absence nobody can explain. The list tolerance this
// used to sit beside went with the batch — there is no list to carry a hole any more.
inject({});
await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]);
await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i);
await expect(readDocumentSubjects("not-a-nuri" as Nuri)).rejects.toThrow(
/not a NextGraph reference/i,
);
});
// ── several subjects inside one document ───────────────────────────────────
@@ -167,7 +211,7 @@ function injectTriples(triplesByDoc: Record<string, Array<[string, string, strin
sparql_update: mock(async () => undefined),
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
throw new Error("read-document must NEVER run an anchorless (union) query");
}
const triples = triplesByDoc[anchor as string];
if (!triples) return { results: { bindings: [] } };
@@ -183,10 +227,12 @@ function injectTriples(triplesByDoc: Record<string, Array<[string, string, strin
}),
};
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng, useShape: () => undefined });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
return ng;
@@ -203,7 +249,7 @@ test("two subjects in ONE document come back as two entries, unmixed", async ()
],
});
const subjects = await readUnion(["did:ng:o:d"]);
const subjects = await readDocumentSubjects("did:ng:o:d");
// Under the old per-document fold this was ONE entry subject'd `did:ng:o:d`,
// whose `${FP}title` carried BOTH ["A","B"] — two entities conflated into one bag,
@@ -215,34 +261,31 @@ test("two subjects in ONE document come back as two entries, unmixed", async ()
const bob = subjects.find((s) => s.subject === "urn:app:bob")!;
expect(alice.props[`${FP}title`]).toEqual(["A"]);
expect(bob.props[`${FP}title`]).toEqual(["B"]);
// Both were read from the same document, and `graph` is the reference the caller
// passed — that is what identifies the document and what goes back into the SDK.
expect(alice.graph).toBe("did:ng:o:d");
expect(bob.graph).toBe("did:ng:o:d");
});
test("a subject written under an IRI of its own is NOT re-labelled with the doc NURI", async () => {
injectTriples({
"did:ng:o:d": [["urn:app:thing", `${FP}title`, "T"]],
});
const [only] = await readUnion(["did:ng:o:d"]);
const [only] = await readDocumentSubjects("did:ng:o:d");
// The old fold reported `did:ng:o:d` here, a subject the document never carried.
expect(only!.subject).toBe("urn:app:thing");
expect(only!.graph).toBe("did:ng:o:d");
});
test("the SAME subject IRI in two documents stays two entries, told apart by graph", async () => {
test("the SAME subject IRI in two documents is never merged - two reads, two answers", async () => {
injectTriples({
"did:ng:o:a": [["urn:app:shared", `${FP}title`, "in-a"]],
"did:ng:o:b": [["urn:app:shared", `${FP}title`, "in-b"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
expect(subjects.length).toBe(2);
expect(subjects.map((s) => s.graph).sort()).toEqual(["did:ng:o:a", "did:ng:o:b"]);
// Never merged across documents: an object is identified by its subject WITHIN a graph.
expect(subjects.find((s) => s.graph === "did:ng:o:a")!.props[`${FP}title`]).toEqual(["in-a"]);
expect(subjects.find((s) => s.graph === "did:ng:o:b")!.props[`${FP}title`]).toEqual(["in-b"]);
const a = await readDocumentSubjects("did:ng:o:a");
const b = await readDocumentSubjects("did:ng:o:b");
// Two documents carrying one subject IRI are two objects. The batch had to say so with
// a `graph` field on every entry; a read that names ONE document says it structurally,
// because nothing of one read's answer can reach the other's.
expect(a.map((s) => s.subject)).toEqual(["urn:app:shared"]);
expect(b.map((s) => s.subject)).toEqual(["urn:app:shared"]);
expect(a[0]!.props[`${FP}title`]).toEqual(["in-a"]);
expect(b[0]!.props[`${FP}title`]).toEqual(["in-b"]);
});
test("machinery is dropped per subject, and the real subjects beside it survive", async () => {
@@ -252,7 +295,7 @@ test("machinery is dropped per subject, and the real subjects beside it survive"
["urn:app:entity", `${FP}title`, "kept"],
],
});
const subjects = await readUnion(["did:ng:o:d"]);
const subjects = await readDocumentSubjects("did:ng:o:d");
expect(subjects.map((s) => s.subject)).toEqual(["urn:app:entity"]);
expect(subjects[0]!.props[`${FP}title`]).toEqual(["kept"]);
});
@@ -263,5 +306,5 @@ test("a document holding ONLY machinery yields no entry at all", async () => {
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
],
});
expect(await readUnion(["did:ng:o:d"])).toEqual([]);
expect(await readDocumentSubjects("did:ng:o:d")).toEqual([]);
});
@@ -31,7 +31,7 @@
*/
import { test, expect, describe, mock, afterAll, beforeEach } from "bun:test";
import { configure, docs, storeRegistry, readUnion } from "../src/index";
import { configure, docs, storeRegistry } from "../src/index";
import {
configureStoreRegistry,
setCurrentUser,
@@ -56,6 +56,25 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/** The application's own predicate — the note's title, in the document it created. */
const TITLE = "urn:test:title";
/**
* The titles `doc` carries, read the way an APPLICATION reads one document since
* `1.0.0-dev.8`: the published anchored `docs.sparqlQuery`, naming the document. It
* replaces `readUnion([doc])`, which was the same anchored query with a batch around it.
*/
async function titlesIn(doc: Nuri): Promise<string[]> {
const res = await docs.sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"readDoc",
);
const rows = (res as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
return rows.filter((r) => r["p"]?.value === TITLE).map((r) => r["o"]!.value);
}
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
// --- the fake broker -------------------------------------------------------
@@ -354,9 +373,11 @@ describe("after a reload, the creator comes back to its own document", () => {
});
}
// Shape 2 — listed, but does reading it answer? `readUnion` is the library's own
// listing read, and it DROPS a document whose cap the reader does not hold: a failure
// that arrives as an absence, which is the family this project has been bitten by.
// Shape 2 — listed, but does reading it answer? The anchored `docs.sparqlQuery` is what
// an application reads one document with, and it REFUSES a document whose cap the reader
// does not hold. It did not always: until `1.0.0-dev.8` the published read for this was
// `readUnion`, which DROPPED such a document — a failure arriving as an absence, which
// is the family this project has been bitten by, and half the reason that call went.
for (const scope of ALL_SCOPES) {
test(`[${scope}] reading it by reference answers its content`, async () => {
const quads: Quad[] = [];
@@ -367,8 +388,7 @@ describe("after a reload, the creator comes back to its own document", () => {
// The application kept the reference (a route, a deep link) and reads it directly,
// without listing its store first.
const subjects = await readUnion([note]);
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
expect(await titlesIn(note)).toEqual(["the note"]);
});
}