Files
infiplot-web/supabase/migrations/20260624135618_stories.sql
T
Kai ki da74e3e763 fix(persistence): address PR review feedback (4 low-cost improvements)
From PR #114 external review agent — adopted the real, low-cost findings;
remaining items (false positives / design trade-offs) explained in PR replies:

- coerceEpoch: !Number.isNaN → Number.isFinite — reject ±Infinity, which
  previously slipped through and produced Invalid Date via new Date(Infinity)
- enforceRetentionCap pass2/pass3: decrement overflow only when idbDelete
  actually succeeds — a failed best-effort delete no longer under-evicts
- cloudListStories: explicit column list instead of select() — avoid pulling
  the bulky session_jsonb when only metadata is needed
- Supabase stories: composite primary key (user_id, id) + onConflict user_id,id
  — avoid a cross-user Session.id collision rejecting the second user's save
  (skeleton not yet deployed, so the migration is edited in place)

typecheck + build:cf green.
2026-06-25 19:20:55 +08:00

59 lines
3.0 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 not null, -- = Session.id ("s_xxx"), unique only per user
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)
-- Composite PK: a random Session.id ("s_xxx") is unique only within a user, so
-- scope the key by user_id — otherwise a cross-user id collision would reject
-- the second user's save with a PK violation.
primary key (user_id, id)
);
-- 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);