Merge pull request #117 from zonghaoyuan/cloudflare-migration
feat(persistence): bidirectional local/cloud story sync (Supabase)
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUser } from "@/lib/supabase/guard";
|
||||||
|
import { cloudSoftDeleteStory } from "@/lib/persistence/cloudStore";
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
// Validate rev/deletedAt as finite values (see push route rationale): reject
|
||||||
|
// bad input with 400 rather than letting NaN/Infinity reach the PostgREST
|
||||||
|
// filter or toISOString().
|
||||||
|
if (typeof body.rev !== "number" || !Number.isFinite(body.rev) || body.rev <= 0) {
|
||||||
|
return NextResponse.json({ error: "invalid rev" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body.deletedAt !== "number" || !Number.isFinite(body.deletedAt)) {
|
||||||
|
return NextResponse.json({ error: "invalid deletedAt" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await cloudSoftDeleteStory(id, body.rev, body.deletedAt);
|
||||||
|
return NextResponse.json({ ok });
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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 },
|
||||||
|
{ headers: { "Cache-Control": "private, no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Pre-check Content-Length to reject an oversized body before buffering it.
|
||||||
|
// The post-read byteLength check below still covers chunked/omitted headers.
|
||||||
|
const contentLength = req.headers.get("content-length");
|
||||||
|
if (contentLength && Number(contentLength) > MAX_PUSH_BYTES) {
|
||||||
|
return NextResponse.json({ error: "payload too large" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
// Validate the LWW-ordering fields as finite values: a non-finite rev /
|
||||||
|
// updatedAt would otherwise reach the RPC, throw at toISOString(), and surface
|
||||||
|
// as a silent { stored:null, won:false } 200 — return 400 so the caller can
|
||||||
|
// diagnose a bad request rather than mistake it for a normal lost conflict.
|
||||||
|
if (typeof env.rev !== "number" || !Number.isFinite(env.rev) || env.rev <= 0) {
|
||||||
|
return NextResponse.json({ error: "invalid rev" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof env.updatedAt !== "number" || !Number.isFinite(env.updatedAt)) {
|
||||||
|
return NextResponse.json({ error: "invalid updatedAt" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
env.deletedAt != null &&
|
||||||
|
(typeof env.deletedAt !== "number" || !Number.isFinite(env.deletedAt))
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "invalid deletedAt" }, { 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, 0),
|
||||||
|
deletedAt: env.deletedAt == null ? null : coerceEpoch(env.deletedAt, 0),
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
+10
-1
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { syncOnLogin } from "@/lib/persistence/cloudSync";
|
||||||
import type { AuthChangeEvent, Session, User } from "@supabase/supabase-js";
|
import type { AuthChangeEvent, Session, User } from "@supabase/supabase-js";
|
||||||
|
|
||||||
export function UserChip() {
|
export function UserChip() {
|
||||||
@@ -15,8 +16,16 @@ export function UserChip() {
|
|||||||
supabase.auth.getUser().then(({ data }: { data: { user: User | null } }) => setUser(data.user));
|
supabase.auth.getUser().then(({ data }: { data: { user: User | null } }) => setUser(data.user));
|
||||||
const {
|
const {
|
||||||
data: { subscription },
|
data: { subscription },
|
||||||
} = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => {
|
} = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
|
||||||
setUser(session?.user ?? 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();
|
return () => subscription.unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
loadStorySession as loadSession,
|
loadStorySession as loadSession,
|
||||||
softDeleteStory,
|
softDeleteStory,
|
||||||
} from "@/lib/persistence/localStore";
|
} from "@/lib/persistence/localStore";
|
||||||
|
import { pushOnSave, pushDeletion } from "@/lib/persistence/cloudSync";
|
||||||
|
|
||||||
export type SaveResult =
|
export type SaveResult =
|
||||||
| { ok: true; storyId: string }
|
| { ok: true; storyId: string }
|
||||||
@@ -23,9 +24,11 @@ export type SaveResult =
|
|||||||
* never throws, never blocks gameplay/navigation. */
|
* never throws, never blocks gameplay/navigation. */
|
||||||
export async function saveStory(session: Session): Promise<SaveResult> {
|
export async function saveStory(session: Session): Promise<SaveResult> {
|
||||||
const rec = await saveStorySession(session);
|
const rec = await saveStorySession(session);
|
||||||
return rec
|
if (!rec) return { ok: false, error: "无法保存到本地存储" };
|
||||||
? { ok: true, storyId: rec.id }
|
// Fire-and-forget cloud push. pushOnSave short-circuits when auth is off /
|
||||||
: { ok: false, error: "无法保存到本地存储" };
|
// 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). */
|
/** List saved stories for the "我的剧情" page (newest first). */
|
||||||
@@ -40,5 +43,9 @@ export async function loadStorySession(id: string): Promise<Session | null> {
|
|||||||
|
|
||||||
/** Delete a saved story (soft-delete). Returns false if not found. */
|
/** Delete a saved story (soft-delete). Returns false if not found. */
|
||||||
export async function deleteStory(storyId: string): Promise<boolean> {
|
export async function deleteStory(storyId: string): Promise<boolean> {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-60
@@ -1,16 +1,22 @@
|
|||||||
// Cloud story repository — server-only Supabase persistence skeleton for the
|
// Cloud story repository — server-only Supabase persistence for the COMMERCIAL
|
||||||
// COMMERCIAL build. Mirrors the local repository (lib/persistence/localStore.ts)
|
// build. Mirrors the local repository (lib/persistence/localStore.ts) so the
|
||||||
// method-for-method so next-phase local-first bidirectional sync can treat the
|
// reconcile engine (lib/persistence/cloudSync.ts) can treat the cloud as a layer
|
||||||
// cloud as a layer over the local store rather than a parallel branch.
|
// over the local store.
|
||||||
//
|
//
|
||||||
// This phase is a SKELETON: no API route exposes these functions and no client
|
// When AUTH_ENABLED is false (the open-source build) every method short-circuits
|
||||||
// calls them. When AUTH_ENABLED is false (the open-source build) every method
|
// to a safe value on its first line and never touches Supabase.
|
||||||
// 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,
|
// 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
|
// 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
|
// 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).
|
// (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";
|
import "server-only";
|
||||||
|
|
||||||
@@ -18,7 +24,7 @@ import type { Session } from "@infiplot/types";
|
|||||||
import { coerceOrientation } from "@infiplot/types";
|
import { coerceOrientation } from "@infiplot/types";
|
||||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import type { SlimStoryBlob, StoryMeta } from "./types";
|
import type { SlimStoryBlob, StoryMeta, StorySyncMeta, StorySyncEnvelope } from "./types";
|
||||||
import { coerceEpoch } from "./types";
|
import { coerceEpoch } from "./types";
|
||||||
|
|
||||||
/** One row of public.stories (snake_case columns ↔ SlimStoryBlob + sync meta). */
|
/** 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 ──────────────────────────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// CONTRACT NOTE (CR-15): these methods are the cloud COUNTERPARTS of
|
// CONTRACT NOTE: the sync methods (manifest/pull/save/softDelete) speak the
|
||||||
// lib/persistence/localStore.ts, but their return shapes are intentionally NOT
|
// StorySyncEnvelope/StorySyncMeta shapes — the convergence envelope the
|
||||||
// identical — the local store returns rich StoryRecord/Session values (carrying
|
// reconcile engine maps StoryRecord ↔ envelope in one place. The legacy
|
||||||
// schemaVersion/createdAt/updatedAt/deletedAt/syncState), while the cloud store
|
// cloudLoadStory/cloudListStories (leaner SlimStoryBlob/StoryMeta) are retained
|
||||||
// returns the leaner SlimStoryBlob. When next-phase bidirectional sync lands it
|
// for non-sync callers; reconcile does not use them.
|
||||||
// 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
|
/** Upsert one story for the current user via the optimistic-concurrency RPC.
|
||||||
* caller-supplied rev/updated_at are written verbatim and created_at is left to
|
* Returns `{ stored, won }`:
|
||||||
* the DB default (insert only). NOTE (CR-10): this is last-write-wins — there is
|
* - won=true → our version is now the cloud row (fresh insert, winning
|
||||||
* no `updated_at`-monotonic guard, so a slow concurrent writer can clobber newer
|
* update, or already-equal no-op);
|
||||||
* cloud state; the next-phase sync layer must add an optimistic-concurrency
|
* - won=false → a NEWER cloud row existed and was preserved; `stored` is that
|
||||||
* predicate (e.g. only overwrite when excluded.updated_at > stories.updated_at)
|
* newer row so the caller can reconcile by pulling it back.
|
||||||
* before this is wired to real multi-device traffic. Returns the stored blob, or
|
* Auth off / unauthenticated / write failure → `{ stored: null, won: false }`. */
|
||||||
* 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(
|
export async function cloudSaveStory(
|
||||||
blob: SlimStoryBlob,
|
env: StorySyncEnvelope,
|
||||||
): Promise<SlimStoryBlob | null> {
|
): Promise<{ stored: StorySyncEnvelope | null; won: boolean }> {
|
||||||
if (!AUTH_ENABLED) return null;
|
if (!AUTH_ENABLED) return { stored: null, won: false };
|
||||||
const userId = await currentUserId();
|
const userId = await currentUserId();
|
||||||
if (!userId) return null;
|
if (!userId) return { stored: null, won: false };
|
||||||
try {
|
try {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase.rpc("upsert_story_if_newer", {
|
||||||
.from("stories")
|
p_id: env.id,
|
||||||
.upsert(
|
p_world: env.worldSetting ?? "",
|
||||||
{
|
p_style: env.styleGuide ?? "",
|
||||||
id: blob.id,
|
p_orientation: coerceOrientation(env.orientation),
|
||||||
user_id: userId,
|
p_scene_count: env.sceneCount ?? 0,
|
||||||
world_setting: blob.worldSetting ?? "",
|
p_rev: env.rev ?? 1,
|
||||||
style_guide: blob.styleGuide ?? "",
|
p_updated_at: new Date(env.updatedAt).toISOString(),
|
||||||
orientation: coerceOrientation(blob.orientation),
|
p_deleted_at: env.deletedAt ? new Date(env.deletedAt).toISOString() : null,
|
||||||
scene_count: blob.sceneCount ?? 0,
|
p_session: env.session,
|
||||||
rev: blob.rev ?? 1,
|
});
|
||||||
updated_at: new Date().toISOString(),
|
if (error || !data) return { stored: null, won: false };
|
||||||
deleted_at: null,
|
// The RPC `returns public.stories` (a single composite); supabase-js may
|
||||||
session_jsonb: blob.session,
|
// hand it back as the object or wrapped in an array — normalize both.
|
||||||
},
|
const row = (Array.isArray(data) ? data[0] : data) as StoryRow | undefined;
|
||||||
{ onConflict: "user_id,id" },
|
if (!row) return { stored: null, won: false };
|
||||||
)
|
const stored = rowToEnvelope(row);
|
||||||
.select()
|
// We won iff the stored row IS our version. A stale write returns the newer
|
||||||
.single();
|
// cloud row, whose (rev, updatedAt) differ from what we sent → won=false.
|
||||||
if (error || !data) return null;
|
const won = stored.rev === env.rev && stored.updatedAt === env.updatedAt;
|
||||||
return rowToBlob(data as StoryRow);
|
return { stored, won };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return { stored: null, won: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load one story's slim blob for the current user. Tombstoned / absent / not
|
/** 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<SlimStoryBlob | null> {
|
export async function cloudLoadStory(id: string): Promise<SlimStoryBlob | null> {
|
||||||
if (!AUTH_ENABLED) return null;
|
if (!AUTH_ENABLED) return null;
|
||||||
const userId = await currentUserId();
|
const userId = await currentUserId();
|
||||||
@@ -180,21 +198,81 @@ export async function cloudListStories(): Promise<StoryMeta[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft-delete one story (set the tombstone) for the current user so the
|
/** Reconcile diff basis: ALL the current user's rows (INCLUDING tombstones),
|
||||||
* deletion can propagate. Absent / not owned / write failed → false. */
|
* projected to lightweight {id, rev, updatedAt, deletedAt}. Explicit column
|
||||||
export async function cloudSoftDeleteStory(id: string): Promise<boolean> {
|
* list so it never pulls session_jsonb. Auth off / unauth → []. */
|
||||||
|
export async function cloudStoryManifest(): Promise<StorySyncMeta[]> {
|
||||||
|
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<StorySyncEnvelope[]> {
|
||||||
|
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<boolean> {
|
||||||
if (!AUTH_ENABLED) return false;
|
if (!AUTH_ENABLED) return false;
|
||||||
const userId = await currentUserId();
|
const userId = await currentUserId();
|
||||||
if (!userId) return false;
|
if (!userId) return false;
|
||||||
try {
|
try {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const now = new Date().toISOString();
|
const deletedIso = new Date(deletedAt).toISOString();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("stories")
|
.from("stories")
|
||||||
.update({ deleted_at: now, updated_at: now })
|
.update({ deleted_at: deletedIso, updated_at: deletedIso, rev })
|
||||||
.eq("id", id)
|
|
||||||
.eq("user_id", userId)
|
.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");
|
.select("id");
|
||||||
if (error || !data || data.length === 0) return false;
|
if (error || !data || data.length === 0) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// 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 { idbGet, STORIES_STORE } from "./idb";
|
||||||
|
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, 0),
|
||||||
|
deletedAt: rec.deletedAt == null ? null : coerceEpoch(rec.deletedAt, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk<T>(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<void> {
|
||||||
|
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, coerceEpoch(rec.updatedAt, 0));
|
||||||
|
} 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<void> {
|
||||||
|
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<string>([...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, coerceEpoch(rec.updatedAt, 0));
|
||||||
|
// !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, coerceEpoch(rec.updatedAt, 0));
|
||||||
|
} 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<void> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!AUTH_ENABLED || !id) return;
|
||||||
|
try {
|
||||||
|
if (!(await isAuthed())) return;
|
||||||
|
const rec = await idbGet<StoryRecord>(STORIES_STORE, 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, coerceEpoch(rec.updatedAt, 0));
|
||||||
|
} catch {
|
||||||
|
/* leave pending */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 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<T>(url: string, body: unknown): Promise<T | null> {
|
||||||
|
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<StorySyncMeta[]> {
|
||||||
|
if (!AUTH_ENABLED) return [];
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stories/manifest", { method: "GET", cache: "no-store" });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as { items?: unknown };
|
||||||
|
return Array.isArray(data.items) ? (data.items as StorySyncMeta[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull full envelopes for the given ids. [] on empty ids / failure / auth off. */
|
||||||
|
export async function pullBlobs(ids: string[]): Promise<StorySyncEnvelope[]> {
|
||||||
|
if (!AUTH_ENABLED || ids.length === 0) return [];
|
||||||
|
const data = await postJson<{ blobs?: unknown }>("/api/stories/pull", { ids });
|
||||||
|
return Array.isArray(data?.blobs) ? (data.blobs as StorySyncEnvelope[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<boolean> {
|
||||||
|
if (!AUTH_ENABLED) return false;
|
||||||
|
const data = await postJson<{ ok?: boolean }>("/api/stories/delete", {
|
||||||
|
id,
|
||||||
|
rev,
|
||||||
|
deletedAt,
|
||||||
|
});
|
||||||
|
return data?.ok ?? false;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import type { Session } from "@infiplot/types";
|
|||||||
import { coerceOrientation } from "@infiplot/types";
|
import { coerceOrientation } from "@infiplot/types";
|
||||||
import { idbGet, idbGetAll, idbPut, idbDelete, idbCount, STORIES_STORE } from "./idb";
|
import { idbGet, idbGetAll, idbPut, idbDelete, idbCount, STORIES_STORE } from "./idb";
|
||||||
import { slimSession } from "./sessionSlim";
|
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
|
/** 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
|
* quota, so this is generous vs the old localStorage cap of 20; it aligns with
|
||||||
@@ -186,3 +186,80 @@ export async function softDeleteStory(id: string): Promise<boolean> {
|
|||||||
};
|
};
|
||||||
return idbPut(STORIES_STORE, updated);
|
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<StoryRecord[]> {
|
||||||
|
return idbGetAll<StoryRecord>(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<boolean> {
|
||||||
|
if (!env?.id) return false;
|
||||||
|
const existing = await idbGet<StoryRecord>(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, updatedAt: number): Promise<void> {
|
||||||
|
const rec = await idbGet<StoryRecord>(STORIES_STORE, id);
|
||||||
|
if (!rec) return;
|
||||||
|
// Guard on BOTH rev and updatedAt. softDeleteStory bumps updatedAt WITHOUT
|
||||||
|
// bumping rev, so a same-rev-but-newer local tombstone produced while a push
|
||||||
|
// was in flight must NOT be marked synced by that older push's ack (it still
|
||||||
|
// owes a delete push). Symmetric with putSyncedRecord's concurrency guard.
|
||||||
|
if ((rec.rev ?? 1) !== rev) return;
|
||||||
|
if (coerceEpoch(rec.updatedAt, 0) !== coerceEpoch(updatedAt, 0)) return;
|
||||||
|
if (rec.syncState === "synced") return;
|
||||||
|
await idbPut(STORIES_STORE, { ...rec, syncState: "synced" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,3 +99,34 @@ export type StoryRecord = {
|
|||||||
* structured-clones objects, so this is stored as-is (no JSON.stringify). */
|
* structured-clones objects, so this is stored as-is (no JSON.stringify). */
|
||||||
session: Session;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- FOUND is the idiomatic PL/pgSQL test for whether RETURNING produced a row:
|
||||||
|
-- true on a fresh insert OR a winning update; false when the row already
|
||||||
|
-- existed AND the where-guard rejected the update (stale write). In the stale
|
||||||
|
-- case fall through and return the current cloud row so the caller sees it
|
||||||
|
-- lost and can reconcile by pulling the newer cloud state.
|
||||||
|
if found 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