Files
ng-eventually/packages/client/test/anti-fork.test.ts
T
Sylvain Duchesne bd48b16e31 fix(client): résolution de compte DÉTERMINISTE + anti-fork restauré
Cause racine du bug de reconnexion (probe contrôlé répété) : le shim d'un compte
accumule des `docPublic`/`docProtected` EN DOUBLE (forks passés), et
resolveAccount/indexDocOf les choisissaient de façon NON-DÉTERMINISTE → l'écrivain
et le lecteur (page fraîche) ancraient sur des docs d'index DIFFÉRENTS → lecture 0.

Fix :
- `canonicalDoc()`/`recordFromRows()` : parmi plusieurs valeurs d'un scope, choisir
  le NURI lexicographiquement le plus petit (les NURIs sont content-addressed →
  ordre total stable). Écrivain et lecteur résolvent TOUJOURS le même doc, même sur
  un shim corrompu par des doublons.
- `resolveAccountReliably` RESTAURÉ (retry borné avant provision sur read shim 0 à
  froid) : j'avais retiré l'anti-fork à tort (`38b1521`) — le gap EST exhibé (fork
  non-déterministe au cold-read), et la barrière `user_connect` n'est PAS accessible
  côté JS → un retry borné est la compensation légitime (pas la barrière-store
  cassée de `45dbd9a`). Budget injecté `provisionRetry` ; défaut attempts:1 (fakes
  synchrones), app/e2e attempts:8.

anti-fork.test.ts réécrit : docPublic dupliqué → même canonique ; retry sur lag →
réutilise ; neuf → provision 1×.

gate : tsc 0 ; bun test 122 ; test:e2e 42/42 (CONTRACT 2 non-fork vert).

Portée : corrige la couche docPublic de la reconnexion. Une couche PROTECTED
distincte (watchShape protected ne converge pas à froid) reste — diagnostic en cours.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:24:58 +02:00

332 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
* polyfill-era shim (src/store-registry.ts). Two guards, one file:
*
* (1) DETERMINISTIC RESOLUTION — a shim whose account subject carries DUPLICATE
* scope-doc values (fork residue: several `shim:docPublic`) must resolve to
* the SAME canonical doc every time (lexicographically-smallest NURI), so the
* session that WROTE an entity and a fresh page that RESOLVES the doc never
* disagree. This is the root-cause fix for the reconnection bug.
*
* (2) ANTI-FORK BOUNDED RETRY — a fresh page over a persistent wallet may read
* the shim as 0 rows while the private store is still syncing. A BOUNDED retry
* (capped backoff, from the injected `provisionRetry` budget) bridges that
* window before concluding "account genuinely new" and provisioning —
* preventing new fork residue. A genuinely-new account (always 0) is still
* provisioned exactly once. The budget defaults to `attempts: 1` (no retry)
* when unset, so the synchronous unit fakes stay single-read and unchanged.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import {
ensureAccount,
resolveAccount,
loadShim,
resetRegistryCache,
} from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
} from "../src/polyfill";
import { resetOpenedRepos } from "../src/open-repo";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
// ---------------------------------------------------------------------------
// Fake ng — in-memory quad store. Two variants:
// - makeFakeNg(): NO doc_subscribe → non-reactive path (no sync lag, no retry).
// - makeLaggyFakeNg({lag}): HAS doc_subscribe (so the anti-fork retry path
// fires) and can simulate a sync lag: the account SELECT returns 0 rows for the
// first `lag` reads, then the seeded data — modelling a shim not yet synced.
// ---------------------------------------------------------------------------
interface Quad { g: string; s: string; p: string; o: string }
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;
}
function makeSparqlUpdate(quads: Quad[]) {
return mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
}
/** Build the account-SELECT bindings from the quads (grouped per subject). Each
* (subject × docPublic × docProtected × docPrivate) combination is a binding, so
* a subject with DUPLICATE scope docs yields a cross-product of bindings — exactly
* what a corrupted shim returns. */
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? { id: "", pub: [], prot: [], priv: [] };
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.pub.push(q.o);
if (q.p === "urn:ng-eventually:shim:docProtected") rec.prot.push(q.o);
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.priv.push(q.o);
bySubject.set(q.s, rec);
}
const bindings: Array<Record<string, { value: string }>> = [];
for (const rec of bySubject.values()) {
if (!rec.id) continue;
const pubs = rec.pub.length ? rec.pub : [""];
const prots = rec.prot.length ? rec.prot : [""];
const privs = rec.priv.length ? rec.priv : [""];
for (const pub of pubs)
for (const prot of prots)
for (const priv of privs)
bindings.push({
id: { value: rec.id },
docPublic: { value: pub },
docProtected: { value: prot },
docPrivate: { value: priv },
});
}
return bindings;
}
/** Non-reactive fake ng (no doc_subscribe → no sync lag, no retry). */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
let accountQueryCount = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = makeSparqlUpdate(quads);
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
if (query.includes("<urn:ng-eventually:shim:id>")) {
accountQueryCount++;
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
}
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads, getAccountQueryCount: () => accountQueryCount };
}
/** Fake ng with a SYNC-LAG simulation: the first `lag` account reads return 0 rows
* (shim not synced yet), then the seeded data appears — modelling a fresh page over
* a persistent wallet. Used with a fast `provisionRetry` budget to exercise the
* anti-fork retry. (Carries a `doc_subscribe` so it also passes as a reactive ng.) */
function makeLaggyFakeNg(opts: { lag?: number } = {}) {
const quads: Quad[] = [];
let docCounter = 0;
let accountQueryCount = 0;
let lag = opts.lag ?? 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = makeSparqlUpdate(quads);
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
if (query.includes("<urn:ng-eventually:shim:id>")) {
accountQueryCount++;
if (lag > 0) { lag--; return { results: { bindings: [] } }; } // sync not landed yet
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
}
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
});
// doc_subscribe: immediately push a State (the barrier) so ensureRepoOpen resolves.
const doc_subscribe = mock(async (..._a: unknown[]) => {
// Return an async-iterator-like the subscribe wrapper can consume; but the
// registry only needs `typeof doc_subscribe === "function"` for the reactive
// path. open-repo's subscribeDoc handles the actual shape; here it is unused
// (ensureAccount does not call ensureRepoOpen). Kept as a real fn.
return () => {};
});
return {
doc_create, sparql_update, sparql_query, doc_subscribe,
_quads: quads,
getAccountQueryCount: () => accountQueryCount,
};
}
function inject(
fakeNg: unknown,
provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number },
) {
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().toLowerCase(),
// Default (unset) → attempts:1 (no retry). The retry tests pass a fast budget.
provisionRetry,
});
resetRegistryCache();
resetOpenedRepos();
}
// ---------------------------------------------------------------------------
// (1) Deterministic resolution
// ---------------------------------------------------------------------------
describe("deterministic resolution over a shim corrupted by fork residue", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const anchor = "did:ng:PRIV-AF";
// Same account subject with 5 duplicate docPublic values (fork residue), out of
// lexicographic order, plus one docProtected/docPrivate each.
const subj = "urn:ng-eventually:shim:account:dupuser";
const dupPublics = [
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a",
];
fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
for (const p of dupPublics)
fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" });
const r1 = await resolveAccount("dupuser");
resetRegistryCache();
const r2 = await resolveAccount("dupuser");
const viaShim = (await loadShim()).get("dupuser");
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
// Stable across independent resolves...
expect(r2?.docPublic).toBe(r1?.docPublic);
// ...and loadShim (full scan) agrees with resolveAccount (targeted).
expect(viaShim?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docProtected).toBe("did:ng:o:prot-1");
expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1");
});
});
// ---------------------------------------------------------------------------
// (2) Anti-fork bounded retry
// ---------------------------------------------------------------------------
describe("ensureAccount: anti-fork bounded retry", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(2a) NO-FORK: account already in shim → reused, 0 new doc_create (non-reactive fake, single read)", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const first = await ensureAccount("LauraBarrier");
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
resetRegistryCache();
resetOpenedRepos();
const second = await ensureAccount("LauraBarrier");
expect(second.docPublic).toBe(first.docPublic);
expect(second.docProtected).toBe(first.docProtected);
expect(second.docPrivate).toBe(first.docPrivate);
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); // still 3, not 6
});
it("(2b) SYNC LAG: shim reads 0 K times then the data → RETRY reuses, no fork", async () => {
// Seed an account into an initial (non-reactive) fake, capture its NURIs.
const seed = makeFakeNg();
inject(seed);
const orig = await ensureAccount("LaggyUser");
expect(seed.doc_create).toHaveBeenCalledTimes(3);
// Fresh reactive session over the SAME quads, with a 3-read sync lag: the first
// 3 account reads return 0 rows (shim not synced), then the seeded data appears.
const reactive = makeLaggyFakeNg({ lag: 3 });
reactive._quads.push(...seed._quads);
inject(reactive, { attempts: 8, baseMs: 1, maxStepMs: 2 }); // fast budget
const resolved = await ensureAccount("LaggyUser");
// Reused the SAME account — NO fork provisioning despite the initial 0 rows.
expect(resolved.docPublic).toBe(orig.docPublic);
expect(resolved.docProtected).toBe(orig.docProtected);
expect(resolved.docPrivate).toBe(orig.docPrivate);
expect(reactive.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
// It DID retry: more than one account read (1 initial + retries until data).
expect(reactive.getAccountQueryCount()).toBeGreaterThan(1);
});
it("(2c) GENUINELY NEW: shim always reads 0 → provisioned exactly once after the budget", async () => {
const reactive = makeLaggyFakeNg({ lag: 999 }); // never resolves → genuinely new
inject(reactive, { attempts: 5, baseMs: 1, maxStepMs: 2 });
const rec = await ensureAccount("BrandNewUser");
// Provisioned exactly ONE set of scope docs.
expect(reactive.doc_create).toHaveBeenCalledTimes(3);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic);
// Retry was BOUNDED: exactly `attempts` account reads, then it gave up and provisioned.
expect(reactive.getAccountQueryCount()).toBe(5);
});
it("(2d) default budget (unset → attempts:1): genuinely-new account → single read, no retry", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg); // provisionRetry unset → attempts:1 (synchronous default)
const rec = await ensureAccount("SyncUser");
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
// Exactly ONE account read — the default budget does not retry.
expect(fakeNg.getAccountQueryCount()).toBe(1);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
});
it("(2e) idempotence within a session: ensureAccount twice never creates 2 sets", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const a = await ensureAccount("SameUser");
const b = await ensureAccount("SameUser");
expect(b).toEqual(a);
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
});
});