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
45 lines
1.6 KiB
TypeScript
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);
|
|
}
|