feat(persistence): bidirectional local/cloud story sync (Supabase)
Connect the previously-skeleton cloudStore to the client with a full
bidirectional reconcile engine. Commercial build (AUTH_ENABLED) only; the
open-source build is byte-for-byte unchanged — all cloud paths short-circuit
when AUTH_ENABLED is false.
- cloudSync.ts: reconcile engine — decideAction (pure, LWW rev->updatedAt with
tombstone priority) + syncOnLogin/pushOnSave/pushDeletion (best-effort,
serialized, isAuthed-gated)
- cloudSyncClient.ts: browser fetch bridge (short-circuit + fault-tolerant)
- /api/stories/{manifest,pull,push,delete}: RLS-guarded sync endpoints
- upsert_story_if_newer RPC: optimistic concurrency (SECURITY INVOKER,
auth.uid() injection, rev->updated_at guard, revoked from public)
- cloudStore: +manifest/pullBlobs, save->RPC {stored,won}, softDelete w/ rev
- localStore: +listAllRecordsForSync/putSyncedRecord/markRecordSynced
(concurrency-guarded sync writes); types: +StorySyncMeta/StorySyncEnvelope
- facade + UserChip: inject pushOnSave/pushDeletion + login-triggered reconcile
Sync model: full reconcile on login + background push on save (no Realtime;
eventual consistency). Conflict resolution: last-write-wins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
-- Story cloud sync — optimistic-concurrency upsert RPC (story-cloud-sync).
|
||||
--
|
||||
-- Why an RPC (not a plain .upsert): the bare upsert in cloudStore.cloudSaveStory
|
||||
-- was last-write-wins with NO monotonic guard, so a slow concurrent writer could
|
||||
-- clobber newer cloud state. This function moves the "only overwrite when newer"
|
||||
-- decision into SQL, matching the reconcile decision table (rev wins; on a rev
|
||||
-- tie, the later updated_at wins). A stale write leaves the cloud row untouched
|
||||
-- and returns the CURRENT cloud row, so the client can detect it lost and pull
|
||||
-- the newer state back instead of erroring.
|
||||
--
|
||||
-- Security model: SECURITY INVOKER (the default, stated explicitly) so the
|
||||
-- existing RLS policies on public.stories (auth.uid() = user_id) still apply —
|
||||
-- no service_role, no RLS bypass. user_id is injected from auth.uid(), never
|
||||
-- from the client, so a caller cannot write rows for another user. Granted to
|
||||
-- the `authenticated` role only.
|
||||
--
|
||||
-- Idempotent: create or replace + idempotent grant — safe to re-run.
|
||||
|
||||
create or replace function public.upsert_story_if_newer(
|
||||
p_id text,
|
||||
p_world text,
|
||||
p_style text,
|
||||
p_orientation text,
|
||||
p_scene_count integer,
|
||||
p_rev integer,
|
||||
p_updated_at timestamptz,
|
||||
p_deleted_at timestamptz,
|
||||
p_session jsonb
|
||||
)
|
||||
returns public.stories
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_uid uuid := auth.uid();
|
||||
v_row public.stories;
|
||||
begin
|
||||
-- Defense in depth: RLS would already reject an anonymous write, but failing
|
||||
-- fast here avoids inserting with a null user_id and yields a clearer error.
|
||||
if v_uid is null then
|
||||
raise exception 'upsert_story_if_newer: not authenticated';
|
||||
end if;
|
||||
|
||||
insert into public.stories (
|
||||
id, user_id, world_setting, style_guide, orientation,
|
||||
scene_count, rev, created_at, updated_at, deleted_at, session_jsonb
|
||||
)
|
||||
values (
|
||||
p_id, v_uid, coalesce(p_world, ''), coalesce(p_style, ''),
|
||||
coalesce(p_orientation, 'landscape'), coalesce(p_scene_count, 0),
|
||||
coalesce(p_rev, 1), now(), coalesce(p_updated_at, now()),
|
||||
p_deleted_at, p_session
|
||||
)
|
||||
on conflict (user_id, id) do update
|
||||
set world_setting = excluded.world_setting,
|
||||
style_guide = excluded.style_guide,
|
||||
orientation = excluded.orientation,
|
||||
scene_count = excluded.scene_count,
|
||||
rev = excluded.rev,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = excluded.deleted_at,
|
||||
session_jsonb = excluded.session_jsonb
|
||||
-- Optimistic-concurrency guard: overwrite ONLY when the incoming version is
|
||||
-- strictly newer. created_at is intentionally NOT in the SET list, so an
|
||||
-- update preserves the original insert timestamp.
|
||||
where excluded.rev > public.stories.rev
|
||||
or (excluded.rev = public.stories.rev
|
||||
and excluded.updated_at > public.stories.updated_at)
|
||||
returning * into v_row;
|
||||
|
||||
-- v_row is populated on a fresh insert OR a winning update. It is NULL when
|
||||
-- the row already existed AND the where-guard rejected the update (stale
|
||||
-- write) — in that case return the current cloud row so the caller sees it
|
||||
-- lost and can reconcile by pulling the newer cloud state.
|
||||
if v_row.id is not null then
|
||||
return v_row;
|
||||
end if;
|
||||
|
||||
select * into v_row
|
||||
from public.stories
|
||||
where user_id = v_uid and id = p_id;
|
||||
return v_row;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Lock down execution. Postgres grants EXECUTE to PUBLIC by default on function
|
||||
-- creation, which would let the `anon` role reach this RPC via PostgREST. The
|
||||
-- SECURITY INVOKER + null check + RLS would still reject an anonymous call, but
|
||||
-- least-privilege says don't rely on the function body as the only gate —
|
||||
-- revoke PUBLIC, then grant only the authenticated role.
|
||||
revoke execute on function public.upsert_story_if_newer(
|
||||
text, text, text, text, integer, integer, timestamptz, timestamptz, jsonb
|
||||
) from public;
|
||||
grant execute on function public.upsert_story_if_newer(
|
||||
text, text, text, text, integer, integer, timestamptz, timestamptz, jsonb
|
||||
) to authenticated;
|
||||
Reference in New Issue
Block a user