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>
87 lines
2.5 KiB
TypeScript
87 lines
2.5 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import { isReplicaRowAlive } from '@/libs/replicaInterpret';
|
|
import { hlcPack } from '@/libs/crdt';
|
|
import type { Hlc, ReplicaRow } from '@/types/replica';
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
const DEV = 'dev-a';
|
|
|
|
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
|
|
user_id: 'u1',
|
|
kind: 'dictionary',
|
|
replica_id: 'content-hash-abc',
|
|
fields_jsonb: {},
|
|
manifest_jsonb: null,
|
|
deleted_at_ts: null,
|
|
reincarnation: null,
|
|
updated_at_ts: hlcPack(NOW, 0, DEV) as Hlc,
|
|
schema_version: 1,
|
|
...overrides,
|
|
});
|
|
|
|
describe('isReplicaRowAlive', () => {
|
|
test('alive when no tombstone and no reincarnation (fresh row)', () => {
|
|
expect(isReplicaRowAlive(baseRow())).toBe(true);
|
|
});
|
|
|
|
test('alive when no tombstone (just deleted_at_ts is null)', () => {
|
|
expect(isReplicaRowAlive(baseRow({ reincarnation: 'epoch-1' }))).toBe(true);
|
|
});
|
|
|
|
test('dead when tombstoned and no reincarnation token (the user just deleted it)', () => {
|
|
const tombstone = hlcPack(NOW, 0, DEV) as Hlc;
|
|
expect(
|
|
isReplicaRowAlive(
|
|
baseRow({
|
|
deleted_at_ts: tombstone,
|
|
updated_at_ts: tombstone,
|
|
reincarnation: null,
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('alive when reincarnation token is newer than the tombstone', () => {
|
|
expect(
|
|
isReplicaRowAlive(
|
|
baseRow({
|
|
deleted_at_ts: hlcPack(NOW, 0, DEV) as Hlc,
|
|
reincarnation: 'epoch-1',
|
|
updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc,
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('dead when tombstone is newer than the reincarnation (deleted again after revival)', () => {
|
|
expect(
|
|
isReplicaRowAlive(
|
|
baseRow({
|
|
deleted_at_ts: hlcPack(NOW + 5000, 0, DEV) as Hlc,
|
|
reincarnation: 'epoch-1',
|
|
updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc,
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('alive when reincarnation == tombstone HLC (edge of order)', () => {
|
|
const t = hlcPack(NOW + 1000, 0, DEV) as Hlc;
|
|
expect(
|
|
isReplicaRowAlive(
|
|
baseRow({
|
|
deleted_at_ts: t,
|
|
reincarnation: 'epoch-1',
|
|
updated_at_ts: t,
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('dead when reincarnation is set but tombstone is null is impossible — but tolerate gracefully', () => {
|
|
expect(isReplicaRowAlive(baseRow({ deleted_at_ts: null, reincarnation: 'epoch-1' }))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|