Files
ng-eventually/packages/polyfill/test/wallet-fake.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

349 lines
14 KiB
TypeScript

/**
* wallet-fake — a durable fake broker, and the page RELOAD that runs over it.
*
* Not a `*.test.ts`, so `bun test` does not pick it up: it is the montage two suites share
* ({@link reloadOwnDocument} / the inbox drain), and both of them are about what survives a
* reload — which is exactly the thing a per-file fake cannot express, because the wallet
* has to outlive the library while the library keeps nothing.
*
* ── What makes it a wallet and not a stub ─────────────────────────────────
* The quads live in the fake, never in the library. {@link reloadPage} drops every piece of
* the library's module state and hands back a session that has to find its way home through
* the store-root pointer, the doc-shim and the account record — the way a fresh page does.
* Nothing is planted: a second session sees exactly what the first one WROTE.
*
* The SPARQL it answers is a tokenizer plus five shapes, rather than one regex per query
* the author happened to think of: the reload path issues reads and writes from six
* modules, and a fake that answers only the shapes someone enumerated is how a suite goes
* green over a state the library never reaches.
*/
import { mock } from "bun:test";
import { configure } from "../src/index";
import {
configureStoreRegistry,
setCurrentUser,
resetCaps,
resetConfig,
resetStoreRegistry,
} from "../src/shared-wallet/bootstrap";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { connectedUser } from "../src/emulated-verifier/connect";
import type { NgLike, UseShapeLike } from "../src/model/types";
export const SESSION: RegistrySession = { sessionId: "sid-wallet", privateStoreId: "PRIV-WALLET" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
export interface Quad {
g: string;
s: string;
p: string;
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 = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
interface Token {
kind: "term" | "sep";
value: string;
}
/** Tokenize a triple body into IRIs, literals, `a`, `;` and `.`. */
function tokenize(body: string): Token[] {
const re = /<([^>]*)>|"((?:[^"\\]|\\.)*)"|(;)|(\.)|\ba\b/g;
const out: Token[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
if (m[1] !== undefined) out.push({ kind: "term", value: m[1] });
else if (m[2] !== undefined) out.push({ kind: "term", value: unescapeLiteral(m[2]) });
else if (m[3] !== undefined) out.push({ kind: "sep", value: ";" });
else if (m[4] !== undefined) out.push({ kind: "sep", value: "." });
else out.push({ kind: "term", value: RDF_TYPE });
}
return out;
}
/** `<s> p o ; p o . <s2> p o` → the triples it carries. */
function parseTriples(body: string): Array<{ s: string; p: string; o: string }> {
const toks = tokenize(body);
const out: Array<{ s: string; p: string; o: string }> = [];
let subject: string | null = null;
let i = 0;
while (i < toks.length) {
const t = toks[i]!;
if (t.kind === "sep") {
if (t.value === ".") subject = null;
i += 1;
continue;
}
if (subject === null) {
subject = t.value;
i += 1;
continue;
}
const p = toks[i];
const o = toks[i + 1];
if (!p || !o || p.kind === "sep" || o.kind === "sep") break;
out.push({ s: subject, p: p.value, o: o.value });
i += 2;
}
return out;
}
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.
*
* 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[], 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 () => {
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;
const anchor = a[2] as string | undefined;
const del = query.match(/DELETE\s+WHERE\s*\{([\s\S]*)\}/i);
if (del) {
const pattern = del[1]!.match(/<([^>]+)>\s+<([^>]+)>\s+\?/);
if (pattern && anchor !== undefined) {
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === pattern[1] && q.p === pattern[2]) quads.splice(i, 1);
}
}
return undefined;
}
const wrapped = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const g = wrapped ? wrapped[1]! : anchor;
if (g === undefined) return undefined;
const body = wrapped
? wrapped[2]!
: query.replace(/^[\s\S]*?INSERT\s+DATA\s*\{/i, "").replace(/\}\s*$/, "");
for (const t of parseTriples(body)) quads.push({ g, ...t });
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
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`).
if (/SELECT\s+\?s\s+\?p\s+\?o/.test(query)) {
return {
results: {
bindings: inGraph.map((q) => ({
s: { value: q.s },
p: { value: q.p },
o: { value: q.o },
})),
},
};
}
// The account record — several predicates on one subject.
if (query.includes(`<${SHIM}:docPublic>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<[^>]*:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of inGraph) {
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id !== undefined)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
// An inbox's deposits — several predicates on one subject, `from` optional.
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of inGraph) {
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
}
// Everything else the library reads is one bound subject, one bound predicate, one
// variable: the pointer, the store index, the Store/User/Header branches, the inbox
// index and its owner.
const one = query.match(/<([^>]+)>\s+<([^>]+)>\s+\?(\w+)/);
if (one) {
const bindings = inGraph
.filter((q) => q.s === one[1] && q.p === one[2])
.map((q) => ({ [one[3]!]: { value: q.o } }));
return { results: { bindings } };
}
return { results: { bindings: [] } };
});
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[], 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;
}
/**
* Drop everything the library holds in module scope — what a page reload does.
*
* Each call drops one module's state, and together they are all of it: the config and the
* captured session, the registry caches (accounts, the resolved doc-shim, the inbox index),
* the opened repos, the outer-overlay memo, the caps, and who is connected.
*/
export function forgetEverything(): void {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetPublicStoreFetches();
resetCaps();
}
/** A page RELOAD: the library forgets, the wallet does not. */
export function reloadPage(quads: Quad[], options: WalletOptions = {}): FakeWallet {
forgetEverything();
return bootPage(quads, options);
}
/**
* Sign in and let the connection work finish — what `ensureIdentity()` awaits, reached by
* the harness's internal path rather than through the `barrier` (there is no DOM here).
*
* It REJECTS exactly where `ensureIdentity()` would, which is the point: a suite about a
* sign-in that fails cannot use a sign-in that cannot fail.
*/
export async function signIn(id: string): Promise<void> {
setCurrentUser(id);
await connectedUser();
}