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:
55
supabase/migrations/016_sync_conflicts.sql
Normal file
55
supabase/migrations/016_sync_conflicts.sql
Normal 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.
|
||||
119
supabase/tests/010_sync_conflicts.sql
Normal file
119
supabase/tests/010_sync_conflicts.sql
Normal 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;
|
||||
Reference in New Issue
Block a user