Files
ng-eventually/packages/polyfill/test/cold-open-document-inbox.test.ts
T
Sylvain Duchesne 76ae9ffbb7 fix: trois chemins vers une inbox en double, et la lecture qui manquait
Une application a rapporté quatre appels simultanés sur un même document
enregistrant trois inboxes. Le contrat garantissait l'inverse. En cherchant, on
en a trouvé DEUX autres, indépendantes, qui produisent le même dégât durable :
le propriétaire surveille une inbox pendant que les dépôts arrivent dans une
autre.

La concurrence. openDocumentInbox ne partageait rien avec userInbox — module
différent, registre propre, aucune coalescence. Reproduit pire que rapporté :
quatre appels donnaient QUATRE inboxes. Une carte en vol par (détenteur,
document), et le corps déplacé pour que l'invariant soit porté par la
composition plutôt que par la position d'une vérification.

La limite est nommée plutôt que cachée : deux onglets ne partagent aucune carte,
chacun lit, chacun ne trouve rien, chacun frappe. Ce n'est pas réparable ici —
une branche est en ajout seul, et ça ne se réconcilie pas après coup, le
propriétaire lisant sa branche User quand un déposant lit l'adresse publiée du
document. Le contrat porte donc une garantie positive ET une non-garantie.

La page froide. readInboxCapPairs était le seul lecteur de store sans barrière,
correct uniquement parce qu'une autre fonction s'exécutait avant lui à la
connexion. Une dépendance d'ordre, pas une garantie portée par la lecture : sur
une page froide il lisait le store privé non synchronisé, répondait « aucune
inbox » et en frappait une seconde. Un seul appel, aucune concurrence. La
barrière est désormais dans la lecture, et elle ne coûte rien aux chemins
connectés, la connexion ayant déjà ouvert les trois stores.

Et la lecture qui manquait. readSynced donnait la garantie, readForDocument
l'adressage, pas leur intersection — si bien que matérialiser des dépôts
obligeait une application à résoudre une adresse d'inbox elle-même, ce que le
contrat lui interdit explicitement. inbox.readSyncedForDocument la lui épargne.
Elle traverse deux dépôts, l'adresse vivant sur l'en-tête du document et les
dépôts sur l'inbox — franchir la barrière sur la seule inbox ne réparait rien.

Au passage, le compteur d'identifiants de la doublure était par page : une page
rechargée refrappait le même identifiant PAR-DESSUS une inbox existante,
aliasant deux dépôts en silence. Il est monotone.
2026-08-17 09:46:16 +02:00

176 lines
7.8 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 { 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("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<Nuri> {
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]);
});
});