Files
infiplot-web/supabase/migrations/20260624135618_stories.sql
T
Kai ki 610dba78b7 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
2026-06-25 18:19:08 +08:00

55 lines
2.8 KiB
SQL

-- Story persistence — Supabase single-table JSONB + RLS skeleton.
--
-- This migration lands the cloud schema for the COMMERCIAL build only. The
-- open-source build persists stories browser-locally (IndexedDB) and never
-- reaches this table. Cloud sync is NOT wired to any client this phase — the
-- table + RLS exist so next-phase local-first bidirectional sync can layer on
-- without a schema change.
--
-- One row mirrors the local StoryRecord's shared SlimStoryBlob payload
-- (lib/persistence/types.ts): list-view metadata is denormalized into columns
-- and the slim Session lives in session_jsonb. Per-user isolation is enforced
-- entirely by RLS (auth.uid() = user_id) against the SSR client's anon key +
-- user cookie — no service_role key is used.
--
-- Idempotent: safe to re-run. Tables/indexes use `if not exists`; policies are
-- dropped-then-created (Postgres has no `create policy if not exists`).
create table if not exists public.stories (
id text primary key, -- = Session.id ("s_xxx"), shared with the local record
user_id uuid not null references auth.users (id) on delete cascade,
world_setting text not null default '',
style_guide text not null default '',
orientation text not null default 'landscape', -- "portrait" | "landscape"
scene_count integer not null default 0,
rev integer not null default 1, -- revision; new = 1, +1 per save
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz, -- soft-delete tombstone; null = live
session_jsonb jsonb not null -- slim Session blob (voice + styleReferenceImage stripped)
);
-- List query path: a user's stories newest-first.
create index if not exists stories_user_updated_idx
on public.stories (user_id, updated_at desc);
alter table public.stories enable row level security;
-- Authenticated users may read/write ONLY their own rows. Four policies, one
-- per command, all keyed on auth.uid() = user_id.
drop policy if exists "stories_select_own" on public.stories;
create policy "stories_select_own" on public.stories
for select using (auth.uid() = user_id);
drop policy if exists "stories_insert_own" on public.stories;
create policy "stories_insert_own" on public.stories
for insert with check (auth.uid() = user_id);
drop policy if exists "stories_update_own" on public.stories;
create policy "stories_update_own" on public.stories
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
drop policy if exists "stories_delete_own" on public.stories;
create policy "stories_delete_own" on public.stories
for delete using (auth.uid() = user_id);