Files
infiplot-web/lib/clientStoryPersistence.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

45 lines
1.6 KiB
TypeScript

// Client-side story persistence facade.
//
// Thin wrapper over the browser-local IndexedDB store (lib/persistence/localStore).
// Keeps a stable public contract for the UI (play page + "我的剧情" page) while the
// storage medium lives in lib/persistence. All D1 / server code paths were
// removed: open-source persistence is browser-local only; account-based cloud
// sync (Supabase) layers on next phase behind AUTH_ENABLED.
import type { Session } from "@infiplot/types";
import type { StoryMeta } from "@/lib/persistence/types";
import {
saveStorySession,
listStories,
loadStorySession as loadSession,
softDeleteStory,
} from "@/lib/persistence/localStore";
export type SaveResult =
| { ok: true; storyId: string }
| { ok: false; error: string };
/** Persist the current session locally (upsert by id). Safe to fire-and-forget:
* never throws, never blocks gameplay/navigation. */
export async function saveStory(session: Session): Promise<SaveResult> {
const rec = await saveStorySession(session);
return rec
? { ok: true, storyId: rec.id }
: { ok: false, error: "无法保存到本地存储" };
}
/** List saved stories for the "我的剧情" page (newest first). */
export async function loadStoryList(): Promise<StoryMeta[]> {
return listStories();
}
/** Load the full (slim) Session for a saved story, or null if absent/deleted. */
export async function loadStorySession(id: string): Promise<Session | null> {
return loadSession(id);
}
/** Delete a saved story (soft-delete). Returns false if not found. */
export async function deleteStory(storyId: string): Promise<boolean> {
return softDeleteStory(storyId);
}