/** * use-shape-query.test.ts - behavioural tests for `useShapeQuery` * (src/surface/use-shape-query.ts), against the SDK's shape query stood in for by * `orm-fake.ts` and a stateful fake `ng` for everything else the page does. * * -- The montage, and why it takes TWO fakes ------------------------------- * The read goes through the SDK now, so the thing that answers it is the injected * shape query (`test/orm-fake.ts`, a small engine that honours the scope axes the way * `schema_shape_to_sparql` does). Everything AROUND the read is still this package's: * signing in, creating a document, filing its capability. That is the fake `ng` - and * keeping it is what makes the documents here real ones, obtained from * `createEntityDoc` rather than named by the test. * * These prove the distinctions the surface exists for: * (a) isPending until the initial payload arrives, isSuccess once it has; * (b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction); * (c) an engine patch updates data (reactivity, no polling); * (d) a query that never delivers stays isPending - it never becomes "nothing"; * plus the three readings of the SDK `Scope`, which this module no longer interprets * and therefore has to hand over VERBATIM, and the capability filter over what comes * back. */ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test"; import { useShapeQuery } from "../src/surface/use-shape-query"; import { configure } from "../src/index"; import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap"; import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry"; import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo"; import { mintCap } from "../src/emulated-verifier/caps"; import { makeOrm, type FakeOrm } from "./orm-fake"; import type { BaseType, ShapeType } from "@ng-org/shex-orm"; import type { NgLike, UseShapeLike } from "../src/model/types"; const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; /** The whole local dataset - upstream's own marker, what `""` normalises to. */ const EVERYTHING = { graphs: "did:ng:i" }; const FP = "http://festipod.org/"; const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" }; interface Quad { g: string; s: string; p: string; o: string } /** Reverse of escapeLiteral: single left-to-right pass over `\x`. */ function unescapeLiteral(s: string): string { let out = ""; for (let i = 0; i < s.length; i++) { if (s[i] === "\\" && i + 1 < s.length) { const next = s[++i]; out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!; } else out += s[i]; } return out; } interface SubRec { nuri: string; cb: (r: unknown) => void } /** * The stateful fake `ng` - the shim account, the per-scope index and the document * creation the page does around the read. It is NOT what answers the read any more. */ function makeFake(options: { pushState?: boolean } = {}) { /** Anchors whose anchored read throws. Mutable after boot, so a suite builds a healthy * world first and breaks only the one call it is about. */ const unreachable = new Set(); // A broker that never pushes the first `State` is the pathological case the bootstrap // open's bounded fallback exists for - not the ordinary one, which CONTRACT 3 in // `e2e/` measures at about a millisecond. It is what makes the barrier's cost visible // without waiting for it. const pushState = options.pushState !== false; const quads: Quad[] = []; let docCounter = 0; const subs: SubRec[] = []; const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`); const sparql_update = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[2] as string | undefined; const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); let g: string; let body: string; if (gm) { g = gm[1]!; body = gm[2]!; } else { if (!anchor) return undefined; g = anchor; body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); } const sm = body.match(/<([^>]+)>/); if (!sm) return undefined; const s = sm[1]!; const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g; let m: RegExpExecArray | null; const after = body.slice(body.indexOf(sm[0]) + sm[0].length); while ((m = pairRe.exec(after)) !== null) { const p = m[1] ?? "urn:ng-eventually:shim:Account"; const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); quads.push({ g, s, p, o }); } for (const sub of subs) { if (sub.nuri === g) sub.cb({ V0: { Patch: {} } }); } return undefined; }); const sparql_query = mock(async (...a: unknown[]) => { // The broker cannot answer for this repo right now - an outage, a repo this session // has not synced, a rejected read. It REJECTS, as `resolve_target_for_sparql` does // for a repo the verifier does not hold, and never answers 0 rows. if (unreachable.has(a[3] as string)) throw new Error("RepoNotFound (emulated outage)"); const query = a[1] as string; const anchor = a[3] as string | undefined; // Pointer SELECT (store-root -> doc-shim). if (query.includes("")) { const bindings = quads .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc") .map((q) => ({ shimDoc: { value: q.o } })); return { results: { bindings } }; } if (query.includes("")) { const subjM = query.match(/<([^>]+)>\s+a\s+/); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; if (onlySubject !== null && q.s !== onlySubject) continue; const rec = bySubject.get(q.s) ?? {}; if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o; if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o; if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o; if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] .filter((r) => r.id) .map((r) => ({ id: { value: r.id! }, docPublic: { value: r.docPublic ?? "" }, docProtected: { value: r.docProtected ?? "" }, docPrivate: { value: r.docPrivate ?? "" }, })); return { results: { bindings } }; } for (const marker of ["inboxCap", "readCap", "contains", "exposedReadCap"] as const) { if (query.includes(``)) { const key = marker === "contains" ? "e" : "c"; const bindings = quads .filter((q) => q.g === anchor && q.p === `urn:ng-eventually:shim:${marker}`) .map((q) => ({ [key]: { value: q.o } })); return { results: { bindings } }; } } const bindings = quads .filter((q) => q.g === anchor) .map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })); return { results: { bindings } }; }); const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => { subs.push({ nuri, cb }); setTimeout(() => cb({ V0: { TabInfo: {} } }), 0); if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0); return () => {}; }); return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads, _unreachable: unreachable }; } function inject(ng: ReturnType, orm: FakeOrm) { configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike, ormSubscription: orm.query, }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(), }); resetRegistryCache(); resetOpenedRepos(); resetCaps(); } const tick = () => new Promise((r) => setTimeout(r, 5)); /** * A minimal SHEX ShapeType pinning `rdf:type` to `${FP}Event` and carrying a title. * * Both predicates are `minCardinality: 1`, because that is what * `schema_shape_to_sparql` turns into REQUIRED triple patterns: a shape with none of * them would match every subject in the dataset. `rdf-orm build` generates exactly this * object from a `.shex` file, and the generator is not what is under test here. */ const EventShape = { shape: `${FP}EventShape`, schema: { [`${FP}EventShape`]: { iri: `${FP}EventShape`, predicates: [ { iri: TYPE, readablePredicate: "@type", dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }], minCardinality: 1, maxCardinality: 1, }, { iri: `${FP}title`, readablePredicate: "title", dataTypes: [{ valType: "string" }], minCardinality: 1, maxCardinality: 1, }, ], }, }, } satisfies ShapeType; /** The titles a snapshot carries, sorted - what a view would render. */ function titles(data: BaseType[]): string[] { return data.map((d) => String(d.title)).sort(); } /** Seed one shape-matching object into a document's graph. */ function seedEvent(orm: FakeOrm, doc: string, subject: string, title: string): void { orm.seed(doc, subject, TYPE, `${FP}Event`); orm.seed(doc, subject, `${FP}title`, title); } beforeEach(() => { resetRegistryCache(); }); afterEach(() => { setCurrentUser(null); }); afterAll(() => { resetConfig(); resetStoreRegistry(); resetRegistryCache(); resetOpenedRepos(); // The cap registry is process-wide: leaving caps behind would put the possession // gate in force for a suite that never declares any. resetCaps(); }); describe("useShapeQuery", () => { it("(a) isPending until the initial payload arrives, then isSuccess with the data", async () => { const ng = makeFake(); const orm = makeOrm({ autoDeliver: false }); inject(ng, orm); setCurrentUser("alice"); const doc = await createEntityDoc("alice", "protected"); seedEvent(orm, doc, `${doc}:q:one`, "Alpha"); const obs = useShapeQuery(EventShape, EVERYTHING); let notes = 0; const unsub = obs.subscribe(() => { notes += 1; }); // Before the engine answers: pending, no data. await tick(); expect(obs.getSnapshot().isPending).toBe(true); expect(obs.getSnapshot().isSuccess).toBe(false); expect(obs.getSnapshot().data).toEqual([]); orm.deliver(); await tick(); const snap = obs.getSnapshot(); expect(snap.isPending).toBe(false); expect(snap.isSuccess).toBe(true); expect(snap.isError).toBe(false); expect(titles(snap.data)).toEqual(["Alpha"]); expect(notes).toBeGreaterThan(0); unsub(); }); it("(b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction)", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("bob"); // bob has no document of this shape anywhere - the scope is genuinely empty. const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); await tick(); await tick(); const snap = obs.getSnapshot(); expect(snap.isPending).toBe(false); expect(snap.isSuccess).toBe(true); // answered, NOT stuck pending expect(snap.isError).toBe(false); expect(snap.data).toEqual([]); // empty - distinguishable from "still asking" unsub(); }); it("(c) an engine patch updates data (reactivity, no polling)", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("carol"); const doc = await createEntityDoc("carol", "protected"); seedEvent(orm, doc, `${doc}:q:one`, "One"); const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); await tick(); await tick(); expect(titles(obs.getSnapshot().data)).toEqual(["One"]); // A second object lands in a second document - the engine pushes it into the live // subscription, and the snapshot follows. No document subscription of ours, no timer. const doc2 = await createEntityDoc("carol", "protected"); seedEvent(orm, doc2, `${doc2}:q:one`, "Two"); await tick(); expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]); unsub(); }); it("(d) a query that never delivers stays isPending - never 'there is nothing'", async () => { // The state the real ORM produces when the engine refuses a start: `orm_start_graph` // is called inside a `try { ... } catch (e) { console.error(e) }` and the readiness // promise is built with `resolve` alone, so nothing ever settles it. The surface must // keep saying "still asking" - the one thing it must never do is publish the // empty-and-answered snapshot, which is what an application renders as "you have // created nothing". const ng = makeFake(); const orm = makeOrm({ autoDeliver: false }); inject(ng, orm); setCurrentUser("dave"); const doc = await createEntityDoc("dave", "protected"); seedEvent(orm, doc, `${doc}:q:one`, "Never arrives"); const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); await tick(); await tick(); await new Promise((r) => setTimeout(r, 50)); const snap = obs.getSnapshot(); expect(snap.isPending).toBe(true); expect(snap.isSuccess).toBe(false); expect(snap.isError).toBe(false); expect(snap.data).toEqual([]); unsub(); }); it("(e) opening the shape query threw -> isError, and never an empty answer", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("erin"); orm.refuse("the shape could not be subscribed"); const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); await tick(); const snap = obs.getSnapshot(); expect(snap.isError).toBe(true); expect(snap.isSuccess).toBe(false); expect(snap.isPending).toBe(false); expect(String((snap.error as Error).message)).toMatch(/could not be subscribed/); unsub(); }); it("(f) `configure` without a shape query says so, rather than answering empty", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); // ...and then a bootstrap that forgot the injection. The read has nothing to go // through, and an empty set would be the one lie this surface exists to prevent. configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(), }); const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); await tick(); const snap = obs.getSnapshot(); expect(snap.isError).toBe(true); expect(snap.isSuccess).toBe(false); expect(String((snap.error as Error).message)).toMatch(/ormSubscription/); unsub(); }); // -- the scope axis: the three readings upstream gives it ------------------ // // These are not variants of one case. `graphs` names ONE document's graph per entry; // `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or // `undefined` returns no objects at all. This module no longer interprets any of it - // the scope is handed to the SDK verbatim - so each case pins BOTH that the object // arrived unchanged and what the engine then answers. it("(g) a named `graphs` list reaches the SDK verbatim and reads those documents only", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("frank"); const kept = await createEntityDoc("frank", "protected"); seedEvent(orm, kept, `${kept}:q:one`, "Named"); const other = await createEntityDoc("frank", "protected"); seedEvent(orm, other, `${other}:q:one`, "Unnamed"); // The whole dataset sees both - that is the reading the named list departs from. const all = useShapeQuery(EventShape, EVERYTHING); const unsubAll = all.subscribe(() => {}); await tick(); await tick(); expect(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]); unsubAll(); const scope = { graphs: [kept] }; const obs = useShapeQuery(EventShape, scope); const unsub = obs.subscribe(() => {}); await tick(); await tick(); expect(obs.getSnapshot().isSuccess).toBe(true); expect(titles(obs.getSnapshot().data)).toEqual(["Named"]); // The object the caller wrote is the object the SDK received: no translation, no // copy, nothing added. That is what removing the pipeline bought. expect(orm.asked.at(-1)!.scope).toBe(scope); unsub(); }); it("(h) an empty `graphs` returns no objects, and says so as SUCCESS", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("gina"); const doc = await createEntityDoc("gina", "protected"); seedEvent(orm, doc, `${doc}:q:one`, "Unreachable"); // Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned." // It is an ANSWER, not a failure - and not the pending state either, which is what an // application would otherwise be left staring at. // `graphs` became REQUIRED in `@ng-org/orm@0.1.2-alpha.21`, so the "leaving it // `undefined`" half of that sentence is no longer reachable by omitting the key. `[]` // is the other half, and it is the one that still spells "empty". const obs = useShapeQuery(EventShape, { graphs: [] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); const snap = obs.getSnapshot(); expect(snap.isPending).toBe(false); expect(snap.isSuccess).toBe(true); expect(snap.isError).toBe(false); expect(snap.data).toEqual([]); unsub(); }); it("(i) `subjects` narrows to the subject IRIs named, literally", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("hank"); const doc = await createEntityDoc("hank", "protected"); // Two subjects in ONE document: the `subjects` axis is what tells them apart, and // `graphs` cannot - both carry the same `@graph`. seedEvent(orm, doc, `${FP}one`, "First"); seedEvent(orm, doc, `${FP}two`, "Second"); const both = useShapeQuery(EventShape, EVERYTHING); const unsubBoth = both.subscribe(() => {}); await tick(); await tick(); expect(titles(both.getSnapshot().data)).toEqual(["First", "Second"]); unsubBoth(); const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); expect(titles(obs.getSnapshot().data)).toEqual(["Second"]); unsub(); }); // -- the capability filter, which this module still owns ------------------- it("(j) an object whose document the holder has no capability for does not come back", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("iris"); // Hers: created through the surface, so its capability is filed as a by-product. const mine = await createEntityDoc("iris", "protected"); seedEvent(orm, mine, `${mine}:q:one`, "Mine"); // Somebody else's, sitting in the same local dataset - which is exactly what // `did:ng:i` returns, since the engine adds nothing to narrow it by what the reader // may open. The capability filter is the only thing between it and the caller. seedEvent(orm, "did:ng:o:someone-elses", "did:ng:o:someone-elses:q:one", "Theirs"); expect(getCaps().isEnforcing()).toBe(true); const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); await tick(); await tick(); expect(titles(obs.getSnapshot().data)).toEqual(["Mine"]); unsub(); }); // -- a NAMED document is reached before the query opens, since `1.0.0-dev.9` --- // // `graphs` does not only narrow a search: each entry NAMES a document, and this surface // used to name one, hand it to the engine, and empty the result with the capability // filter - publishing `isSuccess` with `data: []`, which is the one snapshot it exists // to reserve for "this scope holds nothing". The four cases below are the distinct ones: // the document that cannot be reached, the one that can and is empty, the list where // only one member fails, and the emulation not yet in force. it("(l) a named document the holder holds no capability for is isError, not an empty success", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("kate"); // Creating one document is what puts the emulation in force - the cap registry is // inert until a first cap exists, and (o) below is the case where it still is. await createEntityDoc("kate", "protected"); const theirs = "did:ng:o:someone-elses"; seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs"); expect(getCaps().isEnforcing()).toBe(true); const obs = useShapeQuery(EventShape, { graphs: [theirs] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); const snap = obs.getSnapshot(); expect(snap.isError).toBe(true); expect(snap.isSuccess).toBe(false); expect(snap.isPending).toBe(false); expect(String((snap.error as Error).message)).toContain("does not hold this document"); // RULE 2, and it is the half a filter could never give: the question was not put at // all. Kate's session never asked the engine about a document she cannot open. expect(orm.openCount()).toBe(0); unsub(); }); it("(m) a named document that IS reachable and holds nothing of the shape is isSuccess with data []", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("liam"); // His own, so the barrier passes; nothing of the shape is ever seeded into it. const mine = await createEntityDoc("liam", "protected"); expect(getCaps().isEnforcing()).toBe(true); const obs = useShapeQuery(EventShape, { graphs: [mine] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); // THE line that must not move. An empty document is a document that answered, and // the repair above would only have relocated the lie if this had become an error. const snap = obs.getSnapshot(); expect(snap.isSuccess).toBe(true); expect(snap.isError).toBe(false); expect(snap.isPending).toBe(false); expect(snap.data).toEqual([]); unsub(); }); it("(n) one unreachable document in a named list refuses the whole scope", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("maya"); const mine = await createEntityDoc("maya", "protected"); seedEvent(orm, mine, `${mine}:q:one`, "Mine"); const theirs = "did:ng:o:not-mayas"; seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs"); const obs = useShapeQuery(EventShape, { graphs: [mine, theirs] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); // Not a short list. Answering "Mine" alone would say the scope holds one object when // the truth is that half of it was never asked - the tolerance `readUnion` was // removed for in `1.0.0-dev.8`, arriving by another door. const snap = obs.getSnapshot(); expect(snap.isError).toBe(true); expect(snap.isSuccess).toBe(false); expect(snap.data).toEqual([]); expect(orm.openCount()).toBe(0); unsub(); }); it("(o) INERT: before the first cap exists a named document reads, and nothing refuses", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("nora"); // No document created here, so no cap was ever minted: the emulation is not in force, // which is the state every early path runs in. A barrier that fired here would break // all of them. const early = "did:ng:o:early-doc"; seedEvent(orm, early, `${early}:q:one`, "Early"); expect(getCaps().isEnforcing()).toBe(false); const obs = useShapeQuery(EventShape, { graphs: [early] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); const snap = obs.getSnapshot(); expect(snap.isError).toBe(false); expect(snap.isSuccess).toBe(true); expect(titles(snap.data)).toEqual(["Early"]); unsub(); }); it("(p) the empty-string `graphs` is the whole dataset too, and is not guarded either", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("omar"); const mine = await createEntityDoc("omar", "protected"); seedEvent(orm, mine, `${mine}:q:one`, "Mine"); // Somebody else's, in the same local dataset: the filter is what keeps it out, and // must stay the one that decides here - `""` names no document to refuse. seedEvent(orm, "did:ng:o:not-omars", "did:ng:o:not-omars:q:one", "Theirs"); // `normalizeScope` maps `""` onto `did:ng:i` (`@ng-org/orm`'s `types.ts`), so the two // spellings are one marker. A barrier that treated `""` as a document name would // refuse the whole dataset for a reference nobody wrote. const obs = useShapeQuery(EventShape, { graphs: "" }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); const snap = obs.getSnapshot(); expect(snap.isError).toBe(false); expect(snap.isSuccess).toBe(true); expect(titles(snap.data)).toEqual(["Mine"]); unsub(); }); it("(q) unsubscribing while the barrier is still crossing leaves no query open", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("pia"); const mine = await createEntityDoc("pia", "protected"); seedEvent(orm, mine, `${mine}:q:one`, "Mine"); // The barrier is asynchronous now, so there is a window in which the observable has // subscribers, no query, and an open on its way. A teardown landing in that window // must not be overtaken by the open it superseded - otherwise the shape query is // created after the last listener left and nothing is ever going to close it. const obs = useShapeQuery(EventShape, { graphs: [mine] }); const unsub = obs.subscribe(() => {}); unsub(); // synchronously, while `ensureRepoOpen` is still awaiting the first `State` await tick(); await tick(); await tick(); expect(orm.openCount()).toBe(0); }); // -- the cap that arrives LATE, which is the ordinary case -------------- // // A page reload, a `settle` still in flight, an inbox delivery landing while the // person is looking at the screen: in all three the view is subscribed BEFORE the // keyring holds what it needs. The contract promises that such a view converges by // itself - "a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and // publishes the document it now opens" - and `CapRegistry.onChange` is the channel. // A refusal that cannot be re-tried on that signal is not a stricter answer, it is a // dead view. it("(r) a NAMED document whose cap arrives after the subscribe converges, it does not stay isError", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); // Alice writes the note. Bob is the one reading, and he holds nothing yet - the // state a page has while its connection work is still restoring the keyring. setCurrentUser("alice"); const note = await createEntityDoc("alice", "protected"); seedEvent(orm, note, `${note}:q:one`, "Shared"); setCurrentUser("bob"); expect(getCaps().isEnforcing()).toBe(true); const obs = useShapeQuery(EventShape, { graphs: [note] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); // Honest for now: he cannot read it, and being told so beats an empty success. expect(obs.getSnapshot().isError).toBe(true); // ...and now the cap lands, exactly as the applied share does it // (`branch-registers.addLink` for the connected holder, `inbox-dispatch` for a named // one): filed into the keyring, which fires `CapRegistry.onChange`. getCaps().learn(mintCap(note)); await tick(); await tick(); const snap = obs.getSnapshot(); expect(snap.isError).toBe(false); expect(snap.isSuccess).toBe(true); expect(titles(snap.data)).toEqual(["Shared"]); unsub(); }); it("(s) a re-attempt that cannot succeed spends NO round-trip: an absence is memoised", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("alice"); const note = await createEntityDoc("alice", "protected"); seedEvent(orm, note, `${note}:q:one`, "Shared"); setCurrentUser("bob"); const obs = useShapeQuery(EventShape, { graphs: [note] }); let notes = 0; const unsub = obs.subscribe(() => { notes += 1; }); for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick(); expect(obs.getSnapshot().isError).toBe(true); /** How many times the network was asked ABOUT this document - anchored reads only. */ const asks = (): number => ng.sparql_query.mock.calls.filter((c) => c[3] === note).length; const afterRefusal = asks(); const notesAfterRefusal = notes; // What this replaced, and why. An earlier version gated the re-attempt on "does the // holder hold every named document's cap yet", so that an unrelated cap would not // buy a round-trip. That gate was CIRCULAR for a document in a public store - the // only channel that can ever file its cap is the very fetch the gate was blocking - // and an adversarial pass proved the class unrecoverable. The gate is gone: every // keyring change re-attempts, for the holder that was refused. // // What makes that affordable is the memo, and it is worth pinning rather than // assuming: the network ANSWERED here (this document exposes no cap), and an answer // is remembered. So the re-attempts below re-run the barrier and ask nobody. A // failure is the other case - deliberately not memoised, so it IS re-asked, which is // exactly the repair for it (see (v)). for (let round = 0; round < 3; round++) { getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`)); for (let i = 0; i < 5; i++) await tick(); } // BOTH halves, or this test is vacuous: a re-attempt that never ran would also leave // the count untouched. The republication is what proves the barrier was re-run. expect(notes).toBeGreaterThan(notesAfterRefusal); expect(obs.getSnapshot().isError).toBe(true); expect(asks()).toBe(afterRefusal); unsub(); }); it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => { const ng = makeFake({ pushState: false }); const orm = makeOrm(); inject(ng, orm); setOpenTimeoutForTests(40); // after `inject`, which resets it to the 8s default setCurrentUser("quinn"); const a = await createEntityDoc("quinn", "protected"); const b = await createEntityDoc("quinn", "protected"); const c = await createEntityDoc("quinn", "protected"); seedEvent(orm, a, `${a}:q:one`, "A"); seedEvent(orm, b, `${b}:q:one`, "B"); seedEvent(orm, c, `${c}:q:one`, "C"); const named: string[] = [a, b, c]; const subscribesTo = (): number => ng.doc_subscribe.mock.calls.filter((call) => named.includes(String(call[0]))).length; expect(subscribesTo()).toBe(0); const obs = useShapeQuery(EventShape, { graphs: [a, b, c] }); const unsub = obs.subscribe(() => {}); await tick(); // The deterministic form of "the waits overlap": all three opens are in flight // before any of them has answered. A serial barrier would show exactly one here and // pay the bounded fallback three times over before publishing anything - which on // the production timeout is 24 seconds of `isPending` for a scope naming three // documents that never push. expect(subscribesTo()).toBe(3); // ...and it still answers, once the fallbacks have fired. for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick(); expect(titles(obs.getSnapshot().data)).toEqual(["A", "B", "C"]); unsub(); }); it("(u) INERT is not untouched: no refusal, but the cold-start open is real and is paid", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("rex"); const early = "did:ng:o:inert-named"; seedEvent(orm, early, `${early}:q:one`, "Early"); expect(getCaps().isEnforcing()).toBe(false); const obs = useShapeQuery(EventShape, { graphs: [early] }); const unsub = obs.subscribe(() => {}); await tick(); await tick(); // Nothing REFUSES - that half is genuinely inert, since `mayReach` answers true for // everything before the first cap exists. But the open is not: `ensureRepoOpen` runs // whatever the registry is enforcing, and issues a real `doc_subscribe` held for the // session. That is deliberate and it is the cold-start heal - without it a named // document this session has never synced answers `isSuccess` with `data: []`, the // very lie the barrier is here to remove - but it is a CHANGE, and a scope naming a // document now waits on that barrier where it used to open the query at once. expect(ng.doc_subscribe.mock.calls.some((call) => call[0] === early)).toBe(true); const snap = obs.getSnapshot(); expect(snap.isError).toBe(false); expect(titles(snap.data)).toEqual(["Early"]); unsub(); }); // -- the three the second adversarial pass found ------------------------- it("(v) a PUBLIC document whose FETCH failed is asked again when the keyring moves", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("alice"); const pub = await createEntityDoc("alice", "public"); seedEvent(orm, pub, `${pub}:q:one`, "Public"); setCurrentUser("bob"); // Bob holds nothing, and the public store is the ONLY channel that can ever hand him // this document's cap - which is exactly what the broker now cannot answer. ng._unreachable.add(pub); const obs = useShapeQuery(EventShape, { graphs: [pub] }); const unsub = obs.subscribe(() => {}); for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick(); expect(obs.getSnapshot().isError).toBe(true); // The broker heals, and an ordinary cap arrival follows - a connection restoring a // key, an inbox delivery for some other document. The first gate written for this // asked "does the holder hold every named document's cap yet", and for a PUBLIC // document only the failed fetch can ever make that true: the gate waited on an // answer that only the action it gated could produce. ng._unreachable.delete(pub); getCaps().learn(mintCap("did:ng:o:unrelated-arrival")); for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick(); const snap = obs.getSnapshot(); expect(snap.isError).toBe(false); expect(snap.isSuccess).toBe(true); expect(titles(snap.data)).toEqual(["Public"]); unsub(); }); it("(w) a refused scope never re-opens under a DIFFERENT identity", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("alice"); const hers = await createEntityDoc("alice", "protected"); seedEvent(orm, hers, `${hers}:q:one`, "Hers"); setCurrentUser("bob"); const obs = useShapeQuery(EventShape, { graphs: [hers] }); const unsub = obs.subscribe(() => {}); for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick(); expect(obs.getSnapshot().isError).toBe(true); // The page becomes Alice. No application can do this - `ensureIdentity` is the only // published call that settles an identity and it returns the one already set // (`access-gate.resolveIdentity` reads `getCurrentUser()` first) - but the re-open is // a NEW access, not a re-filtering of an existing one, and a new access must never be // issued on behalf of somebody who did not ask for it. setCurrentUser("alice"); getCaps().learn(mintCap("did:ng:o:unrelated-arrival")); for (let i = 0; i < 10; i++) await tick(); expect(orm.openCount()).toBe(0); // nothing was subscribed or queried as Alice // ...and Bob's view must not come back as a SUCCESS holding nothing, which is the lie // this whole task exists to remove, arriving by the repair's own door. setCurrentUser("bob"); getCaps().learn(mintCap("did:ng:o:unrelated-arrival-2")); for (let i = 0; i < 10; i++) await tick(); expect(obs.getSnapshot().isSuccess).toBe(false); expect(obs.getSnapshot().isError).toBe(true); unsub(); }); it("(x) the failure reported is the FIRST named document that failed, in scope order", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("alice"); const hers = await createEntityDoc("alice", "protected"); const alsoHers = await createEntityDoc("alice", "public"); setCurrentUser("bob"); // Two named documents failing for two DIFFERENT reasons. The first answers and holds // no cap for Bob (a refusal); the second cannot be asked at all (a rejection, which // arrives first because it does not wait for the sync barrier). Running the opens // together and letting the first rejection win would report the SECOND document. ng._unreachable.add(alsoHers); const obs = useShapeQuery(EventShape, { graphs: [hers, alsoHers] }); const unsub = obs.subscribe(() => {}); for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick(); const message = String((obs.getSnapshot().error as Error).message); expect(message).toContain(hers); expect(message).not.toContain(alsoHers); unsub(); }); // -- teardown ------------------------------------------------------------ it("(k) the last unsubscribe closes the shape query", async () => { const ng = makeFake(); const orm = makeOrm(); inject(ng, orm); setCurrentUser("jane"); const obs = useShapeQuery(EventShape, EVERYTHING); const first = obs.subscribe(() => {}); const second = obs.subscribe(() => {}); await tick(); expect(orm.openCount()).toBe(1); first(); expect(orm.openCount()).toBe(1); // still a subscriber second(); expect(orm.openCount()).toBe(0); // the query is closed, not leaked }); });