/** * open-repo.test.ts — behavioral tests for ensureRepoOpen (src/open-repo.ts). * * Core invariant: on a fresh session over a persistent wallet, a scope-index * or entity repo is NOT yet in `self.repos`, so an anchored sparql_query returns * 0 rows. `ensureRepoOpen(nuri)` calls `doc_subscribe(nuri, …)` FIRST (which * pushes the repo into the session), then the anchored read returns data. * * Fake design: * - sparql_query returns EMPTY for a nuri UNTIL doc_subscribe has been called * for that nuri (tracked in a Set). * - 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 `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, getSyncState, resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo"; import { readDocumentSubjects } from "../src/surface/read-document"; import { docChannel, subscribeDoc } from "../src/surface/subscribe"; import { configure } from "../src/index"; import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { resetRegistryCache } from "../src/shared-wallet/account-registry"; afterAll(() => { resetConfig(); resetStoreRegistry(); resetRegistryCache(); resetOpenedRepos(); }); // The reach guard and the cap registry are process-wide: once ANY cap exists the // boundary applies to every reader. A suite that declares none must start from an // empty one, or it inherits another suite's enforcement. beforeEach(() => { resetOpenedRepos(); resetRegistryCache(); resetCaps(); setCurrentUser(null); }); const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" }; const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const FP = "http://festipod.org/"; // --------------------------------------------------------------------------- // Fake ng builder: tracks which nuris have been doc_subscribe-d. // sparql_query returns rows only AFTER the corresponding nuri is subscribed. // --------------------------------------------------------------------------- function makeFakeNgWithSubscribe( triplesByDoc: Record>, options: { pushState?: boolean; subscribeFails?: boolean } = {}, ) { // A broker that accepts the subscription and never pushes is the state the bounded // fallback exists for, and the only way to hold an open genuinely IN FLIGHT. const pushState = options.pushState !== false; const subscribed = new Set(); const subscribeCallOrder: string[] = []; // doc_subscribe: record the call, fire callback immediately (initial push), return unsub const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => { subscribed.add(nuri); subscribeCallOrder.push(nuri); // A broker that REFUSES the subscription - a real failure, and one `subscribe.ts` // deliberately makes retryable by offering `onSetupFailed`. if (options.subscribeFails) throw new Error("BrokerError: cannot subscribe"); // Simulate initial State push (synchronously deferred so the subscription // setup promise path in ensureRepoOpen can resolve it). if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0); return () => {}; // unsubscribe fn }); const sparql_query = mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => { const doc = anchor as string | undefined; if (!doc) return { results: { bindings: [] } }; // Only return data if the repo has been subscribed (i.e. opened) if (!subscribed.has(doc)) return { results: { bindings: [] } }; 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 } }; }); const doc_create = mock(async () => "did:ng:o:new"); const sparql_update = mock(async () => undefined); return { doc_subscribe, sparql_query, doc_create, sparql_update, subscribed, subscribeCallOrder }; } function inject(ng: ReturnType) { configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u: string) => u, }); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("ensureRepoOpen", () => { it("calls doc_subscribe BEFORE the anchored read returns data", async () => { const ng = makeFakeNgWithSubscribe({ "did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]], }); inject(ng); // Directly call ensureRepoOpen then verify read sees data await ensureRepoOpen("did:ng:o:a"); // doc_subscribe was called for the nuri expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); expect(ng.subscribeCallOrder[0]).toBe("did:ng:o:a"); // sparql_query was called AFTER subscribe (ensureRepoOpen guarantees ordering) const result = await readDocumentSubjects("did:ng:o:a"); expect(result.length).toBe(1); expect(result[0]!.props[`${FP}title`]).toEqual(["Alpha"]); }); it("WITHOUT doc_subscribe, sparql_query returns 0 rows (verifies fake mechanics)", async () => { const ng = makeFakeNgWithSubscribe({ "did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]], }); inject(ng); // Do NOT call ensureRepoOpen — subscribed Set remains empty // 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) }); it("idempotence: a 2nd ensureRepoOpen for the same nuri does NOT re-subscribe", async () => { const ng = makeFakeNgWithSubscribe({ "did:ng:o:b": [[TYPE, `${FP}Event`]], }); inject(ng); await ensureRepoOpen("did:ng:o:b"); await ensureRepoOpen("did:ng:o:b"); // second call // doc_subscribe must have been called exactly ONCE expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); }); it("no-op when the fake ng has no doc_subscribe (unit fake path)", async () => { // Fake ng WITHOUT doc_subscribe const noSubscribeNg = { doc_create: mock(async () => "did:ng:o:new"), sparql_update: mock(async () => undefined), sparql_query: mock(async () => ({ results: { bindings: [] } })), }; configure({ ng: noSubscribeNg as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u: string) => u, }); // Must not throw; nuri is added to opened Set (guard skips subscribe) await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined(); // Calling again should also be a no-op (idempotent, already in opened) await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined(); }); // An open ENDS, whatever happens to the registry underneath it. // // A reset - which is what a session change does, `syncSession` - tears down the // subscription an in-flight open is waiting on. Until 2026-09-21 it then left that open // hanging: its `State` could no longer arrive, so the caller waited out the whole // 8-second fallback and only then proceeded, with its sync state CLEARED. Cleared reads // as `"unknown"`, and `unknown` is the LAX answer - `emulated-verifier/index-deposit.ts` // accepts it as "a zero-row read means the document is empty" - so a read taken right // after a session change could have an absence read into it. Both halves are the point: // it must END, and it must end as `"timed-out"`. it("a reset ENDS the opens it abandons, and marks them timed-out rather than unknown", async () => { const ng = makeFakeNgWithSubscribe({}, { pushState: false }); inject(ng); const inFlight = ensureRepoOpen("did:ng:o:abandoned"); await new Promise((r) => setTimeout(r, 5)); // let the subscribe land expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); resetOpenedRepos(); // Ends at once. Without the fix it resolves only when the 8s fallback fires, which is // longer than any test's patience and longer than any page's. await expect( Promise.race([ inFlight.then(() => "ended"), new Promise((r) => setTimeout(() => r("still waiting"), 200)), ]), ).resolves.toBe("ended"); // ...and NOT as a barrier that was reached. expect(getSyncState("did:ng:o:abandoned")).toBe("timed-out"); }); // A reset must INVALIDATE the opens it abandons, not merely end them. // // The continuation of an in-flight open records the repo as opened. Settling that open // from the reset (2026-09-21) made the continuation run right after the maps were // cleared, so it wrote a stale `opened` entry into the FRESH registry - and every later // `ensureRepoOpen` for that nuri then short-circuited on it. The repo was never // subscribed in the new session, and a read over it answered as though it had been. // Before that change the same line ran only when the 8s fallback fired, so a re-open // inside that window did re-subscribe; ending the open promptly is right, but it moved // the write into the window where it does damage. it("a reset INVALIDATES the opens it abandons, so the new session subscribes again", async () => { const ng = makeFakeNgWithSubscribe({}, { pushState: false }); inject(ng); const abandoned = ensureRepoOpen("did:ng:o:reopened"); await new Promise((r) => setTimeout(r, 5)); expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); resetOpenedRepos(); await abandoned; // its continuation runs here, on the cleared registry await new Promise((r) => setTimeout(r, 5)); // The new session has subscribed NOTHING yet, so this must open the repo for real. // NOT awaited: this broker never pushes a `State`, so the open runs to its bounded // fallback - and what is under test is that the subscribe HAPPENS, not how it ends. const reopened = ensureRepoOpen("did:ng:o:reopened"); await new Promise((r) => setTimeout(r, 5)); expect(ng.doc_subscribe).toHaveBeenCalledTimes(2); // Released so the fallback's timer does not outlive the test with a live handle. resetOpenedRepos(); await reopened; }); // A subscribe the broker REFUSED leaves nothing behind, so it can be tried again. // // `subscribe.ts` reports a failed establish through `onSetupFailed` precisely so a // caller can retry; `open-repo` ignored it, waited out the bounded fallback, and then // recorded the repo as OPEN. Every later open short-circuited on that record, so // `refetch()` issued no new subscribe and the read had no exit at all. it("a subscribe the broker REFUSED is not recorded as open, so it can be retried", async () => { const ng = makeFakeNgWithSubscribe({}, { subscribeFails: true }); inject(ng); setOpenTimeoutForTests(30); await ensureRepoOpen("did:ng:o:refused-sub"); expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); // Not authoritative: no `State` was ever going to arrive. expect(getSyncState("did:ng:o:refused-sub")).toBe("timed-out"); // The retry a caller is entitled to make. await ensureRepoOpen("did:ng:o:refused-sub"); expect(ng.doc_subscribe).toHaveBeenCalledTimes(2); }); // -- ONE answer to "is this document open in this session" --------------- // // Both of these are the same defect: a record of openness kept HERE, which outlives the // subscription it describes because `surface/subscribe.ts` drops its listeners without // telling anybody. `configure()` is PUBLISHED and calls `resetDocSubscriptions()`, so this // is a sequence an application performs, not a montage. it("a second configure() leaves NOTHING claiming the document is open or synced", async () => { const ngA = makeFakeNgWithSubscribe({ "did:ng:o:reconf": [[TYPE, `${FP}Event`]] }); inject(ngA); await ensureRepoOpen("did:ng:o:reconf"); expect(getSyncState("did:ng:o:reconf")).toBe("synced"); // A second SDK. The subscription behind that `"synced"` is gone with the first one, so the // barrier it reports was crossed against a verifier nobody is talking to any more. Until // 2026-09-24 this module kept saying `"synced"` - which is AUTHORITY, a zero-row read over // it being a definitive absence - and its own `held` entry then short-circuited every // re-open for the rest of the session, so `refetch()` had no exit either. const ngB = makeFakeNgWithSubscribe({ "did:ng:o:reconf": [[TYPE, `${FP}Event`]] }); inject(ngB); expect(getSyncState("did:ng:o:reconf")).toBe("timed-out"); // ...and the re-open actually happens, against the SDK that is now injected. await ensureRepoOpen("did:ng:o:reconf"); expect(ngB.doc_subscribe).toHaveBeenCalledTimes(1); expect(getSyncState("did:ng:o:reconf")).toBe("synced"); }); // The second face, and the one the failure channel cannot reach. // // `subscribe.ts` reports a refused establish only for the attempt it is still WAITING ON, so // a rejection that arrives after `resetDocSubscriptions` released the fan-out is logged and // nothing else. A record written when the subscribe was REQUESTED therefore stood for the // whole session with nothing able to clear it: measured on the previous code, zero subscribes // under the new SDK, for ever. The repair is that nothing here records openness at all. it("an establish still outstanding when the subscription layer is torn down leaves no claim", async () => { let refuse: ((error: Error) => void) | null = null; const doc_subscribe = mock( async () => await new Promise((_resolve, reject) => { refuse = reject; }), ); const ngA = { doc_subscribe, sparql_query: mock(async () => ({ results: { bindings: [] } })), doc_create: mock(async () => "did:ng:o:new"), sparql_update: mock(async () => undefined), }; inject(ngA as unknown as ReturnType); setOpenTimeoutForTests(20); // Stops waiting on the bounded fallback, with the subscribe STILL outstanding: at this // instant nothing is true yet, and that is precisely when the old code recorded a hold. await ensureRepoOpen("did:ng:o:outstanding"); expect(doc_subscribe).toHaveBeenCalledTimes(1); const ngB = makeFakeNgWithSubscribe({}); inject(ngB); // configure() -> resetDocSubscriptions(), mid-establish // The first SDK finally refuses - into a fan-out that no longer exists, so no caller is // told. Nothing may depend on being told. refuse!(new Error("BrokerError: late refusal")); await new Promise((r) => setTimeout(r, 5)); setOpenTimeoutForTests(20); await ensureRepoOpen("did:ng:o:outstanding"); expect(ngB.doc_subscribe).toHaveBeenCalledTimes(1); expect(getSyncState("did:ng:o:outstanding")).toBe("synced"); }); // The other half of the same question, and it is why "the channel is open" is not the whole // answer either. // // A reset releases the bootstrap listener, which is what kept the repo open for the session. // If something ELSE was following the document, the fan-out survives and `resubscribeDocs` // re-opens its channel - so the channel is `"open"` again while this module holds nothing. // Skipping the work there would leave the document held by a listener that is free to leave, // and the read would not wait for the new session's barrier either. it("a reset leaves the document to be HELD again, even though its channel is open", async () => { const ng = makeFakeNgWithSubscribe({}); inject(ng); const doc = "did:ng:o:alsowatched"; // Somebody else follows this document - an inbox observation, or an application's own // subscription. It is not this module's listener and it does not hold the repo for it. const appUnsub = subscribeDoc(doc, () => {}); await ensureRepoOpen(doc); expect(getSyncState(doc)).toBe("synced"); // The session changes: the bootstrap listener goes, the other one stays, and the fan-out's // channel is re-opened against the new session with its barrier forgotten. resetOpenedRepos(); await new Promise((r) => setTimeout(r, 5)); expect(docChannel(doc).state).toBe("open"); await ensureRepoOpen(doc); expect(getSyncState(doc)).toBe("synced"); // The other listener leaves. The document must still be open, because the bootstrap open is // holding it - which it can only do if the call above actually took a listener out. appUnsub(); expect(docChannel(doc).state).toBe("open"); expect(getSyncState(doc)).toBe("synced"); }); // A BARRIER BELONGS TO THE ATTEMPT THAT PRODUCED IT - and must be FORGOTTEN when that attempt // fails, not merely ignored while nothing stands. // // The push can arrive and the call still fail: the callback is driven locally while the call's // own answer travels the RPC. `subscribe.ts` cleared `establishing` on a refused establish and // LEFT the remembered `State`, so the fan-out held a barrier with no subscription behind it. // The first repair only made `getSyncState` ignore it while the channel read `"none"`, which // MASKED it - see the next test for what the mask let through. it("a barrier pushed by a subscribe that then FAILED is forgotten, not merely ignored", async () => { const doc_subscribe = mock(async (_n: string, _s: string, cb: (r: unknown) => void) => { cb({ V0: { State: {} } }); // the barrier arrives... throw new Error("RepoNotFound: after the push"); // ...and the call then fails }); const ng = { doc_subscribe, sparql_query: mock(async () => ({ results: { bindings: [] } })), doc_create: mock(async () => "did:ng:o:new"), sparql_update: mock(async () => undefined), }; inject(ng as unknown as ReturnType); setOpenTimeoutForTests(20); const doc = "did:ng:o:pushed-then-failed"; // Something else holds the fan-out, so the refusal does not take the entry with it - which is // what would have cleared the barrier for the wrong reason. const other = subscribeDoc(doc, () => {}); await ensureRepoOpen(doc); await new Promise((r) => setTimeout(r, 10)); expect(docChannel(doc).barrierCrossed).toBe(false); // forgotten with the attempt expect(docChannel(doc).state).toBe("none"); expect(getSyncState(doc)).toBe("timed-out"); other(); }); // What MASKING the stale barrier let through, and it is the destroyed deposit this whole family // exists to prevent. // // A fan-out that kept the `State` of a FAILED call replays it to the next joiner. So the next // subscribe restored `establishing`, the channel left `"none"`, the mask lifted - and a // bootstrap open crossed its barrier in two milliseconds on a `State` from a call that had // never taken, logging `BARRIER ... synced`. `getSyncState` then answers `"synced"`, which is // AUTHORITY: `index-deposit.ts` `noRowsMeansAbsent` calls a zero-row read a definitive absence. it("the next subscribe is not handed the barrier of a call that FAILED", async () => { let calls = 0; const doc_subscribe = mock(async (_n: string, _s: string, cb: (r: unknown) => void) => { calls += 1; if (calls === 1) { cb({ V0: { State: {} } }); throw new Error("RepoNotFound: after the push"); } return await new Promise(() => {}); // the retry never answers }); const ng = { doc_subscribe, sparql_query: mock(async () => ({ results: { bindings: [] } })), doc_create: mock(async () => "did:ng:o:new"), sparql_update: mock(async () => undefined), }; inject(ng as unknown as ReturnType); setOpenTimeoutForTests(200); const doc = "did:ng:o:replayed-corpse"; const app = subscribeDoc(doc, () => {}); // the application's own subscription fails await new Promise((r) => setTimeout(r, 20)); // The bootstrap open joins the SAME fan-out and starts a second establish. const at = Date.now(); await ensureRepoOpen(doc); const paid = Date.now() - at; expect(calls).toBe(2); // a real second attempt was made... expect(paid).toBeGreaterThanOrEqual(150); // ...and this open waited its OWN barrier out expect(docChannel(doc).barrierCrossed).toBe(false); expect(getSyncState(doc)).toBe("timed-out"); // never authority over a corpse app(); resetOpenedRepos(); }); // `release` does not answer "am I still listening", and treating it as though it did leaves the // document held by somebody else's listener. // // `resetDocSubscriptions()` - which the PUBLISHED `configure()` calls - drops every listener // and deletes the entry without telling this module, so its handle goes on looking live. If // anything else subscribes to the same document first, the channel is `"open"` again and a // short-circuit that asks only "do I hold a handle" skips the open it owes. it("a second configure() re-holds the document even when something else subscribed first", async () => { const ngA = makeFakeNgWithSubscribe({}); inject(ngA); const doc = "did:ng:o:foreign-first"; await ensureRepoOpen(doc); expect(getSyncState(doc)).toBe("synced"); const ngB = makeFakeNgWithSubscribe({}); inject(ngB); // resetDocSubscriptions(): every listener dropped, this module not told const foreign = subscribeDoc(doc, () => {}); // ...and something else gets there first await new Promise((r) => setTimeout(r, 10)); expect(docChannel(doc).state).toBe("open"); await ensureRepoOpen(doc); await new Promise((r) => setTimeout(r, 10)); // The document must survive the foreign listener leaving, which it can only do if the open // above took a listener of its own. foreign(); expect(docChannel(doc).state).toBe("open"); expect(getSyncState(doc)).toBe("synced"); }); // An attempt that has not ANSWERED still STANDS, so a repeat open costs nothing. // // A second `doc_subscribe` cannot be placed while one is in flight - the fan-out dedupes it, // and upstream it would EVICT the first - so making a fresh attempt buys nothing and costs the // caller the whole bounded fallback again: 8s in production, on every read of a document whose // subscribe never answers, and the anchored read path awaits it. it("a subscribe that never answers costs the fallback ONCE, not once per read", async () => { const doc_subscribe = mock(async () => await new Promise(() => {})); const ng = { doc_subscribe, sparql_query: mock(async () => ({ results: { bindings: [] } })), doc_create: mock(async () => "did:ng:o:new"), sparql_update: mock(async () => undefined), }; inject(ng as unknown as ReturnType); // A WIDE fallback on purpose: what is measured is a difference between "waited the barrier // out" and "did not wait at all", and the two must stay far apart under a loaded runner. setOpenTimeoutForTests(200); const doc = "did:ng:o:never-answers"; let at = Date.now(); await ensureRepoOpen(doc); const paid = Date.now() - at; at = Date.now(); await ensureRepoOpen(doc); const again = Date.now() - at; expect(paid).toBeGreaterThanOrEqual(150); // the first read waits the barrier out expect(again).toBeLessThan(60); // every later one does not - 140ms of margin between them expect(doc_subscribe).toHaveBeenCalledTimes(1); // and no second call was ever placed // Not authority either way: nothing has been heard from this document. expect(getSyncState(doc)).toBe("timed-out"); resetOpenedRepos(); }); 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 readDocumentSubjects("did:ng:o:p"); // 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"]); }); });