Files
infiplot-web/lib/persistence/idb.ts
T
Kai ki 610dba78b7 feat(persistence): local-first story persistence (IndexedDB + Supabase skeleton)
Remove Cloudflare D1 entirely (4 API routes, lib/db/, Drizzle config/migrations,
drizzle-orm/drizzle-kit deps, wrangler D1/R2/KV bindings) and replace with
browser-local-first architecture:

Open-source build (IndexedDB, no auth):
- lib/persistence/ 5-file module: types, idb adapter (zero-dep, fault-tolerant,
  post-open invalidation retry), localStore (CRUD + sync-reserved metadata +
  slim/rebuild + retention-cap eviction with tombstone reap + sync-state
  protection + last-resort bounded fallback), sessionSlim (voice strip +
  styleRef absent-delete), cloudStore (Supabase skeleton, server-only)
- Autosave: persistence fingerprint (history.length:lastBeatCount:playerName),
  serial saveChain, failure rollback retry, replaySourceRef guard (prevents
  replayed shared stories from clobbering user saves)
- clientStoryPersistence.ts: thin facade (SaveResult discriminated union)
- Stories page: /[locale]/stories with 3-language i18n (zh-CN/en/ja)
- Homepage: book icon entry point in header

Commercial build (Supabase, skeleton only):
- Single table public.stories (JSONB + RLS 4 policies on auth.uid()=user_id)
- supabase/migrations/ DDL (idempotent)
- cloudStore.ts server-only repository, AUTH_ENABLED short-circuit
- Not wired to client this phase

Featured stories: pure fallback (buildFallbackCards + localizeCards), no D1

Includes fixes from 3 rounds of subagent code-review (tasks 16-30):
- CR1: autosave restructure, coerceOrientation, D1 comment cleanup
- CR2: fingerprint+serial+rollback+replay guard, idb post-open retry,
  enforceRetentionCap latent defense, sessionSlim absent invariant
- CR3: single-scene share guard (replaySourceRef), insert-beat fingerprint
  (beats.length), pass3 overflow double-count fix, detach gate unification
2026-06-25 18:19:08 +08:00

191 lines
6.5 KiB
TypeScript

// IndexedDB medium adapter — zero-dependency wrapper over a single object store.
//
// Why IndexedDB (not localStorage): async + non-blocking (localStorage's
// synchronous write is the known cause of the freeze when navigating back to
// home), hundreds of MB of quota, and a quota namespace separate from the
// gallery export's localStorage usage. Hand-rolled to avoid adding an `idb`
// dependency and keep the OpenNext bundle lean.
//
// Every function is fault-tolerant: when IndexedDB is unavailable (SSR, private
// mode, blocked) or any operation fails, it resolves to a safe value
// (null / [] / false) and never throws.
const DB_NAME = "infiplot";
const DB_VERSION = 1;
/** The single object store holding story records (keyPath = "id"). */
export const STORIES_STORE = "stories";
// Memoized open promise — opened once per page, reused thereafter.
let dbPromise: Promise<IDBDatabase | null> | null = null;
function isAvailable(): boolean {
return typeof window !== "undefined" && typeof indexedDB !== "undefined";
}
function promisifyRequest<T>(req: IDBRequest<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function txDone(tx: IDBTransaction): Promise<void> {
return new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
/** Open (and lazily create) the database. Resolves to null when IndexedDB is
* unavailable or the open fails/blocks — callers degrade gracefully.
* A transient failure (onerror, onblocked) resets the memoized promise so the
* next call retries rather than permanently disabling persistence for the page
* session. Only a successful open is cached — and even that cache is dropped if
* the connection later dies (onclose / onversionchange), so a post-open
* invalidation reopens on the next call instead of reusing a dead handle. */
export function idbReady(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
if (!isAvailable()) return Promise.resolve(null);
dbPromise = new Promise<IDBDatabase | null>((resolve) => {
try {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
try {
const db = req.result;
if (!db.objectStoreNames.contains(STORIES_STORE)) {
db.createObjectStore(STORIES_STORE, { keyPath: "id" });
}
} catch {
// createObjectStore failed (corrupt/quota/half-open) — the version-
// change transaction will abort, req.onerror fires, and we resolve null
// with the retry reset below.
}
};
req.onsuccess = () => {
const db = req.result;
// Post-open invalidation: a successfully-opened connection can still die.
// The browser may evict the DB under storage pressure (onclose), or
// another tab may request a version upgrade we must yield to
// (onversionchange). Without these handlers the memoized-but-dead db is
// reused forever — every later transaction throws InvalidStateError,
// which each op swallows in its try/catch, so persistence is silently
// dead for the whole page session (exactly the "permanent disable" the
// onerror/onblocked retry above set out to prevent, just on a later
// branch). Dropping dbPromise lets the next call reopen.
db.onclose = () => {
// Connection already closed by the browser; just allow a reopen.
dbPromise = null;
};
db.onversionchange = () => {
// Another tab wants to upgrade — close first so we don't block it with
// onblocked, then allow this tab to reopen at the new version.
dbPromise = null;
try {
db.close();
} catch {
// best-effort
}
};
resolve(db);
};
req.onerror = () => {
// Transient failure — allow retry on next call.
dbPromise = null;
resolve(null);
};
req.onblocked = () => {
// Another tab holds the connection — allow retry once it's released.
dbPromise = null;
resolve(null);
};
} catch {
dbPromise = null;
resolve(null);
}
});
return dbPromise;
}
/** Read one record by key. Returns null when absent or unavailable. */
export async function idbGet<T>(
storeName: string,
key: string,
): Promise<T | null> {
try {
const db = await idbReady();
if (!db) return null;
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<T>(
tx.objectStore(storeName).get(key) as IDBRequest<T>,
);
return result ?? null;
} catch {
return null;
}
}
/** Read every record in the store. Returns [] when empty or unavailable. */
export async function idbGetAll<T>(storeName: string): Promise<T[]> {
try {
const db = await idbReady();
if (!db) return [];
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<T[]>(
tx.objectStore(storeName).getAll() as IDBRequest<T[]>,
);
return result ?? [];
} catch {
return [];
}
}
/** Count records in the store WITHOUT deserializing any values — the cheap way
* to test a capacity threshold before falling back to a full idbGetAll. Returns
* 0 when empty or unavailable. */
export async function idbCount(storeName: string): Promise<number> {
try {
const db = await idbReady();
if (!db) return 0;
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<number>(
tx.objectStore(storeName).count() as IDBRequest<number>,
);
return result ?? 0;
} catch {
return 0;
}
}
/** Upsert one record (keyPath "id"). Returns true on durable commit. */
export async function idbPut<T>(storeName: string, value: T): Promise<boolean> {
try {
const db = await idbReady();
if (!db) return false;
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).put(value);
await txDone(tx);
return true;
} catch {
return false;
}
}
/** Delete one record by key. Returns true on durable commit. */
export async function idbDelete(
storeName: string,
key: string,
): Promise<boolean> {
try {
const db = await idbReady();
if (!db) return false;
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).delete(key);
await txDone(tx);
return true;
} catch {
return false;
}
}