WIP: open/subscribe repos before anchored cold-start reads (branche, non mergée)

Défaut visé : sur une session verifier fraîche (reconnexion), la lecture ancrée
tape des repos pas encore ouverts dans self.repos → 0 ligne. Nouveau module
`open-repo.ts` : `ensureReposOpen`/`ensureRepoOpen` ouvrent/souscrivent un repo
via la primitive existante `subscribeDoc` (doc_subscribe) et attendent le push
d'état initial (borné, sans polling) AVANT la lecture ancrée. Câblé en amont de
`readUnion` (read-model) et `readScopeIndex` (store-registry). Idempotent par
session (Set des NURI ouverts + Map in-flight ; ré-ouverture si la session
injectée change). No-op si le `ng` injecté n'a pas doc_subscribe (fake unitaire).

État — NON MERGÉ, incomplet :
- AIDE le PROTECTED : la participation remonte au cold-start (mesuré app, timing
  un peu bruité).
- N'ADRESSE PAS l'accueil PUBLIC : `readScopeIndex` de l'index public rend 0 même
  côté écrivain même-session — le fix ouvre le repo mais l'index reste vide. Le
  code d'index lib est prouvé scope-symétrique, donc la cause du public est
  ailleurs (broker/store ou chemin app), À MESURER SOUS BROKER (actuellement
  injoignable). Nécessité du fix elle-même non prouvée en e2e lib (broker down).

gate broker-indépendant : tsc --noEmit propre ; bun test 91 pass (worker).
Inclut le scaffolding e2e de repro reconnexion (broker/run/sdk-entry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-08 10:46:20 +02:00
parent d8c36bac3b
commit 1825d4d72f
6 changed files with 402 additions and 6 deletions
+100 -1
View File
@@ -70,7 +70,11 @@ configureStoreRegistry({
// The registry (+ subscribe/inbox/discovery/read-model) reach the session
// through this. It resolves once the broker connects.
getSession: async () => {
const s = await sessionReady;
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start
// for the reconnection cold-start test) swaps `session` in place, and the SDK must
// route reads/opens through the NEW session_id. Fall back to the first-connect
// promise until the initial session lands.
const s = session ?? (await sessionReady);
return {
sessionId: s.session_id,
privateStoreId: s.private_store_id,
@@ -107,6 +111,23 @@ const identity = new IdentityStore(
}
: null,
/**
* Export the CURRENT wallet as a `.ngw` file, base64-encoded so it can cross the
* frame.evaluate bridge back to Node. Used by the reconnection cold-start test to
* re-import the SAME wallet into a CLEAN browser profile (empty local repo storage)
* — the only faithful way to force the broker-only cold-start (a fresh profile has
* no local IndexedDB copy of the repos to eagerly rehydrate).
*/
async exportWalletFile() {
const ws = await (realNg as any).get_wallets();
const walletName = Object.keys(ws ?? {})[0];
const file = await (realNg as any).wallet_get_file(walletName);
const bytes = file instanceof Uint8Array ? file : new Uint8Array(file);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
return { walletName, b64: btoa(bin), len: bytes.length };
},
// ── docs primitives ──────────────────────────────────────────────────────
async docCreate() {
const s = await sessionReady;
@@ -443,6 +464,84 @@ const identity = new IdentityStore(
leaksB: listA.includes(dB1),
};
},
/**
* RECONNECTION cold-start seed (phase 1, run in the FIRST session).
*
* Create a per-entity document under (id, scope) — which also appends its NURI to
* the account's scope-index document — and write a marker triple INTO the entity
* doc (anchored default graph, the canonical shape). Poll until listMyEntityDocs
* sees it, so the index append has actually landed on the broker before we tear
* the session down. Returns everything the FRESH session needs to re-find it
* purely from the persistent wallet: only `id` + `scope` are load-bearing (the
* fresh session re-resolves the account from the shim); entityNuri/marker are the
* expected values to assert against.
*/
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
storeRegistry.resetRegistryCache();
const s = await sessionReady;
const entityNuri = await storeRegistry.createEntityDoc(id, scope);
const marker = "recon-" + Date.now();
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:recon:subject> <urn:e2e:recon:marker> "${marker}" }`,
entityNuri,
);
// Wait until the index append + the triple are visible in THIS session (the
// session that created them — where the repos are already open), so we know the
// data is persisted before the fresh session tries to read it back.
let listed: string[] = [];
for (let i = 0; i < 15; i++) {
storeRegistry.resetRegistryCache();
listed = await storeRegistry.listMyEntityDocs(id, scope);
if (listed.includes(entityNuri)) break;
await new Promise((r) => setTimeout(r, 1000));
}
return { id, scope, entityNuri, marker, listedInSeed: listed };
},
/**
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
* account's entity docs of `scope` (listMyEntityDocs → readScopeIndex) and readUnion
* them, purely from the persistent wallet — nothing from phase 1's session state
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
* the broker-login bootstrap already opens the user's repos, so rawRowCount is
* non-zero even without the heal — this is a reconnection REGRESSION guard, not a
* fail-without-the-fix proof (see run.ts's reconnection step comment).
*/
async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) {
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open at all, first
// thing in the fresh session — reports how many rows the bare anchored query
// resolves for a not-yet-opened repo (the premise: 0 until opened). Uses the
// low-level docs primitive directly, bypassing readUnion's open step.
const s = session ?? (await sessionReady);
let rawRowCount = -1;
try {
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri);
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
} catch (e: any) {
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
}
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, scope);
const subjects = await readModel.readUnion(listed.length ? listed : [entityNuri]);
const markers: string[] = [];
for (const subj of subjects) {
for (const vals of Object.values(subj.props)) {
for (const v of vals) markers.push(v);
}
}
return {
rawRowCount,
listed,
listedCount: listed.length,
foundEntity: listed.includes(entityNuri),
subjectCount: subjects.length,
markerPresent: markers.includes(marker),
markers,
};
},
async scopeResolvers() {
const priv = await storeRegistry.resolveScopeGraph("private");
const prot = await storeRegistry.resolveScopeGraph("protected");