mirror of
https://github.com/readest/readest.git
synced 2026-07-09 16:00:16 +00:00
feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
* 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:
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue