fix(client): durcir le cold-start du shim — ensureRepoOpen(anchor) avant lecture/écriture

Défensif : loadShim/resolveAccount/ensureAccount ouvrent l'anchor (private-store-root)
avant de lire/écrire le compte shim, comme le fait déjà readScopeIndex. Robustesse
same-session si l'anchor est là-mais-pas-encore-souscrit.

NB (vérifié nextgraph-rs) : sur le login broker normal, le bootstrap charge le
private-store dans self.repos AVANT de rendre la session à JS → un wallet FRAIS
retourne 0 rows (pas RepoNotFound) et provisionne. Il n'existe AUCUN primitif JS
pour ouvrir un repo *inconnu* : ce heal n'est pas un remède à un store non
bootstrappé (limite NextGraph), juste une robustesse d'ouverture same-session.

Tests : cold-start-anchor.test.ts (rouge-avant/vert-après unit) ; harness e2e
repro-fresh-wallet (mint un wallet neuf par run — comble le trou "aucun test de
démarrage à froid sur wallet vierge"). Fakes anti-fork/watch-shape honorent
désormais la barrière first-State dont dépend le heal. e2e réel 42/42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-13 15:06:30 +02:00
parent 078d675bbf
commit 3046ead08f
7 changed files with 473 additions and 6 deletions
+8 -6
View File
@@ -178,12 +178,14 @@ function makeLaggyFakeNg(opts: { lag?: number } = {}) {
.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.
// doc_subscribe: immediately push a State (the sync barrier) so `ensureRepoOpen`
// resolves at once. `resolveAccount`/`ensureAccount` now open the anchor repo
// (open-repo.ts) before reading/writing the shim (the cold-start heal); the real
// broker pushes `TabInfo` then a first `State` on subscribe, and open-repo awaits
// that `State`. Model it: invoke the callback with a `State` AppResponse so the
// barrier is reached synchronously (no 8s fallback → the retry tests stay fast).
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
if (typeof cb === "function") cb({ V0: { State: {} } });
return () => {};
});
return {
@@ -0,0 +1,184 @@
/**
* cold-start-anchor.test.ts — the shim ANCHOR (private-store-root) must be OPENED
* before the registry reads/writes it, or a cold anchor throws `RepoNotFound`.
*
* ── The gap this pins ──────────────────────────────────────────────────────
* The shim lives in the private-store-root graph (`did:ng:${privateStoreId}`, the
* "anchor"). Unlike a per-entity doc — whose anchored read on an unopened repo
* SILENTLY returns 0 rows — the private/store target resolves through the verifier's
* `resolve_target_for_sparql`, which HARD-errors `RepoNotFound` when the repo is not
* in `self.repos` (verified in nextgraph-rs `request_processor.rs`). On a wallet whose
* anchor repo is not yet loaded, both the shim READ (`resolveAccount`/`loadShim`) and
* the provision WRITE (`ensureAccount`) throw — so the account never provisions.
*
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
* shim — the same open-before-read guard `readScopeIndex` already applies to its
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
*
* RED without the heal (ensureAccount would throw on the cold anchor); GREEN with it.
*/
import { describe, it, expect, mock, afterAll, beforeEach } from "bun:test";
import { ensureAccount, resolveWriteGraph, resetRegistryCache } from "../src/store-registry";
import { resetOpenedRepos } from "../src/open-repo";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
} from "../src/polyfill";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
beforeEach(() => {
resetRegistryCache();
resetOpenedRepos();
});
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;
}
/**
* A fake `ng` whose ANCHOR repo behaves like the real private-store target:
* `sparql_query`/`sparql_update` anchored to it THROW `RepoNotFound` until the
* anchor has been `doc_subscribe`-d (i.e. opened into `self.repos`). Any OTHER
* anchor (per-entity docs) behaves normally. `doc_subscribe` fires the first
* `State` so `ensureRepoOpen` crosses the barrier.
*/
function makeColdAnchorNg() {
const quads: Quad[] = [];
let docCounter = 0;
const opened = new Set<string>();
let anchorSubscribes = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
if (nuri === ANCHOR) anchorSubscribes += 1;
opened.add(nuri);
setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {};
});
const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
// Parse the shim INSERT (GRAPH <anchor> { <subj> a <Account> ; <p> "o" … }).
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
if (!gm) return undefined;
const g = gm[1]!;
const body = gm[2]!;
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
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;
});
const sparql_query = mock(async (_sid: string, query: string, _base: unknown, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
const subjM = query.match(
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
});
return {
doc_create, doc_subscribe, sparql_update, sparql_query,
_quads: quads,
anchorSubscribeCount: () => anchorSubscribes,
};
}
function inject(ng: ReturnType<typeof makeColdAnchorNg>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
}
describe("cold-start anchor heal", () => {
it("ensureAccount provisions over a COLD anchor (RepoNotFound-until-opened) without throwing", async () => {
const ng = makeColdAnchorNg();
inject(ng);
// Without the open-before-shim heal, the read AND the provision write would both
// throw RepoNotFound on the cold anchor and the account would never persist.
const rec = await ensureAccount("@cold-alice");
expect(rec.docPublic).toBeTruthy();
expect(rec.docProtected).toBeTruthy();
expect(rec.docPrivate).toBeTruthy();
// The anchor repo was actually opened (doc_subscribe-d) before the shim op.
expect(ng.anchorSubscribeCount()).toBeGreaterThan(0);
});
it("the provisioned account re-resolves from the shim (real persistence, no RepoNotFound)", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const first = await ensureAccount("@cold-bob");
// Fresh cache → a real anchored re-read of the shim (anchor already opened → OK).
resetRegistryCache();
const again = await ensureAccount("@cold-bob");
expect(again.docPublic).toBe(first.docPublic);
expect(again.docProtected).toBe(first.docProtected);
expect(again.docPrivate).toBe(first.docPrivate);
});
it("resolveWriteGraph (scope resolver) works over a cold anchor", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const g = await resolveWriteGraph("@cold-carol", "protected");
expect(g).toBeTruthy();
});
});
+7
View File
@@ -68,6 +68,13 @@ function makeFake(opts?: { holdState?: boolean }) {
const hold = opts?.holdState ?? false;
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
const released = new Set<string>();
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
// (the store repo is bootstrapped at connect), so its barrier `State` is always
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
// before touching the shim — pre-release it here so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
// real invariant the production heal relies on.
released.add(`did:ng:${SESSION.privateStoreId}`);
let releaseEverything = false;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);