forked from akai/readest
* 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>
This commit is contained in:
@@ -41,8 +41,8 @@ CREATE POLICY replica_keys_delete ON public.replica_keys
|
||||
-- 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.
|
||||
-- updated_at_ts — max(field HLCs, deleted_at_ts, row-level operation
|
||||
-- HLCs such as manifest commits). Used as the pull cursor.
|
||||
-- schema_version — per-kind schema bump; server enforces bounds.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.replicas (
|
||||
|
||||
@@ -79,7 +79,7 @@ END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- updated_at_ts = max over field HLCs and tombstone HLC.
|
||||
-- Content 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
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Migration 005: keep replica updated_at_ts as the max row-operation HLC.
|
||||
--
|
||||
-- Migration 004 recomputed updated_at_ts from fields_jsonb + deleted_at_ts
|
||||
-- after every conflict. That loses manifest-only operation timestamps:
|
||||
-- manifest_jsonb can change, but pull cursors do not advance, so a device
|
||||
-- that already pulled the metadata-only row can miss the downloadable
|
||||
-- transition. Preserve the max of existing row timestamp, incoming row
|
||||
-- timestamp, and content/tombstone timestamp.
|
||||
--
|
||||
-- Also treat incoming manifest_jsonb = null as "no manifest update" on
|
||||
-- conflict. Metadata-only rows use null, and must not clear an existing
|
||||
-- committed manifest.
|
||||
--
|
||||
-- Reincarnation is derived from the newest non-null token candidate that
|
||||
-- is newer than the merged tombstone. Null metadata/manifest rows do not
|
||||
-- clear a token; a newer tombstone clears it because no token candidate is
|
||||
-- newer than that tombstone. Without this, editing a reincarnated
|
||||
-- dictionary title publishes p_reincarnation = null and erases the revival
|
||||
-- token.
|
||||
|
||||
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 NULL AND EXCLUDED.reincarnation IS NULL
|
||||
THEN NULL
|
||||
WHEN r.reincarnation IS NOT NULL AND EXCLUDED.reincarnation IS NULL
|
||||
THEN CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR r.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN r.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
WHEN r.reincarnation IS NULL AND EXCLUDED.reincarnation IS NOT NULL
|
||||
THEN CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR EXCLUDED.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN EXCLUDED.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
WHEN EXCLUDED.updated_at_ts > r.updated_at_ts
|
||||
THEN CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR EXCLUDED.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN EXCLUDED.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
ELSE CASE
|
||||
WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL
|
||||
OR r.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts)
|
||||
THEN r.reincarnation
|
||||
ELSE NULL
|
||||
END
|
||||
END,
|
||||
manifest_jsonb = CASE
|
||||
WHEN EXCLUDED.manifest_jsonb IS NULL
|
||||
THEN r.manifest_jsonb
|
||||
WHEN r.manifest_jsonb IS NULL
|
||||
THEN EXCLUDED.manifest_jsonb
|
||||
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.hlc_max(
|
||||
public.hlc_max(r.updated_at_ts, EXCLUDED.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;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.crdt_merge_replica(uuid, text, text, jsonb, jsonb, text, text, text, integer) TO authenticated;
|
||||
Reference in New Issue
Block a user