/** * cold-open-document-inbox.test.ts — asking again for the inbox my note already has. * * ── The defect this closes ──────────────────────────────────────────────── * `openDocumentInbox` is a resolve-or-mint: it asks the User branch of the owner's PRIVATE * store whether an inbox is already recorded for this document (`readInboxCapPairs`, the * emulated `AddInboxCap`), and mints one when the answer is no. That read had no sync * barrier of its own. On a fresh page over the same persistent wallet the private store is * present but unsynced, and an anchored read of an unsynced repo returns no rows — no * error (`emulated-verifier/open-repo.ts`). So the register answered "no inbox recorded" * for a document that has one, and the call minted a SECOND. * * What that leaves behind is durable and wrong in the way that cannot be seen: two * `AddInboxCap` records for one document, and the address published ON the note replaced by * the new inbox — so from then on deposits arrive in one box while everything left before * sits in the other. Nothing fails, nothing is logged. * * ── Why it needs no concurrency, and how this suite reaches it ──────────── * The same symptom (several inboxes for one document) was reported by a consuming * application from FOUR simultaneous calls, and that race is closed by coalescing them onto * one call (`openInboxInFlight`). This is the other road to it, and one page is enough: * a single call, no race, on a page whose private store has not been opened yet. * * A page is in exactly that state between SETTLING an identity and CONNECTING it — the * split the access gate makes on purpose (`shared-wallet/access-gate.ts` `settleIdentity` * records who is acting through `adoptCurrentUser`, and `ensureIdentity` connects * afterwards). Connecting is what opens the three stores today (`restoreOwnCaps`), so * before it the register's read is cold. The suite settles the identity exactly as the gate * does, and everything else it does is published calls an application makes. * * Nothing is planted: the second page sees only what the first one WROTE, and the broker * fake withholds exactly what this page has not subscribed to (`wallet-fake.ts`, * `unsyncedUntilSubscribed`). */ import { test, expect, describe, afterAll, beforeEach } from "bun:test"; import { docs, storeRegistry } from "../src/index"; import { adoptCurrentUser } from "../src/shared-wallet/bootstrap"; import { getSyncState } from "../src/emulated-verifier/open-repo"; import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake"; import type { Nuri, PrincipalId } from "../src/model/types"; const TITLE = "urn:test:title"; /** The broker's own cold start — see `wallet-fake.WalletOptions`. */ const COLD = { unsyncedUntilSubscribed: true } as const; /** * Record who is acting, and stop there — the SETTLE half of signing in, which is what the * access gate does before it connects (`settleIdentity` → `adoptCurrentUser`). Not a test * shortcut into an invented state: every page passes through it, and an application that * settles in its `init()` and acts before `ensureIdentity()` resolves is in it for real. */ function settledButNotConnected(id: PrincipalId): void { adoptCurrentUser(id); } /** Alice writes a note and opens it for messages — both named by the NOTE, as an app does. */ async function aNoteOpenedForMessages(quads: Quad[]): Promise { bootPage(quads, COLD); await signIn("alice"); const note = await storeRegistry.createEntityDoc("public"); await docs.sparqlUpdate( SESSION.sessionId, `INSERT DATA { <${note}> <${TITLE}> "Courses" }`, note, "writeEntity", ); await storeRegistry.openDocumentInbox(note); return note; } /** The `AddInboxCap` records the wallet holds for `note` — read off the wallet, because no * published call hands out an inbox address (which is the point of `postToDocument`). */ function inboxesRecordedFor(quads: Quad[], note: Nuri): Nuri[] { return quads .filter((q) => q.p === "urn:ng-eventually:shim:inboxCap" && q.o.startsWith(note + " ")) .map((q) => q.o.split(" ")[1] as Nuri); } /** The address published ON the note — where a depositor is sent. */ function addressPublishedOn(quads: Quad[], note: Nuri): Nuri[] { return quads .filter((q) => q.g === note && q.p === "urn:ng-eventually:shim:inboxAddress") .map((q) => q.o as Nuri); } /** Alice's private store, off the wallet — the repo the register lives in. */ function herPrivateStore(quads: Quad[]): Nuri { const record = quads.find((q) => q.p === "urn:ng-eventually:shim:docPrivate"); if (!record) throw new Error("alice has no account in this wallet"); return record.o as Nuri; } /** Alice's note, found the way her application finds it: by listing her own store. */ async function myNote(): Promise { const mine = await storeRegistry.listMyEntityDocs("public"); const note = mine[0]; if (!note) throw new Error("the note Alice wrote is not in her store"); return note; } beforeEach(() => { forgetEverything(); }); afterAll(() => { forgetEverything(); }); describe("opening my note for messages again, on a page that has just loaded", () => { test("it resolves the inbox the note already has — it does not mint a second", async () => { const quads: Quad[] = []; const written = await aNoteOpenedForMessages(quads); const [theInbox] = inboxesRecordedFor(quads, written); reloadPage(quads, COLD); settledButNotConnected("alice"); const note = await myNote(); expect(await storeRegistry.openDocumentInbox(note)).toBe(theInbox!); }); test("and the wallet still holds ONE record and ONE published address for it", async () => { const quads: Quad[] = []; const written = await aNoteOpenedForMessages(quads); const [theInbox] = inboxesRecordedFor(quads, written); reloadPage(quads, COLD); settledButNotConnected("alice"); await storeRegistry.openDocumentInbox(await myNote()); // The durable damage, which is the part nobody can see at the time: a second record // makes the owner's own drain list ambiguous, and a re-published address sends every // later deposit to a box that holds none of the earlier ones. expect(inboxesRecordedFor(quads, written)).toEqual([theInbox!]); expect(addressPublishedOn(quads, written)).toEqual([theInbox!]); }); test("it crossed the sync barrier on the store the register lives in", async () => { const quads: Quad[] = []; await aNoteOpenedForMessages(quads); const store = herPrivateStore(quads); reloadPage(quads, COLD); settledButNotConnected("alice"); await storeRegistry.openDocumentInbox(await myNote()); // The guarantee itself, not the outcome: past the first `State`, presence is // guaranteed and absence definitive — so "no inbox recorded" would MEAN it. expect(getSyncState(store)).toBe("synced"); }); test("a note nobody has opened for messages still gets one — absence is not ignorance", async () => { const quads: Quad[] = []; bootPage(quads, COLD); await signIn("alice"); const bare = await storeRegistry.createEntityDoc("public"); await docs.sparqlUpdate( SESSION.sessionId, `INSERT DATA { <${bare}> <${TITLE}> "Vierge" }`, bare, "writeEntity", ); reloadPage(quads, COLD); settledButNotConnected("alice"); const note = await myNote(); const inbox = await storeRegistry.openDocumentInbox(note); // The barrier makes an empty answer definitive; it does not stop the mint that a real // absence calls for. Both records land: the entitlement, and the address depositors read. expect(inboxesRecordedFor(quads, note)).toEqual([inbox]); expect(addressPublishedOn(quads, note)).toEqual([inbox]); }); });