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
+17
View File
@@ -112,6 +112,7 @@ export const en = {
start: "Start",
loadStory: "Load Story",
settings: "Settings",
myStories: "My Stories",
searchPlaceholder: "Search styles…",
noMatchingStyle: "No matching styles",
close: "Close",
@@ -388,6 +389,22 @@ Dreamy watercolor style with soft tones and nostalgic atmosphere
current: "Current Language",
select: "Select Language",
},
// ========== Stories Page (app/[locale]/stories/page.tsx) ==========
stories: {
title: "M y · S t o r i e s",
loading: "L o a d i n g",
emptyTitle: "No saved stories yet",
emptyBack: "Go back home to start a new story",
scenes: "{count} scenes",
deleteLabel: "Delete",
deleteConfirm: "Delete this story? This action cannot be undone.",
deleteFailed: "Delete failed. Please try again later.",
today: "Today",
yesterday: "Yesterday",
daysAgo: "{days} days ago",
storiesCount: "{count} stories",
},
} as const;
export type EnTranslations = typeof en;
+17
View File
@@ -123,6 +123,7 @@ export const ja = {
start: "スタート",
loadStory: "シナリオ読み込み",
settings: "設定",
myStories: "マイストーリー",
searchPlaceholder: "スタイルを検索…",
noMatchingStyle: "一致するスタイルがありません",
close: "閉じる",
@@ -428,6 +429,22 @@ export const ja = {
current: "現在の言語",
select: "言語の選択",
},
// ========== Stories Page (app/[locale]/stories/page.tsx) ==========
stories: {
title: "マ イ ス ト ー リ ー",
loading: "読 み 込 み 中",
emptyTitle: "保存されたストーリーはまだありません",
emptyBack: "ホームに戻って新しいストーリーを始める",
scenes: "{count}シーン",
deleteLabel: "削除",
deleteConfirm: "このストーリーを削除しますか?この操作は元に戻せません。",
deleteFailed: "削除に失敗しました。後でもう一度お試しください。",
today: "今日",
yesterday: "昨日",
daysAgo: "{days}日前",
storiesCount: "{count}件",
},
} as const;
export type JaTranslations = typeof ja;
+17
View File
@@ -123,6 +123,7 @@ export const zhCN = {
start: "开始",
loadStory: "载入剧情",
settings: "设置",
myStories: "我的剧情",
searchPlaceholder: "搜索风格…",
noMatchingStyle: "没有匹配的风格",
close: "关闭",
@@ -428,6 +429,22 @@ export const zhCN = {
current: "当前语言",
select: "选择语言",
},
// ========== Stories Page (app/[locale]/stories/page.tsx) ==========
stories: {
title: "我 · 的 · 剧 · 情",
loading: "载 · 入 · 中",
emptyTitle: "还没有保存的剧情",
emptyBack: "回到首页开始新的故事",
scenes: "{count} 幕",
deleteLabel: "删除",
deleteConfirm: "确认删除这个剧情?此操作无法撤销。",
deleteFailed: "删除失败,请稍后重试",
today: "今天",
yesterday: "昨天",
daysAgo: "{days} 天前",
storiesCount: "{count} 个剧情",
},
} as const;
export type ZhCNTranslations = typeof zhCN;