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>
85 lines
5.0 KiB
SQL
85 lines
5.0 KiB
SQL
-- Migration 003: Add replicas table for cross-device sync of user-imported assets
|
|
-- (dictionaries, fonts, textures, OPDS catalogs, dict settings — gated by
|
|
-- a per-kind allowlist enforced both in DB CHECK and in server validation).
|
|
-- See ~/.claude/plans/vivid-orbiting-thimble.md and
|
|
-- src/libs/crdt.README.md for the design.
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────
|
|
-- replica_keys: per-account PBKDF2 salt for the encrypted-fields envelope.
|
|
-- One row per (user_id, alg). A passphrase rotation appends a new row;
|
|
-- forgot-passphrase deletes all rows for the user (and the migrate-time
|
|
-- check on encrypted envelopes makes them unreadable client-side).
|
|
-- ─────────────────────────────────────────────────────────────────────────
|
|
CREATE TABLE IF NOT EXISTS public.replica_keys (
|
|
user_id uuid NOT NULL,
|
|
salt_id text NOT NULL,
|
|
alg text NOT NULL,
|
|
salt bytea NOT NULL,
|
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
CONSTRAINT replica_keys_pkey PRIMARY KEY (user_id, salt_id),
|
|
CONSTRAINT replica_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
ALTER TABLE public.replica_keys ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY replica_keys_select ON public.replica_keys
|
|
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
|
|
CREATE POLICY replica_keys_insert ON public.replica_keys
|
|
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
|
|
CREATE POLICY replica_keys_delete ON public.replica_keys
|
|
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────────
|
|
-- replicas: polymorphic per-user CRDT-backed metadata.
|
|
-- kind — server-allowlisted: 'dictionary' in PR 1; future kinds
|
|
-- require a server release that updates the CHECK below.
|
|
-- fields_jsonb — per-field LWW envelope: {<field>: {v, t: <Hlc>, s}}
|
|
-- PR-validated 64 KiB / 64-field caps server-side.
|
|
-- manifest_jsonb — committed last after binary upload completes.
|
|
-- null = "binaries pending"; row not yet downloadable.
|
|
-- deleted_at_ts — remove-wins tombstone HLC. A field write does NOT
|
|
-- 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, 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 (
|
|
user_id uuid NOT NULL,
|
|
kind text NOT NULL,
|
|
replica_id text NOT NULL,
|
|
fields_jsonb jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
manifest_jsonb jsonb NULL,
|
|
deleted_at_ts text NULL,
|
|
reincarnation text NULL,
|
|
updated_at_ts text NOT NULL,
|
|
schema_version integer NOT NULL DEFAULT 1,
|
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
modified_at timestamp with time zone NOT NULL DEFAULT now(),
|
|
CONSTRAINT replicas_pkey PRIMARY KEY (user_id, kind, replica_id),
|
|
CONSTRAINT replicas_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE,
|
|
-- Server allowlist for kind. Adding a new kind = a coordinated client +
|
|
-- server PR; this CHECK is part of the server release.
|
|
CONSTRAINT replicas_kind_allowlist CHECK (kind IN ('dictionary')),
|
|
-- Hard caps. validateRow() in src/libs/replica-schemas.ts enforces the
|
|
-- same bounds at the API layer with a clearer error code; these CHECKs
|
|
-- are belt-and-suspenders for direct DB writes.
|
|
CONSTRAINT replicas_fields_size CHECK (pg_column_size(fields_jsonb) <= 65536),
|
|
CONSTRAINT replicas_schema_version CHECK (schema_version >= 1 AND schema_version <= 1000)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_replicas_pull_cursor
|
|
ON public.replicas (user_id, kind, updated_at_ts);
|
|
|
|
ALTER TABLE public.replicas ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY replicas_select ON public.replicas
|
|
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
|
|
CREATE POLICY replicas_insert ON public.replicas
|
|
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
|
|
CREATE POLICY replicas_update ON public.replicas
|
|
FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
|
CREATE POLICY replicas_delete ON public.replicas
|
|
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
|