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,119 @@
-- pgTAP: public.sync_conflicts table + RLS (migration 016)
-- Run with: psql -U postgres -d postgres -f supabase/tests/010_sync_conflicts.sql
--
-- Fase 9.3 — append-only audit log of offline-sync last-write-wins
-- conflicts. The tests cover:
-- * structure (table exists, RLS enabled, expected columns)
-- * RLS reads scoped to auth.uid()
-- * RLS inserts only allowed when row.user_id = auth.uid()
-- * UPDATE and DELETE blocked across the board (no policy = denied)
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(12);
-- ── Structure ───────────────────────────────────────────────────────────
SELECT has_table(
'public', 'sync_conflicts',
'public.sync_conflicts exists'
);
SELECT ok(
(SELECT relrowsecurity FROM pg_class WHERE oid = 'public.sync_conflicts'::regclass),
'sync_conflicts has RLS enabled'
);
SELECT col_not_null(
'public', 'sync_conflicts', 'user_id',
'sync_conflicts.user_id is NOT NULL'
);
SELECT col_is_null(
'public', 'sync_conflicts', 'collective_id',
'sync_conflicts.collective_id is nullable (some conflicts have no collective scope)'
);
-- ── Seed two distinct conflicts (one for Ana, one for Borja) as admin ──
-- IDs map to seed.sql.
DO $$
BEGIN
INSERT INTO public.sync_conflicts (user_id, collective_id, entity_type, entity_id, local_version, remote_version, resolution)
VALUES
('11111111-1111-1111-1111-111111111111', NULL, 'shopping_item', gen_random_uuid(), '{"name":"ana-local"}'::jsonb, '{"name":"ana-remote"}'::jsonb, 'remote_won'),
('22222222-2222-2222-2222-222222222222', NULL, 'shopping_item', gen_random_uuid(), '{"name":"borja-local"}'::jsonb, '{"name":"borja-remote"}'::jsonb, 'remote_won');
END $$;
-- ── RLS: Ana sees only her own row ──────────────────────────────────────
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims TO '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}';
SELECT is(
(SELECT count(*)::int FROM public.sync_conflicts),
1,
'Ana can read only the one row she owns'
);
SELECT is(
(SELECT user_id FROM public.sync_conflicts LIMIT 1),
'11111111-1111-1111-1111-111111111111'::uuid,
'Visible row belongs to Ana'
);
-- ── RLS: Ana can INSERT her own row ─────────────────────────────────────
INSERT INTO public.sync_conflicts (user_id, entity_type, entity_id, local_version, remote_version, resolution)
VALUES ('11111111-1111-1111-1111-111111111111', 'shopping_item', gen_random_uuid(), '{"a":1}'::jsonb, '{"a":2}'::jsonb, 'remote_won');
SELECT is(
(SELECT count(*)::int FROM public.sync_conflicts),
2,
'Ana can insert a row attributed to herself'
);
-- ── RLS: Ana cannot INSERT a row owned by Borja ─────────────────────────
SELECT throws_ok(
$$INSERT INTO public.sync_conflicts (user_id, entity_type, entity_id, local_version, remote_version, resolution)
VALUES ('22222222-2222-2222-2222-222222222222', 'shopping_item', gen_random_uuid(), '{}'::jsonb, '{}'::jsonb, 'remote_won')$$,
'42501',
NULL,
'Ana cannot insert a row attributed to Borja'
);
-- ── RLS: UPDATE is denied (no policy) ───────────────────────────────────
-- An UPDATE with no matching policy returns 0 rows updated (silently),
-- not a privilege error. Assert nothing was modified.
WITH bumped AS (
UPDATE public.sync_conflicts SET resolution = 'local_won' WHERE user_id = '11111111-1111-1111-1111-111111111111' RETURNING 1
)
SELECT is(
(SELECT count(*)::int FROM bumped),
0,
'UPDATE on sync_conflicts is denied (no policy)'
);
-- ── RLS: DELETE is denied (no policy) ───────────────────────────────────
WITH gone AS (
DELETE FROM public.sync_conflicts WHERE user_id = '11111111-1111-1111-1111-111111111111' RETURNING 1
)
SELECT is(
(SELECT count(*)::int FROM gone),
0,
'DELETE on sync_conflicts is denied (no policy)'
);
-- ── RLS: Borja cannot see Ana's rows ────────────────────────────────────
SET LOCAL request.jwt.claims TO '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}';
SELECT is(
(SELECT count(*)::int FROM public.sync_conflicts WHERE user_id = '11111111-1111-1111-1111-111111111111'),
0,
'Borja cannot see Ana''s sync_conflicts rows'
);
-- ── RLS: select_own — Borja sees their own seeded row only ──────────────
SELECT is(
(SELECT count(*)::int FROM public.sync_conflicts),
1,
'Borja sees only the row attributed to them'
);
SELECT * FROM finish();
ROLLBACK;