ffa1f94206
Cause racine mesurée (broker réel, access-log) : à la première resolveAccount d'une session, le record de compte tout juste persisté (ou d'une session antérieure) peut lire 0 lignes à cause du LAG DE SYNC broker. ensureAccount interprétait ce 0 comme « compte inexistant » et RE-PROVISIONNAIT un second jeu de docs de scope (docPublic/docProtected forkés) → les lectures d'une session tombaient sur un jeu, celles d'une autre (ou après drop de cache) sur l'autre jeu vide → données « perdues » à la reconnexion. Fix : `resolveAccountReliably` (store-registry.ts) — retry borné (ouverture du repo d'ancre shim + backoff plafonné, défaut 8 tentatives / ≲8.5s) AVANT que ensureAccount ne décide qu'un compte est neuf. Provisionne seulement si, après le budget, la lecture rend toujours 0 (compte réellement neuf). Budget injecté via StoreRegistryDeps.provisionRetry (polyfill.ts), ON en prod ; tests unitaires à provisionRetry synchrone (attempts:1, fake sans lag). Idempotence de session préservée par accountCache (hit court-circuite, déterministe). Portée : corrige le déterminisme de provisioning. NE suffit PAS à réparer la reconnexion (le read public à 0 same-session subsiste, cause distincte encore à mesurer de façon déterministe — le lag broker rend les mesures non-reproductibles). Complémentaire du commit open-repo précédent, pas redondant. gate : tsc --noEmit propre ; bun test 91 pass ; auth @data (vide/distinctes) verts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
110 lines
4.5 KiB
TypeScript
110 lines
4.5 KiB
TypeScript
import { test, expect, mock } from "bun:test";
|
|
import { readUnion } from "../src/read-model";
|
|
import { configure, configureStoreRegistry } from "../src/polyfill";
|
|
|
|
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
|
|
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
|
|
// NO anchorless union scan: each doc is read independently by its own anchor. Each
|
|
// entity subject IRI IS its own document NURI (writeEntity convention), so the
|
|
// fixture keys triples by the doc NURI and returns them for the matching anchor.
|
|
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|
return {
|
|
doc_create: mock(async () => "did:ng:o:new"),
|
|
sparql_update: mock(async () => undefined),
|
|
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
|
|
// Every read is ANCHORED to one doc NURI — never anchorless.
|
|
if (anchor === undefined) {
|
|
throw new Error("read-model must NEVER run an anchorless (union) query");
|
|
}
|
|
const doc = anchor as string;
|
|
const triples = triplesByDoc[doc];
|
|
if (!triples) return { results: { bindings: [] } };
|
|
const bindings = triples.map(([p, o]) => ({
|
|
s: { value: doc },
|
|
p: { value: p },
|
|
o: { value: o },
|
|
}));
|
|
return { results: { bindings } };
|
|
}),
|
|
};
|
|
}
|
|
|
|
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|
const ng = fakeNgWith(triplesByDoc);
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
|
normalizeId: (u: string) => u,
|
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
provisionRetry: { attempts: 1 },
|
|
});
|
|
return ng;
|
|
}
|
|
|
|
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
const FP = "http://festipod.org/";
|
|
|
|
test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => {
|
|
const ng = inject({
|
|
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
|
|
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
|
|
});
|
|
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
|
|
|
|
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
|
|
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
|
|
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
|
|
expect(anchored.length).toBe(2);
|
|
// The anchors are exactly the requested doc NURIs.
|
|
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
|
|
new Set(["did:ng:o:a", "did:ng:o:b"]),
|
|
);
|
|
|
|
expect(subjects.length).toBe(2);
|
|
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
|
|
expect(a.props[`${FP}title`]).toEqual(["A"]);
|
|
expect(a.graph).toBe("did:ng:o:a");
|
|
});
|
|
|
|
test("readUnion groups predicates per subject", async () => {
|
|
inject({
|
|
"did:ng:o:p": [
|
|
[TYPE, `${FP}Participation`],
|
|
[`${FP}event`, "did:ng:o:e"],
|
|
[`${FP}user`, "urn:festipod:user:x"],
|
|
],
|
|
});
|
|
const s = (await readUnion(["did:ng:o:p"]))[0]!;
|
|
expect(s.subject).toBe("did:ng:o:p");
|
|
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
|
|
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
|
|
});
|
|
|
|
test("readUnion returns [] for an empty doc set (no query)", async () => {
|
|
const ng = inject({});
|
|
const subjects = await readUnion([]);
|
|
expect(subjects).toEqual([]);
|
|
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
test("a doc that fails to read is skipped, not aborting the batch", async () => {
|
|
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
|
|
const orig = ng.sparql_query;
|
|
// Make the anchored read throw for the bad doc only.
|
|
ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => {
|
|
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
|
|
return orig(sid, query, base, anchor);
|
|
}) as any;
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
|
normalizeId: (u: string) => u,
|
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
provisionRetry: { attempts: 1 },
|
|
});
|
|
|
|
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
|
// The bad doc failed its read but the good one still lists.
|
|
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
|
|
});
|