diff --git a/.project/concepts/app-contract/contract_sdk-surface.md b/.project/concepts/app-contract/contract_sdk-surface.md index adfa764..0339192 100644 --- a/.project/concepts/app-contract/contract_sdk-surface.md +++ b/.project/concepts/app-contract/contract_sdk-surface.md @@ -54,7 +54,7 @@ export const storeRegistry: { // no identity parameter — the session // ── reading ────────────────────────────────────────────────────────────── export async function readUnion(docs: NuriLike[]): Promise; -export interface UnionSubject { subject: Nuri; graph: Nuri; props: Record } +export interface UnionSubject { subject: string; graph: Nuri; props: Record } export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view export function watchShape(query: ShapeQuery): ShapeObservable; export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe; @@ -97,6 +97,10 @@ export function initNg(...args: any[]): any; **A reference is not recursive.** A widely circulated document may point at a restricted one; following the reference yields a name, not a key. This is what lets confidentiality be composed inside a document you share, and it holds through every read path here. +**A document may hold several objects, and reading returns them separately.** `readUnion` yields one entry per distinct subject present in a document: `subject` is that subject's IRI exactly as it was written, `graph` is the document reference you passed in. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Only `graph` is a `Nuri`, and it is the field to hand back to this surface; `subject` is a `string`, because a subject may be any IRI. **One document per business entity stays the recommended placement** — a key is per document, so isolating an entity requires a document of its own — but it is a recommendation, and reading reports the objects a document actually holds. + +**`urn:ng-eventually:` is a reserved name space.** Subjects under that prefix belong to the library and are not returned by `readUnion`. An application that writes its own data under it will not read it back; every other IRI is yours. + **Writing is ownership.** Only a document's owner writes to it. Holding its read key — however it arrived — never grants a write. **Giving to read is ONE act, and the recipient calls nothing.** `inbox.share(doc, toUser)` names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting. There is no "receive" operation, and an application never handles a key or an inbox address. diff --git a/docs/api-contract.md b/docs/api-contract.md index 40d3e4f..6ccb490 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -229,18 +229,20 @@ So the constraint on the bet: the target can already answer "synced?" (`readyPro ### Today — `@ng-eventually/sdk` ```ts -// read-model.ts:59 +// read-model.ts:60 export interface UnionSubject { - subject: string; - graph: string; + subject: string; // the subject IRI as the document carries it — any IRI, not a Nuri + graph: Nuri; // the document reference the caller passed, unchanged props: Record; } -// read-model.ts:140 +// read-model.ts:166 export async function readUnion(docs: Nuri[]): Promise; ``` Behaviour: one anchored `sparql_query` per doc (default-graph body, no `GRAPH` wrapper), parallel, per-doc failure tolerance, cap filter applied inside, machinery subjects dropped. +**Grouping is per (document, subject), and subjects come back as written.** A document holding several subjects yields several entries — one each, properties never merged across subjects; the same subject IRI seen in two documents stays two entries, told apart by `graph`. This matches level 3, where an object carries `@id` and `@graph` as two distinct read-only properties and the ORM fabricates an `@id` when the writer leaves it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`, `":q:"`) — several objects per graph is the provided case, and `@id` is what distinguishes them inside a `@graph`. Only `graph` is a `Nuri`; `subject` is typed `string` because an RDF subject may be any IRI. One document per business entity remains the recommended placement (a key is per repo, so isolating an entity needs a repo of its own), but that is a recommendation about writing — the read reports what is there rather than making the other arrangement invisible. + ### Target Two verified counterparts, one per level; neither returns `UnionSubject` — that grouping is lib-invented: diff --git a/examples/notebook/app.ts b/examples/notebook/app.ts index fe8845e..f19d58d 100644 --- a/examples/notebook/app.ts +++ b/examples/notebook/app.ts @@ -109,7 +109,7 @@ async function myNotes(scope: Scope): Promise { const docsOfScope = await storeRegistry.listMyEntityDocs(scope); const subjects = await readUnion(docsOfScope); return subjects.map((s) => ({ - doc: s.subject, + doc: s.graph, title: s.props[TITLE]?.[0] ?? "(sans titre)", body: s.props[BODY]?.[0] ?? "", })); @@ -132,7 +132,7 @@ async function readSharedNote(reference: string): Promise { const [note] = await readUnion([reference]); if (!note) return null; return { - doc: note.subject, + doc: note.graph, title: note.props[TITLE]?.[0] ?? "(sans titre)", body: note.props[BODY]?.[0] ?? "", }; diff --git a/packages/sdk/e2e/run.ts b/packages/sdk/e2e/run.ts index 13a05d6..1da049d 100644 --- a/packages/sdk/e2e/run.ts +++ b/packages/sdk/e2e/run.ts @@ -214,13 +214,32 @@ async function main(): Promise { // ── read-model ────────────────────────────────────────────────────────── console.log("\n── read-model ──"); - await step("readUnion over N docs → per-doc subjects", async () => { + await step("readUnion returns one entry per SUBJECT, with the subject it was written under", async () => { + // 3 documents, and the last carries TWO subjects → 4 entries, not 3. Counting alone + // could not distinguish grouping-by-document from grouping-by-subject, which is why + // this step stayed green while `readUnion` conflated them (fixed 2026-08-10). const r = await sdk(frame, "readUnionOverDocs", 3, false); - check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`); + const iris: string[] = r.subjectIris ?? []; + check( + "one entry per subject, not per document", + r.subjectCount === 4 && iris.includes("urn:e2e:rm:extra"), + `entries=${r.subjectCount}/4 subjects=${JSON.stringify(iris)}`, + ); + check( + "each entry carries the subject it was written under, not the document", + iris.every((s) => s.startsWith("urn:e2e:rm:")), + JSON.stringify(iris), + ); + check( + "…and its `graph` is the document reference", + (r.graphs ?? []).every((g: string) => g.startsWith("did:ng:")), + JSON.stringify(r.graphs), + ); }); await step("readUnion per-doc tolerance (bad NURI skipped)", async () => { const r = await sdk(frame, "readUnionOverDocs", 2, true); - check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`); + // 2 documents, the last carrying two subjects → 3 entries. + check("bad NURI does not abort the batch", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`); }); await step("readUnion cap gate", async () => { const r = await sdk(frame, "readUnionCapGate"); diff --git a/packages/sdk/e2e/sdk-entry.ts b/packages/sdk/e2e/sdk-entry.ts index 6d81b7c..fcd99e8 100644 --- a/packages/sdk/e2e/sdk-entry.ts +++ b/packages/sdk/e2e/sdk-entry.ts @@ -345,9 +345,27 @@ const identity = new IdentityStore( ); docNuris.push(d); } + // Two SUBJECTS in the LAST document, so the probe can tell "one entry per document" + // from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a + // document into one bag keyed by the document, and this step stayed green throughout + // because it only ever counted entries and never looked at what they were. + if (n > 0) { + await docs.sparqlUpdate( + s.session_id, + `INSERT DATA { "extra" }`, + docNuris[n - 1]!, + ); + } const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris; const subjects = await readUnion(toRead); - return { docNuris, subjectCount: subjects.length, subjects }; + return { + docNuris, + subjectCount: subjects.length, + // What each entry actually IS — the assertion the count could not make. + subjectIris: subjects.map((x) => x.subject), + graphs: subjects.map((x) => x.graph), + subjects, + }; }, /** * readUnion possession gate: create a doc as owner O (whose keyring gets its diff --git a/packages/sdk/src/surface/read-model.ts b/packages/sdk/src/surface/read-model.ts index 4ffb68e..ef837c7 100644 --- a/packages/sdk/src/surface/read-model.ts +++ b/packages/sdk/src/surface/read-model.ts @@ -59,17 +59,26 @@ void sparqlUpdate; /** One subject read from a doc, with its properties (predicate → values). */ export interface UnionSubject { /** - * The subject IRI (`?s`) — in the polyfill, the doc's own NURI. + * The subject IRI (`?s`), exactly as the document carries it. * - * Typed `Nuri`, not `string`: both fields are always document references here (the - * read is anchored per document and the subject is pinned to the anchor), and typing - * them loosely forced a consumer to cast whatever it had just read before it could - * pass it back — `shareNote(note.doc)`, `leaveMessage(note.doc)`. A cast at that - * boundary re-opens exactly the confusion the template literal types exist to close. - * Found by writing the example application (`examples/notebook`). + * Typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may be ANY + * IRI. An application that writes its entity under the document's own NURI gets a + * NURI back here, but that is its convention, not this type's promise — a document + * may hold several subjects under IRIs of the consumer's choosing, and each comes + * back as written. + * + * The need that once made this `Nuri` is real and is met by {@link graph}: a + * consumer must be able to pass back what it just read without a cast — + * `shareNote(note.doc)`, `leaveMessage(note.doc)` — and a cast at that boundary + * would re-open exactly the confusion the template literal types exist to close. + * `graph` is the document reference, so that is the field to carry around. + */ + subject: string; + /** + * The document this subject was read from — the reference the caller passed to + * {@link readUnion}, unchanged. This is the anchor, it identifies the document + * stably, and it is what goes back into any call of this surface. */ - subject: Nuri; - /** The graph (doc NURI) the subject was read from. */ graph: Nuri; /** predicate IRI → the list of object values (literals or IRIs) for it. */ props: Record; @@ -142,6 +151,13 @@ async function readDoc( * Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the * batch. * + * A document holding SEVERAL subjects yields several entries — one per distinct + * subject, carrying that subject as written, all sharing the document as their + * `graph`. Properties of different subjects are never merged. Placing one business + * entity per document stays the recommended practice (a key is per repo, so + * isolation needs a repo per entity), but it is a recommendation about writing: the + * read reports what is there. + * * Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong * on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is * read with an anchored default-graph query, O(1) per doc, independent of wallet @@ -182,29 +198,44 @@ export async function readUnion(docsLike: NuriLike[]): Promise { ); // Possession gate, kept as defence in depth behind rule 2 above: `reachable` - // already excluded these, so this loop should never drop anything. In this - // polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI. + // already excluded these, so this loop should never drop anything. The read is + // anchored per document, so the unit of possession is the document: the cap key is + // the doc NURI, whatever subjects that document turns out to carry. const caps = getCaps(); + // Keyed by (document, subject) — an entry is one subject INSIDE one graph, which is + // the identity upstream gives an object too: the ORM carries `@id` and `@graph` as + // two distinct read-only properties, and fabricates an `@id` when the writer leaves + // it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`). Several objects per + // graph is therefore the PROVIDED case, and `@id` is what tells them apart within a + // `@graph`. Two documents carrying the same subject IRI stay two entries: they are + // two objects, distinguished by their graph. + // + // Placing one business entity per document remains the recommended practice — a key + // is per repo, so isolating an entity requires a repo of its own. That is a + // recommendation about WRITING, and the read does not get to enforce it by making + // the other arrangement invisible. const bySubject = new Map(); for (const { doc, rows } of perDoc) { if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue; - // Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI - // (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which - // is stable regardless of the repo_graph_name overlay suffix the store carries. + // Anchored to `doc`, so every row belongs to `doc` — hence `graph` is the caller's + // reference to it. The subject comes back exactly as the document carries it. for (const row of rows) { // The polyfill's own compartments live as reserved SUBJECTS inside the very // documents the consumer reads (the Header branch carrying a document's inbox // address is the first). They are machinery, not this entity's properties — // drop them here, once, for every compartment present and future. - if (isMachinerySubject(row.s?.value)) continue; + const s = row.s?.value; + if (isMachinerySubject(s)) continue; const p = row.p?.value; const o = row.o?.value; - if (!p || o === undefined) continue; - let entry = bySubject.get(doc); + if (s === undefined || !p || o === undefined) continue; + // NUL cannot appear in an IRI, so the pair never collides with either half. + const key = `${doc}\u0000${s}`; + let entry = bySubject.get(key); if (!entry) { - entry = { subject: doc, graph: doc, props: {} }; - bySubject.set(doc, entry); + entry = { subject: s, graph: doc, props: {} }; + bySubject.set(key, entry); } (entry.props[p] ??= []).push(o); } diff --git a/packages/sdk/test/read-model.test.ts b/packages/sdk/test/read-model.test.ts index cc25adf..b46b1ec 100644 --- a/packages/sdk/test/read-model.test.ts +++ b/packages/sdk/test/read-model.test.ts @@ -151,3 +151,117 @@ test("readUnion tolerates holes in the list, and refuses a malformed reference", await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]); await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i); }); + +// ── several subjects inside one document ─────────────────────────────────── +// +// The fixture above pins every row's subject to the doc NURI, which is what an +// application writing one entity per document produces — the recommended placement, +// and the only case it can exercise. A document may nevertheless carry SEVERAL +// subjects, and upstream that is the provided case, not an accident: the ORM carries +// `@id` and `@graph` as two distinct read-only properties and fabricates an `@id` +// when the writer leaves it empty, precisely so objects sharing a `@graph` stay +// distinguishable. This fixture lets the subject vary so that case can be tested. +function injectTriples(triplesByDoc: Record>) { + const ng = { + doc_create: mock(async () => "did:ng:o:new"), + 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"); + } + const triples = triplesByDoc[anchor as string]; + if (!triples) return { results: { bindings: [] } }; + return { + results: { + bindings: triples.map(([s, p, o]) => ({ + s: { value: s }, + p: { value: p }, + o: { value: o }, + })), + }, + }; + }), + }; + resetCaps(); + setCurrentUser(null); + configure({ ng, useShape: () => undefined }); + configureStoreRegistry({ + getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), + normalizeId: (u: string) => u, + }); + return ng; +} + +test("two subjects in ONE document come back as two entries, unmixed", async () => { + // The document holds two entities under two IRIs of the consumer's choosing. + injectTriples({ + "did:ng:o:d": [ + ["urn:app:alice", TYPE, `${FP}Person`], + ["urn:app:alice", `${FP}title`, "A"], + ["urn:app:bob", TYPE, `${FP}Person`], + ["urn:app:bob", `${FP}title`, "B"], + ], + }); + + const subjects = await readUnion(["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, + // and both re-labelled with the document's own NURI. + expect(subjects.length).toBe(2); + expect(subjects.map((s) => s.subject).sort()).toEqual(["urn:app:alice", "urn:app:bob"]); + + const alice = subjects.find((s) => s.subject === "urn:app:alice")!; + 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"]); + // 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 () => { + 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"]); +}); + +test("machinery is dropped per subject, and the real subjects beside it survive", async () => { + injectTriples({ + "did:ng:o:d": [ + ["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"], + ["urn:app:entity", `${FP}title`, "kept"], + ], + }); + const subjects = await readUnion(["did:ng:o:d"]); + expect(subjects.map((s) => s.subject)).toEqual(["urn:app:entity"]); + expect(subjects[0]!.props[`${FP}title`]).toEqual(["kept"]); +}); + +test("a document holding ONLY machinery yields no entry at all", async () => { + injectTriples({ + "did:ng:o:d": [ + ["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"], + ], + }); + expect(await readUnion(["did:ng:o:d"])).toEqual([]); +});