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
This commit is contained in:
Kai ki
2026-06-25 18:19:08 +08:00
parent be39fcc77e
commit 610dba78b7
30 changed files with 1043 additions and 2019 deletions
+37
View File
@@ -0,0 +1,37 @@
// Session slimming — the single definition of "shed a Session's bulky,
// reconstructible fields before it crosses a size-sensitive boundary".
//
// Two boundaries consume this, so the rule lives in one place (depends only on
// @infiplot/types, no engine/client imports, so both the storage layer and the
// engine transport layer can import it without pulling in each other's deps):
// - network transport (lib/engineClient.ts) drops voice before POSTing the
// session to scene/vision/insert-beat — voice is only used by /api/beat-audio.
// - local persistence (lib/persistence/localStore.ts) drops voice AND the
// style reference image before writing to IndexedDB.
import type { Session } from "@infiplot/types";
/** Drop each character's `voice` (the ~160-220KB referenceAudioBase64 + provider
* fields). The field is destructured out so it's ABSENT from the result rather
* than serialized as `undefined`. Tolerates a missing `characters` array. */
export function stripSessionVoices(session: Session): Session {
return {
...session,
characters: (session.characters ?? []).map(({ voice: _voice, ...rest }) => rest),
};
}
/** The persistence-grade slim: voices stripped (via stripSessionVoices) AND the
* bulky `styleReferenceImage` removed. Both are reconstructible — voices
* re-provision on the next /api/scene call, and styleReferenceImage is cosmetic
* (the engine paints fine without it). Keeps each stored record small regardless
* of IndexedDB quota headroom. */
export function slimSession(session: Session): Session {
// Destructure styleReferenceImage OUT (rather than set it to `undefined`) so
// it's ABSENT from the result — the same absent-not-undefined invariant as
// stripSessionVoices. structured-clone (IndexedDB) preserves an own key whose
// value is `undefined`, which a next-phase sync reconciler probing
// `'styleReferenceImage' in session` or Object.keys() would misread as present.
const { styleReferenceImage: _styleRef, ...rest } = stripSessionVoices(session);
return rest;
}