From ff12b2759f69c405cd55506c5940c7f81904880c Mon Sep 17 00:00:00 2001 From: Kai ki <155355644+zbf1009@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:20:47 +0800 Subject: [PATCH] feat(persistence): bidirectional local/cloud story sync (Supabase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/stories/delete/route.ts | 32 +++ app/api/stories/manifest/route.ts | 19 ++ app/api/stories/pull/route.ts | 33 +++ app/api/stories/push/route.ts | 51 ++++ components/UserChip.tsx | 11 +- lib/clientStoryPersistence.ts | 15 +- lib/persistence/cloudStore.ts | 198 +++++++++----- lib/persistence/cloudSync.ts | 246 ++++++++++++++++++ lib/persistence/cloudSyncClient.ts | 84 ++++++ lib/persistence/localStore.ts | 74 +++++- lib/persistence/types.ts | 31 +++ .../20260628095015_upsert_story_if_newer.sql | 96 +++++++ 12 files changed, 824 insertions(+), 66 deletions(-) create mode 100644 app/api/stories/delete/route.ts create mode 100644 app/api/stories/manifest/route.ts create mode 100644 app/api/stories/pull/route.ts create mode 100644 app/api/stories/push/route.ts create mode 100644 lib/persistence/cloudSync.ts create mode 100644 lib/persistence/cloudSyncClient.ts create mode 100644 supabase/migrations/20260628095015_upsert_story_if_newer.sql diff --git a/app/api/stories/delete/route.ts b/app/api/stories/delete/route.ts new file mode 100644 index 0000000..735146d --- /dev/null +++ b/app/api/stories/delete/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/supabase/guard"; +import { cloudSoftDeleteStory } from "@/lib/persistence/cloudStore"; +import { coerceEpoch } from "@/lib/persistence/types"; + +export const runtime = "nodejs"; + +// POST /api/stories/delete — body { id, rev, deletedAt } → { ok }. Propagates a +// soft-delete (tombstone) under the same optimistic-concurrency guard as push. +// requireUser 401s an unauthenticated commercial caller; on the open-source +// build cloudSoftDeleteStory short-circuits to false. +export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + + let body: { id?: unknown; rev?: unknown; deletedAt?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + + const id = typeof body.id === "string" ? body.id : ""; + if (!id) { + return NextResponse.json({ error: "missing id" }, { status: 400 }); + } + const rev = typeof body.rev === "number" ? body.rev : 1; + const deletedAt = coerceEpoch(body.deletedAt, Date.now()); + + const ok = await cloudSoftDeleteStory(id, rev, deletedAt); + return NextResponse.json({ ok }); +} diff --git a/app/api/stories/manifest/route.ts b/app/api/stories/manifest/route.ts new file mode 100644 index 0000000..9831ac9 --- /dev/null +++ b/app/api/stories/manifest/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/supabase/guard"; +import { cloudStoryManifest } from "@/lib/persistence/cloudStore"; + +export const runtime = "nodejs"; + +// GET /api/stories/manifest — the reconcile diff basis: every cloud row for the +// signed-in user (INCLUDING tombstones), projected to {id, rev, updatedAt, +// deletedAt} without the bulky session_jsonb. Pure passthrough to cloudStore; +// requireUser 401s an unauthenticated commercial-build caller, and on the +// open-source build (AUTH_ENABLED=false) cloudStoryManifest short-circuits to [] +// without ever constructing a Supabase client. +export async function GET() { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + + const items = await cloudStoryManifest(); + return NextResponse.json({ items }); +} diff --git a/app/api/stories/pull/route.ts b/app/api/stories/pull/route.ts new file mode 100644 index 0000000..d2dbe53 --- /dev/null +++ b/app/api/stories/pull/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/supabase/guard"; +import { cloudPullBlobs } from "@/lib/persistence/cloudStore"; + +export const runtime = "nodejs"; + +// Cap per request — reconcile chunks its pull set, so one call never asks for an +// unbounded id list (a denial-of-wallet / oversized-response guard). +const MAX_PULL_IDS = 200; + +// POST /api/stories/pull — body { ids: string[] } → { blobs: StorySyncEnvelope[] } +// (full payloads, INCLUDING tombstones, for write-back into the local store). +// Pure passthrough to cloudStore; same auth/short-circuit story as manifest. +export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + + let body: { ids?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + + const ids = Array.isArray(body.ids) + ? body.ids + .filter((x): x is string => typeof x === "string" && x.length > 0) + .slice(0, MAX_PULL_IDS) + : []; + + const blobs = await cloudPullBlobs(ids); + return NextResponse.json({ blobs }); +} diff --git a/app/api/stories/push/route.ts b/app/api/stories/push/route.ts new file mode 100644 index 0000000..3403cf0 --- /dev/null +++ b/app/api/stories/push/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { coerceOrientation } from "@infiplot/types"; +import { requireUser } from "@/lib/supabase/guard"; +import { cloudSaveStory } from "@/lib/persistence/cloudStore"; +import { coerceEpoch, type StorySyncEnvelope } from "@/lib/persistence/types"; + +export const runtime = "nodejs"; + +// Matches story-pack's 12 MB doc ceiling — a slim Session (voice + +// styleReferenceImage stripped) is far smaller, so this only rejects +// pathological payloads, never normal saves. +const MAX_PUSH_BYTES = 12_000_000; + +// POST /api/stories/push — body StorySyncEnvelope → { stored, won }. Pure +// passthrough to the optimistic-concurrency RPC; won=false means a newer cloud +// row was preserved. requireUser 401s an unauthenticated commercial caller; on +// the open-source build cloudSaveStory short-circuits to { stored:null, won:false }. +export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + + let raw: string; + try { + raw = await req.text(); + } catch { + return NextResponse.json({ error: "invalid body" }, { status: 400 }); + } + if (Buffer.byteLength(raw, "utf8") > MAX_PUSH_BYTES) { + return NextResponse.json({ error: "payload too large" }, { status: 413 }); + } + + let env: StorySyncEnvelope; + try { + env = JSON.parse(raw) as StorySyncEnvelope; + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + if (!env?.id || typeof env.id !== "string") { + return NextResponse.json({ error: "missing id" }, { status: 400 }); + } + + // Defensive coercion at the trust boundary (the slim session itself is left to + // the client — it's reconstructible and never security-sensitive after slim). + const result = await cloudSaveStory({ + ...env, + orientation: coerceOrientation(env.orientation), + updatedAt: coerceEpoch(env.updatedAt, Date.now()), + deletedAt: env.deletedAt == null ? null : coerceEpoch(env.deletedAt, Date.now()), + }); + return NextResponse.json(result); +} diff --git a/components/UserChip.tsx b/components/UserChip.tsx index 817b844..54202a4 100644 --- a/components/UserChip.tsx +++ b/components/UserChip.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { AUTH_ENABLED } from "@/lib/supabase/config"; import { createClient } from "@/lib/supabase/client"; +import { syncOnLogin } from "@/lib/persistence/cloudSync"; import type { AuthChangeEvent, Session, User } from "@supabase/supabase-js"; export function UserChip() { @@ -15,8 +16,16 @@ export function UserChip() { supabase.auth.getUser().then(({ data }: { data: { user: User | null } }) => setUser(data.user)); const { data: { subscription }, - } = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => { + } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => { setUser(session?.user ?? null); + // A signed-in user — a fresh login (SIGNED_IN) OR an already-authed mount + // (INITIAL_SESSION fires on subscribe with the current session) — triggers + // a full reconcile. syncOnLogin serializes via its in-flight guard, so + // overlapping events never run concurrent syncs (Req 4.1, 4.2, 4.3). This + // is the single global trigger point; AuthModal instances don't duplicate it. + if (session?.user && (event === "SIGNED_IN" || event === "INITIAL_SESSION")) { + void syncOnLogin(); + } }); return () => subscription.unsubscribe(); }, []); diff --git a/lib/clientStoryPersistence.ts b/lib/clientStoryPersistence.ts index fbf4df6..574b4d9 100644 --- a/lib/clientStoryPersistence.ts +++ b/lib/clientStoryPersistence.ts @@ -14,6 +14,7 @@ import { loadStorySession as loadSession, softDeleteStory, } from "@/lib/persistence/localStore"; +import { pushOnSave, pushDeletion } from "@/lib/persistence/cloudSync"; export type SaveResult = | { ok: true; storyId: string } @@ -23,9 +24,11 @@ export type SaveResult = * never throws, never blocks gameplay/navigation. */ export async function saveStory(session: Session): Promise { const rec = await saveStorySession(session); - return rec - ? { ok: true, storyId: rec.id } - : { ok: false, error: "无法保存到本地存储" }; + if (!rec) return { ok: false, error: "无法保存到本地存储" }; + // Fire-and-forget cloud push. pushOnSave short-circuits when auth is off / + // the user is signed out, so the open-source build sees no behavior change. + void pushOnSave(rec); + return { ok: true, storyId: rec.id }; } /** List saved stories for the "我的剧情" page (newest first). */ @@ -40,5 +43,9 @@ export async function loadStorySession(id: string): Promise { /** Delete a saved story (soft-delete). Returns false if not found. */ export async function deleteStory(storyId: string): Promise { - return softDeleteStory(storyId); + const ok = await softDeleteStory(storyId); + // Fire-and-forget tombstone propagation. pushDeletion short-circuits when auth + // is off / signed out, so the open-source build sees no behavior change. + if (ok) void pushDeletion(storyId); + return ok; } diff --git a/lib/persistence/cloudStore.ts b/lib/persistence/cloudStore.ts index 8ab8d5d..1bc2728 100644 --- a/lib/persistence/cloudStore.ts +++ b/lib/persistence/cloudStore.ts @@ -1,16 +1,22 @@ -// 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. +// Cloud story repository — server-only Supabase persistence for the COMMERCIAL +// build. Mirrors the local repository (lib/persistence/localStore.ts) so the +// reconcile engine (lib/persistence/cloudSync.ts) can treat the cloud as a layer +// over the local store. // -// 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. +// 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). +// +// Optimistic concurrency: +// - cloudSaveStory upserts via the upsert_story_if_newer RPC (needs INSERT-if- +// absent + a conditional overwrite, which PostgREST upsert can't express). +// - cloudSoftDeleteStory is UPDATE-only (a story never pushed has no cloud row +// to tombstone), so it expresses the same rev→updatedAt guard with a +// PostgREST .or() filter — no RPC needed. import "server-only"; @@ -18,7 +24,7 @@ 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 type { SlimStoryBlob, StoryMeta, StorySyncMeta, StorySyncEnvelope } from "./types"; import { coerceEpoch } from "./types"; /** One row of public.stories (snake_case columns ↔ SlimStoryBlob + sync meta). */ @@ -78,63 +84,75 @@ function rowToMeta(row: StoryRow): StoryMeta { }; } +/** Full-blob projection for the sync layer: blob + (updatedAt, deletedAt) so + * reconcile has the LWW-ordering fields. Carries tombstones (deletedAt may be + * non-null) — a pulled cloud tombstone mirrors a remote soft-delete locally. */ +function rowToEnvelope(row: StoryRow): StorySyncEnvelope { + 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, + updatedAt: coerceEpoch(row.updated_at, 0), + deletedAt: row.deleted_at ? coerceEpoch(row.deleted_at, 0) : null, + }; +} + // ── 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. +// CONTRACT NOTE: the sync methods (manifest/pull/save/softDelete) speak the +// StorySyncEnvelope/StorySyncMeta shapes — the convergence envelope the +// reconcile engine maps StoryRecord ↔ envelope in one place. The legacy +// cloudLoadStory/cloudListStories (leaner SlimStoryBlob/StoryMeta) are retained +// for non-sync callers; reconcile does not use them. -/** 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). */ +/** Upsert one story for the current user via the optimistic-concurrency RPC. + * Returns `{ stored, won }`: + * - won=true → our version is now the cloud row (fresh insert, winning + * update, or already-equal no-op); + * - won=false → a NEWER cloud row existed and was preserved; `stored` is that + * newer row so the caller can reconcile by pulling it back. + * Auth off / unauthenticated / write failure → `{ stored: null, won: false }`. */ export async function cloudSaveStory( - blob: SlimStoryBlob, -): Promise { - if (!AUTH_ENABLED) return null; + env: StorySyncEnvelope, +): Promise<{ stored: StorySyncEnvelope | null; won: boolean }> { + if (!AUTH_ENABLED) return { stored: null, won: false }; const userId = await currentUserId(); - if (!userId) return null; + if (!userId) return { stored: null, won: false }; 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: "user_id,id" }, - ) - .select() - .single(); - if (error || !data) return null; - return rowToBlob(data as StoryRow); + const { data, error } = await supabase.rpc("upsert_story_if_newer", { + p_id: env.id, + p_world: env.worldSetting ?? "", + p_style: env.styleGuide ?? "", + p_orientation: coerceOrientation(env.orientation), + p_scene_count: env.sceneCount ?? 0, + p_rev: env.rev ?? 1, + p_updated_at: new Date(env.updatedAt).toISOString(), + p_deleted_at: env.deletedAt ? new Date(env.deletedAt).toISOString() : null, + p_session: env.session, + }); + if (error || !data) return { stored: null, won: false }; + // The RPC `returns public.stories` (a single composite); supabase-js may + // hand it back as the object or wrapped in an array — normalize both. + const row = (Array.isArray(data) ? data[0] : data) as StoryRow | undefined; + if (!row) return { stored: null, won: false }; + const stored = rowToEnvelope(row); + // We won iff the stored row IS our version. A stale write returns the newer + // cloud row, whose (rev, updatedAt) differ from what we sent → won=false. + const won = stored.rev === env.rev && stored.updatedAt === env.updatedAt; + return { stored, won }; } catch { - return null; + return { stored: null, won: false }; } } /** Load one story's slim blob for the current user. Tombstoned / absent / not - * owned (RLS) → null. */ + * owned (RLS) → null. Retained for non-sync callers (reconcile uses + * cloudPullBlobs, which carries tombstones + sync-ordering fields). */ export async function cloudLoadStory(id: string): Promise { if (!AUTH_ENABLED) return null; const userId = await currentUserId(); @@ -180,21 +198,81 @@ export async function cloudListStories(): Promise { } } -/** 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 { +/** Reconcile diff basis: ALL the current user's rows (INCLUDING tombstones), + * projected to lightweight {id, rev, updatedAt, deletedAt}. Explicit column + * list so it never pulls session_jsonb. Auth off / unauth → []. */ +export async function cloudStoryManifest(): Promise { + if (!AUTH_ENABLED) return []; + const userId = await currentUserId(); + if (!userId) return []; + try { + const supabase = await createClient(); + const { data, error } = await supabase + .from("stories") + .select("id, rev, updated_at, deleted_at") + .eq("user_id", userId); + if (error || !data) return []; + return (data as StoryRow[]).map((row) => ({ + id: row.id, + rev: row.rev ?? 1, + updatedAt: coerceEpoch(row.updated_at, 0), + deletedAt: row.deleted_at ? coerceEpoch(row.deleted_at, 0) : null, + })); + } catch { + return []; + } +} + +/** Pull full envelopes for the given ids (INCLUDING tombstones — a pulled cloud + * tombstone mirrors a remote soft-delete locally). Empty ids / auth off / + * unauth → []. */ +export async function cloudPullBlobs( + ids: string[], +): Promise { + if (!AUTH_ENABLED) return []; + if (!ids.length) 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) + .in("id", ids); + if (error || !data) return []; + return (data as StoryRow[]).map(rowToEnvelope); + } catch { + return []; + } +} + +/** Propagate a soft-delete (tombstone) for the current user, with the same + * optimistic-concurrency guard as the save RPC expressed as a PostgREST .or() + * filter: only stamp when the incoming version is newer (rev higher, or rev + * tie with a later updatedAt). UPDATE-only — a story never pushed has no cloud + * row and needs no tombstone (returns false, which the caller treats as + * "nothing to delete remotely"). Auth off / unauth / not-newer / absent → + * false. */ +export async function cloudSoftDeleteStory( + id: string, + rev: number, + deletedAt: number, +): Promise { if (!AUTH_ENABLED) return false; const userId = await currentUserId(); if (!userId) return false; try { const supabase = await createClient(); - const now = new Date().toISOString(); + const deletedIso = new Date(deletedAt).toISOString(); const { data, error } = await supabase .from("stories") - .update({ deleted_at: now, updated_at: now }) - .eq("id", id) + .update({ deleted_at: deletedIso, updated_at: deletedIso, rev }) .eq("user_id", userId) - .is("deleted_at", null) + .eq("id", id) + // Quote the timestamptz value so PostgREST parses the colons/dots in the + // ISO string as a literal, not filter syntax. + .or(`rev.lt.${rev},and(rev.eq.${rev},updated_at.lt."${deletedIso}")`) .select("id"); if (error || !data || data.length === 0) return false; return true; diff --git a/lib/persistence/cloudSync.ts b/lib/persistence/cloudSync.ts new file mode 100644 index 0000000..e993d01 --- /dev/null +++ b/lib/persistence/cloudSync.ts @@ -0,0 +1,246 @@ +// Reconcile engine — the bidirectional local↔cloud sync orchestration for the +// COMMERCIAL build. Browser-only. This is the single place that maps +// StoryRecord ↔ StorySyncEnvelope ↔ StorySyncMeta and owns every merge decision. +// +// Triggers (all best-effort, never throw, never block gameplay): +// - syncOnLogin(): full reconcile on sign-in / authed mount, serialized so a +// second trigger joins the in-flight run instead of racing it. +// - pushOnSave(record): fire-and-forget single push after a local autosave. +// - pushDeletion(id): fire-and-forget tombstone propagation after a soft-delete. +// +// Conflict policy is last-write-wins: rev wins; on a rev tie, the later +// updatedAt wins (decideAction). A losing side is overwritten — acceptable for +// single-player, full-snapshot galgame saves (see design.md conflict tradeoff). + +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { isAuthed } from "@/lib/authResume"; +import { + pullManifest, + pullBlobs, + pushBlob, + pushDelete, +} from "./cloudSyncClient"; +import { + listAllRecordsForSync, + putSyncedRecord, + markRecordSynced, +} from "./localStore"; +import { coerceEpoch, type StoryRecord, type StorySyncMeta, type StorySyncEnvelope } from "./types"; + +// Keep in lockstep with the pull route's MAX_PULL_IDS. +const PULL_CHUNK = 200; + +type ReconcileAction = "push" | "pull" | "delete-remote" | "noop"; + +/** Which side is newer by the LWW order (rev, then updatedAt). Pure. */ +function newerSide( + local: StoryRecord, + cloud: StorySyncMeta, +): "local" | "cloud" | "equal" { + const lr = local.rev ?? 1; + const cr = cloud.rev ?? 1; + if (lr > cr) return "local"; + if (lr < cr) return "cloud"; + const lu = coerceEpoch(local.updatedAt, 0); + const cu = coerceEpoch(cloud.updatedAt, 0); + if (lu > cu) return "local"; + if (lu < cu) return "cloud"; + return "equal"; +} + +/** Pure merge decision for one id (no I/O) — implements the design decision + * table incl. tombstone priority ("the newer operation wins"). A soft-delete + * carries (rev, updatedAt) and is compared like an edit. NOTE softDeleteStory + * does NOT bump rev, so within the SAME rev a later-updatedAt delete propagates + * and a later-updatedAt edit resurrects; ACROSS revs the rev-primary LWW order + * applies (a higher-rev edit beats a wall-clock-later but lower-rev delete). + * Exported for the decision-matrix test. + * + * - only cloud, live → pull + * - only cloud, tombstone→ noop (don't materialize an already-reaped / never-held + * tombstone — avoids a 30-day-reap → re-pull-of-blob loop) + * - only local, live → push + * - only local, tombstone→ noop (no cloud row to delete; reaped locally) + * - both, local newer → tombstone ? delete-remote : push + * - both, cloud newer → pull + * - both, equal → noop (reconcile markSyncs if local not yet synced) */ +export function decideAction( + local: StoryRecord | undefined, + cloud: StorySyncMeta | undefined, +): ReconcileAction { + if (!local && cloud) return cloud.deletedAt ? "noop" : "pull"; + if (local && !cloud) return local.deletedAt ? "noop" : "push"; + if (!local || !cloud) return "noop"; // both undefined — unreachable in reconcile + + const side = newerSide(local, cloud); + if (side === "local") return local.deletedAt ? "delete-remote" : "push"; + if (side === "cloud") return "pull"; + return "noop"; +} + +/** StoryRecord → envelope for push (carries the LWW-ordering fields). */ +function recordToEnvelope(rec: StoryRecord): StorySyncEnvelope { + return { + id: rec.id, + worldSetting: rec.worldSetting ?? "", + styleGuide: rec.styleGuide ?? "", + orientation: rec.orientation, + sceneCount: rec.sceneCount ?? 0, + rev: rec.rev ?? 1, + session: rec.session, + updatedAt: coerceEpoch(rec.updatedAt, Date.now()), + deletedAt: rec.deletedAt == null ? null : coerceEpoch(rec.deletedAt, Date.now()), + }; +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +/** Push one local record; on a lost optimistic-concurrency race (won=false) + * pull the newer cloud row back instead. Each step swallows its own errors. */ +async function pushOne(rec: StoryRecord): Promise { + const res = await pushBlob(recordToEnvelope(rec)); + if (!res) return; // network/auth failure → leave pending for next reconcile + if (res.won) { + await markRecordSynced(rec.id, rec.rev ?? 1); + } else if (res.stored) { + await putSyncedRecord(res.stored); // we lost → adopt the newer cloud state + } +} + +/** Full bidirectional reconcile. Diffs the local set (incl. tombstones) against + * the cloud manifest, then applies each id's action, every item fault-tolerant + * (one failure skips that id, never the whole pass). */ +async function reconcile(): Promise { + const [localRecords, manifest] = await Promise.all([ + listAllRecordsForSync(), + pullManifest(), + ]); + const localById = new Map(localRecords.map((r) => [r.id, r])); + const cloudById = new Map(manifest.map((m) => [m.id, m])); + const allIds = new Set([...localById.keys(), ...cloudById.keys()]); + + const toPull: string[] = []; + const toPush: StoryRecord[] = []; + const toDelete: StoryRecord[] = []; + const toMarkSynced: StoryRecord[] = []; + + for (const id of allIds) { + const local = localById.get(id); + const cloud = cloudById.get(id); + switch (decideAction(local, cloud)) { + case "pull": + toPull.push(id); + break; + case "push": + if (local) toPush.push(local); + break; + case "delete-remote": + if (local) toDelete.push(local); + break; + case "noop": + // Already consistent on both sides but local not yet flagged synced — + // align its syncState (guard on cloud existing so a local-only tombstone + // isn't wrongly marked synced). + if (local && cloud && local.syncState !== "synced") toMarkSynced.push(local); + break; + } + } + + // Pull (batched, chunked to the route cap). + for (const ids of chunk(toPull, PULL_CHUNK)) { + try { + const blobs = await pullBlobs(ids); + for (const b of blobs) { + try { + await putSyncedRecord(b); + } catch { + /* skip this id */ + } + } + } catch { + /* skip this chunk (consistent with the push/delete loops' fault isolation) */ + } + } + // Push. + for (const rec of toPush) { + try { + await pushOne(rec); + } catch { + /* leave pending */ + } + } + // Tombstone propagation. + for (const rec of toDelete) { + try { + const ok = await pushDelete(rec.id, rec.rev ?? 1, coerceEpoch(rec.deletedAt, Date.now())); + if (ok) await markRecordSynced(rec.id, rec.rev ?? 1); + // !ok → cloud has a newer row; the next reconcile pulls it back. + } catch { + /* leave pending */ + } + } + // Mark already-consistent records synced. + for (const rec of toMarkSynced) { + try { + await markRecordSynced(rec.id, rec.rev ?? 1); + } catch { + /* best-effort */ + } + } +} + +// ── Public triggers ───────────────────────────────────────────────────────── + +// Serialize full syncs: a second trigger joins the in-flight run rather than +// starting a concurrent reconcile (Req 4.3). Module-level, mirrors the play +// page's saveChain dedup idea. +let inFlight: Promise | null = null; + +/** Trigger a full reconcile on sign-in / authed mount. Serialized + best-effort; + * short-circuits when auth is off or the user isn't signed in. */ +export async function syncOnLogin(): Promise { + if (!AUTH_ENABLED) return; + if (inFlight) return inFlight; + inFlight = (async () => { + try { + if (!(await isAuthed())) return; + await reconcile(); + } catch { + /* best-effort */ + } finally { + inFlight = null; + } + })(); + return inFlight; +} + +/** Fire-and-forget single push after a local autosave. Leaves the record pending + * on any failure so the next reconcile re-pushes it. */ +export async function pushOnSave(record: StoryRecord): Promise { + if (!AUTH_ENABLED || !record?.id) return; + try { + if (!(await isAuthed())) return; + await pushOne(record); + } catch { + /* leave pending */ + } +} + +/** Fire-and-forget tombstone propagation after a local soft-delete. Reads the + * local tombstone for its rev/deletedAt, then pushes the delete. */ +export async function pushDeletion(id: string): Promise { + if (!AUTH_ENABLED || !id) return; + try { + if (!(await isAuthed())) return; + const rec = (await listAllRecordsForSync()).find((r) => r.id === id); + if (!rec || !rec.deletedAt) return; // not a tombstone / already gone + const ok = await pushDelete(id, rec.rev ?? 1, coerceEpoch(rec.deletedAt, Date.now())); + if (ok) await markRecordSynced(id, rec.rev ?? 1); + } catch { + /* leave pending */ + } +} diff --git a/lib/persistence/cloudSyncClient.ts b/lib/persistence/cloudSyncClient.ts new file mode 100644 index 0000000..aa5a30c --- /dev/null +++ b/lib/persistence/cloudSyncClient.ts @@ -0,0 +1,84 @@ +// Network bridge — the ONLY fetch layer between the local store / reconcile +// engine and the cloud story API. Browser-only (imports the public AUTH_ENABLED +// flag, never the server-only cloudStore). +// +// Two-layer short-circuit: +// 1. AUTH_ENABLED=false (open-source build) → every method returns a safe empty +// value on its first line and NEVER issues a request. +// 2. The signed-in gate is enforced ONCE by the caller — the reconcile engine +// checks isAuthed() before touching this bridge — so methods here don't +// re-run getUser() per call. If an unauthenticated request slips through +// anyway, the route 401s and the fault-tolerant fetch below maps it to the +// same safe empty value. +// +// Every request is fully fault-tolerant: any non-2xx / network error / parse +// failure resolves to a safe value and never throws (best-effort sync). + +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import type { StorySyncMeta, StorySyncEnvelope } from "./types"; + +async function postJson(url: string, body: unknown): Promise { + try { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +/** GET the cloud manifest (all rows incl. tombstones, lightweight). [] on any + * failure / auth off. */ +export async function pullManifest(): Promise { + if (!AUTH_ENABLED) return []; + try { + const res = await fetch("/api/stories/manifest", { method: "GET" }); + if (!res.ok) return []; + const data = (await res.json()) as { items?: StorySyncMeta[] }; + return data.items ?? []; + } catch { + return []; + } +} + +/** Pull full envelopes for the given ids. [] on empty ids / failure / auth off. */ +export async function pullBlobs(ids: string[]): Promise { + if (!AUTH_ENABLED || ids.length === 0) return []; + const data = await postJson<{ blobs?: StorySyncEnvelope[] }>( + "/api/stories/pull", + { ids }, + ); + return data?.blobs ?? []; +} + +/** Push one envelope through the optimistic-concurrency RPC. Returns the + * `{ stored, won }` result, or null on failure / auth off (caller leaves the + * record pending for the next reconcile). */ +export async function pushBlob( + env: StorySyncEnvelope, +): Promise<{ stored: StorySyncEnvelope | null; won: boolean } | null> { + if (!AUTH_ENABLED) return null; + return postJson<{ stored: StorySyncEnvelope | null; won: boolean }>( + "/api/stories/push", + env, + ); +} + +/** Propagate a soft-delete tombstone. false on failure / auth off / not-newer. */ +export async function pushDelete( + id: string, + rev: number, + deletedAt: number, +): Promise { + if (!AUTH_ENABLED) return false; + const data = await postJson<{ ok?: boolean }>("/api/stories/delete", { + id, + rev, + deletedAt, + }); + return data?.ok ?? false; +} diff --git a/lib/persistence/localStore.ts b/lib/persistence/localStore.ts index 31d4861..c004fd1 100644 --- a/lib/persistence/localStore.ts +++ b/lib/persistence/localStore.ts @@ -11,7 +11,7 @@ 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"; +import { STORY_SCHEMA_VERSION, coerceEpoch, type StoryRecord, type StoryMeta, type StorySyncEnvelope } 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 @@ -186,3 +186,75 @@ export async function softDeleteStory(id: string): Promise { }; return idbPut(STORIES_STORE, updated); } + +// ── Sync support (story-cloud-sync) ───────────────────────────────────────── +// These are the cloud-sync counterparts to the user-write path above. The +// distinction matters: saveStorySession is a USER write (bumps rev, +// synced→pending), while putSyncedRecord is a SYNC write (cloud is +// authoritative: takes the cloud rev verbatim, marks synced, never bumps). + +/** Reconcile diff basis (local side): ALL records INCLUDING tombstones, with + * rev/syncState intact — the local mirror of cloudStoryManifest's + * tombstone-inclusive scan. [] when storage is unavailable. */ +export async function listAllRecordsForSync(): Promise { + return idbGetAll(STORIES_STORE); +} + +/** Write a cloud-pulled version as the authoritative synced baseline: + * rev/updatedAt/deletedAt taken from the envelope, syncState="synced", and + * rev is NOT bumped (unlike saveStorySession). createdAt is preserved if a + * local record already exists, else seeded from the envelope's updatedAt (the + * cloud row carries no createdAt; createdAt is display-only). Keeps the + * schemaVersion invariant and the slim session as-is. Returns false on write + * failure (Req 3.3, 3.6). Runs retention housekeeping after a durable write. */ +export async function putSyncedRecord( + env: StorySyncEnvelope, +): Promise { + if (!env?.id) return false; + const existing = await idbGet(STORIES_STORE, env.id); + // Concurrency guard (symmetric with markRecordSynced's rev guard): if the local + // record was updated to a strictly newer version (rev → updatedAt) between + // reconcile's decision snapshot and this write, don't clobber it — leave it + // (pending) for the next reconcile to re-push. Otherwise a local autosave that + // lands mid-reconcile could be overwritten by a now-stale cloud version (a + // legitimate LWW winner silently lost). + if (existing) { + const er = existing.rev ?? 1; + const nr = env.rev ?? 1; + const eu = coerceEpoch(existing.updatedAt, 0); + const nu = coerceEpoch(env.updatedAt, 0); + if (er > nr || (er === nr && eu > nu)) return false; + } + const record: StoryRecord = { + id: env.id, + schemaVersion: STORY_SCHEMA_VERSION, + worldSetting: env.worldSetting ?? "", + styleGuide: env.styleGuide ?? "", + orientation: coerceOrientation(env.orientation), + sceneCount: env.sceneCount ?? 0, + createdAt: existing + ? coerceEpoch(existing.createdAt, env.updatedAt) + : coerceEpoch(env.updatedAt, Date.now()), + updatedAt: coerceEpoch(env.updatedAt, Date.now()), + rev: env.rev ?? 1, + deletedAt: env.deletedAt == null ? null : coerceEpoch(env.deletedAt, Date.now()), + syncState: "synced", + session: env.session, + }; + const ok = await idbPut(STORIES_STORE, record); + if (ok) await enforceRetentionCap(); + return ok; +} + +/** Mark a local record synced after a successful push, aligning syncState to + * the cloud-acknowledged baseline — but ONLY if the local record still matches + * the rev we pushed. A newer local edit (rev moved past what we pushed) is left + * pending so the next reconcile re-pushes the newer content. No-op if the + * record is gone or already synced (Req 8.1). */ +export async function markRecordSynced(id: string, rev: number): Promise { + const rec = await idbGet(STORIES_STORE, id); + if (!rec) return; + if ((rec.rev ?? 1) !== rev) return; + if (rec.syncState === "synced") return; + await idbPut(STORIES_STORE, { ...rec, syncState: "synced" }); +} diff --git a/lib/persistence/types.ts b/lib/persistence/types.ts index 9ea1f56..240d945 100644 --- a/lib/persistence/types.ts +++ b/lib/persistence/types.ts @@ -98,3 +98,34 @@ export type StoryRecord = { * structured-clones objects, so this is stored as-is (no JSON.stringify). */ session: Session; }; + +// ── Cloud-sync wire types (story-cloud-sync) ──────────────────────────────── + +/** Manifest projection of one cloud story — the lightweight metadata the + * reconcile engine diffs against the local set. Unlike `StoryMeta` it CARRIES + * the tombstone (`deletedAt`) and `rev`, because reconcile needs both to pick + * a winner (rev → updatedAt last-write-wins) and to propagate soft-deletes. + * Never carries the session blob — the manifest is the cheap diff basis. */ +export type StorySyncMeta = { + id: string; + rev: number; + /** epoch ms */ + updatedAt: number; + /** Soft-delete tombstone (epoch ms) or null. */ + deletedAt: number | null; +}; + +/** Full-payload carrier for pull/push between the local store and the cloud. + * Extends the shared `SlimStoryBlob` with the two sync-ordering fields: + * - `updatedAt` is the CLIENT-recorded modification time (NOT a server + * `now()`), so when two devices collide on the same `rev`, `updatedAt` + * stays a meaningful last-write-wins tiebreaker rather than always-now. + * - `deletedAt` lets a tombstone ride the same envelope (delete propagation). + * `rev` is already on `SlimStoryBlob`, so the envelope = blob + (updatedAt, + * deletedAt). This is the single shape crossing the API at pull/push. */ +export type StorySyncEnvelope = SlimStoryBlob & { + /** epoch ms */ + updatedAt: number; + /** Soft-delete tombstone (epoch ms) or null. */ + deletedAt: number | null; +}; diff --git a/supabase/migrations/20260628095015_upsert_story_if_newer.sql b/supabase/migrations/20260628095015_upsert_story_if_newer.sql new file mode 100644 index 0000000..deb5ecb --- /dev/null +++ b/supabase/migrations/20260628095015_upsert_story_if_newer.sql @@ -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;