feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)

* 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:
Huang Xin
2026-05-07 03:39:38 +08:00
committed by GitHub
parent 3b348c8f35
commit cbdc3b8f52
79 changed files with 3929 additions and 101 deletions
@@ -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;