forked from akai/readest
cbdc3b8f52
* feat(sync): cross-device dictionary sync Custom MDict / StarDict / DICT / SLOB dictionaries now sync across signed-in devices via the replica layer. - Store mutations publish replica rows with field-level LWW + tombstones. - Re-importing the same content (renamed or after delete) preserves the user's label and reincarnates the server row instead of duplicating. - Manifest commits after binary upload so other devices never see a row whose binaries aren't on cloud storage yet. - Pull-side orchestrator creates a placeholder dict, queues the binaries via TransferManager, and clears the unavailable flag on completion. - Toast copy branches by transfer kind so dict uploads don't read "Book uploaded". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): boot pull and binary download path - Defer the boot pull until TransferManager is initialized so download enqueues aren't dropped. - Auto-persist the local dict store after applyRemoteDictionary; otherwise the next loadCustomDictionaries wipes the in-memory rows. - Boot pull passes since=null so a device whose cursor advanced past unpersisted rows can still recover. - Skip pulling when not authenticated instead of logging "SyncError: Not authenticated" on every boot of a signed-out device. - downloadReplicaFile resolves the destination against the kind's base dir; binaries previously landed at the literal lfp and openFile then failed with "File not found". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(sync): per-page useReplicaPull hook Lifts the boot-time pull out of EnvContext into a hook each page mounts for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }). Library page and the shared Reader component opt in. The hook fires 10s after page load (so feature mounts hydrate first), dedups per-kind across navigation, and releases the slot on failure so a later mount can retry. Future kinds plug into the hook's per-kind switch. Also closes two refresh-loop bugs: - Hydrate the dict store from settings BEFORE the apply loop, so the auto-persist doesn't clobber persisted rows that the in-memory store hadn't yet read. Library-page refresh was the visible victim. - Skip the download queue when every manifest file is already on disk under the resolved bundle dir. Refreshing is a no-op; partial- download recovery still queues because some files would be missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
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('manifest-only merge advances updated_at_ts so pull cursors see it', () => {
|
|
const metadata = emptyRow({
|
|
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
|
|
manifest_jsonb: null,
|
|
updated_at_ts: hlc(100),
|
|
});
|
|
const manifest = emptyRow({
|
|
fields_jsonb: {},
|
|
manifest_jsonb: {
|
|
schemaVersion: 1,
|
|
files: [{ filename: 'foo.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }],
|
|
},
|
|
updated_at_ts: hlc(200),
|
|
});
|
|
const merged = mergeReplica(metadata, manifest);
|
|
expect(merged.fields_jsonb['name']?.v).toBe('Foo');
|
|
expect(merged.manifest_jsonb?.files).toHaveLength(1);
|
|
expect(merged.updated_at_ts).toBe(hlc(200));
|
|
});
|
|
|
|
test('metadata-only merge does not clear an existing manifest', () => {
|
|
const withManifest = emptyRow({
|
|
fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A),
|
|
manifest_jsonb: {
|
|
schemaVersion: 1,
|
|
files: [{ filename: 'foo.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }],
|
|
},
|
|
updated_at_ts: hlc(200),
|
|
});
|
|
const metadataOnly = emptyRow({
|
|
fields_jsonb: setField({}, 'name', 'Renamed', hlc(300), DEV_A),
|
|
manifest_jsonb: null,
|
|
updated_at_ts: hlc(300),
|
|
});
|
|
const merged = mergeReplica(withManifest, metadataOnly);
|
|
expect(merged.fields_jsonb['name']?.v).toBe('Renamed');
|
|
expect(merged.manifest_jsonb?.files).toHaveLength(1);
|
|
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');
|
|
});
|
|
|
|
test('metadata-only row with null reincarnation does not clear an existing token', () => {
|
|
const revived = emptyRow({
|
|
reincarnation: 'epoch-1',
|
|
deleted_at_ts: hlc(100),
|
|
updated_at_ts: hlc(200),
|
|
});
|
|
const rename = emptyRow({
|
|
fields_jsonb: setField({}, 'name', 'Renamed', hlc(300), DEV_A),
|
|
reincarnation: null,
|
|
deleted_at_ts: null,
|
|
updated_at_ts: hlc(300),
|
|
});
|
|
const merged = mergeReplica(revived, rename);
|
|
expect(merged.fields_jsonb['name']?.v).toBe('Renamed');
|
|
expect(merged.reincarnation).toBe('epoch-1');
|
|
expect(mergeReplica(rename, revived).reincarnation).toBe('epoch-1');
|
|
});
|
|
|
|
test('newer tombstone clears an existing reincarnation token', () => {
|
|
const revived = emptyRow({
|
|
reincarnation: 'epoch-1',
|
|
deleted_at_ts: hlc(100),
|
|
updated_at_ts: hlc(200),
|
|
});
|
|
const deleted = emptyRow({
|
|
reincarnation: null,
|
|
deleted_at_ts: hlc(300),
|
|
updated_at_ts: hlc(300),
|
|
});
|
|
const merged = mergeReplica(revived, deleted);
|
|
expect(merged.deleted_at_ts).toBe(hlc(300));
|
|
expect(merged.reincarnation).toBe(null);
|
|
});
|
|
|
|
test('older duplicate tombstone does not clear a later reincarnation token', () => {
|
|
const revived = emptyRow({
|
|
reincarnation: 'epoch-1',
|
|
deleted_at_ts: hlc(100),
|
|
updated_at_ts: hlc(200),
|
|
});
|
|
const duplicateDelete = emptyRow({
|
|
reincarnation: null,
|
|
deleted_at_ts: hlc(100),
|
|
updated_at_ts: hlc(100),
|
|
});
|
|
expect(mergeReplica(revived, duplicateDelete).reincarnation).toBe('epoch-1');
|
|
});
|
|
});
|