ff12b2759f
Connect the previously-skeleton cloudStore to the client with a full
bidirectional reconcile engine. Commercial build (AUTH_ENABLED) only; the
open-source build is byte-for-byte unchanged — all cloud paths short-circuit
when AUTH_ENABLED is false.
- cloudSync.ts: reconcile engine — decideAction (pure, LWW rev->updatedAt with
tombstone priority) + syncOnLogin/pushOnSave/pushDeletion (best-effort,
serialized, isAuthed-gated)
- cloudSyncClient.ts: browser fetch bridge (short-circuit + fault-tolerant)
- /api/stories/{manifest,pull,push,delete}: RLS-guarded sync endpoints
- upsert_story_if_newer RPC: optimistic concurrency (SECURITY INVOKER,
auth.uid() injection, rev->updated_at guard, revoked from public)
- cloudStore: +manifest/pullBlobs, save->RPC {stored,won}, softDelete w/ rev
- localStore: +listAllRecordsForSync/putSyncedRecord/markRecordSynced
(concurrency-guarded sync writes); types: +StorySyncMeta/StorySyncEnvelope
- facade + UserChip: inject pushOnSave/pushDeletion + login-triggered reconcile
Sync model: full reconcile on login + background push on save (no Realtime;
eventual consistency). Conflict resolution: last-write-wins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
// Persistence wire types — local-first story storage.
|
|
//
|
|
// Shared shapes for the browser-local store (IndexedDB) and the future Supabase
|
|
// cloud store. Replaces the deleted D1 `lib/db/repositories/storyRepo` types,
|
|
// severing all dependency on Drizzle / D1. The local `StoryRecord` and the cloud
|
|
// `public.stories` row both carry the same slim `Session` blob (see
|
|
// `SlimStoryBlob`) so there is no dual data shape to reconcile when cloud sync
|
|
// is layered on next phase.
|
|
|
|
import type { Session, Orientation } from "@infiplot/types";
|
|
|
|
/** Schema version stamped on every local record — migration hook for future
|
|
* structural evolution of `StoryRecord`. Bump when the on-disk shape changes. */
|
|
export const STORY_SCHEMA_VERSION = 1;
|
|
|
|
/** Coerce a Date | string | number (or anything) to epoch milliseconds, falling
|
|
* back when the value is unparseable. Shared by the local store, the cloud store
|
|
* (Supabase timestamptz), and the stories list UI — every site where a timestamp
|
|
* crosses a storage/serialization boundary and could arrive as a non-number,
|
|
* guarding against the historical `t.getTime is not a function` white-screen. */
|
|
export function coerceEpoch(value: unknown, fallback: number): number {
|
|
// Number.isFinite (not just !isNaN) so ±Infinity also falls through to the
|
|
// fallback — new Date(Infinity).getTime() is NaN, not a usable epoch.
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
const d = value instanceof Date ? value : new Date(value as string | number);
|
|
const t = d.getTime();
|
|
return Number.isNaN(t) ? fallback : t;
|
|
}
|
|
|
|
/** local-first sync state of a record.
|
|
* - "local-only": never sent to the cloud (open-source default, or pre-sync).
|
|
* - "synced": in agreement with the cloud row.
|
|
* - "pending": has un-propagated local changes (incl. soft-delete tombstones). */
|
|
export type SyncState = "local-only" | "synced" | "pending";
|
|
|
|
/** List-view projection of a saved story — the lightweight metadata the
|
|
* "我的剧情" page renders without parsing the full session blob. Migrated out of
|
|
* the deleted D1 `storyRepo`; timestamps are unified to epoch milliseconds
|
|
* (the old D1 shape used `Date` and carried `userId`/`status`, both dropped:
|
|
* the local layer has no account concept, and `status` was a D1 leftover). */
|
|
export type StoryMeta = {
|
|
id: string;
|
|
worldSetting: string;
|
|
styleGuide: string;
|
|
orientation: Orientation;
|
|
sceneCount: number;
|
|
/** epoch ms */
|
|
createdAt: number;
|
|
/** epoch ms */
|
|
updatedAt: number;
|
|
};
|
|
|
|
/** The shared core payload for one saved story, identical between the local
|
|
* record and the (future) cloud row. `session` is the SLIM `Session` — the
|
|
* bulky reconstructible fields (`voice.referenceAudioBase64`,
|
|
* `styleReferenceImage`) are stripped before persistence by the store layer. */
|
|
export type SlimStoryBlob = {
|
|
id: string;
|
|
worldSetting: string;
|
|
styleGuide: string;
|
|
orientation: Orientation;
|
|
sceneCount: number;
|
|
rev: number;
|
|
/** Slim Session (voice + styleReferenceImage stripped). Type stays `Session`;
|
|
* slimming is a runtime guarantee enforced by the store, not the type. */
|
|
session: Session;
|
|
};
|
|
|
|
/** One row in the browser-local IndexedDB store (object store keyPath = "id").
|
|
* Carries the slim session payload plus the local-first sync-reserved
|
|
* metadata so cloud sync can be layered on next phase without restructuring. */
|
|
export type StoryRecord = {
|
|
id: string;
|
|
/** = STORY_SCHEMA_VERSION at write time. */
|
|
schemaVersion: number;
|
|
|
|
// ── List-view metadata (denormalized so listing needn't parse the blob) ──
|
|
worldSetting: string;
|
|
styleGuide: string;
|
|
orientation: Orientation;
|
|
sceneCount: number;
|
|
|
|
// ── local-first sync-reserved fields ──
|
|
/** epoch ms; set on first save, preserved across subsequent upserts. */
|
|
createdAt: number;
|
|
/** epoch ms; refreshed on every save. */
|
|
updatedAt: number;
|
|
/** Revision counter; new record = 1, bumped on each local save. */
|
|
rev: number;
|
|
/** Soft-delete tombstone (epoch ms) or null. Delete sets this rather than
|
|
* physically removing the row, so the deletion can propagate to the cloud
|
|
* next phase. List queries filter tombstoned records out. */
|
|
deletedAt: number | null;
|
|
syncState: SyncState;
|
|
|
|
// ── Payload ──
|
|
/** Slim Session (voice + styleReferenceImage stripped). IndexedDB
|
|
* 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;
|
|
};
|