610dba78b7
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
191 lines
6.5 KiB
TypeScript
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;
|
|
}
|
|
}
|