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
+200
View File
@@ -0,0 +1,200 @@
// Cloud story repository — server-only Supabase persistence skeleton for the
// COMMERCIAL build. Mirrors the local repository (lib/persistence/localStore.ts)
// method-for-method so next-phase local-first bidirectional sync can treat the
// cloud as a layer over the local store rather than a parallel branch.
//
// This phase is a SKELETON: no API route exposes these functions and no client
// calls them. When AUTH_ENABLED is false (the open-source build) every method
// short-circuits to a safe value on its first line and never touches Supabase.
//
// Isolation is by RLS only: the SSR client carries the user's anon key + cookie,
// and every public.stories policy is keyed on auth.uid() = user_id — so no
// service_role key is used and no query needs a manual user filter for safety
// (the explicit .eq("user_id") below is belt-and-suspenders + index alignment).
import "server-only";
import type { Session } from "@infiplot/types";
import { coerceOrientation } from "@infiplot/types";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
import type { SlimStoryBlob, StoryMeta } from "./types";
import { coerceEpoch } from "./types";
/** One row of public.stories (snake_case columns ↔ SlimStoryBlob + sync meta). */
type StoryRow = {
id: string;
user_id: string;
world_setting: string;
style_guide: string;
orientation: string;
scene_count: number;
rev: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
session_jsonb: Session;
};
/** Resolve the authenticated user's id (= auth.uid()) from the SSR session, or
* null when unauthenticated. Repository-level (no NextResponse) so callers stay
* framework-agnostic; methods short-circuit to safe values on null. */
async function currentUserId(): Promise<string | null> {
try {
const supabase = await createClient();
const claims = await supabase.auth.getClaims();
return claims.data?.claims?.sub ?? null;
} catch {
return null;
}
}
function rowToBlob(row: StoryRow): SlimStoryBlob {
return {
id: row.id,
worldSetting: row.world_setting ?? "",
styleGuide: row.style_guide ?? "",
orientation: coerceOrientation(row.orientation),
sceneCount: row.scene_count ?? 0,
rev: row.rev ?? 1,
session: row.session_jsonb,
};
}
function rowToMeta(row: StoryRow): StoryMeta {
return {
id: row.id,
worldSetting: row.world_setting ?? "",
styleGuide: row.style_guide ?? "",
orientation: coerceOrientation(row.orientation),
sceneCount: row.scene_count ?? 0,
// coerceEpoch (not a raw new Date().getTime()) guards against an unparseable
// timestamptz string yielding NaN, which would render as "Invalid Date" and
// crash any client doing `new Date(updatedAt).getTime()`. Ordering is done
// SQL-side (.order("updated_at") in cloudListStories), so these JS values
// don't drive the sort. Same shared helper the local store uses.
createdAt: coerceEpoch(row.created_at, 0),
updatedAt: coerceEpoch(row.updated_at, 0),
};
}
// ── Public API ──────────────────────────────────────────────────────────────
//
// CONTRACT NOTE (CR-15): these methods are the cloud COUNTERPARTS of
// lib/persistence/localStore.ts, but their return shapes are intentionally NOT
// identical — the local store returns rich StoryRecord/Session values (carrying
// schemaVersion/createdAt/updatedAt/deletedAt/syncState), while the cloud store
// returns the leaner SlimStoryBlob. When next-phase bidirectional sync lands it
// must map StoryRecord ↔ SlimStoryBlob ↔ Session in one reconciliation layer
// rather than assuming a single shared shape; the intended convergence is a
// common envelope (SlimStoryBlob + sync-meta) at both edges. Documented here so
// the asymmetry is a known, bounded cost, not a surprise.
/** Upsert one story for the current user. onConflict targets the `id` PK; the
* caller-supplied rev/updated_at are written verbatim and created_at is left to
* the DB default (insert only). NOTE (CR-10): this is last-write-wins — there is
* no `updated_at`-monotonic guard, so a slow concurrent writer can clobber newer
* cloud state; the next-phase sync layer must add an optimistic-concurrency
* predicate (e.g. only overwrite when excluded.updated_at > stories.updated_at)
* before this is wired to real multi-device traffic. Returns the stored blob, or
* null when auth is off / unauthenticated / the write failed (incl. an RLS-hidden
* cross-user id collision surfacing as a PK violation). */
export async function cloudSaveStory(
blob: SlimStoryBlob,
): Promise<SlimStoryBlob | null> {
if (!AUTH_ENABLED) return null;
const userId = await currentUserId();
if (!userId) return null;
try {
const supabase = await createClient();
const { data, error } = await supabase
.from("stories")
.upsert(
{
id: blob.id,
user_id: userId,
world_setting: blob.worldSetting ?? "",
style_guide: blob.styleGuide ?? "",
orientation: coerceOrientation(blob.orientation),
scene_count: blob.sceneCount ?? 0,
rev: blob.rev ?? 1,
updated_at: new Date().toISOString(),
deleted_at: null,
session_jsonb: blob.session,
},
{ onConflict: "id" },
)
.select()
.single();
if (error || !data) return null;
return rowToBlob(data as StoryRow);
} catch {
return null;
}
}
/** Load one story's slim blob for the current user. Tombstoned / absent / not
* owned (RLS) → null. */
export async function cloudLoadStory(id: string): Promise<SlimStoryBlob | null> {
if (!AUTH_ENABLED) return null;
const userId = await currentUserId();
if (!userId) return null;
try {
const supabase = await createClient();
const { data, error } = await supabase
.from("stories")
.select()
.eq("id", id)
.eq("user_id", userId)
.is("deleted_at", null)
.maybeSingle();
if (error || !data) return null;
return rowToBlob(data as StoryRow);
} catch {
return null;
}
}
/** List the current user's non-tombstoned stories as lightweight metadata,
* newest first (mirrors localStore.listStories). Auth off / unauth → []. */
export async function cloudListStories(): Promise<StoryMeta[]> {
if (!AUTH_ENABLED) return [];
const userId = await currentUserId();
if (!userId) return [];
try {
const supabase = await createClient();
const { data, error } = await supabase
.from("stories")
.select()
.eq("user_id", userId)
.is("deleted_at", null)
.order("updated_at", { ascending: false });
if (error || !data) return [];
return (data as StoryRow[]).map(rowToMeta);
} catch {
return [];
}
}
/** Soft-delete one story (set the tombstone) for the current user so the
* deletion can propagate. Absent / not owned / write failed → false. */
export async function cloudSoftDeleteStory(id: string): Promise<boolean> {
if (!AUTH_ENABLED) return false;
const userId = await currentUserId();
if (!userId) return false;
try {
const supabase = await createClient();
const now = new Date().toISOString();
const { data, error } = await supabase
.from("stories")
.update({ deleted_at: now, updated_at: now })
.eq("id", id)
.eq("user_id", userId)
.is("deleted_at", null)
.select("id");
if (error || !data || data.length === 0) return false;
return true;
} catch {
return false;
}
}