3547967d37
- Migration des comptes legacy supprimée (migrateLegacyRecords + garde migratedInto + call-sites). Un wallet pré-fix (records store-root, pas de pointeur) provisionne simplement un doc-shim frais; contenu legacy ignoré (voulu, données = dev). La résolution barrière-autoritative + anti-fork (resolvePointer/ensureRepoOpen/ canonicalDoc/ensureInFlight/pointerGuard) est inchangée. - Logs d'accès SDK préfixés [polyfill] + NURI tronqué via shortNuri() (retire did:ng:o: et :v:…, garde 8 chars) → moins verbeux. Ex: [polyfill] [user1] READ vDlwbZio… (resolvePointer) → 1 triple-rows Tests: bun test unit 126/0. Docs (nextgraph-current-state/simulation/migration-guide) mis à jour (migration legacy retirée du modèle décrit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
/**
|
|
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
|
|
* polyfill-era shim (src/store-registry.ts), redesigned around the
|
|
* pointer → doc-shim indirection. Two groups, one file:
|
|
*
|
|
* (1) DETERMINISTIC RESOLUTION — a doc-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. Robustness against PAST fork residue.
|
|
*
|
|
* (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a
|
|
* persistent wallet resolves the SAME account through the doc-shim's
|
|
* first-`State` BARRIER, with NO account-level retry. The account records
|
|
* live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once
|
|
* POINTER triple in the store-root; opening the doc-shim makes a cold read
|
|
* authoritative, so a genuinely-present account is found on the first read
|
|
* and never re-provisioned (no fork). This replaced the deleted
|
|
* `resolveAccountReliably` / `provisionRetry` account retry.
|
|
*/
|
|
|
|
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" };
|
|
const ROOT = "did:ng:PRIV-AF";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake ng — in-memory quad store modelling the pointer → doc-shim indirection.
|
|
//
|
|
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
|
|
// graph (keyed by GRAPH <ROOT>).
|
|
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
|
|
// anchor arg = the doc-shim NURI).
|
|
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
|
|
// (the barrier).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 in ONE graph (the doc-shim),
|
|
* grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
|
|
* of bindings — a corrupted shim. */
|
|
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;
|
|
}
|
|
|
|
/** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe`
|
|
* pushes a first `State` so the doc-shim barrier resolves synchronously. */
|
|
function makeFakeNg() {
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
|
|
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
|
|
// one doc-shim account read" (no account RETRY).
|
|
const accountReadsByAnchor = new Map<string, number>();
|
|
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;
|
|
// Pointer SELECT (store-root).
|
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
// Account SELECT — anchored to the doc-shim (the authoritative read).
|
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
|
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
|
|
const subjM = query.match(/<([^>]+)>\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 } };
|
|
});
|
|
// Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once.
|
|
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
|
|
if (typeof cb === "function") cb({ V0: { State: {} } });
|
|
return () => {};
|
|
});
|
|
return {
|
|
doc_create, sparql_update, sparql_query, doc_subscribe,
|
|
_quads: quads,
|
|
// Total account reads across all anchors.
|
|
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
|
|
// Account reads anchored to a did:ng:o: doc-shim.
|
|
getDocShimAccountReads: () =>
|
|
[...accountReadsByAnchor.entries()]
|
|
.filter(([g]) => g.startsWith("did:ng:o:"))
|
|
.reduce((a, [, n]) => a + n, 0),
|
|
};
|
|
}
|
|
|
|
function inject(
|
|
fakeNg: unknown,
|
|
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number },
|
|
) {
|
|
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => SESSION,
|
|
normalizeId: (u) => u.trim().toLowerCase(),
|
|
pointerGuard,
|
|
});
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (1) Deterministic resolution over fork residue (in the doc-shim)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("deterministic resolution over a doc-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 docShim = "did:ng:o:shimdoc";
|
|
// Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
|
|
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
|
|
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: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
|
|
for (const p of dupPublics)
|
|
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
|
|
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
|
|
fakeNg._quads.push({ g: docShim, 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");
|
|
expect(r2?.docPublic).toBe(r1?.docPublic);
|
|
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) Barrier-authoritative reconnect — the core fix (no account retry)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => {
|
|
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
|
|
|
|
it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => {
|
|
const fakeNg = makeFakeNg();
|
|
inject(fakeNg);
|
|
// First login: provisions the account (1 doc-shim + 3 scope docs).
|
|
const first = await ensureAccount("LauraBarrier");
|
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
|
|
|
// Fresh page over the SAME persistent quads: reset all in-memory caches, keep the
|
|
// quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs.
|
|
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);
|
|
// Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created.
|
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => {
|
|
// Seed session 1; capture the account NURIs + the persistent quads.
|
|
const seed = makeFakeNg();
|
|
inject(seed);
|
|
const orig = await ensureAccount("BarrierUser");
|
|
expect(seed.doc_create).toHaveBeenCalledTimes(4);
|
|
|
|
// Fresh reactive session over the SAME persistent quads. The doc-shim pushes a
|
|
// `State` on subscribe → resolveShimDoc opens the barrier → the account read is
|
|
// authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove
|
|
// it is NOT used for the account.
|
|
const reconnect = makeFakeNg();
|
|
reconnect._quads.push(...seed._quads);
|
|
inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 });
|
|
|
|
const resolved = await ensureAccount("BarrierUser");
|
|
|
|
expect(resolved.docPublic).toBe(orig.docPublic);
|
|
expect(resolved.docProtected).toBe(orig.docProtected);
|
|
expect(resolved.docPrivate).toBe(orig.docPrivate);
|
|
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
|
|
// Barrier-authoritative: found on the FIRST doc-shim read.
|
|
expect(reconnect.getDocShimAccountReads()).toBe(1);
|
|
});
|
|
|
|
it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => {
|
|
// Pointer + doc-shim exist but the doc-shim holds NO record for this account.
|
|
const fakeNg = makeFakeNg();
|
|
inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 });
|
|
const docShim = "did:ng:o:preexisting-shim";
|
|
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
|
|
|
|
const rec = await ensureAccount("BrandNewUser");
|
|
|
|
// Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed).
|
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
|
expect(rec.docProtected).not.toBe(rec.docPublic);
|
|
// Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive).
|
|
expect(fakeNg.getDocShimAccountReads()).toBe(1);
|
|
});
|
|
|
|
it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => {
|
|
const fakeNg = makeFakeNg();
|
|
inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default)
|
|
|
|
const rec = await ensureAccount("SyncUser");
|
|
|
|
// 1 doc-shim + 3 scope docs.
|
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
|
expect(fakeNg.getDocShimAccountReads()).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(4); // 1 doc-shim + 3 scope docs
|
|
});
|
|
});
|
|
|