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;
}
}
+190
View File
@@ -0,0 +1,190 @@
// IndexedDB medium adapter — zero-dependency wrapper over a single object store.
//
// Why IndexedDB (not localStorage): async + non-blocking (localStorage's
// synchronous write is the known cause of the freeze when navigating back to
// home), hundreds of MB of quota, and a quota namespace separate from the
// gallery export's localStorage usage. Hand-rolled to avoid adding an `idb`
// dependency and keep the OpenNext bundle lean.
//
// Every function is fault-tolerant: when IndexedDB is unavailable (SSR, private
// mode, blocked) or any operation fails, it resolves to a safe value
// (null / [] / false) and never throws.
const DB_NAME = "infiplot";
const DB_VERSION = 1;
/** The single object store holding story records (keyPath = "id"). */
export const STORIES_STORE = "stories";
// Memoized open promise — opened once per page, reused thereafter.
let dbPromise: Promise<IDBDatabase | null> | null = null;
function isAvailable(): boolean {
return typeof window !== "undefined" && typeof indexedDB !== "undefined";
}
function promisifyRequest<T>(req: IDBRequest<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function txDone(tx: IDBTransaction): Promise<void> {
return new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
/** Open (and lazily create) the database. Resolves to null when IndexedDB is
* unavailable or the open fails/blocks — callers degrade gracefully.
* A transient failure (onerror, onblocked) resets the memoized promise so the
* next call retries rather than permanently disabling persistence for the page
* session. Only a successful open is cached — and even that cache is dropped if
* the connection later dies (onclose / onversionchange), so a post-open
* invalidation reopens on the next call instead of reusing a dead handle. */
export function idbReady(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
if (!isAvailable()) return Promise.resolve(null);
dbPromise = new Promise<IDBDatabase | null>((resolve) => {
try {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
try {
const db = req.result;
if (!db.objectStoreNames.contains(STORIES_STORE)) {
db.createObjectStore(STORIES_STORE, { keyPath: "id" });
}
} catch {
// createObjectStore failed (corrupt/quota/half-open) — the version-
// change transaction will abort, req.onerror fires, and we resolve null
// with the retry reset below.
}
};
req.onsuccess = () => {
const db = req.result;
// Post-open invalidation: a successfully-opened connection can still die.
// The browser may evict the DB under storage pressure (onclose), or
// another tab may request a version upgrade we must yield to
// (onversionchange). Without these handlers the memoized-but-dead db is
// reused forever — every later transaction throws InvalidStateError,
// which each op swallows in its try/catch, so persistence is silently
// dead for the whole page session (exactly the "permanent disable" the
// onerror/onblocked retry above set out to prevent, just on a later
// branch). Dropping dbPromise lets the next call reopen.
db.onclose = () => {
// Connection already closed by the browser; just allow a reopen.
dbPromise = null;
};
db.onversionchange = () => {
// Another tab wants to upgrade — close first so we don't block it with
// onblocked, then allow this tab to reopen at the new version.
dbPromise = null;
try {
db.close();
} catch {
// best-effort
}
};
resolve(db);
};
req.onerror = () => {
// Transient failure — allow retry on next call.
dbPromise = null;
resolve(null);
};
req.onblocked = () => {
// Another tab holds the connection — allow retry once it's released.
dbPromise = null;
resolve(null);
};
} catch {
dbPromise = null;
resolve(null);
}
});
return dbPromise;
}
/** Read one record by key. Returns null when absent or unavailable. */
export async function idbGet<T>(
storeName: string,
key: string,
): Promise<T | null> {
try {
const db = await idbReady();
if (!db) return null;
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<T>(
tx.objectStore(storeName).get(key) as IDBRequest<T>,
);
return result ?? null;
} catch {
return null;
}
}
/** Read every record in the store. Returns [] when empty or unavailable. */
export async function idbGetAll<T>(storeName: string): Promise<T[]> {
try {
const db = await idbReady();
if (!db) return [];
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<T[]>(
tx.objectStore(storeName).getAll() as IDBRequest<T[]>,
);
return result ?? [];
} catch {
return [];
}
}
/** Count records in the store WITHOUT deserializing any values — the cheap way
* to test a capacity threshold before falling back to a full idbGetAll. Returns
* 0 when empty or unavailable. */
export async function idbCount(storeName: string): Promise<number> {
try {
const db = await idbReady();
if (!db) return 0;
const tx = db.transaction(storeName, "readonly");
const result = await promisifyRequest<number>(
tx.objectStore(storeName).count() as IDBRequest<number>,
);
return result ?? 0;
} catch {
return 0;
}
}
/** Upsert one record (keyPath "id"). Returns true on durable commit. */
export async function idbPut<T>(storeName: string, value: T): Promise<boolean> {
try {
const db = await idbReady();
if (!db) return false;
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).put(value);
await txDone(tx);
return true;
} catch {
return false;
}
}
/** Delete one record by key. Returns true on durable commit. */
export async function idbDelete(
storeName: string,
key: string,
): Promise<boolean> {
try {
const db = await idbReady();
if (!db) return false;
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).delete(key);
await txDone(tx);
return true;
} catch {
return false;
}
}
+188
View File
@@ -0,0 +1,188 @@
// Local story repository — browser-local persistence built on the IndexedDB
// adapter. Owns CRUD, the local-first sync-reserved metadata, slim/rebuild of
// the Session payload, retention-cap eviction, defensive Date coercion, and
// end-to-end fault tolerance.
//
// Method signatures are expressed in terms of the slim Session blob so the
// future cloud repository (lib/persistence/cloudStore.ts) can mirror them and
// cloud sync can layer on top without changing callers.
import type { Session } from "@infiplot/types";
import { coerceOrientation } from "@infiplot/types";
import { idbGet, idbGetAll, idbPut, idbDelete, idbCount, STORIES_STORE } from "./idb";
import { slimSession } from "./sessionSlim";
import { STORY_SCHEMA_VERSION, coerceEpoch, type StoryRecord, type StoryMeta } from "./types";
/** Max number of non-tombstoned stories retained locally. IndexedDB has ample
* quota, so this is generous vs the old localStorage cap of 20; it aligns with
* the deleted D1 `listByUser` default limit of 50. */
export const LOCAL_STORY_CAP = 50;
/** Tombstoned records are kept (not hard-deleted) so a soft-delete can propagate
* to the cloud next phase — but only for a bounded window. Past this age they're
* reclaimed locally to stop unbounded IndexedDB growth (a pre-sync device may
* never propagate them, and the cloud applies deletes by id idempotently). */
export const TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// ── Internal helpers ───────────────────────────────────────────────────────
function toMeta(rec: StoryRecord): StoryMeta {
return {
id: rec.id,
worldSetting: rec.worldSetting,
styleGuide: rec.styleGuide,
orientation: coerceOrientation(rec.orientation),
sceneCount: rec.sceneCount,
createdAt: coerceEpoch(rec.createdAt, 0),
updatedAt: coerceEpoch(rec.updatedAt, 0),
};
}
/** Best-effort housekeeping run after a save. Guarded by a cheap count() so the
* common case (under cap, no aged tombstones) reads ZERO session blobs. Jobs
* when the guard trips:
* 1. Reap tombstones older than TOMBSTONE_RETENTION_MS — soft-deletes otherwise
* accumulate forever (nothing consumes them until cloud sync lands), bloating
* every idbGetAll.
* 2. Evict the oldest over-cap LIVE records, but SKIP any with un-propagated
* local changes (syncState !== "local-only") so an eviction can't silently
* drop edits a future cloud sync still needs to push.
* 3. If step 2 couldn't reach the cap because every over-cap record was
* protected, evict the oldest regardless — a bounded store beats preserving
* un-synced work forever. Eviction is a local capacity measure, so it
* hard-deletes (no tombstone). Never fails the save. */
async function enforceRetentionCap(): Promise<void> {
try {
// Cheap gate: total rows (incl. tombstones) without reading any value. Under
// the cap, live records are also under it and no tombstone reaping is due
// often enough to matter — skip the full scan entirely. NOTE: idbCount
// returns 0 when IndexedDB is unavailable/fails, so `0 <= CAP` skips eviction
// — intentional best-effort: if we can't even count, we can't safely evict.
const total = await idbCount(STORIES_STORE);
if (total <= LOCAL_STORY_CAP) return;
const all = await idbGetAll<StoryRecord>(STORIES_STORE);
const now = Date.now();
// 1. Reap aged tombstones (bounds tombstone growth, frees slots).
for (const r of all) {
if (r.deletedAt && now - coerceEpoch(r.deletedAt, now) > TOMBSTONE_RETENTION_MS) {
await idbDelete(STORIES_STORE, r.id);
}
}
// 2. Evict oldest over-cap live records, preserving un-synced ones.
const live = all
.filter((r) => !r.deletedAt)
.sort((a, b) => coerceEpoch(a.updatedAt, 0) - coerceEpoch(b.updatedAt, 0));
let overflow = live.length - LOCAL_STORY_CAP;
if (overflow <= 0) return;
for (const r of live) {
if (overflow <= 0) break;
// Keep records that still owe the cloud a push (pending edits/soft-deletes
// or a synced baseline) — hard-deleting them would lose that work silently.
if (r.syncState !== "local-only") continue;
await idbDelete(STORIES_STORE, r.id);
overflow--;
}
// 3. Last-resort: if step 2 couldn't reach the cap, every remaining over-cap
// record is protected (syncState !== "local-only"). Evict the oldest of THOSE
// regardless, so the store stays bounded. We must skip "local-only" here:
// those were already deleted in step 2, but they're still present in the
// in-memory `live` snapshot (idbDelete doesn't mutate it), so re-deleting them
// would burn `overflow` on no-ops and let the loop break before reaching the
// records that actually still occupy slots — leaving the cap exceeded.
// (Currently latent: non-"local-only" LIVE records don't yet exist — pending
// ones are produced only by softDeleteStory, which also tombstones them, so
// they're filtered out of `live` above. This guards the path that opens once
// cloud sync yields un-tombstoned pending/synced records.)
if (overflow > 0) {
for (const r of live) {
if (overflow <= 0) break;
if (r.syncState === "local-only") continue; // already evicted in step 2
await idbDelete(STORIES_STORE, r.id);
overflow--;
}
}
} catch {
// best-effort
}
}
// ── Public API (symmetric with the future cloud repository) ─────────────────
/** Upsert one story by `session.id`. New record gets rev=1 / syncState
* "local-only" / deletedAt null; an existing one bumps rev, refreshes
* updatedAt, preserves createdAt, and (re-)clears any tombstone. The bulky
* fields are stripped via slimSession before write. Returns the written
* record, or null when storage is unavailable / the write failed (Req 2.x). */
export async function saveStorySession(
session: Session,
): Promise<StoryRecord | null> {
if (!session?.id) return null;
const now = Date.now();
const existing = await idbGet<StoryRecord>(STORIES_STORE, session.id);
const record: StoryRecord = {
id: session.id,
schemaVersion: STORY_SCHEMA_VERSION,
worldSetting: session.worldSetting ?? "",
styleGuide: session.styleGuide ?? "",
orientation: coerceOrientation(session.orientation),
sceneCount: session.history?.length ?? 0,
createdAt: existing ? coerceEpoch(existing.createdAt, now) : now,
updatedAt: now,
rev: existing ? (existing.rev ?? 1) + 1 : 1,
// Re-saving (even a tombstoned id) revives the record locally.
deletedAt: null,
// A previously-synced record that changes locally becomes pending; otherwise
// keep its state (new → local-only). Consumed by next-phase cloud sync.
syncState: existing?.syncState === "synced" ? "pending" : existing?.syncState ?? "local-only",
session: slimSession(session),
};
const ok = await idbPut(STORIES_STORE, record);
if (!ok) return null;
await enforceRetentionCap();
return record;
}
/** List non-tombstoned stories as lightweight metadata, newest first (Req 3.1).
* NOTE: idbGetAll deserializes each record's full session blob even though only
* the denormalized meta fields are projected — meta and blob share one object
* store. Acceptable at LOCAL_STORY_CAP=50; if listing ever dominates, split the
* meta into its own store (or a cursor projection) to avoid reading blobs here. */
export async function listStories(): Promise<StoryMeta[]> {
const all = await idbGetAll<StoryRecord>(STORIES_STORE);
return all
.filter((r) => !r.deletedAt)
.map(toMeta)
.sort((a, b) => b.updatedAt - a.updatedAt);
}
/** Load the slim Session for a story id. Tombstoned or absent → null (Req 3.3).
* Defensively coerces the carried session's createdAt across the storage
* boundary (Req 3.6). The slim session is missing voice/styleReferenceImage by
* design — the engine degrades gracefully (Req 3.4). */
export async function loadStorySession(id: string): Promise<Session | null> {
const rec = await idbGet<StoryRecord>(STORIES_STORE, id);
if (!rec || rec.deletedAt || !rec.session) return null;
return { ...rec.session, createdAt: coerceEpoch(rec.session.createdAt, rec.createdAt) };
}
/** Soft-delete: set the tombstone + mark pending so the deletion can propagate
* to the cloud next phase. List queries filter tombstoned records out, so the
* user perceives it as deleted. Absent / already-deleted id → false (Req 3.5). */
export async function softDeleteStory(id: string): Promise<boolean> {
const rec = await idbGet<StoryRecord>(STORIES_STORE, id);
if (!rec || rec.deletedAt) return false;
const now = Date.now();
const updated: StoryRecord = {
...rec,
deletedAt: now,
updatedAt: now,
syncState: "pending",
};
return idbPut(STORIES_STORE, updated);
}
+37
View File
@@ -0,0 +1,37 @@
// Session slimming — the single definition of "shed a Session's bulky,
// reconstructible fields before it crosses a size-sensitive boundary".
//
// Two boundaries consume this, so the rule lives in one place (depends only on
// @infiplot/types, no engine/client imports, so both the storage layer and the
// engine transport layer can import it without pulling in each other's deps):
// - network transport (lib/engineClient.ts) drops voice before POSTing the
// session to scene/vision/insert-beat — voice is only used by /api/beat-audio.
// - local persistence (lib/persistence/localStore.ts) drops voice AND the
// style reference image before writing to IndexedDB.
import type { Session } from "@infiplot/types";
/** Drop each character's `voice` (the ~160-220KB referenceAudioBase64 + provider
* fields). The field is destructured out so it's ABSENT from the result rather
* than serialized as `undefined`. Tolerates a missing `characters` array. */
export function stripSessionVoices(session: Session): Session {
return {
...session,
characters: (session.characters ?? []).map(({ voice: _voice, ...rest }) => rest),
};
}
/** The persistence-grade slim: voices stripped (via stripSessionVoices) AND the
* bulky `styleReferenceImage` removed. Both are reconstructible — voices
* re-provision on the next /api/scene call, and styleReferenceImage is cosmetic
* (the engine paints fine without it). Keeps each stored record small regardless
* of IndexedDB quota headroom. */
export function slimSession(session: Session): Session {
// Destructure styleReferenceImage OUT (rather than set it to `undefined`) so
// it's ABSENT from the result — the same absent-not-undefined invariant as
// stripSessionVoices. structured-clone (IndexedDB) preserves an own key whose
// value is `undefined`, which a next-phase sync reconciler probing
// `'styleReferenceImage' in session` or Object.keys() would misread as present.
const { styleReferenceImage: _styleRef, ...rest } = stripSessionVoices(session);
return rest;
}
+98
View File
@@ -0,0 +1,98 @@
// Persistence wire types — local-first story storage.
//
// Shared shapes for the browser-local store (IndexedDB) and the future Supabase
// cloud store. Replaces the deleted D1 `lib/db/repositories/storyRepo` types,
// severing all dependency on Drizzle / D1. The local `StoryRecord` and the cloud
// `public.stories` row both carry the same slim `Session` blob (see
// `SlimStoryBlob`) so there is no dual data shape to reconcile when cloud sync
// is layered on next phase.
import type { Session, Orientation } from "@infiplot/types";
/** Schema version stamped on every local record — migration hook for future
* structural evolution of `StoryRecord`. Bump when the on-disk shape changes. */
export const STORY_SCHEMA_VERSION = 1;
/** Coerce a Date | string | number (or anything) to epoch milliseconds, falling
* back when the value is unparseable. Shared by the local store, the cloud store
* (Supabase timestamptz), and the stories list UI — every site where a timestamp
* crosses a storage/serialization boundary and could arrive as a non-number,
* guarding against the historical `t.getTime is not a function` white-screen. */
export function coerceEpoch(value: unknown, fallback: number): number {
if (typeof value === "number" && !Number.isNaN(value)) return value;
const d = value instanceof Date ? value : new Date(value as string | number);
const t = d.getTime();
return Number.isNaN(t) ? fallback : t;
}
/** local-first sync state of a record.
* - "local-only": never sent to the cloud (open-source default, or pre-sync).
* - "synced": in agreement with the cloud row.
* - "pending": has un-propagated local changes (incl. soft-delete tombstones). */
export type SyncState = "local-only" | "synced" | "pending";
/** List-view projection of a saved story — the lightweight metadata the
* "我的剧情" page renders without parsing the full session blob. Migrated out of
* the deleted D1 `storyRepo`; timestamps are unified to epoch milliseconds
* (the old D1 shape used `Date` and carried `userId`/`status`, both dropped:
* the local layer has no account concept, and `status` was a D1 leftover). */
export type StoryMeta = {
id: string;
worldSetting: string;
styleGuide: string;
orientation: Orientation;
sceneCount: number;
/** epoch ms */
createdAt: number;
/** epoch ms */
updatedAt: number;
};
/** The shared core payload for one saved story, identical between the local
* record and the (future) cloud row. `session` is the SLIM `Session` — the
* bulky reconstructible fields (`voice.referenceAudioBase64`,
* `styleReferenceImage`) are stripped before persistence by the store layer. */
export type SlimStoryBlob = {
id: string;
worldSetting: string;
styleGuide: string;
orientation: Orientation;
sceneCount: number;
rev: number;
/** Slim Session (voice + styleReferenceImage stripped). Type stays `Session`;
* slimming is a runtime guarantee enforced by the store, not the type. */
session: Session;
};
/** One row in the browser-local IndexedDB store (object store keyPath = "id").
* Carries the slim session payload plus the local-first sync-reserved
* metadata so cloud sync can be layered on next phase without restructuring. */
export type StoryRecord = {
id: string;
/** = STORY_SCHEMA_VERSION at write time. */
schemaVersion: number;
// ── List-view metadata (denormalized so listing needn't parse the blob) ──
worldSetting: string;
styleGuide: string;
orientation: Orientation;
sceneCount: number;
// ── local-first sync-reserved fields ──
/** epoch ms; set on first save, preserved across subsequent upserts. */
createdAt: number;
/** epoch ms; refreshed on every save. */
updatedAt: number;
/** Revision counter; new record = 1, bumped on each local save. */
rev: number;
/** Soft-delete tombstone (epoch ms) or null. Delete sets this rather than
* physically removing the row, so the deletion can propagate to the cloud
* next phase. List queries filter tombstoned records out. */
deletedAt: number | null;
syncState: SyncState;
// ── Payload ──
/** Slim Session (voice + styleReferenceImage stripped). IndexedDB
* structured-clones objects, so this is stored as-is (no JSON.stringify). */
session: Session;
};