From 1825d4d72fb6eec5da78198058c16564516c1743 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Wed, 8 Jul 2026 10:46:20 +0200 Subject: [PATCH] =?UTF-8?q?WIP:=20open/subscribe=20repos=20before=20anchor?= =?UTF-8?q?ed=20cold-start=20reads=20(branche,=20non=20merg=C3=A9e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/client/e2e/broker.ts | 41 +++++++ packages/client/e2e/run.ts | 79 +++++++++++++ packages/client/e2e/sdk-entry.ts | 101 +++++++++++++++- packages/client/src/open-repo.ts | 158 ++++++++++++++++++++++++++ packages/client/src/read-model.ts | 20 +++- packages/client/src/store-registry.ts | 9 ++ 6 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/open-repo.ts diff --git a/packages/client/e2e/broker.ts b/packages/client/e2e/broker.ts index 6bba772..8e8f72c 100644 --- a/packages/client/e2e/broker.ts +++ b/packages/client/e2e/broker.ts @@ -13,6 +13,7 @@ import { chromium, type BrowserContext, type Page, type Frame } from "playwright import { execSync } from "node:child_process"; import * as http from "node:http"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; @@ -139,6 +140,46 @@ export async function launchWalletContext(): Promise { }); } +/** + * Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir). + * Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: + * the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so + * a session over it starts with an empty `self.repos`. Caller must import the wallet + * (see {@link importWalletViaFile}) before opening the SDK page. Returns the context + * and the dir so the caller can clean it up. + */ +export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-")); + const ctx = await chromium.launchPersistentContext(dir, { + headless: true, + executablePath: resolveChromePath(), + args: LAUNCH_ARGS, + }); + return { ctx, dir }; +} + +/** + * Import a `.ngw` wallet FILE into the current (clean) profile via the standalone + * nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After + * this the profile holds the wallet — but NOT the repos' local cache — so the next + * SDK session over it hits the broker-only cold-start. Adapted from the Festipod + * app's `importWalletViaFile` (the proven real-broker wallet-file import). + */ +export async function importWalletViaFile(page: Page, ngwPath: string): Promise { + await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" }); + // Let the SPA render + attach the file input (uploading too early → EncryptionError). + await page.waitForTimeout(3000); + await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 }); + await page.setInputFiles('input[type=file]', ngwPath); + const passwordInput = page.locator('input[type=password]').first(); + await passwordInput.waitFor({ state: "visible", timeout: 15000 }); + await passwordInput.fill(WALLET_PASSWORD); + await passwordInput.press("Enter"); + const confirm = page.getByRole("button", { name: /Confirm/i }); + if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {}); + await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker +} + /** * Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the * broker iframe; unlock the dedicated wallet if a login is shown; return the app diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts index 370ccc8..9a0bd86 100644 --- a/packages/client/e2e/run.ts +++ b/packages/client/e2e/run.ts @@ -12,12 +12,17 @@ * (waitForFunction on a counter), never a blind sleep. */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import type { Frame, Page, BrowserContext } from "playwright"; import { buildBundle, serveHarness, ensureWallet, launchWalletContext, + launchCleanProfileContext, + importWalletViaFile, setupBrokerPage, } from "./broker"; @@ -280,6 +285,80 @@ async function main(): Promise { const cleared = await sdk(frame, "identityClear"); check("IdentityStore set→get→clear", setr === "@ident-user" && got === "@ident-user" && cleared === null, `set=${setr} get=${got} cleared=${cleared}`); }); + + // ── reconnection cold-start (real-broker regression) ───────────────────── + // Phase 1 (THIS session): seed — create a per-entity doc under (id, protected) + // and write a marker triple (`protected` carries participations). Also EXPORT the + // wallet `.ngw` so phase 2 can re-import the SAME wallet into a CLEAN browser + // profile. + // + // Phase 2 (the faithful reconnect): import the wallet into a BRAND-NEW empty + // profile dir (no local IndexedDB copy of the repos) and open a fresh SDK session + // over it — the repos exist on the BROKER but NOT in this profile's local cache, + // so this is a true reconnect (not a same-profile relaunch, which masks the gap by + // eagerly rehydrating repos from local storage). Then re-read purely from the + // wallet and assert the marker comes back. + // + // FINDING (recorded, see the digest): on THIS SDK/broker version the marker also + // comes back WITHOUT the open-before-read heal — the broker-login bootstrap opens + // the user's repos before the read (the `rawAnchoredNoOpen` detail below shows the + // bare anchored query already resolves rows). So this test is a real-broker + // REGRESSION guard for reconnection reads, NOT a fail-without-the-fix proof; the + // cold-start the fix targets was diagnosed in the app and does not reproduce + // through this harness's login path. + console.log("\n── reconnection cold-start (real-broker regression) ──"); + await step("fresh session (clean profile, same wallet) re-reads a persisted entity doc", async () => { + const reconId = "@recon-" + Date.now(); + const scope = "protected"; + const seed = await sdk(frame, "reconnectSeed", reconId, scope); + check( + "seed: entity doc created + listed in the seeding session", + seed.listedInSeed.includes(seed.entityNuri), + `entity=${String(seed.entityNuri).slice(0, 24)}… listedInSeed=${seed.listedInSeed.length} origSession=${info?.session_id}`, + ); + + // Export the wallet file and materialize it for the clean-profile import. + const exp = await sdk(frame, "exportWalletFile"); + const ngwPath = path.join(os.tmpdir(), `ng-eventually-recon-${Date.now()}.ngw`); + fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64")); + + let cleanCtx: BrowserContext | null = null; + let cleanDir: string | null = null; + let cleanPage: Page | null = null; + try { + const launched = await launchCleanProfileContext(); + cleanCtx = launched.ctx; + cleanDir = launched.dir; + cleanPage = await cleanCtx.newPage(); + cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message)); + cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); }); + + // Import the SAME wallet into the empty profile (broker-only repos), then open + // the SDK page in a fresh broker session over it. + await importWalletViaFile(cleanPage, ngwPath); + const cleanFrame = await setupBrokerPage(cleanPage, url); + await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 }); + await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 }); + const cleanInfo = await sdkGet(cleanFrame, "sessionInfo"); + check( + "clean-profile session connected (fresh verifier, broker-only repos)", + cleanInfo?.session_id !== undefined && cleanInfo?.session_id !== null, + `session=${cleanInfo?.session_id}`, + ); + + const r = await sdk(cleanFrame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker); + check( + "fresh clean-profile session re-reads the persisted marker (reconnection regression)", + r.markerPresent === true, + `rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`, + ); + } finally { + try { if (cleanPage) await cleanPage.close(); } catch { /* ignore */ } + try { if (cleanCtx) await cleanCtx.close(); } catch { /* ignore */ } + try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ } + } + }); } finally { try { if (page) await page.close(); } catch { /* ignore */ } try { if (ctx) await ctx.close(); } catch { /* ignore */ } diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index f679a12..f91c6e6 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -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 { "${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"); diff --git a/packages/client/src/open-repo.ts b/packages/client/src/open-repo.ts new file mode 100644 index 0000000..74722c9 --- /dev/null +++ b/packages/client/src/open-repo.ts @@ -0,0 +1,158 @@ +/** + * open-repo — cold-start repo opening for the ANCHORED read path (polyfill-era). + * + * ── The cold-start defect this heals ────────────────────────────────────── + * The anchored read path (`read-model.ts` `readDoc`, `store-registry.ts` + * `readScopeIndex`) assumes the target repo is already in the verifier's + * `self.repos` — true within the session that CREATED the doc (every `doc_create` + * opens it), but FALSE on a FRESH session over the same persistent wallet + * (reconnection / new page / re-login). On that fresh session nothing has opened + * the user's scope-index or entity repos yet, so an anchored `sparql_query` + * resolves a repo absent from `self.repos` and silently returns 0 rows (never a + * `RepoNotFound`) — persisted documents read as empty. + * + * The circularity that made this self-inflicted: `doc_subscribe` WOULD open the + * repo, but the reactive layer only subscribes AFTER the listing produced NURIs — + * and the listing (`readScopeIndex`) is itself an anchored read of a not-yet-open + * index repo → 0 rows → nothing to subscribe → nothing ever opens. Verified fix + * (adversarial pass): on a fresh session, `doc_subscribe()` THEN the + * anchored re-read returns the data. So we OPEN the repo before the anchored read. + * + * ── How we open ─────────────────────────────────────────────────────────── + * We reuse the existing per-document primitive {@link subscribeDoc} (the typed + * wrapper over the platform's `doc_subscribe`) — NOT a parallel channel. The + * subscribe pushes the repo's initial `State` once it is opened/loaded; we await + * that first push (bounded) and treat it as "the repo is now in the session". The + * subscription is kept ALIVE for the whole session (that is what keeps the repo + * open) — it is a bootstrap open, distinct from any reactive subscription a caller + * later establishes for change signals. + * + * ── Idempotence / perf (once per session, no polling) ───────────────────── + * The registry opens each repo at most ONCE per session: `opened` records completed + * opens (a hit skips everything), `inFlight` de-dupes concurrent opens of the same + * repo. A brand-new page / module instance starts with an empty registry; and when + * the injected session id CHANGES within the same page (an in-page re-login / + * `session_stop`+`session_start`, whose new verifier has an empty `self.repos`) the + * registry auto-resets (`syncSession`) so repos are re-opened against the new session + * rather than wrongly skipped as "already open". No polling: we wait on the + * initial-state push, with a bounded fallback timeout so a missing push can't hang. + * + * ── Migration ───────────────────────────────────────────────────────────── + * At the real multi-store migration this becomes "open the user's store repo by + * cap" (a native broker fetch) done once at bootstrap; the anchored read then + * resolves a same-session repo directly. Polyfill-era, removed with the shim. + */ + +import { getConfig, getStoreRegistryDeps } from "./polyfill"; +import { subscribeDoc, type Unsubscribe } from "./subscribe"; +import type { Nuri } from "./types"; + +/** Repos whose bootstrap open has completed (initial state pushed or timed out). */ +const opened = new Set(); +/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */ +const inFlight = new Map>(); +/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */ +const held = new Map(); +/** The session id the current `opened`/`held` entries belong to. A change means a + * new verifier session (fresh `self.repos`) → the registry must be invalidated. */ +let boundSessionId: string | number | null = null; + +/** + * Max wait (ms) for the initial-state push before proceeding with the read anyway. + * The push normally lands quickly once the repo loads; the timeout only guards the + * pathological case (a doc that never pushes), so a read is never blocked forever — + * it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc). + */ +const OPEN_TIMEOUT_MS = 8000; + +/** Reset the open registry (mainly for tests / a switched wallet). Tears down the + * held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */ +export function resetOpenedRepos(): void { + for (const unsub of held.values()) { + try { + unsub(); + } catch { + /* ignore */ + } + } + opened.clear(); + inFlight.clear(); + held.clear(); + boundSessionId = null; +} + +/** + * Invalidate the registry if the active session id changed since it was populated. + * A new session id means a new verifier with an EMPTY `self.repos`, so entries from + * the previous session must NOT suppress re-opening under the new one. Tolerant: if + * the session can't be resolved, keep the current registry (best effort). + */ +async function syncSession(): Promise { + let sid: string | number | null = null; + try { + sid = (await getStoreRegistryDeps().getSession()).sessionId; + } catch { + return; // no session deps wired (unit fake path) — nothing to invalidate against + } + if (boundSessionId !== null && boundSessionId !== sid) resetOpenedRepos(); + boundSessionId = sid; +} + +/** + * Ensure `nuri`'s repo is OPEN in the current session before an anchored read, + * so the cold-start (fresh session, same persistent wallet) resolves it instead + * of returning 0 rows. Opens via {@link subscribeDoc} and awaits the initial state + * push (bounded). Idempotent: a repo already opened (or in flight) is not re-opened. + * + * Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g. + * the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged. + * Never throws; a failed open just leaves the read to behave as it did before. + */ +export async function ensureRepoOpen(nuri: Nuri): Promise { + if (!nuri) return; + // Drop the registry if the session changed (in-page re-login → fresh verifier). + await syncSession(); + if (opened.has(nuri)) return; + const pending = inFlight.get(nuri); + if (pending) return pending; + + // No reactive primitive on the injected ng (fake-ng unit suite): nothing to open. + const ng = getConfig().ng as { doc_subscribe?: unknown }; + if (typeof ng.doc_subscribe !== "function") { + opened.add(nuri); + return; + } + + const p = (async () => { + await new Promise((resolve) => { + let settled = false; + const done = (): void => { + if (settled) return; + settled = true; + resolve(); + }; + // The bootstrap subscription is kept ALIVE for the session — holding it open + // is the whole point; we resolve on the FIRST push (the initial State), which + // means the repo is now loaded in the session. + const unsub = subscribeDoc(nuri, () => done()); + held.set(nuri, unsub); + // Bounded fallback: proceed even if no push arrives (a genuinely-absent doc + // reads 0 rows anyway — same as before — never a hang). NO polling. + setTimeout(done, OPEN_TIMEOUT_MS); + }); + opened.add(nuri); + inFlight.delete(nuri); + })(); + inFlight.set(nuri, p); + return p; +} + +/** + * Open a SET of repos before an anchored batch read, in parallel, each tolerant + * ({@link ensureRepoOpen} never throws). Empty / falsy entries are ignored. + */ +export async function ensureReposOpen(nuris: Nuri[]): Promise { + const unique = [...new Set(nuris.filter(Boolean))]; + if (unique.length === 0) return; + await Promise.all(unique.map((n) => ensureRepoOpen(n))); +} diff --git a/packages/client/src/read-model.ts b/packages/client/src/read-model.ts index cf83af1..db7449e 100644 --- a/packages/client/src/read-model.ts +++ b/packages/client/src/read-model.ts @@ -43,6 +43,7 @@ import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill"; +import { ensureReposOpen } from "./open-repo"; import { assertNuri } from "./sparql"; import type { Nuri } from "./types"; @@ -88,11 +89,13 @@ async function sessionId(): Promise { * This is O(1) in the doc's own size and independent of the rest of the (possibly * bloated / shared) session store — it never iterates other graphs. * - * All same-session repos (every doc `doc_create`d this session — in the mono-wallet - * polyfill, all of them) are already in `self.repos`, so the anchored query resolves - * the repo directly with no separate open. A genuinely-absent repo throws - * `RepoNotFound` here, in isolation, and the doc is skipped — never aborting the - * others (unlike the ORM fan-out). Returns the doc's rows, or `[]` on failure. + * COLD-START (fresh session, same persistent wallet): the repo is NOT in + * `self.repos` until something opens it, and an anchored query against an unopened + * repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore + * opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the + * anchored query resolves a same-session repo directly. A genuinely-absent repo + * still yields `[]` (in isolation, never aborting the others). Returns the doc's + * rows, or `[]` on failure. * * At the real multi-store migration this becomes a real sync: opening a per-user * store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO). @@ -137,6 +140,13 @@ export async function readUnion(docs: Nuri[]): Promise { const unique = [...new Set(docs.filter(Boolean))]; if (unique.length === 0) return []; + // COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the + // target repos are not yet in `self.repos`, so an anchored read would return 0 + // rows. Open/subscribe each repo ONCE (idempotent, per session) and await its + // initial-state push before the anchored reads. No-op once opened / when the + // injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts. + await ensureReposOpen(unique); + // One anchored query per doc, in parallel, tolerant (a bad doc yields []). const perDoc = await Promise.all( unique.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })), diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 10b1b0e..b4c27ab 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -34,6 +34,7 @@ import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { getStoreRegistryDeps } from "./polyfill"; +import { ensureRepoOpen } from "./open-repo"; import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import type { Nuri, Scope } from "./types"; @@ -444,6 +445,14 @@ export async function createEntityDoc(id: string, scope: Scope): Promise { async function readScopeIndex(indexDoc: Nuri): Promise { const s = await session(); const out: Nuri[] = []; + // COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the + // scope-index repo (public OR protected — the protected one carries participations + // and is the one that most often reads empty) is not yet in `self.repos`, so this + // anchored read would return 0 NURIs → nothing gets listed → nothing gets + // subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE + // before reading it. Idempotent per session; no-op with the unit fake ng. See + // open-repo.ts. + await ensureRepoOpen(indexDoc); try { const res = await sparqlQuery( s.sessionId,