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:
@@ -13,6 +13,7 @@ import { chromium, type BrowserContext, type Page, type Frame } from "playwright
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import * as http from "node:http";
|
import * as http from "node:http";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -139,6 +140,46 @@ export async function launchWalletContext(): Promise<BrowserContext> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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
|
* 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
|
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
|
||||||
|
|||||||
@@ -12,12 +12,17 @@
|
|||||||
* (waitForFunction on a counter), never a blind sleep.
|
* (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 type { Frame, Page, BrowserContext } from "playwright";
|
||||||
import {
|
import {
|
||||||
buildBundle,
|
buildBundle,
|
||||||
serveHarness,
|
serveHarness,
|
||||||
ensureWallet,
|
ensureWallet,
|
||||||
launchWalletContext,
|
launchWalletContext,
|
||||||
|
launchCleanProfileContext,
|
||||||
|
importWalletViaFile,
|
||||||
setupBrokerPage,
|
setupBrokerPage,
|
||||||
} from "./broker";
|
} from "./broker";
|
||||||
|
|
||||||
@@ -280,6 +285,80 @@ async function main(): Promise<void> {
|
|||||||
const cleared = await sdk<string | null>(frame, "identityClear");
|
const cleared = await sdk<string | null>(frame, "identityClear");
|
||||||
check("IdentityStore set→get→clear", setr === "@ident-user" && got === "@ident-user" && cleared === null, `set=${setr} get=${got} cleared=${cleared}`);
|
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<any>(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<any>(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<any>(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<any>(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 {
|
} finally {
|
||||||
try { if (page) await page.close(); } catch { /* ignore */ }
|
try { if (page) await page.close(); } catch { /* ignore */ }
|
||||||
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ configureStoreRegistry({
|
|||||||
// The registry (+ subscribe/inbox/discovery/read-model) reach the session
|
// The registry (+ subscribe/inbox/discovery/read-model) reach the session
|
||||||
// through this. It resolves once the broker connects.
|
// through this. It resolves once the broker connects.
|
||||||
getSession: async () => {
|
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 {
|
return {
|
||||||
sessionId: s.session_id,
|
sessionId: s.session_id,
|
||||||
privateStoreId: s.private_store_id,
|
privateStoreId: s.private_store_id,
|
||||||
@@ -107,6 +111,23 @@ const identity = new IdentityStore(
|
|||||||
}
|
}
|
||||||
: null,
|
: 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 ──────────────────────────────────────────────────────
|
// ── docs primitives ──────────────────────────────────────────────────────
|
||||||
async docCreate() {
|
async docCreate() {
|
||||||
const s = await sessionReady;
|
const s = await sessionReady;
|
||||||
@@ -443,6 +464,84 @@ const identity = new IdentityStore(
|
|||||||
leaksB: listA.includes(dB1),
|
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() {
|
async scopeResolvers() {
|
||||||
const priv = await storeRegistry.resolveScopeGraph("private");
|
const priv = await storeRegistry.resolveScopeGraph("private");
|
||||||
const prot = await storeRegistry.resolveScopeGraph("protected");
|
const prot = await storeRegistry.resolveScopeGraph("protected");
|
||||||
|
|||||||
@@ -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(<docNuri>)` 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<Nuri>();
|
||||||
|
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
|
||||||
|
const inFlight = new Map<Nuri, Promise<void>>();
|
||||||
|
/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */
|
||||||
|
const held = new Map<Nuri, Unsubscribe>();
|
||||||
|
/** 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
const unique = [...new Set(nuris.filter(Boolean))];
|
||||||
|
if (unique.length === 0) return;
|
||||||
|
await Promise.all(unique.map((n) => ensureRepoOpen(n)));
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
|
|
||||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||||
|
import { ensureReposOpen } from "./open-repo";
|
||||||
import { assertNuri } from "./sparql";
|
import { assertNuri } from "./sparql";
|
||||||
import type { Nuri } from "./types";
|
import type { Nuri } from "./types";
|
||||||
|
|
||||||
@@ -88,11 +89,13 @@ async function sessionId(): Promise<string> {
|
|||||||
* This is O(1) in the doc's own size and independent of the rest of the (possibly
|
* 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.
|
* bloated / shared) session store — it never iterates other graphs.
|
||||||
*
|
*
|
||||||
* All same-session repos (every doc `doc_create`d this session — in the mono-wallet
|
* COLD-START (fresh session, same persistent wallet): the repo is NOT in
|
||||||
* polyfill, all of them) are already in `self.repos`, so the anchored query resolves
|
* `self.repos` until something opens it, and an anchored query against an unopened
|
||||||
* the repo directly with no separate open. A genuinely-absent repo throws
|
* repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore
|
||||||
* `RepoNotFound` here, in isolation, and the doc is skipped — never aborting the
|
* opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the
|
||||||
* others (unlike the ORM fan-out). Returns the doc's rows, or `[]` on failure.
|
* 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
|
* 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).
|
* 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<UnionSubject[]> {
|
|||||||
const unique = [...new Set(docs.filter(Boolean))];
|
const unique = [...new Set(docs.filter(Boolean))];
|
||||||
if (unique.length === 0) return [];
|
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 []).
|
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
|
||||||
const perDoc = await Promise.all(
|
const perDoc = await Promise.all(
|
||||||
unique.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
|
unique.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||||
import { getStoreRegistryDeps } from "./polyfill";
|
import { getStoreRegistryDeps } from "./polyfill";
|
||||||
|
import { ensureRepoOpen } from "./open-repo";
|
||||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||||
import type { Nuri, Scope } from "./types";
|
import type { Nuri, Scope } from "./types";
|
||||||
|
|
||||||
@@ -444,6 +445,14 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
|||||||
async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
||||||
const s = await session();
|
const s = await session();
|
||||||
const out: Nuri[] = [];
|
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 {
|
try {
|
||||||
const res = await sparqlQuery(
|
const res = await sparqlQuery(
|
||||||
s.sessionId,
|
s.sessionId,
|
||||||
|
|||||||
Reference in New Issue
Block a user