fix(client): résolution de compte barrière-autoritative — fin du fork à la reconnexion
Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root (did🆖${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows" à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau compte provisionné → FORK → données du compte invisibles. Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et "abonnable" (did:ng:o:<RepoID aléatoire>) sont DISJOINTS — pas de doc à la fois devinable et attendable → une résolution shim purement barrière est impossible. Fix (indirection pointeur → doc-shim abonnable): - Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière). - Un pointeur écrit-une-fois dans le store-root (<shim:root> <shim:shimDoc> <docShim>) le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ. - Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au doc-shim canonique (lexicographiquement-min), sans perte. - Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op. Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion), red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection), simulation, migration-guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,12 +112,18 @@ function makeFakeNg() {
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||||
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 } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
// Account SELECT, scoped to the anchor graph. Two shapes: the full scan
|
||||
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
||||
// <Account>`) which binds one subject — honour that subject so the bounded
|
||||
// query returns exactly that account (or nothing).
|
||||
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
// Account SELECT, anchored to the doc-shim's default graph (records live in the
|
||||
// doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
|
||||
// and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
|
||||
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) {
|
||||
@@ -158,8 +164,6 @@ function inject() {
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
resetRegistryCache();
|
||||
return ng;
|
||||
@@ -170,22 +174,31 @@ beforeEach(() => {
|
||||
fake = inject();
|
||||
});
|
||||
|
||||
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => {
|
||||
test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
|
||||
const rec = await ensureAccount("Alice");
|
||||
expect(rec.id).toBe("Alice");
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
||||
// 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
||||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
||||
// Persisted into the shim anchor graph (did:ng:PRIV).
|
||||
expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV");
|
||||
// The pointer (store-root -> doc-shim) was written into the store-root graph.
|
||||
const pointerWrite = fake.sparql_update.mock.calls.find(
|
||||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
|
||||
);
|
||||
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
|
||||
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
|
||||
const recordWrite = fake.sparql_update.mock.calls.find(
|
||||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
|
||||
);
|
||||
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
||||
const a = await ensureAccount("Alice");
|
||||
const b = await ensureAccount("@alice");
|
||||
expect(b).toEqual(a);
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
|
||||
});
|
||||
|
||||
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
|
||||
@@ -202,8 +215,9 @@ test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 d
|
||||
ensureAccount("BOB"),
|
||||
ensureAccount("bob"),
|
||||
]);
|
||||
// Exactly ONE set of 3 docs was created — not 5×3.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
||||
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
|
||||
// resolution too) — 4 total, not 5×3.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
// Every caller got the SAME record (same docs), so writer/reader can never
|
||||
// disagree on the canonical scope doc.
|
||||
for (const r of results) expect(r).toEqual(results[0]!);
|
||||
@@ -236,8 +250,6 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
||||
protectedStoreId: "PROT",
|
||||
publicStoreId: "PUB",
|
||||
}),
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
provisionRetry: { attempts: 1 },
|
||||
});
|
||||
resetRegistryCache();
|
||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||
@@ -384,10 +396,10 @@ test("normalizeId defaults to trim when not provided", async () => {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetRegistryCache();
|
||||
const a = await ensureAccount(" Ivy ");
|
||||
const b = await ensureAccount("Ivy"); // trimmed key matches
|
||||
expect(b).toEqual(a);
|
||||
expect(ng.doc_create).toHaveBeenCalledTimes(3);
|
||||
expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user