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:
+5
-9
@@ -10,6 +10,7 @@ import {
|
||||
resolveEngineConfig,
|
||||
} from "@/lib/clientModelConfig";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { stripSessionVoices } from "@/lib/persistence/sessionSlim";
|
||||
import type {
|
||||
Character,
|
||||
FreeformClassifyRequest,
|
||||
@@ -78,16 +79,11 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
// data is bulky (~160KB/character via referenceAudioBase64) and the
|
||||
// scene-generation / vision / classify pipelines never need it — voices
|
||||
// are only consumed by /api/beat-audio, which receives them directly, not
|
||||
// via the session. So strip voices before transport.
|
||||
// via the session. So strip voices before transport. The stripping rule itself
|
||||
// lives in lib/persistence/sessionSlim.ts (shared with the local-store layer so
|
||||
// "what counts as voice" has one definition).
|
||||
function stripVoicesForTransport(session: Session): Session {
|
||||
return {
|
||||
...session,
|
||||
// Destructure voice out so the serialized payload drops the field
|
||||
// entirely (voice is optional on Character), rather than serializing
|
||||
// it as undefined/null. This is the ~160KB/character referenceAudioBase64
|
||||
// we want off the wire on the server-fallback path.
|
||||
characters: session.characters.map(({ voice: _voice, ...rest }) => rest),
|
||||
};
|
||||
return stripSessionVoices(session);
|
||||
}
|
||||
|
||||
// The server strips voice from already-known characters before responding
|
||||
|
||||
Reference in New Issue
Block a user