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
+28 -283
View File
@@ -1,299 +1,44 @@
// Client-side story persistence helpers.
// Client-side story persistence facade.
//
// Provides: anonymous user ID management, save/load functions that call
// /api/stories/* and fallback to localStorage when D1 is unavailable.
// 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, Scene, Character, StoryState } from "@infiplot/types";
import type { StorySaveInput, SceneSaveInput, CharacterSaveInput, StoryMeta, StoryLoadResult } from "@/lib/db/repositories/storyRepo";
const USER_ID_KEY = "infiplot:userId";
const SAVE_FALLBACK_KEY = "infiplot:savedStories";
// ── Anonymous User ID ────────────────────────────────────────────────────
export function getOrCreateUserId(): string {
if (typeof window === "undefined") return "";
try {
let id = localStorage.getItem(USER_ID_KEY);
if (!id) {
id = `anon_${crypto.randomUUID()}`;
localStorage.setItem(USER_ID_KEY, id);
}
return id;
} catch {
return `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
}
// ── Session → Save Input Conversion ─────────────────────────────────────
export function sessionToSaveInput(session: Session): {
story: StorySaveInput;
scenes: SceneSaveInput[];
characters: CharacterSaveInput[];
} {
const story: StorySaveInput = {
id: session.id,
userId: getOrCreateUserId(),
worldSetting: session.worldSetting,
styleGuide: session.styleGuide,
styleReferenceImage: session.styleReferenceImage,
orientation: (session.orientation as "portrait" | "landscape") ?? "landscape",
storyState: session.storyState,
status: "active",
};
const scenes: SceneSaveInput[] = (session.history ?? []).map(
(entry, idx) => ({
id: entry.scene.id,
sceneKey: entry.scene.sceneKey,
sceneSummary: entry.scene.scenePrompt,
imageUrl: entry.scene.imageUrl ?? "",
beats: entry.scene.beats,
sortOrder: idx,
}),
);
const characters: CharacterSaveInput[] = (session.characters ?? []).map(
(c) => ({
name: c.name,
visualDescription: c.visualDescription,
voiceDescription: c.voiceDescription,
portrait:
c.basePortraitUrl || c.basePortraitUuid
? { url: c.basePortraitUrl, uuid: c.basePortraitUuid }
: undefined,
voice: c.voice,
}),
);
return { story, scenes, characters };
}
// ── Save ─────────────────────────────────────────────────────────────────
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; source: "server" }
| { ok: true; storyId: string; source: "localStorage" }
| { 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> {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration).
// Anonymous D1 writes lack rate limiting / quota / ownership checks — an
// abuse risk on a public registration-less site. Persist locally instead.
return saveToLocalStorage(session);
/* DISABLED: D1 server path (will re-enable after auth integration)
const { story, scenes, characters } = sessionToSaveInput(session);
try {
const res = await fetch("/api/stories/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ story, scenes, characters }),
});
if (res.ok) {
const data = (await res.json()) as { storyId: string };
return { ok: true, storyId: data.storyId, source: "server" };
}
// Server failed - fallback to localStorage
throw new Error(`Server returned ${res.status}`);
} catch {
// D1 unavailable or network error - fallback to localStorage
return saveToLocalStorage(session);
}
*/
const rec = await saveStorySession(session);
return rec
? { ok: true, storyId: rec.id }
: { ok: false, error: "无法保存到本地存储" };
}
function saveToLocalStorage(session: Session): SaveResult {
try {
const existing = loadFromLocalStorageAll();
// Strip bulky fields before persistence to stay within localStorage quota
// (~5-10MB across ALL keys). Without this, a multi-scene session with
// several voiced characters serializes to 1-2MB+ (voice.referenceAudioBase64
// is ~160KB each, styleReferenceImage 30-80KB), which can exceed quota and
// — worse — block the main thread on the synchronous localStorage write,
// freezing the subsequent navigation back to the home page. Both fields are
// reconstructible: voices re-provision on the next /api/scene call, and
// styleReferenceImage is cosmetic (engine regenerates gracefully without it).
const slimSession: Session = {
...session,
styleReferenceImage: undefined,
characters: session.characters.map((c) => ({ ...c, voice: undefined })),
};
const entry = {
id: session.id,
worldSetting: session.worldSetting,
styleGuide: session.styleGuide,
sceneCount: session.history?.length ?? 0,
savedAt: Date.now(),
sessionJson: JSON.stringify(slimSession),
};
const updated = [entry, ...existing.filter((e) => e.id !== session.id)].slice(0, 20);
localStorage.setItem(SAVE_FALLBACK_KEY, JSON.stringify(updated));
return { ok: true, storyId: session.id, source: "localStorage" };
} catch {
return { ok: false, error: "无法保存到本地存储" };
}
}
// ── Load ─────────────────────────────────────────────────────────────────
/** List saved stories for the "我的剧情" page (newest first). */
export async function loadStoryList(): Promise<StoryMeta[]> {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
const entries = loadFromLocalStorageAll();
return entries.map((e) => ({
id: e.id,
userId: null, // anonymous
worldSetting: e.worldSetting,
styleGuide: e.styleGuide,
orientation: "landscape", // localStorage doesn't store this, default
status: "active",
sceneCount: e.sceneCount,
createdAt: new Date(e.savedAt),
updatedAt: new Date(e.savedAt),
}));
/* DISABLED: D1 server path (will re-enable after auth integration)
const userId = getOrCreateUserId();
try {
const res = await fetch(`/api/stories/list?userId=${encodeURIComponent(userId)}`);
if (res.ok) {
const data = (await res.json()) as { stories: StoryMeta[] };
return data.stories;
}
return [];
} catch {
return [];
}
*/
return listStories();
}
export async function loadStory(storyId: string): Promise<StoryLoadResult | null> {
// TEMPORARY: localStorage-only mode — unused in current code (play page uses
// loadFromLocalStorage directly). Returns null to maintain type compatibility.
// Will be re-enabled when D1 is restored after auth integration.
return null;
/* DISABLED: D1 server path
try {
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`);
if (res.ok) {
return (await res.json()) as StoryLoadResult;
}
return null;
} catch {
return null;
}
*/
/** 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> {
// TEMPORARY: localStorage-only mode
try {
const existing = loadFromLocalStorageAll();
const updated = existing.filter((e) => e.id !== storyId);
if (updated.length === existing.length) return false; // not found
localStorage.setItem(SAVE_FALLBACK_KEY, JSON.stringify(updated));
return true;
} catch {
return false;
}
/* DISABLED: D1 server path
try {
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
method: "DELETE",
});
return res.ok;
} catch {
return false;
}
*/
}
// ── localStorage fallback helpers ────────────────────────────────────────
type LocalStorageEntry = {
id: string;
worldSetting: string;
styleGuide: string;
sceneCount: number;
savedAt: number;
sessionJson: string;
};
function loadFromLocalStorageAll(): LocalStorageEntry[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(SAVE_FALLBACK_KEY);
if (!raw) return [];
return JSON.parse(raw) as LocalStorageEntry[];
} catch {
return [];
}
}
export function loadFromLocalStorage(storyId: string): Session | null {
const entries = loadFromLocalStorageAll();
const entry = entries.find((e) => e.id === storyId);
if (!entry) return null;
try {
return JSON.parse(entry.sessionJson) as Session;
} catch {
return null;
}
}
// ── StoryLoadResult → Session Conversion ─────────────────────────────────
/**
* Convert StoryLoadResult (API response from /api/stories/[id]) back to Session
* shape consumed by app/play/page.tsx.
*/
export function storyLoadResultToSession(result: StoryLoadResult): Session {
const { story, scenes, characters } = result;
// Map scenes back to SceneHistoryEntry structure
const history = scenes.map((s) => {
const beats = s.beats ?? [];
// entryBeatId is not persisted in D1 — recover it from the first beat.
const entryBeatId = beats[0]?.id ?? "";
return {
scene: {
id: s.id,
sceneKey: s.sceneKey,
scenePrompt: s.sceneSummary ?? "",
imageUrl: s.imageUrl,
beats,
entryBeatId,
orientation: s.orientation,
},
visitedBeatIds: entryBeatId ? [entryBeatId] : [], // rebuilt as user navigates
exit: undefined, // Not persisted in D1
};
});
return {
id: story.id,
// createdAt crosses the JSON API boundary as an ISO string, so coerce it
// back to an epoch the Session shape expects (number).
createdAt: new Date(story.createdAt).getTime(),
worldSetting: story.worldSetting,
styleGuide: story.styleGuide,
styleReferenceImage: story.styleReferenceImage,
orientation: story.orientation,
storyState: story.storyState,
history,
characters: characters.map((c) => ({
name: c.name,
voiceDescription: c.voiceDescription ?? "",
visualDescription: c.visualDescription,
basePortraitUuid: c.portrait?.uuid,
basePortraitUrl: c.portrait?.url,
voice: c.voice,
})),
};
return softDeleteStory(storyId);
}