Files
ng-eventually/packages/polyfill/test/open-repo.test.ts
T
Sylvain Duchesne 4af0be433d fix!: joindre un document n-est pas en avoir des nouvelles, et une reinit invalide ce qu-elle abandonne
Deux defauts, aucun des deux sur la reprise - elle a passe cette revue intacte et
n'a pas ete rouverte.

1. La barriere verifiait qu'un document nomme peut etre JOINT, jamais qu'il a
   REPONDU. Elle attendait `ensureRepoOpen` puis demandait `assertMayReach`, qui
   est la question de la capacite, et ne consultait pas l'etat de synchro que
   l'ouverture venait de produire. Un document dont l'abonnement a reussi mais dont
   le premier `State` n'est jamais venu publiait donc `isSuccess` avec `data: []` :
   le defaut d'origine, dans le cas precis pour lequel la barriere a ete ajoutee.

   La verification rejoint le reste de la decision, dans `publish`, donc rejouee a
   chaque publication au lieu d'etre memorisee. Les trois valeurs, enumerees avant
   d'ecrire la condition : `"synced"` fait autorite - zero ligne VEUT DIRE zero
   objet, la ligne tenue depuis cinq revues -, `"timed-out"` et `"syncing"` non, et
   `"unknown"` PASSE deliberement, parce qu'il veut dire « pas de semantique de
   `State` sur ce `ng` » et que le traiter autrement est exactement la regression
   du cas inerte livree aux tours 1 et 3.

   `isPending` plutot que `isError`, des deux que le contrat autorise : rien n'a
   echoue, il manque l'autorite de dire que le document est vide.

   Le test (t) EPINGLAIT le defaut - il attendait `isSuccess` apres trois
   barrieres expirees. Corrige. Ce n'est pas un test affaibli : l'ancienne
   assertion etait fausse.

   Et un trou dans ma propre suite, trouve par mutation : inverser la condition
   pour refuser `"unknown"` passait tous les tests, faute d'un `ng` SANS
   `doc_subscribe` nulle part. (cc) le couvre.

2. Regression de mon correctif du tour 4. Terminer une ouverture abandonnee
   faisait tourner sa continuation juste apres que la reinit avait vide les
   tables, donc elle ecrivait une entree `opened` perimee dans le registre neuf -
   et toute ouverture ulterieure de ce nuri court-circuitait dessus. Le depot
   n'etait jamais abonne dans la nouvelle session. Une epoque de registre corrige
   cela : une reinit l'incremente, une continuation dont l'epoque ne correspond
   plus n'ecrit rien. Une reinit INVALIDE ce qu'elle abandonne, en plus de le
   terminer.

`getSyncState` reste interne, et la doctrine cesse de l'offrir : elle pointait sur
un symbole qu'aucun appelant ne peut importer.
2026-09-24 11:03:07 +02:00

254 lines
11 KiB
TypeScript

/**
* open-repo.test.ts — behavioral tests for ensureRepoOpen (src/open-repo.ts).
*
* Core invariant: on a fresh session over a persistent wallet, a scope-index
* or entity repo is NOT yet in `self.repos`, so an anchored sparql_query returns
* 0 rows. `ensureRepoOpen(nuri)` calls `doc_subscribe(nuri, …)` FIRST (which
* pushes the repo into the session), then the anchored read returns data.
*
* Fake design:
* - sparql_query returns EMPTY for a nuri UNTIL doc_subscribe has been called
* for that nuri (tracked in a Set).
* - doc_subscribe is a mock that records calls, fires the callback once
* (simulating the initial State push), then returns an unsubscribe fn.
*
* We test ensureRepoOpen via `readDocumentSubjects` (from `surface/read-document`)
* because that is the production caller — it opens the document before its anchored
* read, and `emulated-verifier/index-deposit.ts` depends on that ordering to tell an
* absence from an unsynced repo. It was `readUnion` until `1.0.0-dev.8`; the integration
* path is the same one, minus the batch.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, getSyncState, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { readDocumentSubjects } from "../src/surface/read-document";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
// The reach guard and the cap registry are process-wide: once ANY cap exists the
// boundary applies to every reader. A suite that declares none must start from an
// empty one, or it inherits another suite's enforcement.
beforeEach(() => {
resetOpenedRepos();
resetRegistryCache();
resetCaps();
setCurrentUser(null);
});
const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" };
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
// ---------------------------------------------------------------------------
// Fake ng builder: tracks which nuris have been doc_subscribe-d.
// sparql_query returns rows only AFTER the corresponding nuri is subscribed.
// ---------------------------------------------------------------------------
function makeFakeNgWithSubscribe(
triplesByDoc: Record<string, Array<[string, string]>>,
options: { pushState?: boolean } = {},
) {
// A broker that accepts the subscription and never pushes is the state the bounded
// fallback exists for, and the only way to hold an open genuinely IN FLIGHT.
const pushState = options.pushState !== false;
const subscribed = new Set<string>();
const subscribeCallOrder: string[] = [];
// doc_subscribe: record the call, fire callback immediately (initial push), return unsub
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subscribed.add(nuri);
subscribeCallOrder.push(nuri);
// Simulate initial State push (synchronously deferred so the subscription
// setup promise path in ensureRepoOpen can resolve it).
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {}; // unsubscribe fn
});
const sparql_query = mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
const doc = anchor as string | undefined;
if (!doc) return { results: { bindings: [] } };
// Only return data if the repo has been subscribed (i.e. opened)
if (!subscribed.has(doc)) return { results: { bindings: [] } };
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 } };
});
const doc_create = mock(async () => "did:ng:o:new");
const sparql_update = mock(async () => undefined);
return { doc_subscribe, sparql_query, doc_create, sparql_update, subscribed, subscribeCallOrder };
}
function inject(ng: ReturnType<typeof makeFakeNgWithSubscribe>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ensureRepoOpen", () => {
it("calls doc_subscribe BEFORE the anchored read returns data", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Directly call ensureRepoOpen then verify read sees data
await ensureRepoOpen("did:ng:o:a");
// doc_subscribe was called for the nuri
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(ng.subscribeCallOrder[0]).toBe("did:ng:o:a");
// sparql_query was called AFTER subscribe (ensureRepoOpen guarantees ordering)
const result = await readDocumentSubjects("did:ng:o:a");
expect(result.length).toBe(1);
expect(result[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
});
it("WITHOUT doc_subscribe, sparql_query returns 0 rows (verifies fake mechanics)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Do NOT call ensureRepoOpen — subscribed Set remains empty
// Query directly (bypass `readDocumentSubjects`, which opens the repo internally)
const result = await ng.sparql_query("sid-or", "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, "did:ng:o:a");
const bindings = (result as any).results.bindings;
expect(bindings.length).toBe(0); // not subscribed → 0 rows (confirms fake design)
});
it("idempotence: a 2nd ensureRepoOpen for the same nuri does NOT re-subscribe", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:b": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureRepoOpen("did:ng:o:b");
await ensureRepoOpen("did:ng:o:b"); // second call
// doc_subscribe must have been called exactly ONCE
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
it("no-op when the fake ng has no doc_subscribe (unit fake path)", async () => {
// Fake ng WITHOUT doc_subscribe
const noSubscribeNg = {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: noSubscribeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
// Must not throw; nuri is added to opened Set (guard skips subscribe)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
// Calling again should also be a no-op (idempotent, already in opened)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
});
// An open ENDS, whatever happens to the registry underneath it.
//
// A reset - which is what a session change does, `syncSession` - tears down the
// subscription an in-flight open is waiting on. Until 2026-09-21 it then left that open
// hanging: its `State` could no longer arrive, so the caller waited out the whole
// 8-second fallback and only then proceeded, with its sync state CLEARED. Cleared reads
// as `"unknown"`, and `unknown` is the LAX answer - `emulated-verifier/index-deposit.ts`
// accepts it as "a zero-row read means the document is empty" - so a read taken right
// after a session change could have an absence read into it. Both halves are the point:
// it must END, and it must end as `"timed-out"`.
it("a reset ENDS the opens it abandons, and marks them timed-out rather than unknown", async () => {
const ng = makeFakeNgWithSubscribe({}, { pushState: false });
inject(ng);
const inFlight = ensureRepoOpen("did:ng:o:abandoned");
await new Promise((r) => setTimeout(r, 5)); // let the subscribe land
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
resetOpenedRepos();
// Ends at once. Without the fix it resolves only when the 8s fallback fires, which is
// longer than any test's patience and longer than any page's.
await expect(
Promise.race([
inFlight.then(() => "ended"),
new Promise((r) => setTimeout(() => r("still waiting"), 200)),
]),
).resolves.toBe("ended");
// ...and NOT as a barrier that was reached.
expect(getSyncState("did:ng:o:abandoned")).toBe("timed-out");
});
// A reset must INVALIDATE the opens it abandons, not merely end them.
//
// The continuation of an in-flight open records the repo as opened. Settling that open
// from the reset (2026-09-21) made the continuation run right after the maps were
// cleared, so it wrote a stale `opened` entry into the FRESH registry - and every later
// `ensureRepoOpen` for that nuri then short-circuited on it. The repo was never
// subscribed in the new session, and a read over it answered as though it had been.
// Before that change the same line ran only when the 8s fallback fired, so a re-open
// inside that window did re-subscribe; ending the open promptly is right, but it moved
// the write into the window where it does damage.
it("a reset INVALIDATES the opens it abandons, so the new session subscribes again", async () => {
const ng = makeFakeNgWithSubscribe({}, { pushState: false });
inject(ng);
const abandoned = ensureRepoOpen("did:ng:o:reopened");
await new Promise((r) => setTimeout(r, 5));
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
resetOpenedRepos();
await abandoned; // its continuation runs here, on the cleared registry
await new Promise((r) => setTimeout(r, 5));
// The new session has subscribed NOTHING yet, so this must open the repo for real.
// NOT awaited: this broker never pushes a `State`, so the open runs to its bounded
// fallback - and what is under test is that the subscribe HAPPENS, not how it ends.
const reopened = ensureRepoOpen("did:ng:o:reopened");
await new Promise((r) => setTimeout(r, 5));
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
// Released so the fallback's timer does not outlive the test with a live handle.
resetOpenedRepos();
await reopened;
});
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
});
inject(ng);
const subjects = await readDocumentSubjects("did:ng:o:p");
// doc_subscribe was called as part of the `ensureRepoOpen` inside the read
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(subjects.length).toBe(1);
expect(subjects[0]!.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
});
});