refactor: renommer client → sdk, et fusionner les deux portes en une
Deux mouvements de surface, aucun changement de comportement. **`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.** « client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans `docs/source-layout-by-fate.md` et le tableau des paquets du README. **Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs — `configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types — vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`. Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de `docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par `test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de vocabulaire sur les noms publiés. **Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le choix visible plutôt qu'hérité : - `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par `shared-wallet/bootstrap` ; - `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes par leur chemin interne, ce qui est leur raison d'être ; - le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux fois brouillait la frontière qu'elle servait à marquer. Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` / `hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait `capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire. 179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 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,
|
||||
resetRegistryCache,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { configure, configureStoreRegistry } from "../src/index";
|
||||
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/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");
|
||||
resetRegistryCache();
|
||||
const viaShim = await resolveAccount("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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user