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
+22 -52
View File
@@ -123,8 +123,8 @@ const OPTS: Opt[] = [
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
// 首页卡片的统一渲染形态——无论来自 D1 featured API 还是硬编码 STORIES 降级
// 都归一到这个形状后只走一条渲染路径
// 首页卡片的统一渲染形态。卡片来自硬编码 STORIESbuildFallbackCards
// 按当前 locale 本地化后渲染
type FeaturedCard = {
id: string; // e.g. "m0" / "f12",用于 ?card= 与封面拼接
title: string;
@@ -132,22 +132,6 @@ type FeaturedCard = {
coverPath: string; // e.g. "/home/m0.webp"
};
// D1 featured API 的响应行(与 lib/db/schema.ts FeaturedStory 对应的线上子集)。
type FeaturedStoryRow = {
id: string;
gender: string;
title: string;
outline: string;
style: string;
tags: string; // JSON 字符串
coverPath: string;
firstactPath: string;
firstscenePath?: string | null;
sortOrder: number;
isActive: number;
clickCount: number;
};
import { STYLE_MAP } from "@/lib/options";
/* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。
@@ -798,8 +782,8 @@ const DISPLAY_ORDER: Record<Gender, number[]> = {
],
};
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(featured API 故障/空时的降级源,
// 同时作为首屏即时渲染初始值,避免等 fetch 期间卡片区空白)。
// 从硬编码 STORIES + DISPLAY_ORDER 构造首页卡片(卡片的唯一数据源,
// localizeCards 三语本地化;惰性初始化为首屏即时渲染初始值,无需任何 fetch)。
function buildFallbackCards(g: Gender): FeaturedCard[] {
const imgPrefix = g === "女性向" ? "f" : "m";
const localStories = STORIES[g];
@@ -1532,8 +1516,8 @@ export default function HomePage() {
return () => clearTimeout(t);
}, [gender, galleryGender]);
// Featured stories 动态加载(从 /api/stories/featured),降级用硬编码 STORIES。
// 惰性初始化确保首屏即有卡片内容SSR + hydration 一致),fetch 成功后无缝替换
// Featured 卡片来自硬编码 STORIES 的本地化结果。惰性初始化确保首屏即有内容
//SSR + hydration 一致),locale/gender 变化时重算本地化
const storiesI18nRef = useRef<{ locale: string; data: StoriesI18n | null }>({ locale: "", data: null });
const [featuredCards, setFeaturedCards] = useState<FeaturedCard[]>(() =>
buildFallbackCards(galleryGender),
@@ -1546,34 +1530,11 @@ export default function HomePage() {
}
const i18n = storiesI18nRef.current.data;
if (cancelled) return;
const apiGender = galleryGender === "女性向" ? "female" : "male";
try {
const r = await fetch(`/api/stories/featured?gender=${apiGender}`);
const data: { stories: FeaturedStoryRow[] } = await r.json();
// API 已按 sortOrder 排序且仅返回 isActive=1 的记录。
// D1 故障时 featured route 返回 { stories: [] }HTTP 200),
// 空数组也必须降级到常量,否则首页白屏。
const rows = data.stories ?? [];
if (cancelled) return;
if (rows.length === 0) {
setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
return;
}
setFeaturedCards(
localizeCards(
rows.map((s) => ({
id: s.id,
title: s.title,
outline: s.outline,
coverPath: s.coverPath,
})),
i18n,
),
);
} catch {
if (!cancelled) setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
}
// Featured cards come from the hardcoded fallback set (buildFallbackCards),
// localized for the active locale. The D1 /api/stories/featured route was
// removed — it had no real data and always degraded to this fallback, so
// this is behavior-identical with one fewer network round-trip.
setFeaturedCards(localizeCards(buildFallbackCards(galleryGender), i18n));
})();
return () => { cancelled = true; };
}, [galleryGender, locale]);
@@ -1878,8 +1839,17 @@ export default function HomePage() {
</span>
<div className="flex items-center gap-4 md:gap-5">
<LanguageSwitcher variant="compact" />
{/* Story persistence UI hidden until auth integration is ready.
Code in app/stories/, app/api/stories/, lib/db/ is retained. */}
{/* "我的剧情" — entry to the browser-local story list (IndexedDB).
Needs no auth; the /[locale]/stories page reads the local store. */}
<button
type="button"
onClick={() => router.push(lp("/stories"))}
aria-label={t("home.ui.myStories")}
title={t("home.ui.myStories")}
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
>
<i className="fa-solid fa-book" />
</button>
<button
type="button"
onClick={() => {
+107 -23
View File
@@ -21,7 +21,7 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
import { collectBeatAudioForExport } from "@/lib/exportAudio";
import { loadFromLocalStorage } from "@/lib/clientStoryPersistence";
import { saveStory, loadStorySession } from "@/lib/clientStoryPersistence";
import { PRESETS } from "@/lib/presets";
import {
STORY_SHARE_STORAGE_KEY,
@@ -52,6 +52,7 @@ import type {
TtsConfig,
TtsProvider,
} from "@infiplot/types";
import { coerceOrientation } from "@infiplot/types";
import { track } from "@/lib/analytics";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
@@ -480,7 +481,7 @@ function prefetchScenePath(
source: "prefetch" as const,
kind,
http_status,
orientation: baseSession.orientation ?? "landscape",
orientation: coerceOrientation(baseSession.orientation),
connection: getConnectionType(),
was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden",
scene_index: baseSession.history.length,
@@ -711,7 +712,7 @@ function PlayInner() {
session: sess,
beatId: beat.id,
visitedBeats: [...visitedBeatsRef.current],
orientation: sess.orientation ?? "landscape",
orientation: coerceOrientation(sess.orientation),
imageOriginalUrl,
pendingAction: pendingResumeActionRef.current ?? undefined,
};
@@ -851,6 +852,61 @@ function PlayInner() {
useEffect(() => {
sessionRef.current = session;
}, [session]);
// Autosave bookkeeping. We persist on a stable FINGERPRINT of the durable,
// session-level state — committed-scene count + playerName — not the raw
// `session` reference, which churns on every beat advance (visitedBeatIds).
// - lastSavedFingerprintRef holds the fingerprint of the last SUCCESSFUL save.
// On failure it's cleared so the next session change retries: a
// fire-and-forget that silently failed (IndexedDB transiently unavailable)
// must not strand the scene unsaved.
// - saveChainRef serializes writes so a slow save for scene N can't land after
// a faster save for N+1 and persist a stale, shorter session.
const lastSavedFingerprintRef = useRef("");
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
// Persist to the browser-local store when the durable state changes (Req 2.1).
// Fingerprint = committed-scene count + last-scene beat count + playerName:
// - scene count grows on a normal scene commit;
// - last-scene beat count grows on an insert-beat (freeform / background-click
// appends a beat to the current scene WITHOUT changing history.length), which
// is real generated narrative that must persist — keying on length alone
// would silently drop it;
// - playerName captures a late rename.
// Within-scene *visited* progress (visitedBeatIds) is deliberately NOT in the
// fingerprint, so merely advancing through existing beats doesn't re-save. The
// resume path primes the fingerprint so loading a story stays a pure read (no
// re-save / rev bump / list reorder). No debounce — the write is issued on the
// committing render, so navigating home right after a change can't drop it (the
// IndexedDB put is already in flight, serialized, not cancelled by unmount).
// Fire-and-forget: never blocks.
useEffect(() => {
// Never persist a replayed shared story into the user's own library — it
// isn't theirs and its id can collide with (and clobber) a real local save.
// Guard on replaySourceRef (set unconditionally on import, cleared by
// detachRecordedReplay when the user takes over) — NOT replayActiveRef, which
// means "more recorded scenes remain" and is false for a single-scene share,
// so that share would otherwise slip through and overwrite a real save.
if (!session || replaySourceRef.current) return;
const history = session.history ?? [];
if (history.length < 1) return;
const lastBeatCount = history[history.length - 1]?.scene?.beats?.length ?? 0;
const fingerprint = `${history.length}:${lastBeatCount}:${session.playerName ?? ""}`;
if (fingerprint === lastSavedFingerprintRef.current) return;
lastSavedFingerprintRef.current = fingerprint; // optimistic; rolled back on failure
const snapshot = session;
saveChainRef.current = saveChainRef.current
.then(async () => {
const r = await saveStory(snapshot);
// Roll back only if no newer save has superseded us, so the next session
// change retries this content instead of the failure being permanent.
if (!r.ok && lastSavedFingerprintRef.current === fingerprint) {
lastSavedFingerprintRef.current = "";
}
})
// Defensive: saveStory is contracted never to throw, but if a future edit
// to this callback ever does, an unhandled rejection here would poison the
// chain and freeze ALL subsequent saves. Swallow to keep the chain alive.
.catch(() => {});
}, [session]);
useEffect(() => {
currentSceneRef.current = currentScene;
}, [currentScene]);
@@ -1382,7 +1438,7 @@ function PlayInner() {
v: audioByBeatId && Object.keys(audioByBeatId).length > 0 ? 3 : 2,
id,
createdAt: Date.now(),
orientation: s.orientation ?? "landscape",
orientation: coerceOrientation(s.orientation),
scenes,
alternates,
characters,
@@ -1712,27 +1768,50 @@ function PlayInner() {
// ── Load saved story path ──
if (storyId) {
// TEMPORARY: localStorage-only mode (D1 disabled until auth integration)
const loadedSession = loadFromLocalStorage(storyId);
if (!loadedSession) {
setError(t("play.savedStoryNotFound"));
return;
}
const firstScene = loadedSession.history[0]?.scene;
if (!firstScene) {
setError(t("play.savedStoryCorrupted"));
return;
}
(async () => {
// Browser-local store (IndexedDB) is async; load inside the IIFE.
const loadedSession = await loadStorySession(storyId);
if (!loadedSession) {
setError(t("play.savedStoryNotFound"));
return;
}
// Resume at the player's last position. Walk from the newest scene back
// to the first and resume at the latest one that actually has a rendered
// image: the final scene → correct position; if the very last scene
// failed to image (committed without one), a small rewind beats a blank
// canvas (Req 3.3). If NO scene has an image the story can't render —
// surface savedStoryCorrupted instead of landing on getOrCreateBlobUrl("").
const history = loadedSession.history;
let resumeEntry = history[history.length - 1];
for (let i = history.length - 1; i >= 0; i--) {
if (history[i]?.scene?.imageUrl) {
resumeEntry = history[i];
break;
}
}
const resumeScene = resumeEntry?.scene;
if (!resumeScene?.imageUrl) {
setError(t("play.savedStoryCorrupted"));
return;
}
// Pure read: prime the autosave fingerprint so loading doesn't re-save /
// bump rev / reorder the list. Must match the effect's fingerprint shape
// exactly (scene count + last-scene beat count + playerName) or the first
// render would re-persist.
{
const lastBeatCount =
history[history.length - 1]?.scene?.beats?.length ?? 0;
lastSavedFingerprintRef.current = `${history.length}:${lastBeatCount}:${loadedSession.playerName ?? ""}`;
}
try {
const blobUrl = await getOrCreateBlobUrl(firstScene.imageUrl ?? "");
lastImageOriginalUrlRef.current = firstScene.imageUrl ?? "";
const blobUrl = await getOrCreateBlobUrl(resumeScene.imageUrl);
lastImageOriginalUrlRef.current = resumeScene.imageUrl;
setSession(loadedSession);
setCurrentScene(firstScene);
setCurrentBeatId(firstScene.entryBeatId);
setCurrentScene(resumeScene);
setCurrentBeatId(resumeScene.entryBeatId);
setImageUrl(blobUrl);
visitedBeatsRef.current = [firstScene.entryBeatId];
setOrientation(loadedSession.orientation ?? "landscape");
visitedBeatsRef.current = [resumeScene.entryBeatId];
setOrientation(coerceOrientation(loadedSession.orientation));
setPhase("ready");
track("scene_reached", { scene_index: loadedSession.history.length });
} catch (e) {
@@ -2238,7 +2317,10 @@ function PlayInner() {
async function onFreeformInput(text: string) {
if (phase !== "ready" || !session || !currentScene) return;
if (replayActiveRef.current) detachRecordedReplay();
// Detach if we're still replaying a shared story (gate on replaySourceRef,
// not replayActiveRef — the latter is false for a single-scene share, which
// would otherwise leave us "stuck" in replay and block autosave forever).
if (replaySourceRef.current) detachRecordedReplay();
track("freeform_input", {
scene_index: session.history.length,
@@ -2295,7 +2377,9 @@ function PlayInner() {
async function onBackgroundClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
if (replayActiveRef.current) detachRecordedReplay();
// Gate on replaySourceRef, not replayActiveRef (false for a single-scene
// share) — see onFreeformInput for the rationale.
if (replaySourceRef.current) detachRecordedReplay();
const visionT0 = Date.now();
setPhase("vision-thinking");
setPendingClick(click);
+23 -19
View File
@@ -3,11 +3,14 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { loadStoryList, deleteStory } from "@/lib/clientStoryPersistence";
import type { StoryMeta } from "@/lib/db/repositories/storyRepo";
import type { StoryMeta } from "@/lib/persistence/types";
import { coerceEpoch } from "@/lib/persistence/types";
import { useLocalePath } from "@/lib/i18n/hooks";
import { useI18n } from "@/lib/i18n/client";
export default function StoriesPage() {
const lp = useLocalePath();
const { t, locale } = useI18n();
const [stories, setStories] = useState<StoryMeta[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -20,7 +23,7 @@ export default function StoriesPage() {
}, []);
const handleDelete = async (storyId: string) => {
if (!confirm("确认删除这个剧情?此操作无法撤销。")) return;
if (!confirm(t("stories.deleteConfirm"))) return;
setDeletingId(storyId);
const success = await deleteStory(storyId);
@@ -28,30 +31,31 @@ export default function StoriesPage() {
if (success) {
setStories((prev) => prev.filter((s) => s.id !== storyId));
} else {
alert("删除失败,请稍后重试");
alert(t("stories.deleteFailed"));
}
setDeletingId(null);
};
// D1 timestamps arrive as ISO strings over the JSON API boundary (the
// server-side Date is serialized by NextResponse.json), so coerce before use.
// Story timestamps cross the storage boundary as epoch ms (the local store
// coerces them); coerceEpoch is the shared guard for any legacy string/Date.
const formatDate = (value: Date | string | number) => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const ms = coerceEpoch(value, NaN);
if (Number.isNaN(ms)) return "";
const date = new Date(ms);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "今天";
if (days === 1) return "昨天";
if (days < 7) return `${days} 天前`;
if (days === 0) return t("stories.today");
if (days === 1) return t("stories.yesterday");
if (days < 7) return t("stories.daysAgo", { days });
return date.toLocaleDateString("zh-CN", {
return date.toLocaleDateString(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit"
day: "2-digit",
});
};
@@ -67,7 +71,7 @@ export default function StoriesPage() {
InfiPlot
</Link>
<span className="text-[10px] smallcaps text-clay-500">
· · ·
{t("stories.title")}
</span>
</header>
@@ -76,20 +80,20 @@ export default function StoriesPage() {
{loading ? (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
· ·
{t("stories.loading")}
</p>
</div>
) : stories.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<i className="fa-solid fa-book-open text-4xl text-clay-300 mb-6" />
<p className="font-serif italic text-lg text-clay-500 mb-4">
{t("stories.emptyTitle")}
</p>
<Link
href={lp("/")}
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors cursor-pointer"
>
{t("stories.emptyBack")}
</Link>
</div>
) : (
@@ -117,7 +121,7 @@ export default function StoriesPage() {
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500">
<span className="flex items-center gap-1">
<i className="fa-solid fa-photo-film text-[9px]" />
{story.sceneCount}
{t("stories.scenes", { count: story.sceneCount })}
</span>
<span className="flex items-center gap-1">
<i className="fa-solid fa-clock text-[9px]" />
@@ -134,7 +138,7 @@ export default function StoriesPage() {
handleDelete(story.id);
}}
disabled={deletingId === story.id}
aria-label="删除"
aria-label={t("stories.deleteLabel")}
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-clay-400 hover:text-ember-500 disabled:opacity-50 cursor-pointer"
>
<i className={deletingId === story.id ? "fa-solid fa-spinner fa-spin" : "fa-solid fa-trash-can"} />
@@ -151,7 +155,7 @@ export default function StoriesPage() {
<div className="hairline-full w-full mb-4" />
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
<span>MMXXVI</span>
<span className="num">{stories.length} </span>
<span className="num">{t("stories.storiesCount", { count: stories.length })}</span>
</div>
</footer>
</div>
-31
View File
@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
/**
* GET/DELETE /api/stories/[id] — TEMPORARILY DISABLED (2026-06-09)
*
* D1 persistence disabled until authentication integration.
* Returns 404 so client handles gracefully (localStorage is the source of truth).
*
* To re-enable: Restore original implementation after auth integration.
*/
export async function GET(
_req: Request,
_context: { params: Promise<{ id: string }> },
) {
return NextResponse.json(
{ error: "Server persistence temporarily disabled" },
{ status: 404 },
);
}
export async function DELETE(
_req: Request,
_context: { params: Promise<{ id: string }> },
) {
return NextResponse.json(
{ error: "Server persistence temporarily disabled" },
{ status: 404 },
);
}
-48
View File
@@ -1,48 +0,0 @@
import { NextResponse } from "next/server";
import { getDb } from "@/lib/db/client";
import { FeaturedRepository } from "@/lib/db/repositories/featuredRepo";
export const runtime = "nodejs";
/**
* GET /api/stories/featured?gender=male
*
* List active featured stories for homepage display.
* Fallback: D1 query fails → return empty array (homepage shows no cards, gracefully degrades).
*
* Query Params:
* gender: "male" | "female" (required)
*
* Response: { stories: FeaturedStory[] }
* Errors: 400 (invalid gender), 500 (should not reach user - caught and degraded)
*/
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const genderParam = searchParams.get("gender");
// Validate gender
if (!genderParam || !["male", "female"].includes(genderParam)) {
return NextResponse.json(
{ error: "gender query parameter must be 'male' or 'female'" },
{ status: 400 },
);
}
const gender = genderParam as "male" | "female";
try {
const db = getDb();
const repo = new FeaturedRepository(db);
const stories = await repo.listByGender(gender);
return NextResponse.json({ stories });
} catch (err) {
// D1 unavailable or query failed - degrade to empty array
// (homepage will show no cards but remain functional)
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[stories/featured] D1 query failed, returning empty array:", message);
return NextResponse.json({ stories: [] });
}
}
-15
View File
@@ -1,15 +0,0 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
/**
* GET /api/stories/list — TEMPORARILY DISABLED (2026-06-09)
*
* D1 persistence disabled until authentication integration.
* Returns empty list so client falls back to localStorage-only mode.
*
* To re-enable: Restore original implementation after auth integration.
*/
export async function GET(_req: Request) {
return NextResponse.json({ stories: [] });
}
-27
View File
@@ -1,27 +0,0 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
/**
* POST /api/stories/save — TEMPORARILY DISABLED (2026-06-09)
*
* D1 persistence is disabled until an authentication system (better-auth) is
* integrated. Without auth, anonymous writes to D1 have no rate limiting,
* per-user quota, or ownership verification — an abuse/DoS risk on a public,
* registration-less site. The client (lib/clientStoryPersistence.ts) now
* persists stories to localStorage only; this 503 keeps the contract intact
* for any caller that still hits the endpoint.
*
* The full D1 implementation lives in StoryRepository (lib/db/repositories/
* storyRepo.ts), which is untouched. To re-enable after auth integration:
* restore the handler to validate input + call `repo.save(...)` (see the
* task-10 implementation log) and gate it behind an authenticated session.
*
* See: ARCHITECTURE_DESIGN.md Phase 2, memory tech_d1_anonymous_write_risk
*/
export async function POST(_req: Request) {
return NextResponse.json(
{ error: "Server persistence temporarily disabled - using local storage" },
{ status: 503 },
);
}