forked from akai/readest
feat(sync): CRDT replica sync foundation (#4075)
* feat(sync): foundation for CRDT-based cross-device replica sync (Phase 1+2)
Adds the primitives and orchestration layer for syncing user-imported
assets (dictionaries, fonts, textures, OPDS catalogs, dict settings)
across devices via a polymorphic `replicas` table with field-level LWW
under HLC ordering. Phase 1 ships the foundation (CRDT, crypto, server
schemas, SQL migrations, push/pull endpoint); Phase 2 adds the adapter
registry, HTTP client, and sync manager. No modifications to existing
book sync — additive only.
Phase 1:
- src/libs/crdt.ts — HlcGenerator (monotonic + remote-absorption +
clock-regression-safe), per-field LWW with deviceId tiebreak,
remove-wins tombstones, reincarnation token revival.
- src/libs/crypto/{derive,encrypt,envelope,passphrase}.ts —
PBKDF2-600k key derivation (OWASP 2024), AES-GCM round-trip,
envelope {c,i,s,alg,h} with SHA-256 sidecar integrity check,
passphrase storage abstraction (web ephemeral; Tauri keychain stub).
- src/libs/replica-schemas.ts — Zod-backed allowlist (dictionary only
in PR 1), 64KiB row cap, 64-field cap, schemaVersion bounds,
filename validator.
- src/libs/replica-sync-server.ts — push batch validation
(auth + allowlist + schema + HLC ±60s skew clamp).
- src/pages/api/sync/replicas.ts — POST/GET endpoint wrapping the
Postgres crdt_merge_replica function via RPC.
- docker/volumes/db/migrations/003_add_replicas.sql — replicas table
+ replica_keys table + RLS.
- docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql — atomic
per-field LWW merge function (forwards-compat preserves unknown
fields).
Phase 2:
- src/services/sync/replicaRegistry.ts — adapter contract
(core + optional BinaryCapability + LifecycleHooks per eng review).
- src/libs/replica-sync-client.ts — HTTP wrapper mapping status codes
to typed SyncError codes.
- src/services/sync/replicaSyncManager.ts — 5s debounced push,
immediate flush on visibilitychange/online, per-kind pull cursor,
remote HLC absorption.
Tests: 125 new (crdt 26, crypto 32, schemas 21, server 16, client 12,
registry 6, manager 12). Full suite 3656 passing, lint clean. Existing
book/config/note sync paths untouched.
Plan: ~/.claude/plans/vivid-orbiting-thimble.md
CEO plan: ~/.gstack/projects/readest-readest/ceo-plans/2026-05-06-replica-sync-cathedral.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): add kind="replica" path through TransferManager (Phase 3)
Adds the replica branch to the existing book-shaped transfer
infrastructure so dictionary (and future kinds) bundles can flow through
the same queue, retry, and progress UI as book uploads.
Existing book transfer paths remain unchanged. The book-side regression
suite (37 tests in transfer-store.test.ts, 37 in transfer-manager.test.ts)
all stay green.
Store (src/store/transferStore.ts):
- TransferItem gains kind: 'book' | 'replica' (default 'book' on legacy
persisted rows), replicaKind, replicaId, replicaFiles, replicaBase.
- New addReplicaTransfer(replicaKind, replicaId, displayTitle, type, opts)
with files + base in opts; auto-computes totalBytes from file sizes.
- New getReplicaTransfer(replicaKind, replicaId, type) lookup.
- getTransferByBookHash filters to kind === 'book' (defensive against
bookHash="" collisions on replica items).
- restoreTransfers fills kind: 'book' for legacy persisted rows.
Manager (src/services/transferManager.ts):
- queueReplicaUpload / queueReplicaDownload / queueReplicaDelete.
- executeTransfer dispatches by kind to the new executeReplicaTransfer
(iterates files, calls appService.uploadReplicaFile per file with
per-file progress aggregation) or the existing executeBookTransfer
(refactored out, byte-identical behavior).
- Dispatches replica-transfer-complete event on success so stores can
react (e.g., commit manifest_jsonb to the replica row).
Storage / cloud (src/libs/storage.ts, src/services/cloudService.ts):
- uploadReplicaFile bypasses the book-only File.name smuggling and
takes an explicit cfp (cloud file path).
- uploadReplicaFileToCloud / downloadReplicaFileFromCloud /
deleteReplicaBundleFromCloud orchestrate per-file operations under
${userId}/Readest/replicas/<kind>/<replicaId>/<filename>.
- replicaCloudKey() centralizes the path-construction rule.
- New CLOUD_REPLICAS_SUBDIR constant.
App service (src/services/appService.ts, src/types/system.ts):
- AppService gains uploadReplicaFile, downloadReplicaFile,
deleteReplicaBundle (file-level operations; orchestration lives in
TransferManager).
Tests: 18 new (12 in transfer-store.test.ts, 5 in transfer-manager.test.ts,
1 fixture). Full suite 3674 passing, lint clean. Existing book regression
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): dictionary replica adapter + bootstrap (Phase 4, partial)
Lands the safe-to-ship foundation of Phase 4 — adapter logic, registry
bootstrap, and SystemSettings hooks for replica sync. The on-disk
migration of legacy customDictionaries (bundleDir → content-hash id),
the live store wiring, and the Settings → Sync UI are deferred to a
follow-up PR so they can land with real-device QA.
Adapter (src/services/sync/adapters/dictionary.ts):
- dictionaryAdapter: kind='dictionary', schemaVersion=1.
- pack/unpack — only synced subset (name, kind, lang, addedAt,
unsupported{,Reason}). bundleDir / files / unavailable / deletedAt
stay per-device or are handled by the tombstone mechanism.
- BinaryCapability.enumerateFiles dispatches by bundle kind:
- mdict: mdx + mdd[] + css[]
- stardict: ifo + idx + dict + syn (skips .idx.offsets / .syn.offsets
sidecars — those are device-local indices)
- dict: dict + index
- slob: single .slob file
- primaryDictionaryFile() picks the anchor file per kind for
partialMD5 hashing.
- computeDictionaryReplicaId(partialMd5, byteSize, sortedFilenames)
produces a deterministic 32-hex content-hash id used at import time.
- 23 tests cover pack/unpack identity, kind dispatch, file enumeration,
id determinism, and per-device-field exclusion.
Bootstrap (src/services/sync/replicaBootstrap.ts):
- bootstrapReplicaAdapters() registers all known adapters once at app
start. Idempotent (safe to call multiple times). Wired into
EnvContext.tsx so the registry populates on app mount.
- 3 tests cover registration, idempotency, and the PR-1 allowlist.
SystemSettings (src/types/settings.ts, src/services/constants.ts):
- +SyncCategory = 'book' | 'progress' | 'note' | 'dictionary' — typed
union for the user-facing sync toggles. 'progress' gates the
existing book-config sync (reading progress); 'note' gates
annotations; 'book' gates book binaries + metadata; 'dictionary'
gates the new replica sync. Future replica kinds extend the union.
- +SYNC_CATEGORIES readonly array for UI iteration.
- +syncCategories: Partial<Record<SyncCategory, boolean>> — per-
category opt-in toggles in DEFAULT_SYSTEM_SETTINGS (default ON for
all four). UI panel ships in the follow-up.
- +lastSyncedAtReplicas: Record<string, string> — per-kind HLC pull
cursors (matches replicaSyncManager's CursorStore contract).
Registry type cleanup (src/services/sync/replicaRegistry.ts):
- BinaryCapability.enumerateFiles return shape: localRelPath → lfp
to match the existing TransferStore.ReplicaTransferFile convention.
Tests: 28 new (23 dict + 3 bootstrap + 2 syncCategories defaults).
Full suite 3702 passing, lint clean. Existing book/config/note sync
paths untouched.
Deferred to PR 1 follow-up (with real-device QA):
- customDictionaryStore migration: rehash legacy uniqueId() bundleDir
to content-hash id; preserve providerOrder mapping; staged
.legacy/<old-id>/ backup.
- Wire customDictionaryStore mutations through replicaSyncManager
(markDirty on add/rename/delete; pull on init).
- Settings → Sync panel: per-category toggles + last-sync timestamps.
- Sync passphrase modal: set / change / forgot flow (lazy first prompt
on encrypted-field push/pull).
- <CloudReplicaRow> in CustomDictionaries.tsx for "Download from cloud
(X MB)" affordance.
- Tauri keychain backend for sync passphrase storage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): replicaSync singleton + content-hash id at dict import (Phase 4b)
Two foundation pieces that everything UI-side will sit on top of, both
purely additive — no live store wiring, no behavior change for existing
dictionary imports.
replicaSync singleton (src/services/sync/replicaSync.ts):
- initReplicaSync({deviceId, cursorStore, hlcStore?, client?}) builds
one ReplicaSyncManager backed by an HlcGenerator with persistence
wrapped around .next()/.observe(). Idempotent (second init returns
the existing instance).
- LocalStorageHlcStore (src/libs/hlc-store.ts) snapshots the HLC
counter under 'readest_replica_hlc' so it survives restart. Falls
back silently when localStorage is unavailable (private mode, SSR);
client re-derives via the existing remote max(updated_at_ts) repair
path. InMemoryHlcStore is the test backend.
- 16 tests (9 hlc-store, 7 replicaSync) covering snapshot persistence,
restore-on-init, and idempotency.
- Wiring into EnvContext for production deferred to the follow-up that
also adds the cursor store backed by useSettingsStore.
Content-hash id at dictionary import
(src/services/dictionaries/contentId.ts):
- computeDictionaryContentId(primaryFile, filenames) wraps
computeDictionaryReplicaId(partialMd5(primary), byteSize,
sortedFilenames) — the cross-device id used as the replica_id when
the dict actually pushes/pulls.
- Wired into all four import paths in dictionaryService.ts:
- stardict primary = .ifo (small text, partialMD5 ≈ full hash)
- mdict primary = .mdx (body)
- dict primary = .dict.dz (gzipped body)
- slob primary = .slob (single-file bundle)
ImportedDictionary gains contentId?: string. Optional for backwards
compat; legacy bundles without contentId are flagged as
"needs rehash before sync" by the upcoming store-wiring follow-up.
- 6 tests cover identity determinism, byteSize sensitivity, filename-
set sensitivity, and order-independence.
Tests: 22 new (9 + 7 + 6). Full suite 3724 passing, lint clean.
Existing dictionary import flow unchanged for users — contentId is an
additional field, not a replacement for the bundleDir-based id.
Deferred to follow-up (with on-device QA):
- Production cursor store backed by useSettingsStore +
appService.saveSettings.
- EnvContext call to initReplicaSync after appService boot.
- customDictionaryStore mutation hooks → replicaSyncManager.markDirty.
- Legacy bundleDir → contentId migration with .legacy/ backup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(sync): land replica-sync design plan in repo
Moves the plan document that drove this PR's foundation work
(`~/.claude/plans/vivid-orbiting-thimble.md`) into the project tree at
`apps/readest-app/.claude/plans/` so reviewers and future contributors
can read it alongside the code without leaving the repo.
The plan went through three review passes — Codex (19 findings, all
absorbed), CEO/scope review (mode SCOPE EXPANSION; encrypted secrets
pulled forward to v1, "private-only forever" posture lock), and eng
review (FULL_REVIEW mode, 16 findings absorbed). The full review trail
lives in the file's `## GSTACK REVIEW REPORT` section at the bottom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(crdt): point README at in-repo plan path
Now that vivid-orbiting-thimble.md lives at
apps/readest-app/.claude/plans/, the README link should point there
rather than at the home-dir copy that no longer exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sync): rename src/libs files to camelCase per project convention
Test files renamed in lockstep. Imports + comment references updated
across cloudService, storage, transferManager, replicaSync,
replicaSyncManager, /api/sync/replicas, and all four test files. No
behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
-- Migration 003: Add replicas table for cross-device sync of user-imported assets
|
||||
-- (dictionaries, fonts, textures, OPDS catalogs, dict settings — gated by
|
||||
-- a per-kind allowlist enforced both in DB CHECK and in server validation).
|
||||
-- See ~/.claude/plans/vivid-orbiting-thimble.md and
|
||||
-- src/libs/crdt.README.md for the design.
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- replica_keys: per-account PBKDF2 salt for the encrypted-fields envelope.
|
||||
-- One row per (user_id, alg). A passphrase rotation appends a new row;
|
||||
-- forgot-passphrase deletes all rows for the user (and the migrate-time
|
||||
-- check on encrypted envelopes makes them unreadable client-side).
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.replica_keys (
|
||||
user_id uuid NOT NULL,
|
||||
salt_id text NOT NULL,
|
||||
alg text NOT NULL,
|
||||
salt bytea NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT replica_keys_pkey PRIMARY KEY (user_id, salt_id),
|
||||
CONSTRAINT replica_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE public.replica_keys ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY replica_keys_select ON public.replica_keys
|
||||
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY replica_keys_insert ON public.replica_keys
|
||||
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY replica_keys_delete ON public.replica_keys
|
||||
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- replicas: polymorphic per-user CRDT-backed metadata.
|
||||
-- kind — server-allowlisted: 'dictionary' in PR 1; future kinds
|
||||
-- require a server release that updates the CHECK below.
|
||||
-- fields_jsonb — per-field LWW envelope: {<field>: {v, t: <Hlc>, s}}
|
||||
-- PR-validated 64 KiB / 64-field caps server-side.
|
||||
-- manifest_jsonb — committed last after binary upload completes.
|
||||
-- null = "binaries pending"; row not yet downloadable.
|
||||
-- deleted_at_ts — remove-wins tombstone HLC. A field write does NOT
|
||||
-- revive a tombstoned row.
|
||||
-- reincarnation — explicit re-import token; swaps row to alive under a
|
||||
-- new logical identity.
|
||||
-- updated_at_ts — max(field HLCs, deleted_at_ts). Used as the pull
|
||||
-- cursor.
|
||||
-- schema_version — per-kind schema bump; server enforces bounds.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.replicas (
|
||||
user_id uuid NOT NULL,
|
||||
kind text NOT NULL,
|
||||
replica_id text NOT NULL,
|
||||
fields_jsonb jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
manifest_jsonb jsonb NULL,
|
||||
deleted_at_ts text NULL,
|
||||
reincarnation text NULL,
|
||||
updated_at_ts text NOT NULL,
|
||||
schema_version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
modified_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT replicas_pkey PRIMARY KEY (user_id, kind, replica_id),
|
||||
CONSTRAINT replicas_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE,
|
||||
-- Server allowlist for kind. Adding a new kind = a coordinated client +
|
||||
-- server PR; this CHECK is part of the server release.
|
||||
CONSTRAINT replicas_kind_allowlist CHECK (kind IN ('dictionary')),
|
||||
-- Hard caps. validateRow() in src/libs/replica-schemas.ts enforces the
|
||||
-- same bounds at the API layer with a clearer error code; these CHECKs
|
||||
-- are belt-and-suspenders for direct DB writes.
|
||||
CONSTRAINT replicas_fields_size CHECK (pg_column_size(fields_jsonb) <= 65536),
|
||||
CONSTRAINT replicas_schema_version CHECK (schema_version >= 1 AND schema_version <= 1000)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_replicas_pull_cursor
|
||||
ON public.replicas (user_id, kind, updated_at_ts);
|
||||
|
||||
ALTER TABLE public.replicas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY replicas_select ON public.replicas
|
||||
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY replicas_insert ON public.replicas
|
||||
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY replicas_update ON public.replicas
|
||||
FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY replicas_delete ON public.replicas
|
||||
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
@@ -0,0 +1,189 @@
|
||||
-- Migration 004: crdt_merge_replica() — atomic CRDT merge for the replicas
|
||||
-- table. Mirrors src/libs/crdt.ts mergeReplica() so client and server
|
||||
-- converge on identical merge results.
|
||||
--
|
||||
-- Properties (verified by tests in src/__tests__/libs/crdt.test.ts and
|
||||
-- the server-merge race test):
|
||||
-- * commutative, associative, idempotent on fields_jsonb
|
||||
-- * remove-wins: a field write never revives a tombstone (deleted_at_ts
|
||||
-- stays the larger of the two sides)
|
||||
-- * preserves unknown fields from either side (forwards-compat across
|
||||
-- schemaVersion bumps)
|
||||
-- * deviceId tiebreak when two field envelopes share the same HLC
|
||||
--
|
||||
-- Called via INSERT … ON CONFLICT … DO UPDATE in a single SQL statement
|
||||
-- so two concurrent pushes can't interleave fetch-then-upsert. RUNS AS
|
||||
-- SECURITY INVOKER (default) — the surrounding INSERT/UPDATE is RLS-
|
||||
-- gated, so we don't need DEFINER. The server endpoint additionally
|
||||
-- asserts auth.uid() = NEW.user_id before invoking the upsert.
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- HLC max helper. NULLs lose. Plain text comparison since the HLC packing
|
||||
-- format makes lexicographic order match temporal order.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.hlc_max(a text, b text)
|
||||
RETURNS text
|
||||
LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN a IS NULL THEN b
|
||||
WHEN b IS NULL THEN a
|
||||
WHEN a >= b THEN a
|
||||
ELSE b
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Field-level LWW merge for fields_jsonb. Per-key: keep the envelope with
|
||||
-- the larger envelope.t (HLC string). Tie on HLC: deviceId (envelope.s)
|
||||
-- lex-order tiebreak. Preserves keys present on either side.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.crdt_merge_fields(local_fields jsonb, remote_fields jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
||||
AS $$
|
||||
DECLARE
|
||||
result jsonb := COALESCE(local_fields, '{}'::jsonb);
|
||||
k text;
|
||||
l_env jsonb;
|
||||
r_env jsonb;
|
||||
l_t text;
|
||||
r_t text;
|
||||
l_s text;
|
||||
r_s text;
|
||||
BEGIN
|
||||
IF remote_fields IS NULL THEN
|
||||
RETURN result;
|
||||
END IF;
|
||||
FOR k IN SELECT jsonb_object_keys(remote_fields) LOOP
|
||||
r_env := remote_fields -> k;
|
||||
l_env := result -> k;
|
||||
IF l_env IS NULL THEN
|
||||
result := jsonb_set(result, ARRAY[k], r_env, true);
|
||||
ELSE
|
||||
l_t := l_env ->> 't';
|
||||
r_t := r_env ->> 't';
|
||||
IF r_t > l_t THEN
|
||||
result := jsonb_set(result, ARRAY[k], r_env, true);
|
||||
ELSIF r_t = l_t THEN
|
||||
l_s := COALESCE(l_env ->> 's', '');
|
||||
r_s := COALESCE(r_env ->> 's', '');
|
||||
IF r_s > l_s THEN
|
||||
result := jsonb_set(result, ARRAY[k], r_env, true);
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- updated_at_ts = max over field HLCs and tombstone HLC.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.crdt_compute_updated_at(fields jsonb, deleted_at text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
||||
AS $$
|
||||
DECLARE
|
||||
result text := COALESCE(deleted_at, '0000000000000-00000000-');
|
||||
k text;
|
||||
env jsonb;
|
||||
t text;
|
||||
BEGIN
|
||||
IF fields IS NULL THEN
|
||||
RETURN result;
|
||||
END IF;
|
||||
FOR k IN SELECT jsonb_object_keys(fields) LOOP
|
||||
env := fields -> k;
|
||||
t := env ->> 't';
|
||||
IF t IS NOT NULL AND t > result THEN
|
||||
result := t;
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Full row merge. Used in:
|
||||
-- INSERT INTO replicas (...) VALUES (...)
|
||||
-- ON CONFLICT (user_id, kind, replica_id) DO UPDATE SET
|
||||
-- fields_jsonb = crdt_merge_fields(replicas.fields_jsonb, EXCLUDED.fields_jsonb),
|
||||
-- deleted_at_ts = hlc_max(replicas.deleted_at_ts, EXCLUDED.deleted_at_ts),
|
||||
-- reincarnation = CASE WHEN replicas.reincarnation = EXCLUDED.reincarnation
|
||||
-- THEN replicas.reincarnation
|
||||
-- WHEN EXCLUDED.updated_at_ts > replicas.updated_at_ts
|
||||
-- THEN EXCLUDED.reincarnation
|
||||
-- ELSE replicas.reincarnation END,
|
||||
-- manifest_jsonb = CASE WHEN EXCLUDED.updated_at_ts > replicas.updated_at_ts
|
||||
-- THEN EXCLUDED.manifest_jsonb
|
||||
-- ELSE replicas.manifest_jsonb END,
|
||||
-- schema_version = GREATEST(replicas.schema_version, EXCLUDED.schema_version),
|
||||
-- updated_at_ts = crdt_compute_updated_at(
|
||||
-- crdt_merge_fields(replicas.fields_jsonb, EXCLUDED.fields_jsonb),
|
||||
-- hlc_max(replicas.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
-- ),
|
||||
-- modified_at = now()
|
||||
--
|
||||
-- Or via the wrapper below for shorter call sites.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.crdt_merge_replica(
|
||||
p_user_id uuid,
|
||||
p_kind text,
|
||||
p_replica_id text,
|
||||
p_fields_jsonb jsonb,
|
||||
p_manifest_jsonb jsonb,
|
||||
p_deleted_at_ts text,
|
||||
p_reincarnation text,
|
||||
p_updated_at_ts text,
|
||||
p_schema_version integer
|
||||
) RETURNS public.replicas
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
result public.replicas;
|
||||
BEGIN
|
||||
INSERT INTO public.replicas AS r (
|
||||
user_id, kind, replica_id,
|
||||
fields_jsonb, manifest_jsonb, deleted_at_ts,
|
||||
reincarnation, updated_at_ts, schema_version
|
||||
) VALUES (
|
||||
p_user_id, p_kind, p_replica_id,
|
||||
COALESCE(p_fields_jsonb, '{}'::jsonb),
|
||||
p_manifest_jsonb, p_deleted_at_ts,
|
||||
p_reincarnation, p_updated_at_ts, p_schema_version
|
||||
)
|
||||
ON CONFLICT (user_id, kind, replica_id) DO UPDATE SET
|
||||
fields_jsonb = public.crdt_merge_fields(r.fields_jsonb, EXCLUDED.fields_jsonb),
|
||||
deleted_at_ts = public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts),
|
||||
reincarnation = CASE
|
||||
WHEN r.reincarnation IS NOT DISTINCT FROM EXCLUDED.reincarnation
|
||||
THEN r.reincarnation
|
||||
WHEN EXCLUDED.updated_at_ts > r.updated_at_ts
|
||||
THEN EXCLUDED.reincarnation
|
||||
ELSE r.reincarnation
|
||||
END,
|
||||
manifest_jsonb = CASE
|
||||
WHEN EXCLUDED.updated_at_ts > r.updated_at_ts
|
||||
THEN EXCLUDED.manifest_jsonb
|
||||
ELSE r.manifest_jsonb
|
||||
END,
|
||||
schema_version = GREATEST(r.schema_version, EXCLUDED.schema_version),
|
||||
updated_at_ts = public.crdt_compute_updated_at(
|
||||
public.crdt_merge_fields(r.fields_jsonb, EXCLUDED.fields_jsonb),
|
||||
public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
),
|
||||
modified_at = now()
|
||||
RETURNING * INTO result;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Surface the function to the API role only after an explicit user-id
|
||||
-- match check in src/pages/api/sync/replicas.ts. RLS on the replicas
|
||||
-- table is the second line of defense (the function runs as caller).
|
||||
GRANT EXECUTE ON FUNCTION public.hlc_max(text, text) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.crdt_merge_fields(jsonb, jsonb) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.crdt_compute_updated_at(jsonb, text) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.crdt_merge_replica(uuid, text, text, jsonb, jsonb, text, text, text, integer) TO authenticated;
|
||||
Reference in New Issue
Block a user