feat(fase-9): wire sync_conflicts table + queue logging + debug panel

Closes 9.3 in full: migration 016 + pgTAP test 010 + queue.ts
conflict-detection path + /settings debug panel + integration test.

- Migration 016 adds public.sync_conflicts (append-only audit log of
  last-write-wins conflicts surfaced by the offline mutation queue).
  RLS allows owners to SELECT + INSERT their own rows only; UPDATE
  and DELETE have no policy and are silently denied. collective_id
  is intentionally nullable so user-level conflicts (theme, language
  in future) also fit. Indexed on (user_id, created_at desc) for the
  "show me my last 20 conflicts" query.
- queue.ts gains a `QueueContext` (currentUserId + collectiveId) and
  an optional `preImage` field on UpdateOp. Before sending an UPDATE
  the queue fetches the remote row (limited to the patched fields),
  proceeds with the UPDATE (last-write-wins), then fires a best-effort
  INSERT into sync_conflicts when the remote diverged. Failure to log
  never blocks the UPDATE or pollutes the pending_ops queue. Three
  unit specs (SC-01..SC-03) lock the contract in.
- sync/index.ts exposes setSyncContext(), called from (app)/+layout's
  $effect whenever currentUser / currentCollective change.
- New SyncConflictsPanel.svelte renders the last 20 conflict rows
  for the current user with an "Export JSON" button. Gated on
  import.meta.env.DEV (or a forceShow prop for a future secret
  unlock) — never ships in the production bundle. Slotted into
  /settings just above the Account section.
- Integration tests (packages/test-utils/tests/sync-conflicts.test.ts,
  4 SC-INT specs) exercise the real PostgREST + RLS contract end-to-end
  with the existing seed users (Ana, Borja). Requires fake-indexeddb in
  the test-utils dev deps (queue's IDB shim).
- pgTAP test 010 covers structure, RLS reads/writes, ownership rejection,
  and the append-only invariant (UPDATE/DELETE return 0 rows).
- packages/types/src/database.ts adds the sync_conflicts table type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 01:31:35 +02:00
parent a0e00b8044
commit ed556ce245
14 changed files with 822 additions and 4 deletions

View File

@@ -0,0 +1,55 @@
-- Migration 016: sync_conflicts (Fase 9.3)
--
-- Append-only log of last-write-wins conflicts surfaced by the offline
-- mutation queue (apps/web/src/lib/sync/queue.ts). When the client detects
-- that a local UPDATE's pre-image disagrees with the remote row it just
-- patched (i.e. someone else mutated the same row in between), it appends
-- a row here with both payloads so users can audit and operators can
-- debug. The resolution column records which side ended up persisted —
-- always 'remote_won' under MVP's last-write-wins; the column is kept for
-- forward compatibility when a smarter merger lands.
--
-- RLS rules:
-- * SELECT: only the user who owns the row (auth.uid() = user_id)
-- * INSERT: only the user inserting their own row
-- * UPDATE / DELETE: blocked entirely — the log is append-only.
--
-- `collective_id` is intentionally nullable: most conflicts will be scoped
-- to a collective, but the column also covers future user-level entities
-- (e.g. theme, language) that have no collective context. We can tighten
-- to NOT NULL later if we never use it that way.
CREATE TABLE public.sync_conflicts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
collective_id uuid NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
entity_type text NOT NULL,
entity_id uuid NOT NULL,
local_version jsonb NOT NULL,
remote_version jsonb NOT NULL,
resolution text NOT NULL CHECK (resolution IN ('remote_won', 'local_won')),
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.sync_conflicts IS
'Append-only audit log of offline-sync conflicts (last-write-wins). Inserted by the client when a queued UPDATE landed on a row that had been mutated remotely in the meantime.';
-- Most queries are "give me the latest N conflicts for the current user".
CREATE INDEX sync_conflicts_user_created_idx
ON public.sync_conflicts (user_id, created_at DESC);
ALTER TABLE public.sync_conflicts ENABLE ROW LEVEL SECURITY;
-- A user can read only their own conflicts.
CREATE POLICY sync_conflicts_select_own
ON public.sync_conflicts FOR SELECT
USING (auth.uid() = user_id);
-- A user can insert only rows attributed to themselves.
CREATE POLICY sync_conflicts_insert_own
ON public.sync_conflicts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- No UPDATE / DELETE policies — append-only by design. The absence of
-- ALL / UPDATE / DELETE policies + RLS being enabled means any non-owner
-- INSERT also gets rejected, and UPDATE / DELETE are uniformly denied.