182 lines
8.2 KiB
TypeScript
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]);
|
|
});
|
|
});
|