-- 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: {: {v, t: , 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);