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.
This commit is contained in:
Sylvain Duchesne
2026-08-17 09:46:16 +02:00
parent 90712e0ad0
commit 76ae9ffbb7
9 changed files with 686 additions and 17 deletions
@@ -0,0 +1,175 @@
/**
* 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]);
});
});
@@ -0,0 +1,164 @@
/**
* cold-read-for-document.test.ts — coming back to the messages left on my note.
*
* ── The gap this closes ───────────────────────────────────────────────────
* The inbox surface offered a read with the SYNC GUARANTEE (`readSynced`) and a read
* ADDRESSED BY DOCUMENT (`readForDocument`), and not their intersection. An application
* materializing deposits needs both, so it had to resolve an inbox address itself — the
* one gesture the contract says an application never performs.
*
* ── Why the document-addressed path needs the barrier TWICE ───────────────
* Reading a document's messages crosses two repos: the DOCUMENT, whose Header branch
* carries the address, and the INBOX, which carries the deposits. On a fresh session over
* the same persistent wallet both are present and unsynced, and an anchored read of an
* unsynced repo returns no rows — no error (`emulated-verifier/open-repo.ts`). So the
* ADDRESS read comes back empty, `readForDocument` concludes "this document has no inbox",
* and answers `[]` for a note whose inbox holds the message somebody left on it.
*
* That is the state this suite starts from, reached the way a real page reaches it: Alice
* writes a note and opens it for messages, Bob leaves one, and Alice comes back on a new
* page. Nothing is planted — a second page sees exactly what the first one WROTE, and the
* broker fake only withholds what this page has not subscribed to yet
* (`wallet-fake.ts`, `unsyncedUntilSubscribed`).
*
* The pair of tests is the point: on ONE state, the ungated read answers empty and the
* gated one answers the message. A test that only showed `readSyncedForDocument` returning
* deposits would pass just as well over a plain `read`.
*/
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { docs, inbox as inboxSurface, storeRegistry } from "../src/index";
import { getSyncState } from "../src/emulated-verifier/open-repo";
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
import type { Nuri } from "../src/model/types";
const TITLE = "urn:test:title";
const MESSAGE = "j'apporte le café";
/** The broker's own cold start — see `wallet-fake.WalletOptions`. */
const COLD = { unsyncedUntilSubscribed: true } as const;
/**
* The first visit, in the application's own vocabulary: Alice writes a note and opens it
* for messages; Bob leaves one on it. Both name the NOTE and nothing else.
*
* Returns the note, which is all an application ever holds.
*/
async function aNoteWithAMessageOnIt(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);
await signIn("bob");
await inboxSurface.postToDocument(note, { payload: { text: MESSAGE }, from: "bob", ts: 1 });
return note;
}
/**
* The inbox the note was opened on, read off the WALLET — the emulated `AddInboxCap`
* record. The test asks the wallet because no application can ask the package: there is
* deliberately no published call that hands out an address, which is the whole reason
* `readSyncedForDocument` has to exist.
*/
function inboxOnTheNote(quads: Quad[], note: Nuri): Nuri {
const record = quads.find(
(q) => q.p === "urn:ng-eventually:shim:inboxCap" && q.o.startsWith(note + " "),
);
if (!record) throw new Error("no AddInboxCap record was written for the note");
return record.o.split(" ")[1] 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;
}
/** Alice comes back on a NEW page, over the wallet the first one wrote. */
async function aliceComesBack(quads: Quad[]): Promise<Nuri> {
reloadPage(quads, COLD);
await signIn("alice");
return myNote();
}
beforeEach(() => {
forgetEverything();
});
afterAll(() => {
forgetEverything();
});
describe("reading the messages left on my note, on a page that has just loaded", () => {
test("the ungated document-addressed read answers EMPTY — the note's repo never synced", async () => {
const quads: Quad[] = [];
await aNoteWithAMessageOnIt(quads);
const note = await aliceComesBack(quads);
// Not a failure anyone can see: the message is on the broker, Alice owns the inbox,
// and the call returns a perfectly ordinary empty list.
expect(await inboxSurface.readForDocument(note)).toEqual([]);
// …because nothing ever brought the NOTE into view. The address lives on it.
expect(getSyncState(note)).toBe("unknown");
});
test("the synced document-addressed read answers the message, over that same state", async () => {
const quads: Quad[] = [];
await aNoteWithAMessageOnIt(quads);
const note = await aliceComesBack(quads);
const mine = await inboxSurface.readSyncedForDocument(note);
expect(mine.map((d) => (d.payload as { text: string }).text)).toEqual([MESSAGE]);
expect(mine.map((d) => d.from)).toEqual(["bob"]);
});
test("it crosses the sync barrier on BOTH repos the answer depends on", async () => {
const quads: Quad[] = [];
const written = await aNoteWithAMessageOnIt(quads);
const inbox = inboxOnTheNote(quads, written);
const note = await aliceComesBack(quads);
await inboxSurface.readSyncedForDocument(note);
// The guarantee itself, not the payload: past the first `State` on each, presence is
// guaranteed and absence definitive — so an empty answer would MEAN empty. The note's
// barrier is the one this call adds (nothing else on the page opens a note); the
// inbox's is `readSynced`'s, and connecting may have crossed it already.
expect(getSyncState(note)).toBe("synced");
expect(getSyncState(inbox)).toBe("synced");
});
test("a document nobody opened an inbox on answers empty, not an error", async () => {
const quads: Quad[] = [];
bootPage(quads, COLD);
await signIn("alice");
const bare = await storeRegistry.createEntityDoc("public");
reloadPage(quads, COLD);
await signIn("alice");
expect(await inboxSurface.readSyncedForDocument(bare)).toEqual([]);
});
test("it is still a read of MY inbox — the owner's guard is not bypassed", async () => {
const quads: Quad[] = [];
await aNoteWithAMessageOnIt(quads);
const note = await aliceComesBack(quads);
// Bob can find where to deposit for Alice's public note, and that is all: reading it
// would collect the caps addressed to her. A second door onto the same read must not
// be a way around the guard the first one carries.
await signIn("bob");
await expect(inboxSurface.readSyncedForDocument(note)).rejects.toThrow(
/does not belong to the connected wallet/i,
);
});
});
@@ -25,7 +25,11 @@ import {
resolveWriteGraph,
userInbox,
} from "../src/shared-wallet/account-registry";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import {
documentInboxAddress,
openDocumentInbox,
readInboxCapPairs,
} from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
@@ -611,6 +615,88 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
});
// CONCURRENCY, and it is NOT the "a failure resolved like a success" family this suite is
// otherwise full of: nothing here fails. `openDocumentInbox` reads "have I already opened
// one" and mints when the answer is no, with a dozen awaits between the two — so callers
// that ask at the same time each look, each honestly finds nothing, and each mints. The
// sequential test above passes because the first call's write has landed before the second
// one reads. Reported by a consuming application: four simultaneous calls on one document
// registered THREE inboxes in 0.3s, after which the owner drained one while deposits
// arrived in another — the same end state as a fork, reached without an error anywhere.
test("concurrent opens on ONE document converge on ONE inbox", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
// Four at once — no await between them, which is what an application does when four
// components mount together and each opens the inbox of the document it renders.
const handed = await Promise.all([
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
]);
expect(new Set(handed).size).toBe(1);
// …and the DURABLE record has to agree, which is the half that actually bites: a second
// `AddInboxCap` pair means `readInboxCapsFor` picks one of two afterwards, and the
// published address is whichever write landed last. One pair, one address, and the
// address is what every caller was handed.
const pairs = (await readInboxCapPairs()).filter((p) => p.doc === doc);
expect(pairs.map((p) => p.inbox)).toEqual([handed[0]!]);
resetRegistryCache(); // a depositor's session, not a warmed cache
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBe(handed[0]!);
await postToDocument(doc, { payload: { racing: true } });
setCurrentUser("alice");
expect((await readInbox(handed[0]!)).map((d) => d.payload)).toEqual([{ racing: true }]);
});
// The coalescing must not outlive the call that needed it, nor answer for a DIFFERENT
// document: a map keyed too coarsely (or never emptied) would pass the test above while
// handing document B the inbox opened for A.
test("concurrent opens on DIFFERENT documents get their own inbox each", async () => {
inject();
setCurrentUser("alice");
const a = await createEntityDoc("alice", "public");
const b = await createEntityDoc("alice", "public");
const [inboxA, inboxB] = await Promise.all([openDocumentInbox(a), openDocumentInbox(b)]);
expect(inboxA).not.toBe(inboxB);
expect((await readInboxCapPairs()).filter((p) => p.doc === a).map((p) => p.inbox)).toEqual([inboxA!]);
expect((await readInboxCapPairs()).filter((p) => p.doc === b).map((p) => p.inbox)).toEqual([inboxB!]);
// …and a LATER burst, once the record exists, still answers with the recorded inbox
// rather than treating "the in-flight map is empty" as "nobody opened one".
const again = await Promise.all([openDocumentInbox(a), openDocumentInbox(a)]);
expect(again).toEqual([inboxA!, inboxA!]);
});
// A refusal must not be cached as an answer, and must not leave a poisoned entry behind
// for the callers that follow: Bob asking four times at once gets four refusals, and
// Alice's own record is untouched.
test("concurrent opens by a NON-owner are all refused, and leave no residue", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
const outcomes = await Promise.allSettled([
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
]);
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected", "rejected", "rejected"]);
setCurrentUser("alice");
expect((await readInboxCapPairs()).filter((p) => p.doc === doc).map((p) => p.inbox)).toEqual([aliceInbox]);
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
@@ -512,6 +512,11 @@ test("nothing about the deferred service reaches the published surface", () => {
"read",
"readForDocument",
"readSynced",
// Added 2026-08-17. It names a DOCUMENT, like every other door an application has
// here: the two reads it composes were published separately, so reaching the synced
// one meant holding an inbox address. It processes nobody else's queue — the owner
// guard is `readSynced`'s, unchanged.
"readSyncedForDocument",
"share",
"watch",
]);
+79 -11
View File
@@ -47,6 +47,18 @@ export interface Quad {
o: string;
}
/**
* The repo ids this fake broker has ever handed out — MONOTONIC, and deliberately not a
* counter inside {@link makeWallet}.
*
* A per-wallet counter restarted at each {@link bootPage}, so a reloaded page re-issued the
* NURIs the previous one had minted: a document created after a reload came back as
* `did:ng:o:doc6` when `did:ng:o:doc6` was already somebody else's inbox, and the two
* aliased into one repo with no error anywhere. A broker never mints a repo id twice — an
* id is a public key — so neither does this.
*/
let minted = 0;
/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
@@ -110,21 +122,70 @@ export interface FakeWallet {
doc_create: ReturnType<typeof mock>;
sparql_update: ReturnType<typeof mock>;
sparql_query: ReturnType<typeof mock>;
/** Present only under {@link WalletOptions.unsyncedUntilSubscribed}. */
doc_subscribe?: ReturnType<typeof mock>;
_quads: Quad[];
}
export interface WalletOptions {
/**
* Model the broker's cold start: a repo this PAGE has not subscribed to answers an
* anchored read with **nothing**, and `doc_subscribe` is what brings its commits into
* view (pushing the first `State` — the sync barrier `ensureRepoOpen` awaits).
*
* ── Why this is the real system's state, not a convenient one ─────────────
* On a fresh session over the same persistent wallet, `Verifier::load` repopulates
* `self.repos` from user storage, so the repo is PRESENT but unsynced and the anchored
* query legitimately matches nothing — no error, no rows (the mechanism written out in
* `emulated-verifier/open-repo.ts`, corrected there on 2026-08-03). Two consequences the
* fake keeps faithfully:
*
* - a repo CREATED on this page is synced by construction (`doc_create` opens it, and
* there is no remote history to fetch), which is why the defect is invisible to the
* session that wrote the data;
* - a WRITE does not sync anything. Appending a commit to a repo whose remote commits
* have not arrived leaves them just as absent, so `sparql_update` never marks a repo
* synced — only `doc_subscribe` does.
*
* OFF by default: the two reload suites that predate this run without a `doc_subscribe`
* at all, where `ensureRepoOpen` is the documented no-op of the unit-fake path.
*/
unsyncedUntilSubscribed?: boolean;
}
/**
* A quad-store fake `ng` over `quads` — the durable half. The library holds nothing across
* a {@link reloadPage}; this does.
*
* No `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the unit-fake path
* (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly. A limit of the
* fake broker, not a library state.
* By default no `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the
* unit-fake path (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly.
* A limit of the fake broker, not a library state — and the one
* {@link WalletOptions.unsyncedUntilSubscribed} lifts, for the suites that are about the
* sync barrier itself.
*/
export function makeWallet(quads: Quad[]): FakeWallet {
let docCounter = 0;
export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWallet {
/** The repos whose commits this PAGE can see — created here, or subscribed to. */
const synced = new Set<string>();
const cold = options.unsyncedUntilSubscribed === true;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const doc_create = mock(async () => {
const nuri = `did:ng:o:doc${++minted}`;
// Created here: nothing remote to wait for. This is why the session that wrote the
// data never sees the cold-start defect, and the next one does.
synced.add(nuri);
return nuri;
});
const doc_subscribe = mock(async (...a: unknown[]) => {
const nuri = a[0] as string;
const onChange = a[2] as (r: unknown) => void;
synced.add(nuri);
// `TabInfo` first, then the initial `State` — the platform's own order, so a waiter
// that resolved on "the first push of any kind" would return BEFORE the barrier.
setTimeout(() => onChange({ V0: { TabInfo: {} } }), 0);
setTimeout(() => onChange({ V0: { State: {} } }), 0);
return () => {};
});
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
@@ -157,6 +218,11 @@ export function makeWallet(quads: Quad[]): FakeWallet {
const anchor = a[3] as string | undefined;
const wrapped = query.match(/GRAPH\s+<([^>]+)>/);
const g = wrapped ? wrapped[1]! : anchor;
// The repo the verifier resolves the read against — the anchor when there is one,
// otherwise the graph named in the query.
const target = anchor ?? g;
// COLD: present but unsynced. No error, no rows — which is exactly why it is dangerous.
if (cold && target !== undefined && !synced.has(target)) return { results: { bindings: [] } };
const inGraph = quads.filter((q) => q.g === g);
// The whole-document read (`read-model.readDoc`).
@@ -233,12 +299,14 @@ export function makeWallet(quads: Quad[]): FakeWallet {
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
return cold
? { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads }
: { doc_create, sparql_update, sparql_query, _quads: quads };
}
/** Wire the library onto `quads` — what a page load does. */
export function bootPage(quads: Quad[]): FakeWallet {
const ng = makeWallet(quads);
export function bootPage(quads: Quad[], options: WalletOptions = {}): FakeWallet {
const ng = makeWallet(quads, options);
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
return ng;
@@ -262,9 +330,9 @@ export function forgetEverything(): void {
}
/** A page RELOAD: the library forgets, the wallet does not. */
export function reloadPage(quads: Quad[]): FakeWallet {
export function reloadPage(quads: Quad[], options: WalletOptions = {}): FakeWallet {
forgetEverything();
return bootPage(quads);
return bootPage(quads, options);
}
/**