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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user