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:
+28
-283
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* Get D1 database instance from Cloudflare Workers env binding.
|
||||
*
|
||||
* Usage in API routes:
|
||||
* const db = getDb();
|
||||
* const stories = await db.select().from(schema.stories).where(...);
|
||||
*
|
||||
* @throws Error if called outside Cloudflare Workers runtime (e.g. local dev without wrangler)
|
||||
*/
|
||||
export function getDb() {
|
||||
try {
|
||||
const { env } = getCloudflareContext();
|
||||
|
||||
if (!env.DB) {
|
||||
throw new Error(
|
||||
"D1 binding 'DB' not found. " +
|
||||
"Ensure wrangler.jsonc has d1_databases configured and you're running via wrangler dev/deploy."
|
||||
);
|
||||
}
|
||||
|
||||
return drizzle(env.DB, { schema });
|
||||
} catch (error) {
|
||||
// Re-throw with more context for debugging
|
||||
throw new Error(
|
||||
`Failed to get D1 database: ${error instanceof Error ? error.message : String(error)}. ` +
|
||||
"Make sure you're running in Cloudflare Workers context (wrangler dev/deploy)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for the Drizzle D1 database instance.
|
||||
* Useful for dependency injection and testing.
|
||||
*/
|
||||
export type DbInstance = ReturnType<typeof getDb>;
|
||||
@@ -1,45 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import type { DbInstance } from "../client";
|
||||
import { featuredStories } from "../schema";
|
||||
import type { FeaturedStory } from "../schema";
|
||||
|
||||
/**
|
||||
* Featured Story Repository - encapsulates D1 access for homepage featured stories.
|
||||
*
|
||||
* Provides: listByGender (active only, sorted by sortOrder), incrementClick (analytics).
|
||||
*/
|
||||
export class FeaturedRepository {
|
||||
constructor(private db: DbInstance) {}
|
||||
|
||||
/**
|
||||
* List active featured stories for a given gender, ordered by sortOrder.
|
||||
*
|
||||
* @param gender "male" or "female"
|
||||
* @returns Array of FeaturedStory (only isActive=1, sorted by sortOrder ASC)
|
||||
*/
|
||||
async listByGender(gender: "male" | "female"): Promise<FeaturedStory[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(featuredStories)
|
||||
.where(and(eq(featuredStories.gender, gender), eq(featuredStories.isActive, 1)))
|
||||
.orderBy(featuredStories.sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment click count for a featured story (analytics).
|
||||
*
|
||||
* @param id Featured story ID (e.g. "m0", "f12")
|
||||
* @returns true if updated, false if not found
|
||||
*/
|
||||
async incrementClick(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.update(featuredStories)
|
||||
.set({ clickCount: sql`${featuredStories.clickCount} + 1` })
|
||||
.where(eq(featuredStories.id, id));
|
||||
|
||||
// Drizzle D1 update returns { success, meta: { changes }, results }
|
||||
return ((result as any).meta?.changes ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq, desc, sql, inArray } from "drizzle-orm";
|
||||
import type { DbInstance } from "../client";
|
||||
import { stories, scenes, characters } from "../schema";
|
||||
import type { Session, Scene as EngineScene, Character as EngineCharacter, StoryState } from "@infiplot/types";
|
||||
|
||||
// ── Type Adapters ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Input shape for saving a story session.
|
||||
* Mirrors Session but with explicit story-level fields.
|
||||
*/
|
||||
export type StorySaveInput = {
|
||||
id: string; // Session ID
|
||||
userId?: string; // nullable - Phase 1 uses anonymous sessionId
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
styleReferenceImage?: string; // data URI or R2 key (TBD in save logic)
|
||||
orientation: "portrait" | "landscape";
|
||||
storyState?: StoryState;
|
||||
status?: "active" | "archived";
|
||||
};
|
||||
|
||||
export type SceneSaveInput = {
|
||||
id: string;
|
||||
sceneKey?: string;
|
||||
sceneSummary?: string;
|
||||
imageUrl: string; // Runware CDN URL (primary)
|
||||
beats: EngineScene["beats"]; // Beat graph - will be serialized to beatsJson
|
||||
orientation?: "portrait" | "landscape";
|
||||
sortOrder: number; // scene sequence in story
|
||||
};
|
||||
|
||||
export type CharacterSaveInput = {
|
||||
name: string;
|
||||
visualDescription?: string;
|
||||
voiceDescription?: string;
|
||||
portrait?: {
|
||||
url?: string;
|
||||
uuid?: string;
|
||||
};
|
||||
voice?: EngineCharacter["voice"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Story metadata for list views.
|
||||
*/
|
||||
export type StoryMeta = {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
orientation: string;
|
||||
status: string;
|
||||
sceneCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Full story load result (maps back to Session structure).
|
||||
*/
|
||||
export type StoryLoadResult = {
|
||||
story: {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
styleReferenceImage?: string;
|
||||
orientation: "portrait" | "landscape";
|
||||
storyState?: StoryState;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
scenes: Array<{
|
||||
id: string;
|
||||
sceneKey?: string;
|
||||
sceneSummary?: string;
|
||||
imageUrl: string;
|
||||
beats: EngineScene["beats"];
|
||||
orientation?: "portrait" | "landscape";
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
characters: Array<{
|
||||
name: string;
|
||||
visualDescription?: string;
|
||||
voiceDescription?: string;
|
||||
portrait?: {
|
||||
url?: string;
|
||||
uuid?: string;
|
||||
};
|
||||
voice?: EngineCharacter["voice"];
|
||||
}>;
|
||||
};
|
||||
|
||||
// ── Repository ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Story Repository - encapsulates D1 access for story persistence.
|
||||
*
|
||||
* **Atomic save**: uses D1 batch transaction to ensure all-or-nothing writes.
|
||||
* **Cascade delete**: relies on schema FK ON DELETE CASCADE.
|
||||
* **Serialization**: beats and storyState are JSON-serialized to TEXT columns.
|
||||
*/
|
||||
export class StoryRepository {
|
||||
constructor(private db: DbInstance) {}
|
||||
|
||||
/**
|
||||
* Save a complete story session (story + scenes + characters) atomically.
|
||||
* Uses D1 batch transaction - all writes succeed or all fail.
|
||||
*
|
||||
* @param input Story metadata
|
||||
* @param sceneInputs Scene list (beats will be serialized)
|
||||
* @param characterInputs Character list (voice will be serialized)
|
||||
* @returns storyId on success
|
||||
* @throws Error if D1 transaction fails
|
||||
*/
|
||||
async save(
|
||||
input: StorySaveInput,
|
||||
sceneInputs: SceneSaveInput[],
|
||||
characterInputs: CharacterSaveInput[],
|
||||
): Promise<{ storyId: string }> {
|
||||
const now = new Date();
|
||||
|
||||
// Build story record
|
||||
const storyRecord = {
|
||||
id: input.id,
|
||||
userId: input.userId ?? null,
|
||||
worldSetting: input.worldSetting,
|
||||
styleGuide: input.styleGuide,
|
||||
styleReferenceImageKey: input.styleReferenceImage ?? null, // Phase 1: store data URI as-is; R2 upload TBD
|
||||
orientation: input.orientation,
|
||||
storyStateJson: input.storyState ? JSON.stringify(input.storyState) : null,
|
||||
status: input.status ?? "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Build scene records (serialize beats to JSON)
|
||||
const sceneRecords = sceneInputs.map((s, idx) => ({
|
||||
id: s.id,
|
||||
storyId: input.id,
|
||||
sceneKey: s.sceneKey ?? null,
|
||||
sceneSummary: s.sceneSummary ?? null,
|
||||
sceneImageKey: null, // Phase 1: R2 upload TBD
|
||||
sceneImageUrl: s.imageUrl,
|
||||
beatsJson: JSON.stringify(s.beats),
|
||||
sortOrder: s.sortOrder ?? idx,
|
||||
createdAt: now,
|
||||
}));
|
||||
|
||||
// Build character records (serialize voice to JSON, ensure uniqueness per story+name)
|
||||
const characterRecords = characterInputs.map((c, idx) => ({
|
||||
id: `${input.id}_char_${idx}`, // synthetic ID
|
||||
storyId: input.id,
|
||||
name: c.name,
|
||||
visualDescription: c.visualDescription ?? null,
|
||||
voiceDescription: c.voiceDescription ?? null,
|
||||
basePortraitKey: null, // Phase 1: R2 upload TBD
|
||||
basePortraitUrl: c.portrait?.url ?? null,
|
||||
basePortraitUuid: c.portrait?.uuid ?? null,
|
||||
voiceJson: c.voice ? JSON.stringify(c.voice) : null,
|
||||
createdAt: now,
|
||||
}));
|
||||
|
||||
// Execute atomic batch transaction
|
||||
await this.db.batch([
|
||||
this.db.insert(stories).values(storyRecord).onConflictDoUpdate({
|
||||
target: stories.id,
|
||||
set: {
|
||||
worldSetting: storyRecord.worldSetting,
|
||||
styleGuide: storyRecord.styleGuide,
|
||||
styleReferenceImageKey: storyRecord.styleReferenceImageKey,
|
||||
orientation: storyRecord.orientation,
|
||||
storyStateJson: storyRecord.storyStateJson,
|
||||
status: storyRecord.status,
|
||||
updatedAt: now,
|
||||
},
|
||||
}),
|
||||
// Clear old scenes/characters (will cascade delete via FK)
|
||||
this.db.delete(scenes).where(eq(scenes.storyId, input.id)),
|
||||
this.db.delete(characters).where(eq(characters.storyId, input.id)),
|
||||
// Insert new scenes/characters
|
||||
...sceneRecords.map((r) => this.db.insert(scenes).values(r)),
|
||||
...characterRecords.map((r) => this.db.insert(characters).values(r)),
|
||||
]);
|
||||
|
||||
return { storyId: input.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a complete story by ID, reconstructing Session shape.
|
||||
*
|
||||
* @param storyId Story primary key
|
||||
* @returns StoryLoadResult with deserialized beats/storyState, or null if not found
|
||||
*/
|
||||
async findById(storyId: string): Promise<StoryLoadResult | null> {
|
||||
const [storyRow] = await this.db
|
||||
.select()
|
||||
.from(stories)
|
||||
.where(eq(stories.id, storyId))
|
||||
.limit(1);
|
||||
|
||||
if (!storyRow) return null;
|
||||
|
||||
const sceneRows = await this.db
|
||||
.select()
|
||||
.from(scenes)
|
||||
.where(eq(scenes.storyId, storyId))
|
||||
.orderBy(scenes.sortOrder);
|
||||
|
||||
const characterRows = await this.db
|
||||
.select()
|
||||
.from(characters)
|
||||
.where(eq(characters.storyId, storyId));
|
||||
|
||||
return {
|
||||
story: {
|
||||
id: storyRow.id,
|
||||
userId: storyRow.userId,
|
||||
worldSetting: storyRow.worldSetting,
|
||||
styleGuide: storyRow.styleGuide,
|
||||
styleReferenceImage: storyRow.styleReferenceImageKey ?? undefined,
|
||||
orientation: storyRow.orientation as "portrait" | "landscape",
|
||||
storyState: storyRow.storyStateJson
|
||||
? (JSON.parse(storyRow.storyStateJson) as StoryState)
|
||||
: undefined,
|
||||
status: storyRow.status,
|
||||
createdAt: storyRow.createdAt,
|
||||
updatedAt: storyRow.updatedAt,
|
||||
},
|
||||
scenes: sceneRows.map((s) => ({
|
||||
id: s.id,
|
||||
sceneKey: s.sceneKey ?? undefined,
|
||||
sceneSummary: s.sceneSummary ?? undefined,
|
||||
imageUrl: s.sceneImageUrl ?? "", // CR-5: nullable column, fallback to empty string
|
||||
beats: s.beatsJson ? JSON.parse(s.beatsJson) : [],
|
||||
orientation: s.sceneImageUrl ? undefined : undefined, // Phase 1: no per-scene orientation in schema
|
||||
sortOrder: s.sortOrder,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
characters: characterRows.map((c) => ({
|
||||
name: c.name,
|
||||
visualDescription: c.visualDescription ?? undefined,
|
||||
voiceDescription: c.voiceDescription ?? undefined,
|
||||
portrait: c.basePortraitUrl
|
||||
? { url: c.basePortraitUrl, uuid: c.basePortraitUuid ?? undefined }
|
||||
: undefined,
|
||||
voice: c.voiceJson ? JSON.parse(c.voiceJson) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List story metadata for a given user, ordered by most recent first.
|
||||
*
|
||||
* @param userId User ID (or anonymous sessionId in Phase 1)
|
||||
* @param limit Max stories to return (default 50)
|
||||
* @returns Array of StoryMeta
|
||||
*/
|
||||
async listByUser(userId: string, limit = 50): Promise<StoryMeta[]> {
|
||||
const storyRows = await this.db
|
||||
.select()
|
||||
.from(stories)
|
||||
.where(eq(stories.userId, userId))
|
||||
.orderBy(desc(stories.updatedAt))
|
||||
.limit(limit);
|
||||
|
||||
if (storyRows.length === 0) return [];
|
||||
|
||||
// CR-10: batch scene count in 2 queries total (not N+1)
|
||||
const storyIds = storyRows.map((r) => r.id);
|
||||
const countRows = await this.db
|
||||
.select({ storyId: scenes.storyId, count: sql<number>`count(*)` })
|
||||
.from(scenes)
|
||||
.where(inArray(scenes.storyId, storyIds))
|
||||
.groupBy(scenes.storyId);
|
||||
|
||||
const countMap = new Map(countRows.map((r) => [r.storyId, r.count]));
|
||||
|
||||
return storyRows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
worldSetting: row.worldSetting,
|
||||
styleGuide: row.styleGuide,
|
||||
orientation: row.orientation,
|
||||
status: row.status,
|
||||
sceneCount: countMap.get(row.id) ?? 0,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a story and all associated scenes/characters (cascade via FK).
|
||||
*
|
||||
* @param storyId Story primary key
|
||||
* @returns true if deleted, false if not found
|
||||
*/
|
||||
async delete(storyId: string): Promise<boolean> {
|
||||
const result = await this.db.delete(stories).where(eq(stories.id, storyId));
|
||||
// Drizzle D1 delete returns { success, meta: { changes }, results }
|
||||
return ((result as any).meta?.changes ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ── Stories ──────────────────────────────────────────────────────────────
|
||||
// User story sessions (REQ-4). Each story contains multiple scenes and characters.
|
||||
export const stories = sqliteTable(
|
||||
"stories",
|
||||
{
|
||||
id: text("id").primaryKey(), // s_xxx session ID
|
||||
userId: text("user_id"), // nullable - Phase 1 uses anonymous sessionId
|
||||
worldSetting: text("world_setting").notNull(),
|
||||
styleGuide: text("style_guide").notNull(),
|
||||
styleReferenceImageKey: text("style_reference_image_key"), // R2 key (optional)
|
||||
orientation: text("orientation").notNull().default("landscape"), // "portrait" | "landscape"
|
||||
storyStateJson: text("story_state_json"), // JSON: StoryState
|
||||
status: text("status").notNull().default("active"), // "active" | "archived"
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`)
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("stories_user_id_idx").on(table.userId),
|
||||
createdAtIdx: index("stories_created_at_idx").on(table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Scenes ───────────────────────────────────────────────────────────────
|
||||
// Story scenes (REQ-4). Beats stored as JSON blob (not separate table).
|
||||
export const scenes = sqliteTable(
|
||||
"scenes",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
storyId: text("story_id")
|
||||
.notNull()
|
||||
.references(() => stories.id, { onDelete: "cascade" }),
|
||||
sceneKey: text("scene_key"), // e.g. "classroom-dusk"
|
||||
sceneSummary: text("scene_summary"),
|
||||
sceneImageKey: text("scene_image_key"), // R2 key (optional)
|
||||
sceneImageUrl: text("scene_image_url"), // Runware CDN URL (primary)
|
||||
beatsJson: text("beats_json"), // JSON: Beat[] - whole scene beats graph
|
||||
sortOrder: integer("sort_order").notNull(), // scene sequence in story
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
storyIdIdx: index("scenes_story_id_idx").on(table.storyId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Characters ───────────────────────────────────────────────────────────
|
||||
// Story characters (REQ-4). Each character belongs to a story.
|
||||
export const characters = sqliteTable(
|
||||
"characters",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
storyId: text("story_id")
|
||||
.notNull()
|
||||
.references(() => stories.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
visualDescription: text("visual_description"),
|
||||
voiceDescription: text("voice_description"),
|
||||
basePortraitKey: text("base_portrait_key"), // R2 key (optional)
|
||||
basePortraitUrl: text("base_portrait_url"), // CDN URL (primary)
|
||||
basePortraitUuid: text("base_portrait_uuid"), // image service UUID
|
||||
voiceJson: text("voice_json"), // JSON: CharacterVoice
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
storyNameIdx: uniqueIndex("characters_story_name_idx").on(
|
||||
table.storyId,
|
||||
table.name,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Featured Stories ─────────────────────────────────────────────────────
|
||||
// Featured story cards displayed on homepage (REQ-5).
|
||||
export const featuredStories = sqliteTable(
|
||||
"featured_stories",
|
||||
{
|
||||
id: text("id").primaryKey(), // e.g. "m0", "f12"
|
||||
gender: text("gender").notNull(), // "male" | "female"
|
||||
title: text("title").notNull(),
|
||||
outline: text("outline").notNull(),
|
||||
style: text("style").notNull(),
|
||||
tags: text("tags").notNull(), // JSON array
|
||||
coverPath: text("cover_path").notNull(), // e.g. "/home/m0.webp"
|
||||
firstactPath: text("firstact_path").notNull(), // e.g. "/home/firstact/m0.json"
|
||||
firstscenePath: text("firstscene_path"), // e.g. "/home/firstscene/m0.webp"
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
isActive: integer("is_active").notNull().default(1), // 1 = active, 0 = inactive
|
||||
clickCount: integer("click_count").notNull().default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
genderActiveIdx: index("featured_gender_active_idx").on(
|
||||
table.gender,
|
||||
table.isActive,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Type exports ─────────────────────────────────────────────────────────
|
||||
export type Story = typeof stories.$inferSelect;
|
||||
export type NewStory = typeof stories.$inferInsert;
|
||||
|
||||
export type Scene = typeof scenes.$inferSelect;
|
||||
export type NewScene = typeof scenes.$inferInsert;
|
||||
|
||||
export type Character = typeof characters.$inferSelect;
|
||||
export type NewCharacter = typeof characters.$inferInsert;
|
||||
|
||||
export type FeaturedStory = typeof featuredStories.$inferSelect;
|
||||
export type NewFeaturedStory = typeof featuredStories.$inferInsert;
|
||||
+5
-9
@@ -10,6 +10,7 @@ import {
|
||||
resolveEngineConfig,
|
||||
} from "@/lib/clientModelConfig";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { stripSessionVoices } from "@/lib/persistence/sessionSlim";
|
||||
import type {
|
||||
Character,
|
||||
FreeformClassifyRequest,
|
||||
@@ -78,16 +79,11 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
// data is bulky (~160KB/character via referenceAudioBase64) and the
|
||||
// scene-generation / vision / classify pipelines never need it — voices
|
||||
// are only consumed by /api/beat-audio, which receives them directly, not
|
||||
// via the session. So strip voices before transport.
|
||||
// via the session. So strip voices before transport. The stripping rule itself
|
||||
// lives in lib/persistence/sessionSlim.ts (shared with the local-store layer so
|
||||
// "what counts as voice" has one definition).
|
||||
function stripVoicesForTransport(session: Session): Session {
|
||||
return {
|
||||
...session,
|
||||
// Destructure voice out so the serialized payload drops the field
|
||||
// entirely (voice is optional on Character), rather than serializing
|
||||
// it as undefined/null. This is the ~160KB/character referenceAudioBase64
|
||||
// we want off the wire on the server-fallback path.
|
||||
characters: session.characters.map(({ voice: _voice, ...rest }) => rest),
|
||||
};
|
||||
return stripSessionVoices(session);
|
||||
}
|
||||
|
||||
// The server strips voice from already-known characters before responding
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user