diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index f43a2b3..552e7c2 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -134,12 +134,19 @@ async function anchorNuri(): Promise { // --- cache ---------------------------------------------------------------- -// In-memory cache of the shim, keyed by normalized username. +// In-memory cache of the FULL shim (all accounts), keyed by account key. Set +// only once loadShim() has read every account — used by the all-accounts paths. let cache: Map | null = null; +// Per-account cache, keyed by account key. Populated by the TARGETED resolver +// (resolveAccount) and by loadShim(). Independent of `cache` so a single +// targeted resolve never forces a full shim scan. Both are cleared together. +const accountCache = new Map(); + /** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ export function resetRegistryCache(): void { cache = null; + accountCache.clear(); } // --- SPARQL result helpers ------------------------------------------------ @@ -182,12 +189,16 @@ export async function loadShim(): Promise> { for (const row of readBindings(result)) { const username = bindingValue(row, "username"); if (!username) continue; - map.set(accountKey(username), { + const key = accountKey(username); + const record: AccountRecord = { username, docPublic: bindingValue(row, "docPublic"), docProtected: bindingValue(row, "docProtected"), docPrivate: bindingValue(row, "docPrivate"), - }); + }; + map.set(key, record); + // Feed the per-account cache too, so a subsequent targeted resolve is free. + accountCache.set(key, record); } } catch (error) { console.error("[storeRegistry] loadShim failed:", error); @@ -196,6 +207,55 @@ export async function loadShim(): Promise> { return map; } +/** + * Resolve ONE account by its shim key with a BOUNDED query — O(1), independent + * of the number of accounts in the shim. This is the HOT-PATH lookup: it hits + * the account record at its known subject (`accountSubject(username)`) directly, + * instead of scanning EVERY account like {@link loadShim}. Returns the account's + * record or `null` if it does not exist yet. + * + * Cached per account (in `accountCache`); a hit skips the query entirely, so + * repeated resolves of the same account are free. `resetRegistryCache` clears it. + */ +export async function resolveAccount(username: string): Promise { + const key = accountKey(username); + const cached = accountCache.get(key); + if (cached) return cached; + + const s = await session(); + const anchor = await anchorNuri(); + // `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a + // trusted-shaped NURI → assertNuri. The query is bounded to this one subject. + const subj = accountSubject(username); + const query = ` + SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { + GRAPH <${assertNuri(anchor)}> { + <${subj}> a <${P.type}> ; + <${P.username}> ?username ; + <${P.docPublic}> ?docPublic ; + <${P.docProtected}> ?docProtected ; + <${P.docPrivate}> ?docPrivate . + } + }`; + try { + const result = await sparqlQuery(s.sessionId, query, undefined, anchor); + const rows = readBindings(result); + if (rows.length === 0) return null; + const row = rows[0]!; + const record: AccountRecord = { + username: bindingValue(row, "username") || username, + docPublic: bindingValue(row, "docPublic"), + docProtected: bindingValue(row, "docProtected"), + docPrivate: bindingValue(row, "docPrivate"), + }; + accountCache.set(key, record); + return record; + } catch (error) { + console.error("[storeRegistry] resolveAccount failed:", error); + return null; + } +} + /** All known accounts (from the shim). */ export async function allAccounts(): Promise { return [...(await loadShim()).values()]; @@ -214,9 +274,10 @@ async function createDoc(): Promise { * first sight. Idempotent — returns the existing record if already present. */ export async function ensureAccount(username: string): Promise { - const map = await loadShim(); const key = accountKey(username); - const existing = map.get(key); + // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead + // of a full-shim scan (loadShim). Off the read/write hot path entirely. + const existing = await resolveAccount(username); if (existing) return existing; const [docPublic, docProtected, docPrivate] = await Promise.all([ @@ -248,7 +309,10 @@ export async function ensureAccount(username: string): Promise { } catch (error) { console.error("[storeRegistry] ensureAccount persist failed:", error); } - map.set(key, record); + // Feed the per-account cache, and the full-shim cache if it is already loaded + // (so allAccounts / the fan-out see the freshly-created account too). + accountCache.set(key, record); + cache?.set(key, record); return record; } diff --git a/packages/client/test/discovery.test.ts b/packages/client/test/discovery.test.ts index 79c0131..5c2a6a6 100644 --- a/packages/client/test/discovery.test.ts +++ b/packages/client/test/discovery.test.ts @@ -86,11 +86,16 @@ function makeFakeNg() { const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; - // Shim account SELECT. + // Shim account SELECT. Two shapes: the full scan (`?acc a `) and + // the TARGETED bounded resolve (` a `), which binds one + // subject — honour that subject filter so the bounded query is O(1)/exact. if (query.includes(`<${SHIM}:username>`)) { + const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`)); + const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; + if (onlySubject !== null && q.s !== onlySubject) continue; const rec = bySubject.get(q.s) ?? {}; if (q.p === `${SHIM}:username`) rec.username = q.o; if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o; diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts index 11ab647..27907a3 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -97,10 +97,16 @@ function makeFakeNg() { const query = a[1] as string; const anchor = a[3] as string | undefined; if (query.includes("")) { - // Account SELECT, scoped to the anchor graph. + // Account SELECT, scoped to the anchor graph. Two shapes: the full scan + // (`?acc a `) and the TARGETED bounded resolve (` a + // `) which binds one subject — honour that subject so the bounded + // query returns exactly that account (or nothing). + const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+/); + const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; + if (onlySubject !== null && q.s !== onlySubject) continue; const rec = bySubject.get(q.s) ?? {}; if (q.p === "urn:ng-eventually:shim:username") rec.username = q.o; if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;