fd98616
import { ENVELOPE_VERSION, type CacheEnvelope, 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<string, CacheEnvelope>()
let driverPromise: Promise<KVDriver> | null = null
async function selectDriver(): Promise<KVDriver> {
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<KVDriver> {
driverPromise ??= selectDriver()
return driverPromise
}
/** Which persistent backend won the probe — surfaced for diagnostics. */
export function cacheBackend(): Promise<string> {
return driver().then(d => d.name)
}
/** Synchronous L1 read: the first-paint fast path. */
export function peekCache<T>(key: string): CacheEnvelope<T> | undefined {
const hit = memory.get(key)
return hit && hit.v === ENVELOPE_VERSION ? (hit as CacheEnvelope<T>) : undefined
}
/** L1 read, falling back to one disk hydration. Best-effort: never rejects. */
export async function readCache<T>(key: string): Promise<CacheEnvelope<T> | undefined> {
const hit = peekCache<T>(key)
if (hit) return hit
try {
const stored = await (await driver()).get(key)
if (stored) {
memory.set(key, stored)
return stored as CacheEnvelope<T>
}
} 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<T>(key: string, data: T): void {
const envelope: CacheEnvelope<T> = { 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<void> {
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<void> {
memory.clear()
try {
await (await driver()).clearPrefix('')
} catch {
/* best-effort */
}
}