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:
Huang Xin
2026-05-06 21:50:15 +08:00
committed by GitHub
parent dd0ff6ae9d
commit 3b348c8f35
62 changed files with 5489 additions and 38 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
import { describe, expect, test, vi } from 'vitest';
import {
HlcGenerator,
hlcCompare,
hlcPack,
hlcParse,
mergeFields,
mergeReplica,
removeReplica,
setField,
withReincarnation,
} from '@/libs/crdt';
import type { FieldsObject, Hlc, ReplicaRow } from '@/types/replica';
const DEV_A = 'dev-a';
const DEV_B = 'dev-b';
const hlc = (ms: number, counter = 0, dev = DEV_A): Hlc => hlcPack(ms, counter, dev);
const emptyRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
user_id: 'u1',
kind: 'dictionary',
replica_id: 'r1',
fields_jsonb: {},
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: hlc(0),
schema_version: 1,
...overrides,
});
describe('HLC pack/parse', () => {
test('roundtrips physicalMs + counter + deviceId', () => {
const packed = hlcPack(1700000000000, 7, 'device-xyz');
const parsed = hlcParse(packed);
expect(parsed.physicalMs).toBe(1700000000000);
expect(parsed.counter).toBe(7);
expect(parsed.deviceId).toBe('device-xyz');
});
test('format is 13-hex-ms - 8-hex-counter - deviceId', () => {
const packed = hlcPack(0, 0, 'd');
expect(packed).toBe('0000000000000-00000000-d');
const max = hlcPack(0xfffffffffffff, 0xffffffff, 'd');
expect(max).toBe('fffffffffffff-ffffffff-d');
});
test('lexicographic order matches temporal order across 1000 random HLCs', () => {
const samples: { ms: number; counter: number; packed: Hlc }[] = [];
for (let i = 0; i < 1000; i++) {
const ms = Math.floor(Math.random() * 0x100000000);
const counter = Math.floor(Math.random() * 0x10000);
samples.push({ ms, counter, packed: hlcPack(ms, counter, DEV_A) });
}
const lex = [...samples].sort((a, b) =>
a.packed < b.packed ? -1 : a.packed > b.packed ? 1 : 0,
);
const temporal = [...samples].sort((a, b) => a.ms - b.ms || a.counter - b.counter);
expect(lex.map((s) => s.packed)).toEqual(temporal.map((s) => s.packed));
});
test('compare returns -1, 0, 1', () => {
expect(hlcCompare(hlc(1), hlc(2))).toBe(-1);
expect(hlcCompare(hlc(2), hlc(2))).toBe(0);
expect(hlcCompare(hlc(3), hlc(2))).toBe(1);
});
});
describe('HlcGenerator', () => {
test('strictly monotonic across calls in the same ms', () => {
const now = vi.fn(() => 1000);
const gen = new HlcGenerator(DEV_A, now);
const a = gen.next();
const b = gen.next();
const c = gen.next();
expect(hlcCompare(a, b)).toBe(-1);
expect(hlcCompare(b, c)).toBe(-1);
expect(hlcParse(a).counter).toBe(0);
expect(hlcParse(b).counter).toBe(1);
expect(hlcParse(c).counter).toBe(2);
});
test('counter resets when physical clock advances', () => {
let t = 1000;
const now = () => t;
const gen = new HlcGenerator(DEV_A, now);
gen.next();
gen.next();
expect(hlcParse(gen.next()).counter).toBe(2);
t = 2000;
expect(hlcParse(gen.next()).counter).toBe(0);
});
test('absorbs remote HLC: next() > any observed remote', () => {
const t = 1000;
const gen = new HlcGenerator(DEV_A, () => t);
const remote = hlcPack(5000, 0, DEV_B);
gen.observe(remote);
const next = gen.next();
expect(hlcCompare(remote, next)).toBe(-1);
});
test('survives clock regression by holding the higher physical time', () => {
let t = 5000;
const gen = new HlcGenerator(DEV_A, () => t);
const a = gen.next();
t = 3000;
const b = gen.next();
expect(hlcCompare(a, b)).toBe(-1);
});
test('serialize/restore preserves state', () => {
const t = 1000;
const gen = new HlcGenerator(DEV_A, () => t);
gen.next();
gen.next();
const snapshot = gen.serialize();
const gen2 = HlcGenerator.restore(snapshot, DEV_A, () => t);
expect(hlcParse(gen2.next()).counter).toBe(2);
});
});
describe('setField', () => {
test('writes envelope with v, t, s', () => {
const fields = setField({}, 'name', 'Foo', hlc(100), DEV_A);
expect(fields['name']).toEqual({ v: 'Foo', t: hlc(100), s: DEV_A });
});
test('replaces an existing field with a newer HLC', () => {
const old = setField({}, 'name', 'Old', hlc(100), DEV_A);
const next = setField(old, 'name', 'New', hlc(200), DEV_A);
expect(next['name']).toEqual({ v: 'New', t: hlc(200), s: DEV_A });
});
test('returns a new object (immutable)', () => {
const a: FieldsObject = {};
const b = setField(a, 'x', 1, hlc(1), DEV_A);
expect(a).not.toBe(b);
expect(a).toEqual({});
});
});
describe('mergeFields (CRDT properties)', () => {
test('commutativity: merge(a, b) === merge(b, a)', () => {
const a = setField({}, 'name', 'Foo', hlc(100), DEV_A);
const b = setField({}, 'enabled', true, hlc(150), DEV_B);
expect(mergeFields(a, b)).toEqual(mergeFields(b, a));
});
test('associativity: merge(merge(a, b), c) === merge(a, merge(b, c))', () => {
const a = setField({}, 'x', 1, hlc(100), DEV_A);
const b = setField({}, 'y', 2, hlc(150), DEV_B);
const c = setField({}, 'z', 3, hlc(200), DEV_A);
expect(mergeFields(mergeFields(a, b), c)).toEqual(mergeFields(a, mergeFields(b, c)));
});
test('idempotence: merge(a, a) === a', () => {
const a = setField({}, 'name', 'Foo', hlc(100), DEV_A);
expect(mergeFields(a, a)).toEqual(a);
});
test('preserves fields unique to each side', () => {
const a = setField({}, 'name', 'Foo', hlc(100), DEV_A);
const b = setField({}, 'enabled', true, hlc(150), DEV_B);
const merged = mergeFields(a, b);
expect(merged['name']?.v).toBe('Foo');
expect(merged['enabled']?.v).toBe(true);
});
test('larger HLC wins on same-field collision', () => {
const a = setField({}, 'name', 'Foo', hlc(100), DEV_A);
const b = setField({}, 'name', 'Bar', hlc(200), DEV_B);
expect(mergeFields(a, b)['name']?.v).toBe('Bar');
expect(mergeFields(b, a)['name']?.v).toBe('Bar');
});
test('ties on HLC: deterministic deviceId tiebreak', () => {
const a = setField({}, 'name', 'Foo', hlcPack(100, 0, 'aaa'), 'aaa');
const b = setField({}, 'name', 'Bar', hlcPack(100, 0, 'bbb'), 'bbb');
expect(mergeFields(a, b)).toEqual(mergeFields(b, a));
});
});
describe('removeReplica + mergeReplica (tombstones)', () => {
test('removeReplica sets deleted_at_ts and bumps updated_at_ts', () => {
const row = emptyRow({
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
updated_at_ts: hlc(100),
});
const tombstoned = removeReplica(row, hlc(200));
expect(tombstoned.deleted_at_ts).toBe(hlc(200));
expect(tombstoned.updated_at_ts).toBe(hlc(200));
});
test('field write does NOT revive a tombstoned row (remove-wins)', () => {
const tombstoned = emptyRow({
deleted_at_ts: hlc(100),
updated_at_ts: hlc(100),
});
const fieldWrite = emptyRow({
fields_jsonb: setField({}, 'name', 'Resurrected!', hlc(200), DEV_B),
updated_at_ts: hlc(200),
});
const merged = mergeReplica(tombstoned, fieldWrite);
expect(merged.deleted_at_ts).toBe(hlc(100));
expect(merged.fields_jsonb['name']?.v).toBe('Resurrected!');
});
test('reincarnation token swaps the row to alive', () => {
const tombstoned = emptyRow({
deleted_at_ts: hlc(100),
updated_at_ts: hlc(100),
});
const reborn = withReincarnation(tombstoned, 'epoch-1');
expect(reborn.reincarnation).toBe('epoch-1');
expect(reborn.deleted_at_ts).toBe(null);
});
test('mergeReplica updated_at_ts = max(field HLCs, tombstone HLC)', () => {
const a = emptyRow({
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
updated_at_ts: hlc(100),
});
const b = emptyRow({
fields_jsonb: setField({}, 'enabled', true, hlc(300), DEV_B),
updated_at_ts: hlc(300),
});
const merged = mergeReplica(a, b);
expect(merged.updated_at_ts).toBe(hlc(300));
});
test('two tombstones: keep the larger HLC', () => {
const a = emptyRow({ deleted_at_ts: hlc(100), updated_at_ts: hlc(100) });
const b = emptyRow({ deleted_at_ts: hlc(200), updated_at_ts: hlc(200) });
expect(mergeReplica(a, b).deleted_at_ts).toBe(hlc(200));
expect(mergeReplica(b, a).deleted_at_ts).toBe(hlc(200));
});
test('mergeReplica is commutative', () => {
const a = emptyRow({
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
updated_at_ts: hlc(100),
});
const b = emptyRow({
fields_jsonb: setField({}, 'enabled', true, hlc(200), DEV_B),
updated_at_ts: hlc(200),
});
expect(mergeReplica(a, b)).toEqual(mergeReplica(b, a));
});
test('mergeReplica is idempotent', () => {
const a = emptyRow({
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
updated_at_ts: hlc(100),
});
expect(mergeReplica(a, a)).toEqual(a);
});
});
describe('mergeReplica reincarnation interactions', () => {
test('reincarnation field merges per-field LWW (later epoch wins)', () => {
const a = emptyRow({ reincarnation: 'epoch-1', deleted_at_ts: null, updated_at_ts: hlc(100) });
const b = emptyRow({ reincarnation: 'epoch-2', deleted_at_ts: null, updated_at_ts: hlc(200) });
expect(mergeReplica(a, b).reincarnation).toBe('epoch-2');
});
});
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';
import { derivePbkdf2Key, exportRawKey } from '@/libs/crypto/derive';
const enc = new TextEncoder();
const toHex = (b: Uint8Array): string =>
Array.from(b)
.map((x) => x.toString(16).padStart(2, '0'))
.join('');
describe('derivePbkdf2Key (PBKDF2-HMAC-SHA-256)', () => {
test('reference vector: password="password", salt="salt", c=1', async () => {
const key = await derivePbkdf2Key('password', enc.encode('salt'), 1);
const raw = await exportRawKey(key);
expect(toHex(raw)).toBe('120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b');
});
test('reference vector: password="password", salt="salt", c=4096', async () => {
const key = await derivePbkdf2Key('password', enc.encode('salt'), 4096);
const raw = await exportRawKey(key);
expect(toHex(raw)).toBe('c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a');
});
test('different passphrases produce different keys', async () => {
const salt = enc.encode('salt');
const a = await derivePbkdf2Key('passwordA', salt, 1000);
const b = await derivePbkdf2Key('passwordB', salt, 1000);
const rawA = toHex(await exportRawKey(a));
const rawB = toHex(await exportRawKey(b));
expect(rawA).not.toBe(rawB);
});
test('different salts produce different keys', async () => {
const a = await derivePbkdf2Key('password', enc.encode('saltA'), 1000);
const b = await derivePbkdf2Key('password', enc.encode('saltB'), 1000);
const rawA = toHex(await exportRawKey(a));
const rawB = toHex(await exportRawKey(b));
expect(rawA).not.toBe(rawB);
});
test('different iteration counts produce different keys', async () => {
const salt = enc.encode('salt');
const a = await derivePbkdf2Key('password', salt, 1000);
const b = await derivePbkdf2Key('password', salt, 2000);
const rawA = toHex(await exportRawKey(a));
const rawB = toHex(await exportRawKey(b));
expect(rawA).not.toBe(rawB);
});
test('default iteration count is 600_000 (OWASP 2024 for PBKDF2-SHA-256)', async () => {
const key = await derivePbkdf2Key('password', enc.encode('salt'));
const raw = await exportRawKey(key);
expect(raw.length).toBe(32);
}, 30_000);
test('key is usable with AES-GCM (256-bit)', async () => {
const key = await derivePbkdf2Key('password', enc.encode('salt'), 1000);
const raw = await exportRawKey(key);
expect(raw.length).toBe(32);
});
});
@@ -0,0 +1,91 @@
import { describe, expect, test } from 'vitest';
import { decryptCiphertext, encryptPlaintext } from '@/libs/crypto/encrypt';
import { derivePbkdf2Key } from '@/libs/crypto/derive';
import { SyncError } from '@/libs/errors';
const enc = new TextEncoder();
const dec = new TextDecoder();
const ITER = 1000;
const makeKey = () => derivePbkdf2Key('test-passphrase', enc.encode('test-salt'), ITER);
describe('encryptPlaintext / decryptCiphertext', () => {
test('round-trip identity for ASCII string', async () => {
const key = await makeKey();
const plain = enc.encode('hello world');
const payload = await encryptPlaintext(plain, key);
const recovered = await decryptCiphertext(payload, key);
expect(dec.decode(recovered)).toBe('hello world');
});
test('round-trip identity for UTF-8 / emoji', async () => {
const key = await makeKey();
const original = '密码🔐 password — multi-byte';
const payload = await encryptPlaintext(enc.encode(original), key);
const recovered = await decryptCiphertext(payload, key);
expect(dec.decode(recovered)).toBe(original);
});
test('round-trip for empty plaintext', async () => {
const key = await makeKey();
const payload = await encryptPlaintext(new Uint8Array(0), key);
const recovered = await decryptCiphertext(payload, key);
expect(recovered.length).toBe(0);
});
test('round-trip for large plaintext (10 KiB)', async () => {
const key = await makeKey();
const plain = new Uint8Array(10 * 1024);
for (let i = 0; i < plain.length; i++) plain[i] = i & 0xff;
const payload = await encryptPlaintext(plain, key);
const recovered = await decryptCiphertext(payload, key);
expect(recovered).toEqual(plain);
});
test('random IV per encryption: same plaintext produces different ciphertext', async () => {
const key = await makeKey();
const plain = enc.encode('same plaintext');
const a = await encryptPlaintext(plain, key);
const b = await encryptPlaintext(plain, key);
expect(a.iv).not.toEqual(b.iv);
expect(a.ciphertext).not.toEqual(b.ciphertext);
});
test('IV is 12 bytes (AES-GCM standard)', async () => {
const key = await makeKey();
const payload = await encryptPlaintext(enc.encode('x'), key);
expect(payload.iv.length).toBe(12);
});
test('wrong key throws SyncError DECRYPT', async () => {
const keyA = await makeKey();
const keyB = await derivePbkdf2Key('different', enc.encode('test-salt'), ITER);
const payload = await encryptPlaintext(enc.encode('secret'), keyA);
await expect(decryptCiphertext(payload, keyB)).rejects.toMatchObject({
name: 'SyncError',
code: 'DECRYPT',
});
});
test('tampered ciphertext throws SyncError DECRYPT (auth tag fails)', async () => {
const key = await makeKey();
const payload = await encryptPlaintext(enc.encode('secret'), key);
const tampered = {
iv: payload.iv,
ciphertext: new Uint8Array(payload.ciphertext),
};
tampered.ciphertext[0] = tampered.ciphertext[0]! ^ 0xff;
await expect(decryptCiphertext(tampered, key)).rejects.toBeInstanceOf(SyncError);
});
test('tampered IV throws SyncError DECRYPT', async () => {
const key = await makeKey();
const payload = await encryptPlaintext(enc.encode('secret'), key);
const tampered = {
iv: new Uint8Array(payload.iv),
ciphertext: payload.ciphertext,
};
tampered.iv[0] = tampered.iv[0]! ^ 0xff;
await expect(decryptCiphertext(tampered, key)).rejects.toBeInstanceOf(SyncError);
});
});
@@ -0,0 +1,89 @@
import { describe, expect, test } from 'vitest';
import { CURRENT_ALG, decryptFromEnvelope, encryptToEnvelope } from '@/libs/crypto/envelope';
import { derivePbkdf2Key } from '@/libs/crypto/derive';
import { isCipherEnvelope } from '@/types/replica';
const enc = new TextEncoder();
const ITER = 1000;
const makeKey = () => derivePbkdf2Key('p', enc.encode('s'), ITER);
describe('encryptToEnvelope / decryptFromEnvelope', () => {
test('round-trip identity for string plaintext', async () => {
const key = await makeKey();
const env = await encryptToEnvelope('hello', key, 'salt-id-1');
expect(isCipherEnvelope(env)).toBe(true);
const recovered = await decryptFromEnvelope(env, key);
expect(recovered).toBe('hello');
});
test('envelope shape: c, i, s, alg, h all present and base64', async () => {
const key = await makeKey();
const env = await encryptToEnvelope('x', key, 'salt-v1');
expect(env.alg).toBe(CURRENT_ALG);
expect(env.s).toBe('salt-v1');
expect(env.c).toMatch(/^[A-Za-z0-9+/=]+$/);
expect(env.i).toMatch(/^[A-Za-z0-9+/=]+$/);
expect(env.h).toMatch(/^[A-Za-z0-9+/=]+$/);
});
test('alg field is exactly aes-gcm/pbkdf2-600k-sha256', async () => {
const key = await makeKey();
const env = await encryptToEnvelope('x', key, 'salt-v1');
expect(env.alg).toBe('aes-gcm/pbkdf2-600k-sha256');
});
test('SHA-256 sidecar mismatch (tampered c) raises INTEGRITY', async () => {
const key = await makeKey();
const env = await encryptToEnvelope('original', key, 'salt-v1');
const goodPlain = await decryptFromEnvelope(env, key);
expect(goodPlain).toBe('original');
const otherEnv = await encryptToEnvelope('different', key, 'salt-v1');
const tampered = { ...env, h: otherEnv.h };
await expect(decryptFromEnvelope(tampered, key)).rejects.toMatchObject({
name: 'SyncError',
code: 'INTEGRITY',
});
});
test('tampered ciphertext raises DECRYPT (auth tag fail)', async () => {
const key = await makeKey();
const env = await encryptToEnvelope('secret', key, 'salt-v1');
const corrupted = atob(env.c);
const bytes = new Uint8Array(corrupted.length);
for (let i = 0; i < corrupted.length; i++) bytes[i] = corrupted.charCodeAt(i);
bytes[0] = bytes[0]! ^ 0xff;
const tampered = { ...env, c: btoa(String.fromCharCode(...bytes)) };
await expect(decryptFromEnvelope(tampered, key)).rejects.toMatchObject({
name: 'SyncError',
code: 'DECRYPT',
});
});
test('unknown alg raises UNSUPPORTED_ALG', async () => {
const key = await makeKey();
const env = await encryptToEnvelope('x', key, 'salt-v1');
const futureAlg = { ...env, alg: 'aes-gcm/pbkdf2-2m-sha512' };
await expect(decryptFromEnvelope(futureAlg, key)).rejects.toMatchObject({
name: 'SyncError',
code: 'UNSUPPORTED_ALG',
});
});
test('UTF-8 plaintext round-trip', async () => {
const key = await makeKey();
const original = '密码 password — 🔐';
const env = await encryptToEnvelope(original, key, 'salt-v1');
const recovered = await decryptFromEnvelope(env, key);
expect(recovered).toBe(original);
});
test('two envelopes of same plaintext have different c/i but same h', async () => {
const key = await makeKey();
const a = await encryptToEnvelope('same', key, 'salt-v1');
const b = await encryptToEnvelope('same', key, 'salt-v1');
expect(a.c).not.toBe(b.c);
expect(a.i).not.toBe(b.i);
expect(a.h).toBe(b.h);
});
});
@@ -0,0 +1,55 @@
import { describe, expect, test } from 'vitest';
import { EphemeralPassphraseStore, TauriPassphraseStore } from '@/libs/crypto/passphrase';
describe('EphemeralPassphraseStore', () => {
test('set then get returns the same passphrase', async () => {
const store = new EphemeralPassphraseStore();
await store.set('correct horse battery staple');
expect(await store.get()).toBe('correct horse battery staple');
});
test('initial state: get returns null', async () => {
const store = new EphemeralPassphraseStore();
expect(await store.get()).toBe(null);
});
test('clear empties the store', async () => {
const store = new EphemeralPassphraseStore();
await store.set('abc');
await store.clear();
expect(await store.get()).toBe(null);
});
test('isAvailable returns true (always)', () => {
const store = new EphemeralPassphraseStore();
expect(store.isAvailable()).toBe(true);
});
test('two instances are independent (per-tab semantic)', async () => {
const a = new EphemeralPassphraseStore();
const b = new EphemeralPassphraseStore();
await a.set('alpha');
await b.set('beta');
expect(await a.get()).toBe('alpha');
expect(await b.get()).toBe('beta');
});
test('set replaces previous value', async () => {
const store = new EphemeralPassphraseStore();
await store.set('first');
await store.set('second');
expect(await store.get()).toBe('second');
});
});
describe('TauriPassphraseStore (stub until plugin lands)', () => {
test('stub: set throws NOT_IMPLEMENTED for v1', async () => {
const store = new TauriPassphraseStore();
await expect(store.set('x')).rejects.toThrow(/Tauri keychain backend not yet wired/);
});
test('stub: isAvailable returns false', () => {
const store = new TauriPassphraseStore();
expect(store.isAvailable()).toBe(false);
});
});
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { InMemoryHlcStore, LocalStorageHlcStore, HLC_LOCAL_STORAGE_KEY } from '@/libs/hlcStore';
describe('InMemoryHlcStore', () => {
test('initial load returns null', () => {
const store = new InMemoryHlcStore();
expect(store.load()).toBe(null);
});
test('save then load roundtrips', () => {
const store = new InMemoryHlcStore();
store.save({ physicalMs: 1700, counter: 7 });
expect(store.load()).toEqual({ physicalMs: 1700, counter: 7 });
});
test('save replaces previous snapshot', () => {
const store = new InMemoryHlcStore();
store.save({ physicalMs: 100, counter: 0 });
store.save({ physicalMs: 200, counter: 5 });
expect(store.load()).toEqual({ physicalMs: 200, counter: 5 });
});
});
describe('LocalStorageHlcStore', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
test('initial load returns null', () => {
expect(new LocalStorageHlcStore().load()).toBe(null);
});
test('save persists to localStorage under the canonical key', () => {
new LocalStorageHlcStore().save({ physicalMs: 1700, counter: 3 });
const raw = localStorage.getItem(HLC_LOCAL_STORAGE_KEY);
expect(raw).not.toBe(null);
expect(JSON.parse(raw!)).toEqual({ physicalMs: 1700, counter: 3 });
});
test('save then load roundtrips', () => {
const store = new LocalStorageHlcStore();
store.save({ physicalMs: 1700, counter: 3 });
expect(store.load()).toEqual({ physicalMs: 1700, counter: 3 });
});
test('survives across instances (different store reads same key)', () => {
new LocalStorageHlcStore().save({ physicalMs: 999, counter: 11 });
expect(new LocalStorageHlcStore().load()).toEqual({ physicalMs: 999, counter: 11 });
});
test('returns null on corrupted JSON instead of throwing', () => {
localStorage.setItem(HLC_LOCAL_STORAGE_KEY, '{not-valid-json');
expect(new LocalStorageHlcStore().load()).toBe(null);
});
test('returns null when localStorage unavailable (server-side)', () => {
const original = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
get() {
throw new Error('localStorage not available');
},
});
try {
expect(new LocalStorageHlcStore().load()).toBe(null);
expect(() => new LocalStorageHlcStore().save({ physicalMs: 1, counter: 0 })).not.toThrow();
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: original,
});
}
});
});
@@ -0,0 +1,178 @@
import { describe, expect, test } from 'vitest';
import {
KIND_ALLOWLIST,
isAllowedKind,
validateFilename,
validateRow,
MAX_JSON_BYTES,
MAX_FIELD_COUNT,
} from '@/libs/replicaSchemas';
import type { ReplicaRow, Hlc } from '@/types/replica';
const HLC_A = '0000000000064-00000000-dev-a' as Hlc;
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
user_id: 'u1',
kind: 'dictionary',
replica_id: 'r1',
fields_jsonb: {
name: { v: 'Webster', t: HLC_A, s: 'dev-a' },
enabled: { v: true, t: HLC_A, s: 'dev-a' },
},
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: HLC_A,
schema_version: 1,
...overrides,
});
describe('isAllowedKind', () => {
test('PR 1 allowlist contains only dictionary', () => {
expect(isAllowedKind('dictionary')).toBe(true);
expect(isAllowedKind('font')).toBe(false);
expect(isAllowedKind('texture')).toBe(false);
expect(isAllowedKind('opds_catalog')).toBe(false);
});
test('rejects arbitrary strings', () => {
expect(isAllowedKind('arbitrary')).toBe(false);
expect(isAllowedKind('')).toBe(false);
expect(isAllowedKind('../etc/passwd')).toBe(false);
});
test('KIND_ALLOWLIST keys match isAllowedKind', () => {
for (const k of Object.keys(KIND_ALLOWLIST)) {
expect(isAllowedKind(k)).toBe(true);
}
});
});
describe('validateFilename', () => {
test('accepts plain filenames', () => {
expect(validateFilename('webster.mdx').ok).toBe(true);
expect(validateFilename('webster.mdd').ok).toBe(true);
expect(validateFilename('manifest.json').ok).toBe(true);
expect(validateFilename('foo-bar_baz.css').ok).toBe(true);
});
test('rejects path traversal', () => {
expect(validateFilename('../etc/passwd').ok).toBe(false);
expect(validateFilename('..').ok).toBe(false);
expect(validateFilename('foo/../bar').ok).toBe(false);
});
test('rejects path separators', () => {
expect(validateFilename('foo/bar').ok).toBe(false);
expect(validateFilename('foo\\bar').ok).toBe(false);
});
test('rejects empty / too-long names', () => {
expect(validateFilename('').ok).toBe(false);
expect(validateFilename('a'.repeat(256)).ok).toBe(false);
});
test('accepts up to 255 chars', () => {
expect(validateFilename('a'.repeat(255)).ok).toBe(true);
});
test('rejects null bytes', () => {
expect(validateFilename('foo\u0000bar').ok).toBe(false);
});
test('rejects control characters', () => {
expect(validateFilename('foo\u0001bar').ok).toBe(false);
expect(validateFilename('foo\u001fbar').ok).toBe(false);
expect(validateFilename('foo\u007fbar').ok).toBe(false);
});
test('accepts unicode (utf-8 dictionary names common in CJK locales)', () => {
expect(validateFilename('韦氏高阶英汉双解词典.mdx').ok).toBe(true);
});
});
describe('validateRow', () => {
test('accepts a valid dictionary row', () => {
const result = validateRow(baseRow());
expect(result.ok).toBe(true);
});
test('rejects unknown kind', () => {
const result = validateRow(baseRow({ kind: 'arbitrary' }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('UNKNOWN_KIND');
});
test('rejects fields_jsonb beyond MAX_JSON_BYTES (64 KiB)', () => {
const huge = { ...baseRow().fields_jsonb };
huge['blob'] = { v: 'x'.repeat(MAX_JSON_BYTES), t: HLC_A, s: 'dev-a' };
const result = validateRow(baseRow({ fields_jsonb: huge }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
test('rejects fields_jsonb with too many fields (> 64)', () => {
const fields: Record<string, unknown> = {};
for (let i = 0; i < MAX_FIELD_COUNT + 1; i++) {
fields[`field${i}`] = { v: i, t: HLC_A, s: 'dev-a' };
}
const result = validateRow(baseRow({ fields_jsonb: fields as never }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
test('rejects schemaVersion below minSupported', () => {
const result = validateRow(baseRow({ schema_version: 0 }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('SCHEMA_TOO_NEW');
});
test('rejects schemaVersion above maxKnown', () => {
const result = validateRow(baseRow({ schema_version: 999 }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('SCHEMA_TOO_NEW');
});
test('rejects manifest with invalid filename', () => {
const result = validateRow(
baseRow({
manifest_jsonb: {
schemaVersion: 1,
files: [{ filename: '../etc/passwd', byteSize: 1, partialMd5: 'abc' }],
},
}),
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
test('accepts manifest with valid filenames', () => {
const result = validateRow(
baseRow({
manifest_jsonb: {
schemaVersion: 1,
files: [
{ filename: 'webster.mdx', byteSize: 1024, partialMd5: 'abcdef' },
{ filename: 'webster.mdd', byteSize: 2048, partialMd5: 'abcdef' },
],
},
}),
);
expect(result.ok).toBe(true);
});
test('preserves unknown fields (forwards-compat)', () => {
const row = baseRow();
row.fields_jsonb['future_field'] = { v: 'unknown', t: HLC_A, s: 'dev-a' };
const result = validateRow(row);
expect(result.ok).toBe(true);
});
test('rejects fields with malformed envelope (missing v/t/s)', () => {
const row = baseRow();
row.fields_jsonb['broken'] = { v: 'x' } as never;
const result = validateRow(row);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
});
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('@/utils/access', () => ({
getAccessToken: vi.fn(async () => 'fake-token'),
}));
vi.mock('@/services/environment', () => ({
getAPIBaseUrl: () => 'https://example.test',
}));
import { ReplicaSyncClient } from '@/libs/replicaSyncClient';
import { hlcPack } from '@/libs/crdt';
import type { Hlc, ReplicaRow } from '@/types/replica';
import { SyncError } from '@/libs/errors';
const HLC = hlcPack(1_700_000_000_000, 0, 'd') as Hlc;
const sampleRow: ReplicaRow = {
user_id: 'u1',
kind: 'dictionary',
replica_id: 'r1',
fields_jsonb: { name: { v: 'Webster', t: HLC, s: 'd' } },
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: HLC,
schema_version: 1,
};
const mockFetch = vi.fn();
beforeEach(() => {
mockFetch.mockReset();
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('ReplicaSyncClient.push', () => {
test('POSTs rows to /sync/replicas with bearer token', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ rows: [sampleRow] }), { status: 200 }),
);
const client = new ReplicaSyncClient();
const result = await client.push([sampleRow]);
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://example.test/sync/replicas');
expect(init.method).toBe('POST');
expect(init.headers.Authorization).toBe('Bearer fake-token');
expect(init.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(init.body)).toEqual({ rows: [sampleRow] });
expect(result).toEqual([sampleRow]);
});
test('400 / VALIDATION → SyncError VALIDATION', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'bad', code: 'VALIDATION' }), { status: 400 }),
);
const client = new ReplicaSyncClient();
await expect(client.push([sampleRow])).rejects.toMatchObject({
name: 'SyncError',
code: 'VALIDATION',
});
});
test('401 → SyncError AUTH', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'unauth', code: 'AUTH' }), { status: 401 }),
);
const client = new ReplicaSyncClient();
await expect(client.push([sampleRow])).rejects.toMatchObject({
code: 'AUTH',
});
});
test('409 / CLOCK_SKEW → SyncError CLOCK_SKEW', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'skew', code: 'CLOCK_SKEW' }), { status: 409 }),
);
const client = new ReplicaSyncClient();
await expect(client.push([sampleRow])).rejects.toMatchObject({ code: 'CLOCK_SKEW' });
});
test('413 / batch too large → SyncError VALIDATION', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'batch', code: 'VALIDATION' }), { status: 413 }),
);
const client = new ReplicaSyncClient();
await expect(client.push([sampleRow])).rejects.toMatchObject({ code: 'VALIDATION' });
});
test('422 / UNKNOWN_KIND → SyncError UNKNOWN_KIND', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'unknown', code: 'UNKNOWN_KIND' }), { status: 422 }),
);
const client = new ReplicaSyncClient();
await expect(client.push([sampleRow])).rejects.toMatchObject({ code: 'UNKNOWN_KIND' });
});
test('5xx → SyncError SERVER', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'oops' }), { status: 500 }),
);
const client = new ReplicaSyncClient();
await expect(client.push([sampleRow])).rejects.toMatchObject({ code: 'SERVER' });
});
test('network error → SyncError TIMEOUT/SERVER', async () => {
mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
const client = new ReplicaSyncClient();
await expect(client.push([sampleRow])).rejects.toBeInstanceOf(SyncError);
});
test('empty rows is a no-op (no fetch call)', async () => {
const client = new ReplicaSyncClient();
const result = await client.push([]);
expect(result).toEqual([]);
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('ReplicaSyncClient.pull', () => {
test('GETs with kind + since query params', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ rows: [sampleRow] }), { status: 200 }),
);
const client = new ReplicaSyncClient();
const rows = await client.pull('dictionary', HLC);
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe(
`https://example.test/sync/replicas?kind=dictionary&since=${encodeURIComponent(HLC)}`,
);
expect(init.method).toBe('GET');
expect(rows).toEqual([sampleRow]);
});
test('GET without since cursor', async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ rows: [] }), { status: 200 }));
const client = new ReplicaSyncClient();
await client.pull('dictionary', null);
const [url] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://example.test/sync/replicas?kind=dictionary');
});
test('404 → empty array (server lacks /api/sync/replicas; old backend)', async () => {
mockFetch.mockResolvedValueOnce(new Response('not found', { status: 404 }));
const client = new ReplicaSyncClient();
const rows = await client.pull('dictionary', null);
expect(rows).toEqual([]);
});
});
@@ -0,0 +1,155 @@
import { describe, expect, test } from 'vitest';
import {
HLC_SKEW_TOLERANCE_MS,
MAX_PUSH_BATCH,
clampHlcSkew,
validatePullParams,
validatePushBatch,
} from '@/libs/replicaSyncServer';
import { hlcPack } from '@/libs/crdt';
import type { Hlc, ReplicaRow } from '@/types/replica';
const USER = 'u1';
const NOW = 1_700_000_000_000;
const HLC_NOW = hlcPack(NOW, 0, 'dev-a') as Hlc;
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
user_id: USER,
kind: 'dictionary',
replica_id: 'r1',
fields_jsonb: {
name: { v: 'Webster', t: HLC_NOW, s: 'dev-a' },
},
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: HLC_NOW,
schema_version: 1,
...overrides,
});
describe('clampHlcSkew', () => {
test('accepts HLC within tolerance', () => {
expect(clampHlcSkew(hlcPack(NOW + 1000, 0, 'd') as Hlc, NOW)).toBe(true);
expect(clampHlcSkew(hlcPack(NOW - 1000, 0, 'd') as Hlc, NOW)).toBe(true);
expect(clampHlcSkew(hlcPack(NOW + HLC_SKEW_TOLERANCE_MS, 0, 'd') as Hlc, NOW)).toBe(true);
});
test('rejects HLC beyond tolerance', () => {
expect(clampHlcSkew(hlcPack(NOW + HLC_SKEW_TOLERANCE_MS + 1, 0, 'd') as Hlc, NOW)).toBe(false);
expect(clampHlcSkew(hlcPack(NOW - HLC_SKEW_TOLERANCE_MS - 1, 0, 'd') as Hlc, NOW)).toBe(false);
});
test('far-future HLC is rejected', () => {
expect(clampHlcSkew(hlcPack(NOW + 1_000_000_000, 0, 'd') as Hlc, NOW)).toBe(false);
});
});
describe('validatePushBatch', () => {
test('accepts an empty batch', () => {
const result = validatePushBatch({ rows: [] }, USER, NOW);
expect(result.ok).toBe(true);
if (result.ok) expect(result.rows).toEqual([]);
});
test('accepts a single valid row', () => {
const result = validatePushBatch({ rows: [baseRow()] }, USER, NOW);
expect(result.ok).toBe(true);
});
test('rejects body that is not an object', () => {
const result = validatePushBatch(null, USER, NOW);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(400);
expect(result.code).toBe('VALIDATION');
}
});
test('rejects body without rows array', () => {
const result = validatePushBatch({ wrong: 'shape' }, USER, NOW);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});
test('rejects batch above MAX_PUSH_BATCH', () => {
const rows = Array.from({ length: MAX_PUSH_BATCH + 1 }, (_, i) =>
baseRow({ replica_id: `r${i}` }),
);
const result = validatePushBatch({ rows }, USER, NOW);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(413);
expect(result.code).toBe('VALIDATION');
}
});
test('rejects row with mismatched user_id (cross-account write attempt)', () => {
const result = validatePushBatch({ rows: [baseRow({ user_id: 'attacker' })] }, USER, NOW);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(403);
expect(result.code).toBe('AUTH');
expect(result.offendingIndex).toBe(0);
}
});
test('rejects row with kind not in allowlist', () => {
const result = validatePushBatch({ rows: [baseRow({ kind: 'evil' })] }, USER, NOW);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
expect(result.code).toBe('UNKNOWN_KIND');
}
});
test('rejects row with HLC outside skew tolerance', () => {
const farFuture = hlcPack(NOW + 1_000_000, 0, 'd') as Hlc;
const result = validatePushBatch({ rows: [baseRow({ updated_at_ts: farFuture })] }, USER, NOW);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(409);
expect(result.code).toBe('CLOCK_SKEW');
}
});
test('reports the offending index for downstream telemetry', () => {
const rows = [
baseRow({ replica_id: 'r0' }),
baseRow({ replica_id: 'r1', kind: 'evil' }),
baseRow({ replica_id: 'r2' }),
];
const result = validatePushBatch({ rows }, USER, NOW);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.offendingIndex).toBe(1);
});
});
describe('validatePullParams', () => {
test('accepts kind=dictionary with no since', () => {
const result = validatePullParams('dictionary', null);
expect(result.ok).toBe(true);
if (result.ok) expect(result.params.since).toBe(null);
});
test('accepts kind=dictionary with a since cursor', () => {
const result = validatePullParams('dictionary', '0000000000064-00000000-dev-a');
expect(result.ok).toBe(true);
if (result.ok) expect(result.params.since).toBe('0000000000064-00000000-dev-a');
});
test('rejects missing kind', () => {
const result = validatePullParams(null, null);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});
test('rejects unknown kind', () => {
const result = validatePullParams('font', null);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
expect(result.code).toBe('UNKNOWN_KIND');
}
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { generateShareToken, hashShareToken, isValidShareToken } from '@/libs/share-server';
import { generateShareToken, hashShareToken, isValidShareToken } from '@/libs/shareServer';
describe('share-server', () => {
describe('shareServer', () => {
describe('generateShareToken', () => {
it('produces a 22-char alphanumeric raw token', async () => {
const { raw, hash } = await generateShareToken();
@@ -243,6 +243,20 @@ describe('services/constants', () => {
expect(DEFAULT_SYSTEM_SETTINGS.screenBrightness!).toBeLessThanOrEqual(100);
});
it('seeds syncCategories with all four SyncCategory keys', () => {
const cats = DEFAULT_SYSTEM_SETTINGS.syncCategories!;
expect(cats).toEqual({
book: true,
progress: true,
note: true,
dictionary: true,
});
});
it('seeds lastSyncedAtReplicas as an empty record', () => {
expect(DEFAULT_SYSTEM_SETTINGS.lastSyncedAtReplicas).toEqual({});
});
it('has library settings', () => {
expect(DEFAULT_SYSTEM_SETTINGS.libraryViewMode).toBe('grid');
expect(typeof DEFAULT_SYSTEM_SETTINGS.librarySortBy).toBe('string');
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'vitest';
import { computeDictionaryContentId } from '@/services/dictionaries/contentId';
const makeFile = (content: string, name: string): File =>
new File([new TextEncoder().encode(content)], name);
describe('computeDictionaryContentId', () => {
test('returns 32-hex md5', async () => {
const id = await computeDictionaryContentId(makeFile('hello world', 'a.ifo'), ['a.ifo']);
expect(id).toMatch(/^[0-9a-f]{32}$/);
});
test('same primary content + same filename list → same id', async () => {
const fileA = makeFile('webster contents', 'webster.mdx');
const fileB = makeFile('webster contents', 'webster.mdx');
const a = await computeDictionaryContentId(fileA, ['webster.mdx', 'webster.mdd']);
const b = await computeDictionaryContentId(fileB, ['webster.mdx', 'webster.mdd']);
expect(a).toBe(b);
});
test('different primary content → different id', async () => {
const a = await computeDictionaryContentId(makeFile('content A', 'x.mdx'), ['x.mdx']);
const b = await computeDictionaryContentId(makeFile('content B', 'x.mdx'), ['x.mdx']);
expect(a).not.toBe(b);
});
test('filename order does NOT change the id (sorted internally)', async () => {
const a = await computeDictionaryContentId(makeFile('x', 'd.mdx'), ['a.mdd', 'd.mdx']);
const b = await computeDictionaryContentId(makeFile('x', 'd.mdx'), ['d.mdx', 'a.mdd']);
expect(a).toBe(b);
});
test('different filename set → different id', async () => {
const a = await computeDictionaryContentId(makeFile('x', 'd.mdx'), ['d.mdx']);
const b = await computeDictionaryContentId(makeFile('x', 'd.mdx'), ['d.mdx', 'd.mdd']);
expect(a).not.toBe(b);
});
test('different primary file SIZE → different id (defends against partialMD5 collision)', async () => {
// Same first-byte content but different total length.
const a = await computeDictionaryContentId(makeFile('A', 'x.mdx'), ['x.mdx']);
const b = await computeDictionaryContentId(makeFile('AB', 'x.mdx'), ['x.mdx']);
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,205 @@
import { describe, expect, test } from 'vitest';
import {
computeDictionaryReplicaId,
dictionaryAdapter,
enumerateDictionaryFiles,
primaryDictionaryFile,
} from '@/services/sync/adapters/dictionary';
import type { ImportedDictionary } from '@/services/dictionaries/types';
const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictionary => ({
id: 'placeholder',
kind: 'mdict',
name: 'Webster',
bundleDir: 'webster-bundle',
files: { mdx: 'webster.mdx', mdd: ['webster.mdd'] },
addedAt: 1700000000000,
...overrides,
});
describe('dictionaryAdapter contract', () => {
test('kind is "dictionary"', () => {
expect(dictionaryAdapter.kind).toBe('dictionary');
});
test('schemaVersion is 1', () => {
expect(dictionaryAdapter.schemaVersion).toBe(1);
});
test('binary capability uses BaseDir "Dictionaries"', () => {
expect(dictionaryAdapter.binary?.localBaseDir).toBe('Dictionaries');
});
test('computeId returns the record id (sync passthrough)', async () => {
const d = baseDict({ id: 'content-hash-xyz' });
expect(await dictionaryAdapter.computeId(d)).toBe('content-hash-xyz');
});
});
describe('pack ∘ unpack = identity for the synced subset', () => {
test('mdict bundle: synced fields round-trip', () => {
const d = baseDict({
kind: 'mdict',
name: 'Webster',
lang: 'en',
addedAt: 1700000000000,
});
const packed = dictionaryAdapter.pack(d);
const unpacked = dictionaryAdapter.unpack(packed);
expect(unpacked.name).toBe('Webster');
expect(unpacked.lang).toBe('en');
expect(unpacked.kind).toBe('mdict');
expect(unpacked.addedAt).toBe(1700000000000);
});
test('per-device fields (bundleDir, files) are NOT in the synced fields object', () => {
const d = baseDict({
bundleDir: 'device-local-uniqueId-123',
files: { mdx: 'webster.mdx' },
});
const packed = dictionaryAdapter.pack(d);
expect(packed['bundleDir']).toBeUndefined();
expect(packed['files']).toBeUndefined();
});
test('unavailable / deletedAt are NOT synced as fields (tombstones handle delete)', () => {
const d = baseDict({ unavailable: true, deletedAt: 999 });
const packed = dictionaryAdapter.pack(d);
expect(packed['unavailable']).toBeUndefined();
expect(packed['deletedAt']).toBeUndefined();
});
test('unsupportedReason round-trips', () => {
const d = baseDict({ unsupported: true, unsupportedReason: 'encrypted MDX' });
const packed = dictionaryAdapter.pack(d);
const unpacked = dictionaryAdapter.unpack(packed);
expect(unpacked.unsupported).toBe(true);
expect(unpacked.unsupportedReason).toBe('encrypted MDX');
});
});
describe('primaryDictionaryFile', () => {
test('mdict → .mdx', () => {
expect(primaryDictionaryFile(baseDict({ kind: 'mdict', files: { mdx: 'w.mdx' } }))).toBe(
'w.mdx',
);
});
test('stardict → .ifo', () => {
expect(
primaryDictionaryFile(
baseDict({ kind: 'stardict', files: { ifo: 'w.ifo', dict: 'w.dict.dz' } }),
),
).toBe('w.ifo');
});
test('dict → .dict (or .dict.dz)', () => {
expect(
primaryDictionaryFile(
baseDict({ kind: 'dict', files: { dict: 'w.dict.dz', index: 'w.index' } }),
),
).toBe('w.dict.dz');
});
test('slob → .slob', () => {
expect(primaryDictionaryFile(baseDict({ kind: 'slob', files: { slob: 'w.slob' } }))).toBe(
'w.slob',
);
});
test('returns null when no primary file is recorded', () => {
expect(primaryDictionaryFile(baseDict({ kind: 'mdict', files: {} }))).toBe(null);
});
});
describe('enumerateDictionaryFiles', () => {
test('mdict bundle: mdx + mdd[] + css[]', () => {
const d = baseDict({
kind: 'mdict',
bundleDir: 'b1',
files: {
mdx: 'webster.mdx',
mdd: ['webster.mdd', 'webster.1.mdd'],
css: ['webster.css'],
},
});
const files = enumerateDictionaryFiles(d);
expect(files.map((f) => f.logical)).toEqual([
'webster.mdx',
'webster.mdd',
'webster.1.mdd',
'webster.css',
]);
expect(files.every((f) => f.lfp.startsWith('b1/'))).toBe(true);
});
test('stardict bundle: ifo + idx + dict + syn (skips offset sidecars)', () => {
const d = baseDict({
kind: 'stardict',
bundleDir: 's1',
files: {
ifo: 'd.ifo',
idx: 'd.idx',
dict: 'd.dict.dz',
syn: 'd.syn',
idxOffsets: 'd.idx.offsets',
synOffsets: 'd.syn.offsets',
},
});
const files = enumerateDictionaryFiles(d);
expect(files.map((f) => f.logical).sort()).toEqual(['d.dict.dz', 'd.idx', 'd.ifo', 'd.syn']);
});
test('dict bundle: dict + index', () => {
const d = baseDict({
kind: 'dict',
bundleDir: 'dd',
files: { dict: 'w.dict.dz', index: 'w.index' },
});
const files = enumerateDictionaryFiles(d);
expect(files.map((f) => f.logical).sort()).toEqual(['w.dict.dz', 'w.index']);
});
test('slob bundle: single .slob file', () => {
const d = baseDict({ kind: 'slob', bundleDir: 'sl', files: { slob: 'w.slob' } });
const files = enumerateDictionaryFiles(d);
expect(files.map((f) => f.logical)).toEqual(['w.slob']);
});
test('absent files are skipped', () => {
const d = baseDict({ kind: 'mdict', bundleDir: 'b', files: { mdx: 'm.mdx' } });
const files = enumerateDictionaryFiles(d);
expect(files.map((f) => f.logical)).toEqual(['m.mdx']);
});
});
describe('computeDictionaryReplicaId', () => {
test('deterministic over (partialMd5, byteSize, sorted filenames)', () => {
const a = computeDictionaryReplicaId('abc123', 1024, ['x.mdx', 'x.mdd']);
const b = computeDictionaryReplicaId('abc123', 1024, ['x.mdd', 'x.mdx']);
expect(a).toBe(b);
});
test('different partialMd5 → different id', () => {
const a = computeDictionaryReplicaId('abc', 1024, ['x.mdx']);
const b = computeDictionaryReplicaId('def', 1024, ['x.mdx']);
expect(a).not.toBe(b);
});
test('different byteSize → different id', () => {
const a = computeDictionaryReplicaId('abc', 1024, ['x.mdx']);
const b = computeDictionaryReplicaId('abc', 2048, ['x.mdx']);
expect(a).not.toBe(b);
});
test('different filename set → different id', () => {
const a = computeDictionaryReplicaId('abc', 1024, ['x.mdx']);
const b = computeDictionaryReplicaId('abc', 1024, ['x.mdx', 'x.mdd']);
expect(a).not.toBe(b);
});
test('returns 32-hex md5', () => {
const id = computeDictionaryReplicaId('abc', 1024, ['x.mdx']);
expect(id).toMatch(/^[0-9a-f]{32}$/);
});
});
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, test } from 'vitest';
import {
__resetBootstrapForTests,
bootstrapReplicaAdapters,
} from '@/services/sync/replicaBootstrap';
import {
clearReplicaAdapters,
getReplicaAdapter,
listReplicaAdapters,
} from '@/services/sync/replicaRegistry';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
afterEach(() => {
clearReplicaAdapters();
__resetBootstrapForTests();
});
describe('bootstrapReplicaAdapters', () => {
test('registers the dictionary adapter', () => {
bootstrapReplicaAdapters();
expect(getReplicaAdapter('dictionary')).toBe(dictionaryAdapter);
});
test('is idempotent: calling twice is a no-op (does not throw)', () => {
bootstrapReplicaAdapters();
bootstrapReplicaAdapters();
expect(listReplicaAdapters()).toHaveLength(1);
});
test('only registers the kinds in the PR-1 allowlist', () => {
bootstrapReplicaAdapters();
const kinds = listReplicaAdapters().map((a) => a.kind);
expect(kinds).toEqual(['dictionary']);
});
});
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, test } from 'vitest';
import {
clearReplicaAdapters,
getReplicaAdapter,
listReplicaAdapters,
registerReplicaAdapter,
} from '@/services/sync/replicaRegistry';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
interface DictRecord {
id: string;
name: string;
enabled: boolean;
}
const dictionaryAdapter: ReplicaAdapter<DictRecord> = {
kind: 'dictionary',
schemaVersion: 1,
pack: (r) => ({ id: r.id, name: r.name, enabled: r.enabled }),
unpack: (f) => ({ id: String(f['id']), name: String(f['name']), enabled: Boolean(f['enabled']) }),
computeId: async (r: DictRecord) => r.id,
};
afterEach(() => clearReplicaAdapters());
describe('replicaRegistry', () => {
test('register + get round-trip', () => {
registerReplicaAdapter(dictionaryAdapter);
const got = getReplicaAdapter('dictionary');
expect(got).toBe(dictionaryAdapter);
});
test('unknown kind returns undefined', () => {
expect(getReplicaAdapter('not-a-kind')).toBeUndefined();
});
test('double registration throws (defensive against doubly-imported modules)', () => {
registerReplicaAdapter(dictionaryAdapter);
expect(() => registerReplicaAdapter(dictionaryAdapter)).toThrow(/already registered/i);
});
test('listReplicaAdapters returns all registered kinds', () => {
registerReplicaAdapter(dictionaryAdapter);
expect(listReplicaAdapters().map((a) => a.kind)).toEqual(['dictionary']);
});
test('pack ∘ unpack = identity for sample adapter', () => {
registerReplicaAdapter(dictionaryAdapter);
const adapter = getReplicaAdapter<DictRecord>('dictionary')!;
const original: DictRecord = { id: 'd1', name: 'Webster', enabled: true };
const packed = adapter.pack(original);
const unpacked = adapter.unpack(packed);
expect(unpacked).toEqual(original);
});
test('clearReplicaAdapters empties the registry (test-only)', () => {
registerReplicaAdapter(dictionaryAdapter);
clearReplicaAdapters();
expect(getReplicaAdapter('dictionary')).toBeUndefined();
expect(listReplicaAdapters()).toEqual([]);
});
});
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
__resetReplicaSyncForTests,
getReplicaSync,
initReplicaSync,
isReplicaSyncReady,
} from '@/services/sync/replicaSync';
import { InMemoryHlcStore } from '@/libs/hlcStore';
import type { ReplicaRow, Hlc } from '@/types/replica';
import type { CursorStore } from '@/services/sync/replicaSyncManager';
const makeMemoryCursorStore = (): CursorStore => {
const map = new Map<string, Hlc>();
return {
get: (k) => map.get(k) ?? null,
set: (k, v) => {
map.set(k, v);
},
};
};
const makeFakeClient = () => ({
push: vi.fn(async (rows: ReplicaRow[]) => rows),
pull: vi.fn(async (_kind: string, _since: Hlc | null) => [] as ReplicaRow[]),
});
afterEach(() => {
__resetReplicaSyncForTests();
});
describe('replicaSync singleton', () => {
test('getReplicaSync returns null before init', () => {
expect(getReplicaSync()).toBe(null);
expect(isReplicaSyncReady()).toBe(false);
});
test('initReplicaSync produces a manager + hlc context', () => {
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
expect(ctx.manager).toBeDefined();
expect(ctx.hlc).toBeDefined();
expect(ctx.deviceId).toBe('dev-a');
expect(isReplicaSyncReady()).toBe(true);
});
test('subsequent initReplicaSync calls are idempotent (return same instance)', () => {
const a = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
const b = initReplicaSync({
deviceId: 'dev-different',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
expect(b).toBe(a);
expect(b.deviceId).toBe('dev-a');
});
test('hlc.next() persists snapshot to the injected store', () => {
const hlcStore = new InMemoryHlcStore();
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore,
client: makeFakeClient() as never,
});
expect(hlcStore.load()).toBe(null);
ctx.hlc.next();
const snap = hlcStore.load();
expect(snap).not.toBe(null);
expect(snap!.counter).toBeGreaterThanOrEqual(0);
});
test('hlc.observe() persists snapshot to the injected store', () => {
const hlcStore = new InMemoryHlcStore();
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore,
client: makeFakeClient() as never,
});
ctx.hlc.observe('fffffffffffff-00000000-dev-other' as Hlc);
expect(hlcStore.load()).not.toBe(null);
});
test('init restores from existing snapshot (counter survives reload)', () => {
const hlcStore = new InMemoryHlcStore();
// Choose a far-future ms so the wall clock won't advance past it during
// the test — that way next() bumps the counter rather than the ms.
const farFutureMs = Date.now() + 10 * 60 * 60 * 1000;
hlcStore.save({ physicalMs: farFutureMs, counter: 42 });
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore,
client: makeFakeClient() as never,
});
const serialized = ctx.hlc.serialize();
expect(serialized.physicalMs).toBe(farFutureMs);
expect(serialized.counter).toBe(42);
ctx.hlc.next();
expect(ctx.hlc.serialize().counter).toBe(43);
});
test('__resetReplicaSyncForTests clears the singleton', () => {
initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
expect(isReplicaSyncReady()).toBe(true);
__resetReplicaSyncForTests();
expect(isReplicaSyncReady()).toBe(false);
expect(getReplicaSync()).toBe(null);
});
});
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { HlcGenerator, hlcPack } from '@/libs/crdt';
import { SyncError } from '@/libs/errors';
import { ReplicaSyncManager } from '@/services/sync/replicaSyncManager';
import type { Hlc, ReplicaRow } from '@/types/replica';
const NOW = 1_700_000_000_000;
const DEV = 'dev-a';
const HLC_NOW = hlcPack(NOW, 0, DEV) as Hlc;
const makeRow = (id: string, hlcStr: Hlc = HLC_NOW): ReplicaRow => ({
user_id: 'u1',
kind: 'dictionary',
replica_id: id,
fields_jsonb: { name: { v: id, t: hlcStr, s: DEV } },
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: hlcStr,
schema_version: 1,
});
const makeFakeClient = () => ({
push: vi.fn(async (rows: ReplicaRow[]) => rows),
pull: vi.fn(async (_kind: string, _since: Hlc | null) => [] as ReplicaRow[]),
});
const makeManager = (clientOverrides: Partial<ReturnType<typeof makeFakeClient>> = {}) => {
const client = { ...makeFakeClient(), ...clientOverrides };
const hlc = new HlcGenerator(DEV, () => NOW);
const cursors = new Map<string, Hlc>();
const manager = new ReplicaSyncManager({
hlc,
client,
debounceMs: 5000,
cursorStore: {
get: (k) => cursors.get(k) ?? null,
set: (k, v) => {
cursors.set(k, v);
},
},
});
return { manager, client, hlc, cursors };
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('ReplicaSyncManager.markDirty + flush', () => {
test('markDirty alone does not push', async () => {
const { manager, client } = makeManager();
manager.markDirty(makeRow('r1'));
await Promise.resolve();
expect(client.push).not.toHaveBeenCalled();
});
test('markDirty then 5s debounce fires push', async () => {
const { manager, client } = makeManager();
manager.markDirty(makeRow('r1'));
await vi.advanceTimersByTimeAsync(4999);
expect(client.push).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2);
expect(client.push).toHaveBeenCalledOnce();
expect(client.push.mock.calls[0]![0]).toHaveLength(1);
});
test('successive markDirty resets the debounce window', async () => {
const { manager, client } = makeManager();
manager.markDirty(makeRow('r1'));
await vi.advanceTimersByTimeAsync(4000);
manager.markDirty(makeRow('r2'));
await vi.advanceTimersByTimeAsync(4000);
expect(client.push).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1100);
expect(client.push).toHaveBeenCalledOnce();
expect(client.push.mock.calls[0]![0]).toHaveLength(2);
});
test('flush() pushes immediately', async () => {
const { manager, client } = makeManager();
manager.markDirty(makeRow('r1'));
manager.markDirty(makeRow('r2'));
await manager.flush();
expect(client.push).toHaveBeenCalledOnce();
expect(client.push.mock.calls[0]![0]).toHaveLength(2);
});
test('flush() with no dirty rows is a no-op', async () => {
const { manager, client } = makeManager();
await manager.flush();
expect(client.push).not.toHaveBeenCalled();
});
test('same replica re-marked dirty: only the latest row pushes', async () => {
const { manager, client } = makeManager();
manager.markDirty(makeRow('r1', hlcPack(NOW, 0, DEV) as Hlc));
manager.markDirty(makeRow('r1', hlcPack(NOW, 1, DEV) as Hlc));
await manager.flush();
const pushed = client.push.mock.calls[0]![0];
expect(pushed).toHaveLength(1);
expect(pushed[0]!.fields_jsonb['name']!.t).toBe(hlcPack(NOW, 1, DEV));
});
test('flush() clears the dirty set on success', async () => {
const { manager, client } = makeManager();
manager.markDirty(makeRow('r1'));
await manager.flush();
expect(client.push).toHaveBeenCalledOnce();
await manager.flush();
expect(client.push).toHaveBeenCalledOnce();
});
test('push rejection: dirty set is preserved for retry', async () => {
const client = {
...makeFakeClient(),
push: vi.fn(async (_rows: ReplicaRow[]): Promise<ReplicaRow[]> => {
throw new SyncError('SERVER', 'simulated outage');
}),
};
const { manager } = makeManager(client);
manager.markDirty(makeRow('r1'));
await expect(manager.flush()).rejects.toThrow(/simulated outage/);
client.push.mockResolvedValueOnce([makeRow('r1')]);
await manager.flush();
expect(client.push).toHaveBeenCalledTimes(2);
});
});
describe('ReplicaSyncManager.pull', () => {
test('passes cursor + advances on success', async () => {
const r1 = makeRow('r1', hlcPack(NOW + 100, 0, DEV) as Hlc);
const r2 = makeRow('r2', hlcPack(NOW + 200, 0, DEV) as Hlc);
const client = {
...makeFakeClient(),
pull: vi.fn(async () => [r1, r2]),
};
const { manager, cursors } = makeManager(client);
const result = await manager.pull('dictionary');
expect(result).toEqual([r1, r2]);
expect(client.pull).toHaveBeenCalledWith('dictionary', null);
expect(cursors.get('dictionary')).toBe(r2.updated_at_ts);
});
test('subsequent pull uses advanced cursor', async () => {
const r1 = makeRow('r1', hlcPack(NOW + 100, 0, DEV) as Hlc);
const client = {
...makeFakeClient(),
pull: vi.fn().mockResolvedValueOnce([r1]).mockResolvedValueOnce([]),
};
const { manager } = makeManager(client);
await manager.pull('dictionary');
await manager.pull('dictionary');
expect(client.pull).toHaveBeenNthCalledWith(2, 'dictionary', r1.updated_at_ts);
});
test('pull observes remote HLCs into local generator', async () => {
const remoteHlc = hlcPack(NOW + 60_000, 7, 'dev-other') as Hlc;
const client = {
...makeFakeClient(),
pull: vi.fn(async () => [makeRow('r1', remoteHlc)]),
};
const { manager, hlc } = makeManager(client);
await manager.pull('dictionary');
const next = hlc.next();
expect(next > remoteHlc).toBe(true);
});
test('empty pull does not advance cursor', async () => {
const client = {
...makeFakeClient(),
pull: vi.fn(async () => []),
};
const { manager, cursors } = makeManager(client);
await manager.pull('dictionary');
expect(cursors.get('dictionary')).toBeUndefined();
});
});
@@ -32,6 +32,7 @@ function makeBook(overrides: Partial<Book> = {}): Book {
function makeTransferItem(overrides: Partial<TransferItem> = {}): TransferItem {
return {
id: 't1',
kind: 'book',
bookHash: 'hash1',
bookTitle: 'Test Book',
type: 'upload',
@@ -671,4 +672,120 @@ describe('TransferManager', () => {
).resolves.not.toThrow();
});
});
// ── queueReplicaUpload / queueReplicaDownload / queueReplicaDelete ──
describe('replica transfer queueing', () => {
const dictFiles = [
{ logical: 'webster.mdx', lfp: 'd1/webster.mdx', byteSize: 1000 },
{ logical: 'webster.mdd', lfp: 'd1/webster.mdd', byteSize: 4000 },
];
test('queueReplicaUpload returns null when not initialized', () => {
const id = transferManager.queueReplicaUpload(
'dictionary',
'd1',
'Webster',
dictFiles,
'Dictionaries',
);
expect(id).toBeNull();
});
test('queueReplicaUpload creates a kind="replica" transfer with files + base', async () => {
const appService = makeAppService();
appService['uploadReplicaFile'] = vi.fn().mockResolvedValue(undefined);
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
const id = transferManager.queueReplicaUpload(
'dictionary',
'd1',
'Webster',
dictFiles,
'Dictionaries',
);
expect(id).toBeTruthy();
const t = useTransferStore.getState().transfers[id!]!;
expect(t.kind).toBe('replica');
expect(t.replicaKind).toBe('dictionary');
expect(t.replicaId).toBe('d1');
expect(t.replicaFiles).toEqual(dictFiles);
expect(t.replicaBase).toBe('Dictionaries');
expect(t.totalBytes).toBe(5000);
});
test('queueReplicaUpload returns existing id if same (kind, id) is already queued', async () => {
const appService = makeAppService();
appService['uploadReplicaFile'] = vi.fn().mockResolvedValue(undefined);
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
const id1 = transferManager.queueReplicaUpload(
'dictionary',
'd1',
'Webster',
dictFiles,
'Dictionaries',
);
const id2 = transferManager.queueReplicaUpload(
'dictionary',
'd1',
'Webster',
dictFiles,
'Dictionaries',
);
expect(id1).toBe(id2);
});
test('different replicaKinds with same id are distinct queue entries', async () => {
const appService = makeAppService();
appService['uploadReplicaFile'] = vi.fn().mockResolvedValue(undefined);
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
const dictId = transferManager.queueReplicaUpload(
'dictionary',
'shared-hash',
'D',
dictFiles,
'Dictionaries',
);
const fontId = transferManager.queueReplicaUpload(
'font',
'shared-hash',
'F',
[{ logical: 'roboto.ttf', lfp: 'f/roboto.ttf', byteSize: 200000 }],
'Fonts',
);
expect(dictId).not.toBe(fontId);
});
test('queueReplicaDownload creates a download-typed transfer', async () => {
const appService = makeAppService();
appService['downloadReplicaFile'] = vi.fn().mockResolvedValue(undefined);
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
const id = transferManager.queueReplicaDownload(
'dictionary',
'd1',
'Webster',
dictFiles,
'Dictionaries',
);
const t = useTransferStore.getState().transfers[id!]!;
expect(t.type).toBe('download');
expect(t.replicaFiles).toEqual(dictFiles);
});
test('queueReplicaDelete creates a delete-typed transfer with filename list', async () => {
const appService = makeAppService();
appService['deleteReplicaBundle'] = vi.fn().mockResolvedValue(undefined);
await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn);
const id = transferManager.queueReplicaDelete('dictionary', 'd1', 'Webster', [
'webster.mdx',
'webster.mdd',
]);
const t = useTransferStore.getState().transfers[id!]!;
expect(t.type).toBe('delete');
expect(t.replicaFiles?.map((f) => f.logical)).toEqual(['webster.mdx', 'webster.mdd']);
});
});
});
@@ -349,6 +349,7 @@ describe('transferStore', () => {
const transfers: Record<string, TransferItem> = {
t1: {
id: 't1',
kind: 'book',
bookHash: 'h1',
bookTitle: 'B1',
type: 'upload',
@@ -366,6 +367,7 @@ describe('transferStore', () => {
},
t2: {
id: 't2',
kind: 'book',
bookHash: 'h2',
bookTitle: 'B2',
type: 'download',
@@ -429,4 +431,133 @@ describe('transferStore', () => {
expect(useTransferStore.getState().activeCount).toBe(5);
});
});
// ── kind discriminator ───────────────────────────────────────────
describe('replica transfers', () => {
test('addTransfer defaults kind to "book"', () => {
const id = useTransferStore.getState().addTransfer('h1', 'B1', 'upload');
expect(useTransferStore.getState().transfers[id]!.kind).toBe('book');
});
test('addReplicaTransfer creates a kind="replica" item with replicaKind/replicaId', () => {
const id = useTransferStore
.getState()
.addReplicaTransfer('dictionary', 'dict-content-hash', 'Webster', 'upload');
const t = useTransferStore.getState().transfers[id]!;
expect(t.kind).toBe('replica');
expect(t.replicaKind).toBe('dictionary');
expect(t.replicaId).toBe('dict-content-hash');
expect(t.bookTitle).toBe('Webster');
expect(t.bookHash).toBe('');
expect(t.type).toBe('upload');
expect(t.status).toBe('pending');
});
test('addReplicaTransfer accepts custom priority and isBackground', () => {
const id = useTransferStore
.getState()
.addReplicaTransfer('dictionary', 'd1', 'D1', 'download', {
priority: 1,
isBackground: true,
});
const t = useTransferStore.getState().transfers[id]!;
expect(t.priority).toBe(1);
expect(t.isBackground).toBe(true);
});
test('addReplicaTransfer stores replicaFiles + replicaBase + computes totalBytes', () => {
const id = useTransferStore
.getState()
.addReplicaTransfer('dictionary', 'd1', 'Webster', 'upload', {
files: [
{ logical: 'webster.mdx', lfp: 'd1/webster.mdx', byteSize: 1000 },
{ logical: 'webster.mdd', lfp: 'd1/webster.mdd', byteSize: 2500 },
],
base: 'Dictionaries',
});
const t = useTransferStore.getState().transfers[id]!;
expect(t.replicaFiles).toHaveLength(2);
expect(t.replicaBase).toBe('Dictionaries');
expect(t.totalBytes).toBe(3500);
});
test('getReplicaTransfer finds replica items by (replicaKind, replicaId, type)', () => {
const id = useTransferStore.getState().addReplicaTransfer('dictionary', 'd1', 'D1', 'upload');
const found = useTransferStore.getState().getReplicaTransfer('dictionary', 'd1', 'upload');
expect(found?.id).toBe(id);
});
test('getReplicaTransfer returns undefined for completed/failed transfers', () => {
const id = useTransferStore.getState().addReplicaTransfer('dictionary', 'd1', 'D1', 'upload');
useTransferStore.getState().setTransferStatus(id, 'completed');
expect(
useTransferStore.getState().getReplicaTransfer('dictionary', 'd1', 'upload'),
).toBeUndefined();
});
test('getReplicaTransfer ignores book transfers', () => {
useTransferStore.getState().addTransfer('book-hash', 'Book', 'upload');
expect(
useTransferStore.getState().getReplicaTransfer('dictionary', 'book-hash', 'upload'),
).toBeUndefined();
});
test('getTransferByBookHash ignores replica transfers (defensive against bookHash="" collisions)', () => {
useTransferStore.getState().addReplicaTransfer('dictionary', 'd1', 'D1', 'upload');
expect(useTransferStore.getState().getTransferByBookHash('', 'upload')).toBeUndefined();
});
test('different replicaKinds with same replicaId are distinct', () => {
const idA = useTransferStore
.getState()
.addReplicaTransfer('dictionary', 'shared-hash', 'Dict', 'upload');
const idB = useTransferStore
.getState()
.addReplicaTransfer('font', 'shared-hash', 'Font', 'upload');
expect(idA).not.toBe(idB);
expect(
useTransferStore.getState().getReplicaTransfer('dictionary', 'shared-hash', 'upload')!.id,
).toBe(idA);
expect(
useTransferStore.getState().getReplicaTransfer('font', 'shared-hash', 'upload')!.id,
).toBe(idB);
});
test('clearAll removes book and replica transfers alike', () => {
useTransferStore.getState().addTransfer('h1', 'B1', 'upload');
useTransferStore.getState().addReplicaTransfer('dictionary', 'd1', 'D1', 'upload');
useTransferStore.getState().clearAll();
expect(Object.keys(useTransferStore.getState().transfers)).toHaveLength(0);
});
test('getQueueStats counts both book and replica transfers', () => {
useTransferStore.getState().addTransfer('h1', 'B1', 'upload');
useTransferStore.getState().addReplicaTransfer('dictionary', 'd1', 'D1', 'upload');
expect(useTransferStore.getState().getQueueStats().total).toBe(2);
expect(useTransferStore.getState().getQueueStats().pending).toBe(2);
});
test('restoreTransfers fills kind="book" for legacy persisted rows missing the field', () => {
const legacy = {
legacy1: {
id: 'legacy1',
bookHash: 'h',
bookTitle: 'Old',
type: 'upload',
status: 'pending',
progress: 0,
totalBytes: 0,
transferredBytes: 0,
transferSpeed: 0,
retryCount: 0,
maxRetries: 3,
createdAt: 1,
priority: 10,
isBackground: false,
} as unknown as TransferItem,
};
useTransferStore.getState().restoreTransfers(legacy, false);
expect(useTransferStore.getState().transfers['legacy1']!.kind).toBe('book');
});
});
});
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { getDownloadSignedUrl } from '@/utils/object';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { rejectionToHttp, resolveActiveShare } from '@/libs/shareServer';
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
interface RouteParams {
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { hashShareToken, isValidShareToken } from '@/libs/share-server';
import { hashShareToken, isValidShareToken } from '@/libs/shareServer';
interface RouteParams {
params: Promise<{ token: string }>;
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { getDownloadSignedUrl } from '@/utils/object';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { rejectionToHttp, resolveActiveShare } from '@/libs/shareServer';
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
interface RouteParams {
@@ -6,7 +6,7 @@ import {
getStoragePlanData,
validateUserAndToken,
} from '@/utils/access';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { rejectionToHttp, resolveActiveShare } from '@/libs/shareServer';
interface RouteParams {
params: Promise<{ token: string }>;
@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { NextResponse } from 'next/server';
import { getDownloadSignedUrl } from '@/utils/object';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { rejectionToHttp, resolveActiveShare } from '@/libs/shareServer';
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
// JSX renderer for the OG image. Lives in a non-route `.tsx` so the route
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { hashShareToken, isValidShareToken } from '@/libs/share-server';
import { hashShareToken, isValidShareToken } from '@/libs/shareServer';
interface RouteParams {
params: Promise<{ token: string }>;
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { rejectionToHttp, resolveActiveShare } from '@/libs/shareServer';
interface RouteParams {
params: Promise<{ token: string }>;
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { generateShareToken } from '@/libs/share-server';
import { generateShareToken } from '@/libs/shareServer';
import { objectExists } from '@/utils/object';
import {
SHARE_BASE_URL,
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { READEST_WEB_BASE_URL, SHARE_BASE_URL } from '@/services/constants';
import { resolveActiveShare } from '@/libs/share-server';
import { resolveActiveShare } from '@/libs/shareServer';
import ShareLanding from './ShareLanding';
// Server-rendered metadata for chat unfurls. Lives on the page (not the
@@ -4,6 +4,7 @@ import React, { createContext, useContext, useState, useMemo, ReactNode } from '
import { EnvConfigType } from '../services/environment';
import { AppService } from '@/types/system';
import env from '../services/environment';
import { bootstrapReplicaAdapters } from '@/services/sync/replicaBootstrap';
interface EnvContextType {
envConfig: EnvConfigType;
@@ -17,6 +18,7 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
const [appService, setAppService] = useState<AppService | null>(null);
React.useEffect(() => {
bootstrapReplicaAdapters();
envConfig.getAppService().then((service) => setAppService(service));
window.addEventListener('error', (e) => {
if (e.message === 'ResizeObserver loop limit exceeded') {
+103
View File
@@ -0,0 +1,103 @@
# `crdt.ts` — Hybrid Logical Clock + per-field LWW
Internal sync primitive for the polymorphic `replicas` table. See
`apps/readest-app/.claude/plans/vivid-orbiting-thimble.md` for the full design.
## What this module does
- Generates **HLC** timestamps that are monotonic per device, absorb remote
ordering when observed, and survive wall-clock regression.
- Merges replica rows under **field-level LWW** with **remove-wins
tombstones**.
- Is pure — no IndexedDB, no fetch, no DOM. Persistence is layered on top
by `replicaSyncManager` and a Tauri/web HLC store.
## HLC packing format
```
0000018e7d6ab5c0-00000007-device-uuid
└── physicalMs ─┘ └counter┘ └─deviceId─┘
13 hex chars 8 hex free-form
```
Lexicographic comparison of the packed string matches temporal order:
```
hlcCompare(packA, packB) === -1 ⟺ (msA, ctrA) < (msB, ctrB)
```
This is invariant — see the 1000-sample property test in
`__tests__/libs/crdt.test.ts`.
## Per-field LWW envelope
Each field in `fields_jsonb` is wrapped:
```
{ v: <any json>, t: <Hlc>, s: <deviceId> }
value when who wrote it
```
`mergeFields(local, remote)` keeps the envelope with the larger HLC. Ties
on HLC are broken by `deviceId` lex order (deterministic, both sides
converge).
## Remove-wins tombstones
A `deleted_at_ts` HLC marks the row deleted. **Field writes do NOT revive
a tombstoned row** — that is the unsafe pattern. To revive, use
`withReincarnation(row, token)` which creates a new logical entity.
```
deleted_at_ts ≠ null
┌─ field writes accumulate normally
│ but the row stays deleted
reincarnation = 'epoch-N' ← explicit token from importer
row is alive again under new logical identity
```
## Merge semantics summary
```
mergeReplica(local, remote):
fields_jsonb ← per-field LWW
deleted_at_ts ← max(local, remote) (tombstones never disappear)
reincarnation ← LWW by row updated_at_ts
manifest_jsonb ← whichever side is newer
schema_version ← max
updated_at_ts ← max over fields and tombstone
```
CRDT properties verified by tests:
- `mergeFields(a, b) === mergeFields(b, a)`
- `mergeFields(mergeFields(a, b), c) === mergeFields(a, mergeFields(b, c))`
- `mergeFields(a, a) === a`
- `mergeReplica` is commutative and idempotent
## When you change this module
This is one of the few places in Readest where correctness is
non-negotiable. Bugs cause silent data loss across devices.
- Never weaken any of the four CRDT properties.
- Never let a field write revive a tombstone.
- Never break the HLC packing format (other devices comparing on the
string format will diverge).
- Add a property test for any new merge rule.
- Re-run `pnpm test src/__tests__/libs/crdt.test.ts` after every change.
## Where things go from here
- `crypto/` (Lane B) — encrypted-field envelopes plug into `setField` /
`mergeFields` transparently. The CRDT machinery sees opaque
ciphertext.
- `replicaSyncManager` (Lane E) — owns the HLC generator instance,
observes remote HLCs on pull, persists snapshots to IndexedDB.
- `crdt_merge_replica()` Postgres function — must implement the same
merge semantics on the server side, atomically. Tests in PR 1 will
enforce client/server parity.
+192
View File
@@ -0,0 +1,192 @@
import type { FieldEnvelope, FieldsObject, Hlc, ReplicaRow } from '@/types/replica';
const MAX_PHYSICAL_MS = 0xfffffffffffff;
const MAX_COUNTER = 0xffffffff;
export const hlcPack = (physicalMs: number, counter: number, deviceId: string): Hlc => {
if (physicalMs < 0 || physicalMs > MAX_PHYSICAL_MS) {
throw new RangeError(`physicalMs out of range: ${physicalMs}`);
}
if (counter < 0 || counter > MAX_COUNTER) {
throw new RangeError(`counter out of range: ${counter}`);
}
const ms = physicalMs.toString(16).padStart(13, '0');
const c = counter.toString(16).padStart(8, '0');
return `${ms}-${c}-${deviceId}` as Hlc;
};
export const hlcParse = (h: Hlc): { physicalMs: number; counter: number; deviceId: string } => {
const dash1 = h.indexOf('-');
const dash2 = h.indexOf('-', dash1 + 1);
if (dash1 < 0 || dash2 < 0) throw new Error(`malformed HLC: ${h}`);
return {
physicalMs: parseInt(h.slice(0, dash1), 16),
counter: parseInt(h.slice(dash1 + 1, dash2), 16),
deviceId: h.slice(dash2 + 1),
};
};
export const hlcCompare = (a: Hlc, b: Hlc): -1 | 0 | 1 => (a < b ? -1 : a > b ? 1 : 0);
export const hlcMax = (a: Hlc | null, b: Hlc | null): Hlc | null => {
if (!a) return b;
if (!b) return a;
return hlcCompare(a, b) >= 0 ? a : b;
};
export interface HlcSnapshot {
physicalMs: number;
counter: number;
}
export class HlcGenerator {
private physicalMs = 0;
private counter = 0;
constructor(
private readonly deviceId: string,
private readonly now: () => number = Date.now,
) {}
static restore(
snapshot: HlcSnapshot,
deviceId: string,
now: () => number = Date.now,
): HlcGenerator {
const gen = new HlcGenerator(deviceId, now);
gen.physicalMs = snapshot.physicalMs;
gen.counter = snapshot.counter;
return gen;
}
next(): Hlc {
const wallMs = this.now();
if (wallMs > this.physicalMs) {
this.physicalMs = wallMs;
this.counter = 0;
} else {
this.counter += 1;
if (this.counter > MAX_COUNTER) {
this.physicalMs += 1;
this.counter = 0;
}
}
return hlcPack(this.physicalMs, this.counter, this.deviceId);
}
observe(remote: Hlc): void {
const { physicalMs: rMs, counter: rC } = hlcParse(remote);
const wallMs = this.now();
const newMs = Math.max(this.physicalMs, rMs, wallMs);
if (newMs === this.physicalMs && newMs === rMs) {
this.counter = Math.max(this.counter, rC) + 1;
} else if (newMs === rMs) {
this.physicalMs = newMs;
this.counter = rC + 1;
} else if (newMs === this.physicalMs) {
this.counter += 1;
} else {
this.physicalMs = newMs;
this.counter = 0;
}
}
serialize(): HlcSnapshot {
return { physicalMs: this.physicalMs, counter: this.counter };
}
}
export const setField = <V>(
fields: FieldsObject,
name: string,
value: V,
hlc: Hlc,
deviceId: string,
): FieldsObject => ({
...fields,
[name]: { v: value, t: hlc, s: deviceId },
});
const pickWinner = (a: FieldEnvelope, b: FieldEnvelope): FieldEnvelope => {
const cmp = hlcCompare(a.t, b.t);
if (cmp > 0) return a;
if (cmp < 0) return b;
return a.s >= b.s ? a : b;
};
export const mergeFields = (local: FieldsObject, remote: FieldsObject): FieldsObject => {
const out: FieldsObject = { ...local };
for (const key of Object.keys(remote)) {
const lo = local[key];
const re = remote[key]!;
out[key] = lo ? pickWinner(lo, re) : re;
}
return out;
};
const computeUpdatedAt = (fields: FieldsObject, deletedAt: Hlc | null): Hlc => {
let max: Hlc | null = deletedAt;
for (const key of Object.keys(fields)) {
const env = fields[key]!;
max = hlcMax(max, env.t);
}
return (max ?? hlcPack(0, 0, '')) as Hlc;
};
export const removeReplica = (row: ReplicaRow, hlc: Hlc): ReplicaRow => {
const deletedAt = hlcMax(row.deleted_at_ts, hlc) ?? hlc;
return {
...row,
deleted_at_ts: deletedAt,
updated_at_ts: hlcMax(row.updated_at_ts, hlc) ?? hlc,
};
};
export const withReincarnation = (row: ReplicaRow, token: string): ReplicaRow => ({
...row,
reincarnation: token,
deleted_at_ts: null,
fields_jsonb: {},
});
export const mergeReplica = (local: ReplicaRow, remote: ReplicaRow): ReplicaRow => {
if (
local.user_id !== remote.user_id ||
local.kind !== remote.kind ||
local.replica_id !== remote.replica_id
) {
throw new Error('mergeReplica: identity mismatch');
}
const fields = mergeFields(local.fields_jsonb, remote.fields_jsonb);
const deleted_at_ts = hlcMax(local.deleted_at_ts, remote.deleted_at_ts);
let reincarnation: string | null;
const localReinc = local.reincarnation;
const remoteReinc = remote.reincarnation;
if (localReinc === remoteReinc) {
reincarnation = localReinc;
} else {
const cmp = hlcCompare(local.updated_at_ts, remote.updated_at_ts);
reincarnation = cmp >= 0 ? localReinc : remoteReinc;
}
const manifest_jsonb =
hlcCompare(remote.updated_at_ts, local.updated_at_ts) > 0
? remote.manifest_jsonb
: local.manifest_jsonb;
const schema_version = Math.max(local.schema_version, remote.schema_version);
const updated_at_ts = computeUpdatedAt(fields, deleted_at_ts);
return {
user_id: local.user_id,
kind: local.kind,
replica_id: local.replica_id,
fields_jsonb: fields,
manifest_jsonb,
deleted_at_ts,
reincarnation,
updated_at_ts,
schema_version,
};
};
@@ -0,0 +1,41 @@
import { SyncError } from '@/libs/errors';
const PBKDF2_DEFAULT_ITERATIONS = 600_000;
const KEY_LENGTH_BITS = 256;
const requireSubtle = (): SubtleCrypto => {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new SyncError('CRYPTO_UNAVAILABLE', 'Web Crypto subtle is not available');
}
return crypto.subtle;
};
export const derivePbkdf2Key = async (
passphrase: string,
salt: Uint8Array,
iterations: number = PBKDF2_DEFAULT_ITERATIONS,
): Promise<CryptoKey> => {
const subtle = requireSubtle();
const passphraseKey = await subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey'],
);
return subtle.deriveKey(
{ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' },
passphraseKey,
{ name: 'AES-GCM', length: KEY_LENGTH_BITS },
true,
['encrypt', 'decrypt'],
);
};
export const exportRawKey = async (key: CryptoKey): Promise<Uint8Array> => {
const subtle = requireSubtle();
const raw = await subtle.exportKey('raw', key);
return new Uint8Array(raw);
};
export const PBKDF2_ITERATIONS = PBKDF2_DEFAULT_ITERATIONS;
@@ -0,0 +1,46 @@
import { SyncError } from '@/libs/errors';
const IV_BYTES = 12;
export interface EncryptedPayload {
iv: Uint8Array;
ciphertext: Uint8Array;
}
const requireSubtle = (): SubtleCrypto => {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new SyncError('CRYPTO_UNAVAILABLE', 'Web Crypto subtle is not available');
}
return crypto.subtle;
};
export const encryptPlaintext = async (
plaintext: Uint8Array,
key: CryptoKey,
): Promise<EncryptedPayload> => {
const subtle = requireSubtle();
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
const ciphertext = await subtle.encrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
plaintext as BufferSource,
);
return { iv, ciphertext: new Uint8Array(ciphertext) };
};
export const decryptCiphertext = async (
payload: EncryptedPayload,
key: CryptoKey,
): Promise<Uint8Array> => {
const subtle = requireSubtle();
try {
const plaintext = await subtle.decrypt(
{ name: 'AES-GCM', iv: payload.iv as BufferSource },
key,
payload.ciphertext as BufferSource,
);
return new Uint8Array(plaintext);
} catch (cause) {
throw new SyncError('DECRYPT', 'AES-GCM decryption failed', { cause });
}
};
@@ -0,0 +1,83 @@
import { SyncError } from '@/libs/errors';
import type { CipherEnvelope } from '@/types/replica';
import { decryptCiphertext, encryptPlaintext } from './encrypt';
export const CURRENT_ALG = 'aes-gcm/pbkdf2-600k-sha256';
const SUPPORTED_ALGS = new Set<string>([CURRENT_ALG]);
const enc = new TextEncoder();
const dec = new TextDecoder();
const requireSubtle = (): SubtleCrypto => {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new SyncError('CRYPTO_UNAVAILABLE', 'Web Crypto subtle is not available');
}
return crypto.subtle;
};
const bytesToBase64 = (bytes: Uint8Array): string => {
let s = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
s += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(s);
};
const base64ToBytes = (b64: string): Uint8Array => {
const s = atob(b64);
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
};
const sha256 = async (bytes: Uint8Array): Promise<Uint8Array> => {
const hash = await requireSubtle().digest('SHA-256', bytes as BufferSource);
return new Uint8Array(hash);
};
const constantTimeEq = (a: Uint8Array, b: Uint8Array): boolean => {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
return diff === 0;
};
export const encryptToEnvelope = async (
plaintext: string | Uint8Array,
key: CryptoKey,
saltId: string,
): Promise<CipherEnvelope> => {
const bytes = typeof plaintext === 'string' ? enc.encode(plaintext) : plaintext;
const payload = await encryptPlaintext(bytes, key);
const sidecar = await sha256(bytes);
return {
c: bytesToBase64(payload.ciphertext),
i: bytesToBase64(payload.iv),
s: saltId,
alg: CURRENT_ALG,
h: bytesToBase64(sidecar),
};
};
export const decryptFromEnvelope = async (
envelope: CipherEnvelope,
key: CryptoKey,
): Promise<string> => {
if (!SUPPORTED_ALGS.has(envelope.alg)) {
throw new SyncError('UNSUPPORTED_ALG', `Unsupported envelope alg: ${envelope.alg}`, {
cause: envelope.alg,
});
}
const ciphertext = base64ToBytes(envelope.c);
const iv = base64ToBytes(envelope.i);
const expectedSidecar = base64ToBytes(envelope.h);
const plain = await decryptCiphertext({ iv, ciphertext }, key);
const actualSidecar = await sha256(plain);
if (!constantTimeEq(expectedSidecar, actualSidecar)) {
throw new SyncError('INTEGRITY', 'SHA-256 sidecar verification failed');
}
return dec.decode(plain);
};
export const isSupportedAlg = (alg: string): boolean => SUPPORTED_ALGS.has(alg);
@@ -0,0 +1,49 @@
import { SyncError } from '@/libs/errors';
export interface PassphraseStore {
set(passphrase: string): Promise<void>;
get(): Promise<string | null>;
clear(): Promise<void>;
isAvailable(): boolean;
}
export class EphemeralPassphraseStore implements PassphraseStore {
private value: string | null = null;
async set(passphrase: string): Promise<void> {
this.value = passphrase;
}
async get(): Promise<string | null> {
return this.value;
}
async clear(): Promise<void> {
this.value = null;
}
isAvailable(): boolean {
return true;
}
}
export class TauriPassphraseStore implements PassphraseStore {
async set(_passphrase: string): Promise<void> {
throw new SyncError(
'CRYPTO_UNAVAILABLE',
'Tauri keychain backend not yet wired (TODO before PR 4 ships).',
);
}
async get(): Promise<string | null> {
return null;
}
async clear(): Promise<void> {}
isAvailable(): boolean {
return false;
}
}
export const createPassphraseStore = (): PassphraseStore => new EphemeralPassphraseStore();
+48
View File
@@ -0,0 +1,48 @@
export type SyncErrorCode =
| 'TIMEOUT'
| 'AUTH'
| 'QUOTA_EXCEEDED'
| 'CLOCK_SKEW'
| 'VALIDATION'
| 'SERVER'
| 'DECRYPT'
| 'INTEGRITY'
| 'UNSUPPORTED_ALG'
| 'SALT_NOT_FOUND'
| 'CRYPTO_UNAVAILABLE'
| 'NO_PASSPHRASE'
| 'LOCAL_FILE_MISSING'
| 'TRANSFER'
| 'STORAGE'
| 'MANIFEST_COMMIT'
| 'UNKNOWN_KIND'
| 'SCHEMA_TOO_NEW'
| 'LEGACY_MIGRATION_SKIP'
| 'HLC_PERSIST';
export interface SyncErrorContext {
replicaId?: string;
kind?: string;
field?: string;
status?: number;
cause?: unknown;
}
export class SyncError extends Error {
readonly code: SyncErrorCode;
readonly context: SyncErrorContext;
constructor(code: SyncErrorCode, message: string, context: SyncErrorContext = {}) {
super(message);
this.name = 'SyncError';
this.code = code;
this.context = context;
}
}
export const isSyncError = (e: unknown): e is SyncError =>
e instanceof SyncError || (e instanceof Error && e.name === 'SyncError');
export const assertNever = (x: never): never => {
throw new SyncError('VALIDATION', `Unexpected value: ${JSON.stringify(x)}`);
};
+49
View File
@@ -0,0 +1,49 @@
import type { HlcSnapshot } from '@/libs/crdt';
export const HLC_LOCAL_STORAGE_KEY = 'readest_replica_hlc';
export interface HlcSnapshotStore {
load(): HlcSnapshot | null;
save(snapshot: HlcSnapshot): void;
}
export class InMemoryHlcStore implements HlcSnapshotStore {
private snapshot: HlcSnapshot | null = null;
load(): HlcSnapshot | null {
return this.snapshot;
}
save(snapshot: HlcSnapshot): void {
this.snapshot = snapshot;
}
}
export class LocalStorageHlcStore implements HlcSnapshotStore {
constructor(private readonly key: string = HLC_LOCAL_STORAGE_KEY) {}
load(): HlcSnapshot | null {
try {
const raw = localStorage.getItem(this.key);
if (!raw) return null;
const parsed = JSON.parse(raw) as HlcSnapshot;
if (typeof parsed?.physicalMs !== 'number' || typeof parsed?.counter !== 'number') {
return null;
}
return parsed;
} catch {
return null;
}
}
save(snapshot: HlcSnapshot): void {
try {
localStorage.setItem(this.key, JSON.stringify(snapshot));
} catch {
// localStorage unavailable (private mode, quota exceeded, SSR);
// HLC reverts to in-memory only for this session. Documented as a
// tolerable degradation; clients re-derive from server max(updated_at_ts)
// on next pull per replicaSyncManager.
}
}
}
+172
View File
@@ -0,0 +1,172 @@
import { z } from 'zod';
import type { ReplicaRow } from '@/types/replica';
import type { SyncErrorCode } from '@/libs/errors';
export const MAX_JSON_BYTES = 64 * 1024;
export const MAX_FIELD_COUNT = 64;
export const MAX_FILENAME_LEN = 255;
const fieldEnvelopeSchema = z.object({
v: z.unknown(),
t: z.string(),
s: z.string(),
});
const cipherEnvelopeSchema = z.object({
c: z.string(),
i: z.string(),
s: z.string(),
alg: z.string(),
h: z.string(),
});
const fieldEnvelopeWithCipher = z.union([fieldEnvelopeSchema, cipherEnvelopeSchema]);
const fieldsObjectSchema = z.record(z.string(), fieldEnvelopeWithCipher);
const dictionaryFieldsSchema = z
.object({
name: fieldEnvelopeSchema.optional(),
enabled: fieldEnvelopeSchema.optional(),
lang: fieldEnvelopeSchema.optional(),
})
.catchall(fieldEnvelopeWithCipher);
interface KindSpec {
minSchemaVersion: number;
maxSchemaVersion: number;
maxRowsPerUser: number;
fields: z.ZodTypeAny;
binary: boolean;
}
export const KIND_ALLOWLIST: Record<string, KindSpec> = {
dictionary: {
minSchemaVersion: 1,
maxSchemaVersion: 1,
maxRowsPerUser: 200,
fields: dictionaryFieldsSchema,
binary: true,
},
};
export const isAllowedKind = (kind: string): boolean => Object.hasOwn(KIND_ALLOWLIST, kind);
export interface FilenameOk {
ok: true;
}
export interface FilenameError {
ok: false;
reason: string;
}
export const validateFilename = (name: string): FilenameOk | FilenameError => {
if (name.length === 0) return { ok: false, reason: 'empty' };
if (name.length > MAX_FILENAME_LEN) return { ok: false, reason: 'too long' };
if (name === '.' || name === '..') return { ok: false, reason: 'dot path' };
if (name.includes('/') || name.includes('\\')) return { ok: false, reason: 'path separator' };
if (name.includes('..')) return { ok: false, reason: 'path traversal' };
for (let i = 0; i < name.length; i++) {
const code = name.charCodeAt(i);
if (code < 0x20 || code === 0x7f) {
return { ok: false, reason: 'control character' };
}
}
return { ok: true };
};
export type ValidationResult =
| { ok: true }
| { ok: false; code: SyncErrorCode; message: string; cause?: unknown };
const measureJsonBytes = (value: unknown): number =>
new TextEncoder().encode(JSON.stringify(value)).length;
const manifestFileSchema = z.object({
filename: z.string(),
byteSize: z.number().int().nonnegative(),
partialMd5: z.string(),
mtime: z.number().optional(),
});
const manifestSchema = z.object({
files: z.array(manifestFileSchema),
schemaVersion: z.number().int(),
});
export const validateRow = (row: ReplicaRow): ValidationResult => {
if (!isAllowedKind(row.kind)) {
return { ok: false, code: 'UNKNOWN_KIND', message: `Unknown kind: ${row.kind}` };
}
const spec = KIND_ALLOWLIST[row.kind]!;
if (row.schema_version < spec.minSchemaVersion || row.schema_version > spec.maxSchemaVersion) {
return {
ok: false,
code: 'SCHEMA_TOO_NEW',
message: `schemaVersion ${row.schema_version} out of bounds [${spec.minSchemaVersion}, ${spec.maxSchemaVersion}] for kind ${row.kind}`,
};
}
const fieldKeys = Object.keys(row.fields_jsonb);
if (fieldKeys.length > MAX_FIELD_COUNT) {
return {
ok: false,
code: 'VALIDATION',
message: `field count ${fieldKeys.length} exceeds MAX_FIELD_COUNT=${MAX_FIELD_COUNT}`,
};
}
const fieldsBytes = measureJsonBytes(row.fields_jsonb);
if (fieldsBytes > MAX_JSON_BYTES) {
return {
ok: false,
code: 'VALIDATION',
message: `fields_jsonb size ${fieldsBytes}B exceeds MAX_JSON_BYTES=${MAX_JSON_BYTES}`,
};
}
const fieldsParse = fieldsObjectSchema.safeParse(row.fields_jsonb);
if (!fieldsParse.success) {
return {
ok: false,
code: 'VALIDATION',
message: 'malformed field envelope',
cause: fieldsParse.error,
};
}
const kindParse = spec.fields.safeParse(row.fields_jsonb);
if (!kindParse.success) {
return {
ok: false,
code: 'VALIDATION',
message: `kind=${row.kind} fields validation failed`,
cause: kindParse.error,
};
}
if (row.manifest_jsonb !== null) {
const manifestParse = manifestSchema.safeParse(row.manifest_jsonb);
if (!manifestParse.success) {
return {
ok: false,
code: 'VALIDATION',
message: 'malformed manifest_jsonb',
cause: manifestParse.error,
};
}
for (const file of row.manifest_jsonb.files) {
const fnCheck = validateFilename(file.filename);
if (!fnCheck.ok) {
return {
ok: false,
code: 'VALIDATION',
message: `manifest filename invalid: ${file.filename} (${fnCheck.reason})`,
};
}
}
}
return { ok: true };
};
@@ -0,0 +1,94 @@
import { getAccessToken } from '@/utils/access';
import { getAPIBaseUrl } from '@/services/environment';
import { SyncError } from '@/libs/errors';
import type { Hlc, ReplicaRow } from '@/types/replica';
import type { SyncErrorCode } from '@/libs/errors';
const ENDPOINT = () => `${getAPIBaseUrl()}/sync/replicas`;
interface ErrorBody {
error?: string;
code?: SyncErrorCode;
offendingIndex?: number;
}
const statusToDefaultCode = (status: number): SyncErrorCode => {
if (status === 401 || status === 403) return 'AUTH';
if (status === 402 || status === 507) return 'QUOTA_EXCEEDED';
if (status === 409) return 'CLOCK_SKEW';
if (status === 413) return 'VALIDATION';
if (status === 422) return 'VALIDATION';
if (status >= 500) return 'SERVER';
return 'VALIDATION';
};
const parseErrorBody = async (response: Response): Promise<ErrorBody> => {
try {
return (await response.json()) as ErrorBody;
} catch {
return {};
}
};
const requireToken = async (): Promise<string> => {
const token = await getAccessToken();
if (!token) throw new SyncError('AUTH', 'Not authenticated');
return token;
};
export class ReplicaSyncClient {
async push(rows: ReplicaRow[]): Promise<ReplicaRow[]> {
if (rows.length === 0) return [];
const token = await requireToken();
let response: Response;
try {
response = await fetch(ENDPOINT(), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ rows }),
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during push', { cause });
}
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(code, body.error ?? `Push failed with status ${response.status}`, {
status: response.status,
});
}
const data = (await response.json()) as { rows: ReplicaRow[] };
return data.rows ?? [];
}
async pull(kind: string, since: Hlc | null): Promise<ReplicaRow[]> {
const token = await requireToken();
const params = new URLSearchParams({ kind });
if (since) params.set('since', since);
const url = `${ENDPOINT()}?${params.toString()}`;
let response: Response;
try {
response = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during pull', { cause });
}
if (response.status === 404) return [];
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(code, body.error ?? `Pull failed with status ${response.status}`, {
status: response.status,
});
}
const data = (await response.json()) as { rows: ReplicaRow[] };
return data.rows ?? [];
}
}
export const replicaSyncClient = new ReplicaSyncClient();
@@ -0,0 +1,121 @@
import { hlcParse } from '@/libs/crdt';
import { isAllowedKind, validateRow } from '@/libs/replicaSchemas';
import type { Hlc, ReplicaRow } from '@/types/replica';
import type { SyncErrorCode } from '@/libs/errors';
export const HLC_SKEW_TOLERANCE_MS = 60_000;
export const MAX_PUSH_BATCH = 100;
export interface PushReplicasBody {
rows: ReplicaRow[];
}
export type PushValidation =
| { ok: true; rows: ReplicaRow[] }
| { ok: false; status: number; code: SyncErrorCode; message: string; offendingIndex?: number };
export const clampHlcSkew = (hlc: Hlc, nowMs: number): boolean => {
const { physicalMs } = hlcParse(hlc);
return Math.abs(physicalMs - nowMs) <= HLC_SKEW_TOLERANCE_MS;
};
export const validatePushBatch = (body: unknown, userId: string, nowMs: number): PushValidation => {
if (typeof body !== 'object' || body === null) {
return { ok: false, status: 400, code: 'VALIDATION', message: 'body must be an object' };
}
const rows = (body as PushReplicasBody).rows;
if (!Array.isArray(rows)) {
return { ok: false, status: 400, code: 'VALIDATION', message: 'body.rows must be an array' };
}
if (rows.length === 0) {
return { ok: true, rows: [] };
}
if (rows.length > MAX_PUSH_BATCH) {
return {
ok: false,
status: 413,
code: 'VALIDATION',
message: `batch size ${rows.length} exceeds MAX_PUSH_BATCH=${MAX_PUSH_BATCH}`,
};
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
if (row.user_id !== userId) {
return {
ok: false,
status: 403,
code: 'AUTH',
message: `row[${i}].user_id does not match authenticated user`,
offendingIndex: i,
};
}
if (!isAllowedKind(row.kind)) {
return {
ok: false,
status: 422,
code: 'UNKNOWN_KIND',
message: `row[${i}].kind=${row.kind} is not in the server allowlist`,
offendingIndex: i,
};
}
const v = validateRow(row);
if (!v.ok) {
return {
ok: false,
status: 422,
code: v.code,
message: `row[${i}] ${v.message}`,
offendingIndex: i,
};
}
if (!clampHlcSkew(row.updated_at_ts, nowMs)) {
return {
ok: false,
status: 409,
code: 'CLOCK_SKEW',
message: `row[${i}].updated_at_ts physical time is outside ±${HLC_SKEW_TOLERANCE_MS}ms of server`,
offendingIndex: i,
};
}
if (row.deleted_at_ts && !clampHlcSkew(row.deleted_at_ts, nowMs)) {
return {
ok: false,
status: 409,
code: 'CLOCK_SKEW',
message: `row[${i}].deleted_at_ts physical time is outside ±${HLC_SKEW_TOLERANCE_MS}ms of server`,
offendingIndex: i,
};
}
}
return { ok: true, rows };
};
export interface PullParams {
kind: string;
since: Hlc | null;
}
export type PullValidation =
| { ok: true; params: PullParams }
| { ok: false; status: number; code: SyncErrorCode; message: string };
export const validatePullParams = (kind: string | null, since: string | null): PullValidation => {
if (!kind) {
return { ok: false, status: 400, code: 'VALIDATION', message: 'kind query parameter required' };
}
if (!isAllowedKind(kind)) {
return {
ok: false,
status: 422,
code: 'UNKNOWN_KIND',
message: `kind=${kind} is not in the server allowlist`,
};
}
return {
ok: true,
params: {
kind,
since: since ? (since as Hlc) : null,
},
};
};
+37
View File
@@ -77,6 +77,43 @@ export const uploadFile = async (
}
};
// Replica file upload. Reuses the books-style signed-URL path so 1+ GB
// dictionaries bypass the CF Workers body limit (per plan-eng-review §1).
// `cfp` is the cloud file path (key under the user's prefix); it must
// already contain the kind + replica-id prefix from CLOUD_REPLICAS_SUBDIR.
// Filenames are server-validated (see src/libs/replicaSchemas.ts:validateFilename).
export const uploadReplicaFile = async (
file: File,
fileFullPath: string,
cfp: string,
onProgress?: ProgressHandler,
) => {
try {
const response = await fetchWithAuth(API_ENDPOINTS.upload, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: cfp,
fileSize: file.size,
temp: false,
}),
});
const { uploadUrl }: { uploadUrl: string } = await response.json();
if (isWebAppPlatform()) {
await webUpload(file, uploadUrl, onProgress);
} else {
await tauriUpload(uploadUrl, fileFullPath, 'PUT', onProgress);
}
} catch (error) {
console.error('Replica file upload failed:', error);
if (error instanceof Error) throw error;
throw new Error('Replica file upload failed');
}
};
export const batchGetDownloadUrls = async (files: { lfp: string; cfp: string }[]) => {
try {
const userId = await getUserID();
@@ -0,0 +1,146 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextRequest, NextResponse } from 'next/server';
import { createSupabaseClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { runMiddleware, corsAllMethods } from '@/utils/cors';
import { validatePullParams, validatePushBatch } from '@/libs/replicaSyncServer';
import type { ReplicaRow } from '@/types/replica';
const errorResponse = (status: number, code: string, message: string, offendingIndex?: number) =>
NextResponse.json(
{
error: message,
code,
...(typeof offendingIndex === 'number' ? { offendingIndex } : {}),
},
{ status },
);
export async function POST(req: NextRequest) {
const { user, token } = await validateUserAndToken(req.headers.get('authorization'));
if (!user || !token) {
return errorResponse(401, 'AUTH', 'Not authenticated');
}
const supabase = createSupabaseClient(token);
let body: unknown;
try {
body = await req.json();
} catch {
return errorResponse(400, 'VALIDATION', 'Invalid JSON body');
}
const validation = validatePushBatch(body, user.id, Date.now());
if (!validation.ok) {
return errorResponse(
validation.status,
validation.code,
validation.message,
validation.offendingIndex,
);
}
const merged: ReplicaRow[] = [];
for (const row of validation.rows) {
const { data, error } = await supabase
.rpc('crdt_merge_replica', {
p_user_id: row.user_id,
p_kind: row.kind,
p_replica_id: row.replica_id,
p_fields_jsonb: row.fields_jsonb,
p_manifest_jsonb: row.manifest_jsonb,
p_deleted_at_ts: row.deleted_at_ts,
p_reincarnation: row.reincarnation,
p_updated_at_ts: row.updated_at_ts,
p_schema_version: row.schema_version,
})
.single<ReplicaRow>();
if (error) {
console.error('crdt_merge_replica failed', { row, error });
return errorResponse(500, 'SERVER', error.message);
}
if (data) merged.push(data);
}
return NextResponse.json({ rows: merged }, { status: 200 });
}
export async function GET(req: NextRequest) {
const { user, token } = await validateUserAndToken(req.headers.get('authorization'));
if (!user || !token) {
return errorResponse(401, 'AUTH', 'Not authenticated');
}
const supabase = createSupabaseClient(token);
const { searchParams } = new URL(req.url);
const validation = validatePullParams(searchParams.get('kind'), searchParams.get('since'));
if (!validation.ok) {
return errorResponse(validation.status, validation.code, validation.message);
}
const { kind, since } = validation.params;
let query = supabase
.from('replicas')
.select('*')
.eq('user_id', user.id)
.eq('kind', kind)
.order('updated_at_ts', { ascending: true })
.limit(1000);
if (since) query = query.gt('updated_at_ts', since);
const { data, error } = await query;
if (error) {
console.error('pull replicas failed', { kind, since, error });
return errorResponse(500, 'SERVER', error.message);
}
return NextResponse.json({ rows: data ?? [] }, { status: 200 });
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!req.url) {
return res.status(400).json({ error: 'Invalid request URL' });
}
const protocol = process.env['PROTOCOL'] || 'http';
const host = process.env['HOST'] || 'localhost:3000';
const url = new URL(req.url, `${protocol}://${host}`);
await runMiddleware(req, res, corsAllMethods);
try {
let response: Response;
if (req.method === 'GET') {
const nextReq = new NextRequest(url.toString(), {
headers: new Headers(req.headers as Record<string, string>),
method: 'GET',
});
response = await GET(nextReq);
} else if (req.method === 'POST') {
const nextReq = new NextRequest(url.toString(), {
headers: new Headers(req.headers as Record<string, string>),
method: 'POST',
body: JSON.stringify(req.body),
});
response = await POST(nextReq);
} else {
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).json({ error: 'Method Not Allowed' });
}
res.status(response.status);
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
res.send(buffer);
} catch (error) {
console.error('Error processing /api/sync/replicas request:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
};
export default handler;
@@ -278,6 +278,44 @@ export abstract class BaseAppService implements AppService {
);
}
async uploadReplicaFile(
kind: string,
replicaId: string,
filename: string,
lfp: string,
base: BaseDir,
onProgress: ProgressHandler,
) {
return CloudSvc.uploadReplicaFileToCloud(this.fs, this.resolveFilePath.bind(this), {
kind,
replicaId,
filename,
lfp,
base,
onProgress,
});
}
async downloadReplicaFile(
kind: string,
replicaId: string,
filename: string,
dst: string,
onProgress?: ProgressHandler,
) {
return CloudSvc.downloadReplicaFileFromCloud(this, {
kind,
replicaId,
filename,
dst,
onProgress,
});
}
async deleteReplicaBundle(kind: string, replicaId: string, filenames: string[]) {
return CloudSvc.deleteReplicaBundleFromCloud(kind, replicaId, filenames);
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
return CloudSvc.uploadBook(this.fs, this.resolveFilePath.bind(this), book, onProgress);
}
+67 -1
View File
@@ -9,13 +9,14 @@ import {
import {
downloadFile,
uploadFile,
uploadReplicaFile,
deleteFile as deleteCloudFile,
createProgressHandler,
batchGetDownloadUrls,
} from '@/libs/storage';
import { ClosableFile } from '@/utils/file';
import { ProgressHandler } from '@/utils/transfer';
import { CLOUD_BOOKS_SUBDIR } from './constants';
import { CLOUD_BOOKS_SUBDIR, CLOUD_REPLICAS_SUBDIR } from './constants';
export async function deleteBook(
fs: FileSystem,
@@ -75,6 +76,71 @@ export async function uploadFileToCloud(
return downloadUrl;
}
// Upload a single replica binary to the cloud under
// CLOUD_REPLICAS_SUBDIR/<kind>/<replicaId>/<filename>. Filename is the
// caller-supplied logical name (server-validated; see replicaSchemas.ts).
export async function uploadReplicaFileToCloud(
fs: FileSystem,
resolveFilePath: (path: string, base: BaseDir) => Promise<string>,
opts: {
kind: string;
replicaId: string;
filename: string;
lfp: string;
base: BaseDir;
onProgress: ProgressHandler;
},
): Promise<void> {
const cfp = `${CLOUD_REPLICAS_SUBDIR}/${opts.kind}/${opts.replicaId}/${opts.filename}`;
console.log('Uploading replica file:', opts.lfp, 'to', cfp);
const file = await fs.openFile(opts.lfp, opts.base, opts.filename);
const localFullpath = await resolveFilePath(opts.lfp, opts.base);
await uploadReplicaFile(file, localFullpath, cfp, opts.onProgress);
const f = file as ClosableFile;
if (f && f.close) {
await f.close();
}
}
// Cloud key for a replica binary. Centralized so adapters and the
// download path share the same path-construction rule.
export const replicaCloudKey = (kind: string, replicaId: string, filename: string): string =>
`${CLOUD_REPLICAS_SUBDIR}/${kind}/${replicaId}/${filename}`;
export async function downloadReplicaFileFromCloud(
appService: AppService,
opts: {
kind: string;
replicaId: string;
filename: string;
dst: string;
onProgress?: ProgressHandler;
},
): Promise<void> {
const cfp = replicaCloudKey(opts.kind, opts.replicaId, opts.filename);
await downloadFile({
appService,
cfp,
dst: opts.dst,
onProgress: opts.onProgress,
});
}
export async function deleteReplicaBundleFromCloud(
kind: string,
replicaId: string,
filenames: string[],
): Promise<void> {
for (const filename of filenames) {
const cfp = replicaCloudKey(kind, replicaId, filename);
try {
await deleteCloudFile(cfp);
} catch (error) {
console.log(`Failed to delete replica file ${cfp}:`, error);
}
}
}
export async function uploadBook(
fs: FileSystem,
resolveFilePath: (path: string, base: BaseDir) => Promise<string>,
@@ -33,6 +33,7 @@ import { DEFAULT_AI_SETTINGS } from './ai/constants';
export const DATA_SUBDIR = 'Readest';
export const LOCAL_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
export const CLOUD_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
export const CLOUD_REPLICAS_SUBDIR = `${DATA_SUBDIR}/Replicas`;
export const LOCAL_FONTS_SUBDIR = `${DATA_SUBDIR}/Fonts`;
export const LOCAL_IMAGES_SUBDIR = `${DATA_SUBDIR}/Images`;
export const LOCAL_DICTIONARIES_SUBDIR = `${DATA_SUBDIR}/Dictionaries`;
@@ -127,6 +128,13 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
lastSyncedAtBooks: 0,
lastSyncedAtConfigs: 0,
lastSyncedAtNotes: 0,
lastSyncedAtReplicas: {},
syncCategories: {
book: true,
progress: true,
note: true,
dictionary: true,
},
};
export const DEFAULT_MOBILE_SYSTEM_SETTINGS: Partial<SystemSettings> = {
@@ -0,0 +1,24 @@
import { partialMD5 } from '@/utils/md5';
import { computeDictionaryReplicaId } from '@/services/sync/adapters/dictionary';
/**
* Compute the cross-device content-hash id for a dictionary bundle at
* import time. Uses partialMD5 (head + middle + tail sample) of the
* primary file, mixed with byteSize and the sorted filename list. The
* mixing makes the id resilient to the partialMD5 collision risk
* (Codex flagged 9/10 confidence; eng review accepted the mitigation).
*
* Stardict primary = .ifo (small text; partialMD5 is effectively full-hash).
* MDict primary = .mdx (body).
* DICT primary = .dict.dz (compressed body).
* Slob primary = .slob (single-file bundle).
*
* Same content → same id across devices, enabling per-record sync identity.
*/
export const computeDictionaryContentId = async (
primary: File,
filenames: string[],
): Promise<string> => {
const partial = await partialMD5(primary);
return computeDictionaryReplicaId(partial, primary.size, filenames);
};
@@ -15,6 +15,7 @@ import { uniqueId } from '@/utils/misc';
import { getFilename } from '@/utils/path';
import type { ImportedDictionary } from './types';
import { scanEntryOffsets, serializeOffsetsSidecar } from './stardictReader';
import { computeDictionaryContentId } from './contentId';
/** GZIP magic bytes — used to detect DictZip-compressed `.dict` files. */
const GZIP_MAGIC = [0x1f, 0x8b, 0x08];
@@ -277,8 +278,14 @@ async function importStarDictBundle(
}
}
// Stardict primary = .ifo (small text; partialMD5 is effectively full-hash).
const stardictFilenames = [group.ifo.name, group.idx.name, group.dict.name];
if (group.syn?.name) stardictFilenames.push(group.syn.name);
const contentId = await computeDictionaryContentId(ifoFile, stardictFilenames);
return {
id: bundleDir,
contentId,
kind: 'stardict',
name,
bundleDir,
@@ -348,8 +355,17 @@ async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise<Imp
}
}
// MDict primary = .mdx (the body file).
const mdictFilenames = [
group.mdx.name,
...group.mdd.map((m) => m.name),
...group.css.map((c) => c.name),
];
const contentId = await computeDictionaryContentId(mdxFile, mdictFilenames);
return {
id: bundleDir,
contentId,
kind: 'mdict',
name,
bundleDir,
@@ -419,8 +435,13 @@ async function importDictBundle(fs: FileSystem, group: DictGroup): Promise<Impor
unsupportedReason = 'Raw .dict files are not supported in v1; please use .dict.dz format.';
}
// DICT primary = .dict (or .dict.dz) — the gzipped body file.
const dictFilenames = [group.dict.name, group.index.name];
const contentId = await computeDictionaryContentId(dictFile, dictFilenames);
return {
id: bundleDir,
contentId,
kind: 'dict',
name,
bundleDir,
@@ -461,8 +482,12 @@ async function importSlobBundle(fs: FileSystem, group: SlobGroup): Promise<Impor
}
}
// Slob primary = .slob (single-file bundle).
const contentId = await computeDictionaryContentId(slobFile, [group.slob.name]);
return {
id: bundleDir,
contentId,
kind: 'slob',
name,
bundleDir,
@@ -57,6 +57,14 @@ export interface ImportedDictionary {
kind: 'stardict' | 'mdict' | 'dict' | 'slob';
/** Display name, derived from `.ifo` `bookname`, `.mdx` `Title`, slob `label`, or DICT `00databaseshort`. */
name: string;
/**
* Stable cross-device content-hash id derived from
* `partialMD5(primary) + byteSize + sortedFilenames`. Used as the
* `replica_id` for cross-device sync (see services/sync/adapters/dictionary.ts).
* Optional for legacy imports written before this field existed; the
* sync wiring treats absent contentId as "needs rehash before sync".
*/
contentId?: string;
/** Subdirectory under `'Dictionaries'` containing this bundle's files. */
bundleDir: string;
/** Filenames inside `bundleDir`. The exact set varies by `kind`. */
@@ -0,0 +1,115 @@
import { md5 } from '@/utils/md5';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
export const DICTIONARY_KIND = 'dictionary';
export const DICTIONARY_SCHEMA_VERSION = 1;
export interface DictionarySyncedFields {
name: string;
kind: ImportedDictionary['kind'];
lang?: string;
addedAt: number;
unsupported?: boolean;
unsupportedReason?: string;
}
export const primaryDictionaryFile = (d: ImportedDictionary): string | null => {
switch (d.kind) {
case 'mdict':
return d.files.mdx ?? null;
case 'stardict':
return d.files.ifo ?? null;
case 'dict':
return d.files.dict ?? null;
case 'slob':
return d.files.slob ?? null;
default:
return null;
}
};
export const enumerateDictionaryFiles = (
d: ImportedDictionary,
): { logical: string; lfp: string; byteSize: number }[] => {
const out: { logical: string; lfp: string; byteSize: number }[] = [];
const push = (filename?: string) => {
if (!filename) return;
out.push({
logical: filename,
lfp: `${d.bundleDir}/${filename}`,
byteSize: 0,
});
};
switch (d.kind) {
case 'mdict':
push(d.files.mdx);
d.files.mdd?.forEach(push);
d.files.css?.forEach(push);
break;
case 'stardict':
push(d.files.ifo);
push(d.files.idx);
push(d.files.dict);
push(d.files.syn);
break;
case 'dict':
push(d.files.dict);
push(d.files.index);
break;
case 'slob':
push(d.files.slob);
break;
}
return out;
};
export const computeDictionaryReplicaId = (
partialMd5: string,
byteSize: number,
filenames: string[],
): string => {
const sortedFilenames = [...filenames].sort();
return md5(`${partialMd5}|${byteSize}|${sortedFilenames.join(',')}`);
};
export const dictionaryAdapter: ReplicaAdapter<ImportedDictionary> = {
kind: DICTIONARY_KIND,
schemaVersion: DICTIONARY_SCHEMA_VERSION,
pack(d: ImportedDictionary): Record<string, unknown> {
const fields: Record<string, unknown> = {
name: d.name,
kind: d.kind,
addedAt: d.addedAt,
};
if (d.lang !== undefined) fields['lang'] = d.lang;
if (d.unsupported) fields['unsupported'] = true;
if (d.unsupportedReason) fields['unsupportedReason'] = d.unsupportedReason;
return fields;
},
unpack(fields: Record<string, unknown>): ImportedDictionary {
return {
id: '',
kind: fields['kind'] as ImportedDictionary['kind'],
name: String(fields['name'] ?? ''),
bundleDir: '',
files: {},
lang: fields['lang'] !== undefined ? String(fields['lang']) : undefined,
addedAt: Number(fields['addedAt'] ?? 0),
unsupported: fields['unsupported'] === true ? true : undefined,
unsupportedReason:
fields['unsupportedReason'] !== undefined ? String(fields['unsupportedReason']) : undefined,
};
},
async computeId(d: ImportedDictionary): Promise<string> {
return d.id;
},
binary: {
localBaseDir: 'Dictionaries',
enumerateFiles: enumerateDictionaryFiles,
},
};
@@ -0,0 +1,22 @@
import { dictionaryAdapter } from './adapters/dictionary';
import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry';
import type { ReplicaAdapter } from './replicaRegistry';
const KNOWN_ADAPTERS: ReplicaAdapter<unknown>[] = [
dictionaryAdapter as unknown as ReplicaAdapter<unknown>,
];
let didBootstrap = false;
export const bootstrapReplicaAdapters = (): void => {
if (didBootstrap) return;
for (const adapter of KNOWN_ADAPTERS) {
if (getReplicaAdapter(adapter.kind)) continue;
registerReplicaAdapter(adapter);
}
didBootstrap = true;
};
export const __resetBootstrapForTests = (): void => {
didBootstrap = false;
};
@@ -0,0 +1,39 @@
import type { BaseDir, FileSystem } from '@/types/system';
export interface BinaryCapability<T> {
localBaseDir: BaseDir;
enumerateFiles(replica: T): { logical: string; lfp: string; byteSize: number }[];
}
export interface LifecycleHooks<T> {
postDownload?(replica: T, fs: FileSystem): Promise<void>;
validateOnLoad?(replica: T, fs: FileSystem): Promise<{ unavailable?: boolean }>;
}
export interface ReplicaAdapter<T = unknown> {
kind: string;
schemaVersion: number;
pack(replica: T): Record<string, unknown>;
unpack(fields: Record<string, unknown>): T;
computeId(input: T): Promise<string>;
binary?: BinaryCapability<T>;
lifecycle?: LifecycleHooks<T>;
}
const registry = new Map<string, ReplicaAdapter<unknown>>();
export const registerReplicaAdapter = <T>(adapter: ReplicaAdapter<T>): void => {
if (registry.has(adapter.kind)) {
throw new Error(`Replica adapter for kind="${adapter.kind}" is already registered`);
}
registry.set(adapter.kind, adapter as ReplicaAdapter<unknown>);
};
export const getReplicaAdapter = <T = unknown>(kind: string): ReplicaAdapter<T> | undefined =>
registry.get(kind) as ReplicaAdapter<T> | undefined;
export const listReplicaAdapters = (): ReplicaAdapter<unknown>[] => Array.from(registry.values());
export const clearReplicaAdapters = (): void => {
registry.clear();
};
@@ -0,0 +1,67 @@
import { HlcGenerator } from '@/libs/crdt';
import { LocalStorageHlcStore, type HlcSnapshotStore } from '@/libs/hlcStore';
import { ReplicaSyncClient } from '@/libs/replicaSyncClient';
import { ReplicaSyncManager, type CursorStore } from './replicaSyncManager';
export interface ReplicaSyncInitOpts {
deviceId: string;
cursorStore: CursorStore;
hlcStore?: HlcSnapshotStore;
client?: Pick<ReplicaSyncClient, 'push' | 'pull'>;
}
export interface ReplicaSyncContext {
manager: ReplicaSyncManager;
hlc: HlcGenerator;
deviceId: string;
}
let instance: ReplicaSyncContext | null = null;
const wrapHlcWithPersistence = (hlc: HlcGenerator, hlcStore: HlcSnapshotStore): HlcGenerator => {
const originalNext = hlc.next.bind(hlc);
const originalObserve = hlc.observe.bind(hlc);
hlc.next = () => {
const v = originalNext();
hlcStore.save(hlc.serialize());
return v;
};
hlc.observe = (remote) => {
originalObserve(remote);
hlcStore.save(hlc.serialize());
};
return hlc;
};
export const initReplicaSync = (opts: ReplicaSyncInitOpts): ReplicaSyncContext => {
if (instance) return instance;
const hlcStore = opts.hlcStore ?? new LocalStorageHlcStore();
const snapshot = hlcStore.load();
const baseHlc = snapshot
? HlcGenerator.restore(snapshot, opts.deviceId)
: new HlcGenerator(opts.deviceId);
const hlc = wrapHlcWithPersistence(baseHlc, hlcStore);
const client = opts.client ?? new ReplicaSyncClient();
const manager = new ReplicaSyncManager({
hlc,
client,
cursorStore: opts.cursorStore,
});
instance = { manager, hlc, deviceId: opts.deviceId };
return instance;
};
export const getReplicaSync = (): ReplicaSyncContext | null => instance;
export const isReplicaSyncReady = (): boolean => instance !== null;
export const __resetReplicaSyncForTests = (): void => {
if (instance) {
instance.manager.stopAutoSync();
}
instance = null;
};
@@ -0,0 +1,122 @@
import { HlcGenerator, hlcCompare } from '@/libs/crdt';
import type { Hlc, ReplicaRow } from '@/types/replica';
import type { ReplicaSyncClient } from '@/libs/replicaSyncClient';
export interface CursorStore {
get(kind: string): Hlc | null;
set(kind: string, hlc: Hlc): void;
}
export interface ReplicaSyncManagerOpts {
hlc: HlcGenerator;
client: Pick<ReplicaSyncClient, 'push' | 'pull'>;
cursorStore: CursorStore;
debounceMs?: number;
}
interface DirtyKey {
kind: string;
replicaId: string;
}
const dirtyKeyOf = (row: ReplicaRow): string => `${row.kind}::${row.replica_id}`;
const splitKey = (k: string): DirtyKey => {
const idx = k.indexOf('::');
return { kind: k.slice(0, idx), replicaId: k.slice(idx + 2) };
};
export class ReplicaSyncManager {
private readonly dirty = new Map<string, ReplicaRow>();
private readonly debounceMs: number;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private autoSyncInstalled = false;
private readonly visibilityHandler = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
void this.flush().catch((e) => console.warn('replica sync flush on hide failed', e));
}
};
private readonly onlineHandler = () => {
void this.flush().catch((e) => console.warn('replica sync flush on online failed', e));
};
constructor(private readonly opts: ReplicaSyncManagerOpts) {
this.debounceMs = opts.debounceMs ?? 5000;
}
markDirty(row: ReplicaRow): void {
this.dirty.set(dirtyKeyOf(row), row);
this.scheduleDebouncedFlush();
}
private scheduleDebouncedFlush(): void {
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
void this.flush().catch((e) => console.warn('replica sync debounced flush failed', e));
}, this.debounceMs);
}
async flush(): Promise<void> {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.dirty.size === 0) return;
const snapshot = Array.from(this.dirty.values());
const snapshotKeys = Array.from(this.dirty.keys());
try {
await this.opts.client.push(snapshot);
for (const k of snapshotKeys) {
const stillSame = this.dirty.get(k);
if (stillSame === snapshot[snapshotKeys.indexOf(k)]) {
this.dirty.delete(k);
}
}
} catch (err) {
throw err;
}
}
async pull(kind: string): Promise<ReplicaRow[]> {
const since = this.opts.cursorStore.get(kind);
const rows = await this.opts.client.pull(kind, since);
if (rows.length === 0) return rows;
let maxHlc: Hlc = rows[0]!.updated_at_ts;
for (const row of rows) {
if (hlcCompare(row.updated_at_ts, maxHlc) > 0) maxHlc = row.updated_at_ts;
this.opts.hlc.observe(row.updated_at_ts);
}
this.opts.cursorStore.set(kind, maxHlc);
return rows;
}
startAutoSync(): void {
if (this.autoSyncInstalled) return;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.visibilityHandler);
}
if (typeof window !== 'undefined') {
window.addEventListener('online', this.onlineHandler);
}
this.autoSyncInstalled = true;
}
stopAutoSync(): void {
if (!this.autoSyncInstalled) return;
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.visibilityHandler);
}
if (typeof window !== 'undefined') {
window.removeEventListener('online', this.onlineHandler);
}
this.autoSyncInstalled = false;
}
pendingCount(): number {
return this.dirty.size;
}
pendingKeys(): DirtyKey[] {
return Array.from(this.dirty.keys()).map(splitKey);
}
}
+198 -21
View File
@@ -1,8 +1,8 @@
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { useTransferStore, TransferItem } from '@/store/transferStore';
import { AppService, BaseDir } from '@/types/system';
import { useTransferStore, TransferItem, ReplicaTransferFile } from '@/store/transferStore';
import { TranslationFunc } from '@/hooks/useTranslation';
import { ProgressPayload } from '@/utils/transfer';
import { ProgressHandler, ProgressPayload } from '@/utils/transfer';
import { eventDispatcher } from '@/utils/event';
const TRANSFER_QUEUE_KEY = 'readest_transfer_queue';
@@ -119,6 +119,85 @@ class TransferManager {
.filter((id): id is string => id !== null);
}
queueReplicaUpload(
replicaKind: string,
replicaId: string,
displayTitle: string,
files: ReplicaTransferFile[],
base: BaseDir,
opts: { priority?: number; isBackground?: boolean } = {},
): string | null {
if (!this.isReady()) {
console.warn('TransferManager not initialized');
return null;
}
const store = useTransferStore.getState();
const existing = store.getReplicaTransfer(replicaKind, replicaId, 'upload');
if (existing) return existing.id;
const id = store.addReplicaTransfer(replicaKind, replicaId, displayTitle, 'upload', {
priority: opts.priority,
isBackground: opts.isBackground,
files,
base,
});
this.persistQueue();
this.processQueue();
return id;
}
queueReplicaDownload(
replicaKind: string,
replicaId: string,
displayTitle: string,
files: ReplicaTransferFile[],
base: BaseDir,
opts: { priority?: number; isBackground?: boolean } = {},
): string | null {
if (!this.isReady()) {
console.warn('TransferManager not initialized');
return null;
}
const store = useTransferStore.getState();
const existing = store.getReplicaTransfer(replicaKind, replicaId, 'download');
if (existing) return existing.id;
const id = store.addReplicaTransfer(replicaKind, replicaId, displayTitle, 'download', {
priority: opts.priority,
isBackground: opts.isBackground,
files,
base,
});
this.persistQueue();
this.processQueue();
return id;
}
queueReplicaDelete(
replicaKind: string,
replicaId: string,
displayTitle: string,
filenames: string[],
opts: { priority?: number; isBackground?: boolean } = {},
): string | null {
if (!this.isReady()) {
console.warn('TransferManager not initialized');
return null;
}
const store = useTransferStore.getState();
const existing = store.getReplicaTransfer(replicaKind, replicaId, 'delete');
if (existing) return existing.id;
const id = store.addReplicaTransfer(replicaKind, replicaId, displayTitle, 'delete', {
priority: opts.priority,
isBackground: opts.isBackground,
files: filenames.map((logical) => ({ logical, lfp: '', byteSize: 0 })),
});
this.persistQueue();
this.processQueue();
return id;
}
cancelTransfer(transferId: string): void {
const controller = this.abortControllers.get(transferId);
if (controller) {
@@ -230,24 +309,10 @@ class TransferManager {
};
try {
const library = this.getLibrary();
const book = library.find((b) => b.hash === transfer.bookHash);
if (!book) {
throw new Error(_('Book not found in library'));
}
if (transfer.type === 'upload') {
await this.appService.uploadBook(book, progressHandler);
book.uploadedAt = Date.now();
await this.updateBook(book);
} else if (transfer.type === 'download') {
await this.appService.downloadBook(book, false, false, progressHandler);
book.downloadedAt = Date.now();
await this.updateBook(book);
} else if (transfer.type === 'delete') {
await this.appService.deleteBook(book, 'cloud');
await this.updateBook(book);
if (transfer.kind === 'replica') {
await this.executeReplicaTransfer(transfer, progressHandler, abortController);
} else {
await this.executeBookTransfer(transfer, progressHandler, abortController);
}
useTransferStore.getState().setTransferStatus(transfer.id, 'completed');
@@ -328,6 +393,118 @@ class TransferManager {
}
}
private async executeBookTransfer(
transfer: TransferItem,
progressHandler: (p: ProgressPayload) => void,
_abortController: AbortController,
): Promise<void> {
const _ = this._!;
const library = this.getLibrary!();
const book = library.find((b) => b.hash === transfer.bookHash);
if (!book) {
throw new Error(_('Book not found in library'));
}
if (transfer.type === 'upload') {
await this.appService!.uploadBook(book, progressHandler);
book.uploadedAt = Date.now();
await this.updateBook!(book);
} else if (transfer.type === 'download') {
await this.appService!.downloadBook(book, false, false, progressHandler);
book.downloadedAt = Date.now();
await this.updateBook!(book);
} else if (transfer.type === 'delete') {
await this.appService!.deleteBook(book, 'cloud');
await this.updateBook!(book);
}
}
private async executeReplicaTransfer(
transfer: TransferItem,
progressHandler: (p: ProgressPayload) => void,
_abortController: AbortController,
): Promise<void> {
const kind = transfer.replicaKind!;
const replicaId = transfer.replicaId!;
const files = transfer.replicaFiles ?? [];
if (transfer.type === 'delete') {
await this.appService!.deleteReplicaBundle(
kind,
replicaId,
files.map((f) => f.logical),
);
eventDispatcher.dispatch('replica-transfer-complete', {
kind,
replicaId,
type: 'delete',
filenames: files.map((f) => f.logical),
});
return;
}
if (files.length === 0) {
throw new Error(`replica ${transfer.type} requires replicaFiles on the transfer`);
}
const totalBytes = files.reduce((sum, f) => sum + f.byteSize, 0) || 1;
let bytesAlreadyDone = 0;
const fileProgressHandler =
(filenameSize: number): ProgressHandler =>
(p: ProgressPayload) => {
const fileFraction = p.total > 0 ? p.progress / p.total : 0;
const overallTransferred = bytesAlreadyDone + filenameSize * fileFraction;
progressHandler({
progress: overallTransferred,
total: totalBytes,
transferSpeed: p.transferSpeed,
});
};
if (transfer.type === 'upload') {
const base = transfer.replicaBase!;
for (const file of files) {
await this.appService!.uploadReplicaFile(
kind,
replicaId,
file.logical,
file.lfp,
base,
fileProgressHandler(file.byteSize),
);
bytesAlreadyDone += file.byteSize;
}
eventDispatcher.dispatch('replica-transfer-complete', {
kind,
replicaId,
type: 'upload',
files,
});
return;
}
if (transfer.type === 'download') {
for (const file of files) {
await this.appService!.downloadReplicaFile(
kind,
replicaId,
file.logical,
file.lfp,
fileProgressHandler(file.byteSize),
);
bytesAlreadyDone += file.byteSize;
}
eventDispatcher.dispatch('replica-transfer-complete', {
kind,
replicaId,
type: 'download',
files,
});
return;
}
}
private async loadPersistedQueue(): Promise<void> {
try {
if (typeof localStorage === 'undefined') return;
+82 -5
View File
@@ -1,12 +1,25 @@
import { create } from 'zustand';
import type { BaseDir } from '@/types/system';
export type TransferType = 'upload' | 'download' | 'delete';
export type TransferStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
export type TransferKind = 'book' | 'replica';
export interface ReplicaTransferFile {
logical: string;
lfp: string;
byteSize: number;
}
export interface TransferItem {
id: string;
kind: TransferKind;
bookHash: string;
bookTitle: string;
replicaKind?: string;
replicaId?: string;
replicaFiles?: ReplicaTransferFile[];
replicaBase?: BaseDir;
type: TransferType;
status: TransferStatus;
progress: number; // 0-100 percentage
@@ -41,6 +54,18 @@ interface TransferState {
priority?: number,
isBackground?: boolean,
) => string;
addReplicaTransfer: (
replicaKind: string,
replicaId: string,
displayTitle: string,
type: TransferType,
opts?: {
priority?: number;
isBackground?: boolean;
files?: ReplicaTransferFile[];
base?: BaseDir;
},
) => string;
removeTransfer: (transferId: string) => void;
updateTransferProgress: (
transferId: string,
@@ -66,6 +91,11 @@ interface TransferState {
getFailedTransfers: () => TransferItem[];
getCompletedTransfers: () => TransferItem[];
getTransferByBookHash: (bookHash: string, type: TransferType) => TransferItem | undefined;
getReplicaTransfer: (
replicaKind: string,
replicaId: string,
type: TransferType,
) => TransferItem | undefined;
getQueueStats: () => {
pending: number;
active: number;
@@ -98,6 +128,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
const id = generateTransferId();
const transfer: TransferItem = {
id,
kind: 'book',
bookHash,
bookTitle,
type,
@@ -120,6 +151,37 @@ export const useTransferStore = create<TransferState>((set, get) => ({
return id;
},
addReplicaTransfer: (replicaKind, replicaId, displayTitle, type, opts = {}) => {
const id = generateTransferId();
const transfer: TransferItem = {
id,
kind: 'replica',
bookHash: '',
bookTitle: displayTitle,
replicaKind,
replicaId,
replicaFiles: opts.files,
replicaBase: opts.base,
type,
status: 'pending',
progress: 0,
totalBytes: opts.files?.reduce((sum, f) => sum + f.byteSize, 0) ?? 0,
transferredBytes: 0,
transferSpeed: 0,
retryCount: 0,
maxRetries: 3,
createdAt: Date.now(),
priority: opts.priority ?? 10,
isBackground: opts.isBackground ?? false,
};
set((state) => ({
transfers: { ...state.transfers, [id]: transfer },
}));
return id;
},
removeTransfer: (transferId) => {
set((state) => {
const { [transferId]: _, ...remaining } = state.transfers;
@@ -261,7 +323,21 @@ export const useTransferStore = create<TransferState>((set, get) => ({
getTransferByBookHash: (bookHash, type) => {
return Object.values(get().transfers).find(
(t) =>
t.bookHash === bookHash && t.type === type && ['pending', 'in_progress'].includes(t.status),
t.kind === 'book' &&
t.bookHash === bookHash &&
t.type === type &&
['pending', 'in_progress'].includes(t.status),
);
},
getReplicaTransfer: (replicaKind, replicaId, type) => {
return Object.values(get().transfers).find(
(t) =>
t.kind === 'replica' &&
t.replicaKind === replicaKind &&
t.replicaId === replicaId &&
t.type === type &&
['pending', 'in_progress'].includes(t.status),
);
},
@@ -279,19 +355,20 @@ export const useTransferStore = create<TransferState>((set, get) => ({
setActiveCount: (count) => set({ activeCount: count }),
restoreTransfers: (transfers, isQueuePaused) => {
// Reset in_progress transfers to pending when restoring
// Legacy rows persisted before the kind discriminator default to 'book'.
const restoredTransfers: Record<string, TransferItem> = {};
Object.entries(transfers).forEach(([id, transfer]) => {
if (transfer.status === 'in_progress') {
const withKind: TransferItem = { ...transfer, kind: transfer.kind ?? 'book' };
if (withKind.status === 'in_progress') {
restoredTransfers[id] = {
...transfer,
...withKind,
status: 'pending',
progress: 0,
transferredBytes: 0,
transferSpeed: 0,
};
} else {
restoredTransfers[id] = transfer;
restoredTransfers[id] = withKind;
}
});
set({ transfers: restoredTransfers, isQueuePaused });
+54
View File
@@ -0,0 +1,54 @@
/**
* Branded HLC string. Lexicographic comparison matches temporal order.
* Format: `${physicalMs:13-hex}-${counter:8-hex}-${deviceId}`
*/
export type Hlc = string & { readonly __brand: 'Hlc' };
export interface FieldEnvelope<V = unknown> {
v: V;
t: Hlc;
s: string;
}
export type FieldsObject = Record<string, FieldEnvelope>;
export interface ManifestFile {
filename: string;
byteSize: number;
partialMd5: string;
mtime?: number;
}
export interface Manifest {
files: ManifestFile[];
schemaVersion: number;
}
export interface ReplicaRow {
user_id: string;
kind: string;
replica_id: string;
fields_jsonb: FieldsObject;
manifest_jsonb: Manifest | null;
deleted_at_ts: Hlc | null;
reincarnation: string | null;
updated_at_ts: Hlc;
schema_version: number;
}
export interface CipherEnvelope {
c: string;
i: string;
s: string;
alg: string;
h: string;
}
export const isCipherEnvelope = (v: unknown): v is CipherEnvelope =>
typeof v === 'object' &&
v !== null &&
typeof (v as CipherEnvelope).c === 'string' &&
typeof (v as CipherEnvelope).i === 'string' &&
typeof (v as CipherEnvelope).s === 'string' &&
typeof (v as CipherEnvelope).alg === 'string' &&
typeof (v as CipherEnvelope).h === 'string';
+28
View File
@@ -79,6 +79,21 @@ export interface HardcoverSettings {
lastSyncedAt: number;
}
/**
* User-facing sync categories. 'progress' gates the existing book-config
* (reading progress) sync, 'note' gates annotations, 'book' gates book
* binaries + metadata, 'dictionary' gates the imported-dictionary replica
* sync. Adding a new replica kind extends this union.
*/
export type SyncCategory = 'book' | 'progress' | 'note' | 'dictionary';
export const SYNC_CATEGORIES: readonly SyncCategory[] = [
'book',
'progress',
'note',
'dictionary',
] as const;
export interface SystemSettings {
version: number;
localBooksDir: string;
@@ -124,6 +139,19 @@ export interface SystemSettings {
lastSyncedAtBooks: number;
lastSyncedAtConfigs: number;
lastSyncedAtNotes: number;
/**
* Per-kind cursor for replica sync. Stores the HLC string of the last
* pulled row per kind. Absent kinds pull from the beginning.
*/
lastSyncedAtReplicas?: Record<string, string>;
/**
* Per-category sync toggles. Missing keys default to ON. The
* 'progress' category gates the existing book-config (reading
* progress) sync; 'note' gates annotation sync; 'book' gates book
* binary + metadata sync; 'dictionary' gates the imported-dictionary
* replica sync. Future replica kinds add new SyncCategory members.
*/
syncCategories?: Partial<Record<SyncCategory, boolean>>;
migrationVersion: number;
+16
View File
@@ -154,6 +154,22 @@ export interface AppService {
hash: string,
temp?: boolean,
): Promise<string | undefined>;
uploadReplicaFile(
kind: string,
replicaId: string,
filename: string,
lfp: string,
base: BaseDir,
onProgress: ProgressHandler,
): Promise<void>;
downloadReplicaFile(
kind: string,
replicaId: string,
filename: string,
dst: string,
onProgress?: ProgressHandler,
): Promise<void>;
deleteReplicaBundle(kind: string, replicaId: string, filenames: string[]): Promise<void>;
downloadBookCovers(books: Book[], redownload?: boolean): Promise<void>;
exportBook(book: Book): Promise<boolean>;
isBookAvailable(book: Book): Promise<boolean>;
@@ -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;