Files
ng-eventually/packages/polyfill/test/cold-open-document-inbox.test.ts
T

182 lines
8.2 KiB
TypeScript

/**
* 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 { listMyEntityDocs } from "../src/shared-wallet/account-registry";
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<Nuri> {
bootPage(quads, COLD);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
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 by listing her own store.
*
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
* this reaches it where it lives. What is under test is unchanged - the cold store still
* has to answer, and it still has to refile the caps while it does. */
async function myNote(): Promise<Nuri> {
const mine = await listMyEntityDocs("alice", "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(await storeRegistry.publicStore());
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]);
});
});