Files
readest/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts
T
Huang Xin 1eae2af23e feat(sync): batch replica sync into one /api/sync/replicas request (#4109)
Auto-sync triggers (boot non-settings, focus, visibilitychange, online,
periodic) used to fan out N parallel `GET /api/sync/replicas?kind=…`
requests, one per replica kind. With 5 kinds today and the focus path
firing on every foreground transition, that's 5x the Cloudflare Worker
invocations of what the work actually requires.

Server: extend POST /api/sync/replicas to accept a batched-pull body
(`{ cursors: [{kind, since}, …] }`) alongside the existing push
(`{ rows: […] }`). Per-kind queries fan out via Promise.all — Supabase
calls inside the Worker aren't billed as Cloudflare requests, so DB
load is unchanged while Worker invocations collapse from N to 1.

Client/manager: add `client.pullBatch` and `manager.pullMany` that
share the existing cursor/HLC machinery. The boot path's `since=null`
override carries over via `pullMany(kinds, { since: null })`.

Orchestrator: `triggerIncrementalPullAll` now does ONE pullMany call
then fans out per-kind apply via Promise.allSettled. Boot does the
same for non-settings kinds (settings stays a single call to preserve
its apply-first ordering invariant).

Foreground triggers: listen to BOTH `focus` and `visibilitychange`,
sharing one throttle. focus is fastest on iOS Tauri WKWebView (~T=0,
~400ms ahead of visibilitychange). visibilitychange is the only
signal that fires on browser tab switching — focus does not. Drops
the Supabase user-ref-change listener (was the slowest of the three
foreground signals; redundant with the DOM events).

Bonus: `useBooksSync` now serializes `handleAutoSync` against
`pullLibrary` via the shared `isPullingRef` gate. The two paths used
to fire two concurrent `/api/sync?type=books` requests on the same
`since` value at startup; now whichever runs first claims the gate
and the other skips (throttle's `emitLast` retries afterwards).

Per session: boot 5→2 Worker calls. Per foreground trigger: 5→1.
2026-05-09 16:18:46 +02:00

237 lines
7.6 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
HLC_SKEW_TOLERANCE_MS,
MAX_PULL_BATCH,
MAX_PUSH_BATCH,
clampHlcSkew,
validatePullBatch,
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('not_a_kind', null);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
expect(result.code).toBe('UNKNOWN_KIND');
}
});
});
describe('validatePullBatch', () => {
test('accepts an empty cursors array', () => {
const result = validatePullBatch({ cursors: [] });
expect(result.ok).toBe(true);
if (result.ok) expect(result.params.cursors).toEqual([]);
});
test('accepts mixed since values (string + null) for allowed kinds', () => {
const result = validatePullBatch({
cursors: [
{ kind: 'dictionary', since: '0000000000064-00000000-dev-a' },
{ kind: 'font', since: null },
{ kind: 'settings', since: null },
],
});
expect(result.ok).toBe(true);
});
test('rejects body that is not an object', () => {
const result = validatePullBatch(null);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});
test('rejects body where cursors is not an array', () => {
const result = validatePullBatch({ cursors: 'not-an-array' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(400);
});
test('rejects unknown kind in cursors', () => {
const result = validatePullBatch({ cursors: [{ kind: 'not_a_kind', since: null }] });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
expect(result.code).toBe('UNKNOWN_KIND');
expect(result.offendingIndex).toBe(0);
}
});
test('rejects since that is not a string or null', () => {
const result = validatePullBatch({
cursors: [{ kind: 'dictionary', since: 1234 as unknown as null }],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(400);
expect(result.offendingIndex).toBe(0);
}
});
test('rejects duplicated kind in the same batch', () => {
// Avoids ambiguity in the response — a server merging two queries
// for the same kind would have to pick one, and the client has no
// way to express which `since` won.
const result = validatePullBatch({
cursors: [
{ kind: 'dictionary', since: null },
{ kind: 'dictionary', since: '0000000000064-00000000-dev-a' },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(400);
expect(result.offendingIndex).toBe(1);
}
});
test('rejects oversized batches with status 413', () => {
const cursors = Array.from({ length: MAX_PULL_BATCH + 1 }, () => ({
kind: 'dictionary',
since: null,
}));
const result = validatePullBatch({ cursors });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.status).toBe(413);
});
});