/** * 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 { 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: 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>) { return { 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) => { // Every read is ANCHORED to one doc NURI — never anchorless. if (anchor === undefined) { throw new Error("read-document must NEVER run an anchorless (union) query"); } const doc = anchor as string; const triples = triplesByDoc[doc]; if (!triples) return { results: { bindings: [] } }; const bindings = triples.map(([p, o]) => ({ s: { value: doc }, p: { value: p }, o: { value: o }, })); return { results: { bindings } }; }), }; } function inject(triplesByDoc: Record>) { const ng = fakeNgWith(triplesByDoc); resetCaps(); resetOpenedRepos(); resetPublicStoreFetches(); setCurrentUser(null); configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }), normalizeId: (u: string) => u, }); return ng; } const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const FP = "http://festipod.org/"; 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 a = await readDocumentSubjects("did:ng:o:a"); const b = await readDocumentSubjects("did:ng:o:b"); // 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 documents that were named. expect(anchored.map((c: unknown[]) => c[3])).toEqual(["did:ng:o:a", "did:ng:o:b"]); 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("groups predicates per subject", async () => { inject({ "did:ng:o:p": [ [TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"], [`${FP}user`, "urn:festipod:user:x"], ], }); 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("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({}); expect(await readDocumentSubjects("did:ng:o:empty")).toEqual([]); expect(ng.sparql_query).toHaveBeenCalledTimes(1); }); 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. ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => { 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-rd", privateStoreId: "priv" }), normalizeId: (u: string) => u, }); // 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: 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 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 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. 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(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"); await expect(readDocumentSubjects(mine)).rejects.toThrow(/does not hold this document/i); }); 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(readDocumentSubjects("not-a-nuri" as 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-document 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(); resetOpenedRepos(); resetPublicStoreFetches(); setCurrentUser(null); configure({ ng, useShape: () => undefined }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rd", 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 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, // 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"]); }); 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 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"); }); 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 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 () => { 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 readDocumentSubjects("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 readDocumentSubjects("did:ng:o:d")).toEqual([]); });