import { type CacheEnvelope, ENVELOPE_VERSION, type KVDriver } from './driver' import { createIdbDriver } from './idb-driver' import { createLocalStorageDriver, createNoopDriver } from './ls-driver' /** * The local-first cache facade: a synchronous memory tier (L1) over a * persistent tier (L2, IndexedDB with automatic localStorage fallback). * * Contract with the UI: * - `peekCache` never blocks — it is what lets a mounting component paint * stored data in the very first render; * - `readCache` hydrates L1 from disk exactly once per key; * - `writeCache` updates L1 synchronously and persists fire-and-forget, so a * broken backend degrades into "session-only cache", never into an error; * - every value belongs to the logged-in account: `setCacheOwner` wipes the * whole store when a different user signs in, so cached data can never leak * across accounts on a shared machine. */ const OWNER_KEY = '__owner' /** L1: written on every cache write, hydrated on every disk read. */ const memory = new Map() let driverPromise: Promise | null = null async function selectDriver(): Promise { if (typeof indexedDB !== 'undefined') { try { const idb = await createIdbDriver() // A real roundtrip: Safari private mode happily opens a database whose // transactions then all fail, and only an actual write exposes that. await idb.probe() return idb } catch { /* fall through to localStorage */ } } try { const ls = createLocalStorageDriver() await ls.probe() return ls } catch { return createNoopDriver() } } function driver(): Promise { driverPromise ??= selectDriver() return driverPromise } /** Which persistent backend won the probe — surfaced for diagnostics. */ export function cacheBackend(): Promise { return driver().then((d) => d.name) } /** Synchronous L1 read: the first-paint fast path. */ export function peekCache(key: string): CacheEnvelope | undefined { const hit = memory.get(key) return hit && hit.v === ENVELOPE_VERSION ? (hit as CacheEnvelope) : undefined } /** L1 read, falling back to one disk hydration. Best-effort: never rejects. */ export async function readCache(key: string): Promise | undefined> { const hit = peekCache(key) if (hit) return hit try { const stored = await (await driver()).get(key) if (stored) { memory.set(key, stored) return stored as CacheEnvelope } } catch { /* storage unavailable — the cache is best-effort by design */ } return undefined } /** * Update L1 synchronously and persist in the background. The merged value is * what gets stored, so a custom merge (e.g. chat transcript dedupe) survives * reloads exactly as the UI shows it. */ export function writeCache(key: string, data: T): void { const envelope: CacheEnvelope = { v: ENVELOPE_VERSION, at: Date.now(), data } memory.set(key, envelope as CacheEnvelope) void driver() .then((d) => d.set(key, envelope as CacheEnvelope)) .catch(() => {}) } /** * Drop one key or a whole subtree. Prefix callers should include the trailing * separator (`invalidateCache('space:foo:')`) so `space:foo` itself and * `space:foobar` are not caught by accident. */ export function invalidateCache(keyOrPrefix: string): void { if (keyOrPrefix === '') return for (const key of memory.keys()) { if (key.startsWith(keyOrPrefix)) memory.delete(key) } void driver() .then((d) => d.clearPrefix(keyOrPrefix)) .catch(() => {}) } /** * Bind the store to the signed-in account. Called once `me` resolves; a * different owner than the recorded one means the machine changed hands (or * the user re-logged as someone else), so every cached payload is wiped * before the new session repopulates it. */ export async function setCacheOwner(ownerId: string): Promise { try { const d = await driver() const recorded = await d.get(OWNER_KEY) if (recorded?.data === ownerId) return await d.clearPrefix('') memory.clear() await d.set(OWNER_KEY, { v: ENVELOPE_VERSION, at: Date.now(), data: ownerId }) } catch { /* best-effort: failing to record the owner must not break the session */ } } /** Full wipe (logout, "clear local data" affordances). */ export async function clearLocalCache(): Promise { memory.clear() try { await (await driver()).clearPrefix('') } catch { /* best-effort */ } }