/** * `listMyEntityDocs` answers a VERIFIED listing, or it surfaces — never a half-truth. * * The call stands on two reads of the same store document, one behind the other: the Main * branch says WHICH documents are in there, the Store branch hands back the KEY of each * (the emulated `AddRepo { read_cap }`). Both have to answer for the array it returns to * mean what a caller reads into it — *these are your documents, here they are*. * * Neither failure is visible from the outside, and that is the whole reason this file * exists. A listing whose keys never arrived is the SAME `Nuri[]` as one whose keys did: * the caller learns nothing at the call, and finds out at the next read, which comes back * empty with the cause long gone. An empty listing over a store that did not answer is * worse still — it says "you created nothing", and an application renders that. * * That is the shape this library shipped twice already: `resolveAccount` answering "no such * account" for a read that FAILED (`8c8ade7`), and `connectedUser` resolving over a restore * that never ran (`f6d1734`, which reached a consuming application). The listing was the * last member of the family, tolerated on the argument that this caller already holds the * array by the time the key read fails — which is exactly what makes returning it a lie * rather than a shortcut. * * ── The fault is the broker's, and it is one a real one produces ────────── * The two reads are two round-trips. Between them the connection can die — that is all * that is simulated here: the broker answers normally, then stops answering, and every * later query rejects the way the wasm binding rejects a `RepoNotFound`/transport error. * Nothing reaches into the library to make one of its functions throw artificially, and * nothing plants a state the wallet could not be in: the documents below were CREATED * through the surface, over the same wallet, in the session before. */ import { test, expect, describe, mock, beforeEach } from "bun:test"; import { configure, storeRegistry, readUnion } from "../src/index"; import { configureStoreRegistry } from "../src/shared-wallet/bootstrap"; import { sparqlUpdate } from "../src/surface/docs"; import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types"; import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake"; const SHIM = "urn:ng-eventually:shim"; /** The Main-branch read — "which documents are in this store". */ const LISTING_READ = `<${SHIM}:contains>`; /** The Store-branch read — "and what is the key of each". */ const KEY_READ = `<${SHIM}:readCap>`; const TITLE = "urn:test:title"; /** * Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query * `lostAt` accepts — that one included, and every one after it, for the rest of the page. * * A dropped connection is not selective, so neither is this: the switch decides *when* the * link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up. */ function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void { const wallet = makeWallet(quads); let lost = false; const ng = { doc_create: wallet.doc_create, sparql_update: wallet.sparql_update, sparql_query: mock(async (...a: unknown[]) => { if (lost || lostAt(a[1] as string)) { lost = true; throw new Error("BrokerError: connection lost"); } return wallet.sparql_query(...a); }), }; configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); } /** * The session before: alice signs in and creates one document, over a broker that works. * * Returns the reference she keeps, and the quads the wallet now holds — the durable half * that outlives the page. */ async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> { const quads: Quad[] = []; forgetEverything(); bootPage(quads, () => false); await signIn("alice"); const note = await storeRegistry.createEntityDoc(scope); await sparqlUpdate( SESSION.sessionId, `INSERT DATA { <${TITLE}> "the note" }`, note, ); return { quads, note }; } /** * The page comes back over the same wallet and alice signs in — and only THEN does the link * die, on `read`. Signing in succeeds, so the failure lands inside the one call under test * rather than upstream of it, which is what makes the assertion about `listMyEntityDocs`. */ async function aSecondSessionLosingTheBrokerOn(quads: Quad[], read: string): Promise { let armed = false; forgetEverything(); bootPage(quads, (query) => armed && query.includes(read)); await signIn("alice"); armed = true; } const ALL_SCOPES: Scope[] = ["public", "protected", "private"]; beforeEach(() => { forgetEverything(); }); describe("listMyEntityDocs answers a verified listing or surfaces", () => { // The NORMAL case first, and it is not a formality: a call that threw unconditionally // would pass both failure branches below. This is what the two of them are a departure // from — the listing answers, and every document it names opens. for (const scope of ALL_SCOPES) { test(`[${scope}] a broker that answers gives a listing whose documents open`, async () => { const { quads, note } = await aFirstSessionThatCreated(scope); forgetEverything(); bootPage(quads, () => false); await signIn("alice"); expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note); const subjects = await readUnion([note]); expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]); }); } // The tolerance this file was written to remove: the listing is IN HAND when the key read // fails, so returning it costs the library nothing and tells the caller something false. for (const scope of ALL_SCOPES) { test(`[${scope}] the keys not answering surfaces, instead of a keyless listing`, async () => { const { quads, note } = await aFirstSessionThatCreated(scope); await aSecondSessionLosingTheBrokerOn(quads, KEY_READ); await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/); // And the point of the refusal, stated as the caller sees it: nothing came back that // could be mistaken for "here are your documents". const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null); expect(answered).toBeNull(); expect(answered).not.toEqual([note]); }); } // The other half of the same promise, and the more dangerous value of the two: an empty // array reads as "this account created nothing", which an application renders as an // empty screen rather than as a breakdown. for (const scope of ALL_SCOPES) { test(`[${scope}] the listing itself not answering surfaces, instead of "you own nothing"`, async () => { const { quads } = await aFirstSessionThatCreated(scope); await aSecondSessionLosingTheBrokerOn(quads, LISTING_READ); await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/); const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null); expect(answered).toBeNull(); expect(answered).not.toEqual([]); }); } });