From ed556ce245e124c1e7a66a548982af91f5c548f1 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 01:31:35 +0200 Subject: [PATCH] feat(fase-9): wire sync_conflicts table + queue logging + debug panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/messages/en.json | 5 +- apps/web/messages/es.json | 5 +- .../lib/components/SyncConflictsPanel.svelte | 124 ++++++++++++++ apps/web/src/lib/sync/index.ts | 11 +- apps/web/src/lib/sync/queue.test.ts | 157 ++++++++++++++++++ apps/web/src/lib/sync/queue.ts | 130 ++++++++++++++- apps/web/src/routes/(app)/+layout.svelte | 13 ++ .../src/routes/(app)/settings/+page.svelte | 5 + packages/test-utils/package.json | 1 + .../test-utils/tests/sync-conflicts.test.ts | 149 +++++++++++++++++ packages/types/src/database.ts | 49 ++++++ pnpm-lock.yaml | 3 + supabase/migrations/016_sync_conflicts.sql | 55 ++++++ supabase/tests/010_sync_conflicts.sql | 119 +++++++++++++ 14 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/lib/components/SyncConflictsPanel.svelte create mode 100644 packages/test-utils/tests/sync-conflicts.test.ts create mode 100644 supabase/migrations/016_sync_conflicts.sql create mode 100644 supabase/tests/010_sync_conflicts.sql diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 25c3113..0154a3b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -187,5 +187,8 @@ "settings_appearance": "Appearance", "settings_theme_light": "Light", "settings_theme_dark": "Dark", - "settings_theme_system": "System" + "settings_theme_system": "System", + "settings_sync_conflicts": "Sync conflicts", + "settings_sync_conflicts_empty": "No conflicts logged.", + "settings_sync_conflicts_export": "Export JSON" } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 0ec98e9..c625734 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -187,5 +187,8 @@ "settings_appearance": "Apariencia", "settings_theme_light": "Claro", "settings_theme_dark": "Oscuro", - "settings_theme_system": "Sistema" + "settings_theme_system": "Sistema", + "settings_sync_conflicts": "Conflictos de sincronización", + "settings_sync_conflicts_empty": "Sin conflictos registrados.", + "settings_sync_conflicts_export": "Exportar JSON" } diff --git a/apps/web/src/lib/components/SyncConflictsPanel.svelte b/apps/web/src/lib/components/SyncConflictsPanel.svelte new file mode 100644 index 0000000..b0602f1 --- /dev/null +++ b/apps/web/src/lib/components/SyncConflictsPanel.svelte @@ -0,0 +1,124 @@ + + +{#if visible} +
+
+

+ {m.settings_sync_conflicts()} +

+ {#if rows.length > 0} + + {/if} +
+ {#if loaded && rows.length === 0} +

+ {m.settings_sync_conflicts_empty()} +

+ {:else} +
    + {#each rows as row (row.id)} +
  • +
    + {row.entity_type} + {new Date(row.created_at).toLocaleString()} +
    +
    +
    +

    local

    +
    {JSON.stringify(row.local_version, null, 0)}
    +
    +
    +

    remote ({row.resolution})

    +
    {JSON.stringify(row.remote_version, null, 0)}
    +
    +
    +
  • + {/each} +
+ {/if} +
+{/if} diff --git a/apps/web/src/lib/sync/index.ts b/apps/web/src/lib/sync/index.ts index 53a3342..c858f1f 100644 --- a/apps/web/src/lib/sync/index.ts +++ b/apps/web/src/lib/sync/index.ts @@ -11,7 +11,7 @@ import { browser } from '$app/environment'; import { getSupabase } from '$lib/supabase'; import { pendingOpsCount } from '$lib/stores/syncStatus'; -import { SyncQueue, attachOnlineFlush, type NewOp } from './queue'; +import { SyncQueue, attachOnlineFlush, type NewOp, type QueueContext } from './queue'; let singleton: SyncQueue | null = null; let detachOnline: (() => void) | null = null; @@ -42,6 +42,15 @@ export async function refreshPending(): Promise { pendingOpsCount.set(ops.length); } +/** + * Update the queue's "who am I / which collective" context. Called from + * the top-level layout when auth + active collective resolve, so the + * conflict logger (Fase 9.3) attributes rows correctly. + */ +export function setSyncContext(ctx: QueueContext): void { + getQueue().setContext(ctx); +} + /** * Enqueue-then-send convenience wrapper. Returns immediately on offline (op * stays queued) and propagates the online-success/failure otherwise. diff --git a/apps/web/src/lib/sync/queue.test.ts b/apps/web/src/lib/sync/queue.test.ts index b2cfc4b..29f92d2 100644 --- a/apps/web/src/lib/sync/queue.test.ts +++ b/apps/web/src/lib/sync/queue.test.ts @@ -184,6 +184,163 @@ describe('SyncQueue — pending_ops contract', () => { }); }); +describe('SyncQueue — last-write-wins conflict logging (Fase 9.3)', () => { + it('SC-01: an UPDATE whose remote pre-image differs from the local pre-image logs a sync_conflicts row', async () => { + // The client tracks two kinds of calls: + // * shopping_items.update → succeeds (last-write-wins). + // * shopping_items.select before that update returns a row whose + // `name` already differs from what the queue believed. + // * sync_conflicts.insert is observed here. + const calls: Array<{ table: string; kind: string; payload?: unknown; patch?: unknown; match?: unknown }> = []; + const client = { + from: (table: string) => ({ + insert: (payload: unknown) => ({ + select: () => ({ + single: async () => { + calls.push({ table, kind: 'insert', payload }); + return { error: null }; + } + }) + }), + update: (patch: unknown) => ({ + match: async (m: Record) => { + calls.push({ table, kind: 'update', patch, match: m }); + return { error: null }; + } + }), + delete: () => ({ + match: async (m: Record) => { + calls.push({ table, kind: 'delete', match: m }); + return { error: null }; + } + }), + select: () => ({ + match: () => ({ + maybeSingle: async () => { + calls.push({ table, kind: 'select' }); + // Remote has a value that disagrees with our preImage. + return { data: { name: 'remote-name' }, error: null }; + } + }) + }) + }) + }; + + const queue = new SyncQueue(client, { currentUserId: 'user-1', collectiveId: 'coll-1' }); + await queue.enqueue({ + kind: 'update', + table: 'shopping_items', + match: { id: 'item-1' }, + patch: { name: 'local-name' }, + preImage: { name: 'old-name' } + }); + + // The UPDATE happened (remote-won under last-write-wins) AND a + // sync_conflicts row was inserted with both payloads. + const updateCall = calls.find((c) => c.kind === 'update' && c.table === 'shopping_items'); + expect(updateCall).toBeTruthy(); + + const conflict = calls.find((c) => c.kind === 'insert' && c.table === 'sync_conflicts'); + expect(conflict).toBeTruthy(); + const payload = conflict?.payload as { + user_id: string; + entity_type: string; + entity_id: string; + local_version: Record; + remote_version: Record; + resolution: string; + }; + expect(payload.user_id).toBe('user-1'); + expect(payload.entity_type).toBe('shopping_item'); + expect(payload.entity_id).toBe('item-1'); + expect(payload.local_version).toEqual({ name: 'local-name' }); + expect(payload.remote_version).toEqual({ name: 'remote-name' }); + expect(payload.resolution).toBe('remote_won'); + }); + + it('SC-02: no conflict is logged when the remote pre-image matches the local one', async () => { + const calls: Array<{ table: string; kind: string }> = []; + const client = { + from: (table: string) => ({ + insert: (_payload: unknown) => ({ + select: () => ({ single: async () => ({ error: null }) }) + }), + update: (_patch: unknown) => ({ + match: async (_m: Record) => { + calls.push({ table, kind: 'update' }); + return { error: null }; + } + }), + delete: () => ({ match: async () => ({ error: null }) }), + select: () => ({ + match: () => ({ + maybeSingle: async () => { + calls.push({ table, kind: 'select' }); + // Remote agrees with our preImage — no conflict. + return { data: { name: 'old-name' }, error: null }; + } + }) + }) + }) + }; + + const queue = new SyncQueue(client, { currentUserId: 'user-1' }); + await queue.enqueue({ + kind: 'update', + table: 'shopping_items', + match: { id: 'item-1' }, + patch: { name: 'new-name' }, + preImage: { name: 'old-name' } + }); + + expect(calls.find((c) => c.table === 'sync_conflicts')).toBeUndefined(); + }); + + it('SC-03: a failed sync_conflicts INSERT does NOT block the UPDATE', async () => { + // The UPDATE must still come through even if the conflict log + // insertion errors (best-effort logging). + let updateLanded = false; + const client = { + from: (table: string) => ({ + insert: (_payload: unknown) => ({ + select: () => ({ + single: async () => { + // sync_conflicts insert blows up + if (table === 'sync_conflicts') return { error: { message: 'rls' } }; + return { error: null }; + } + }) + }), + update: (_patch: unknown) => ({ + match: async (_m: Record) => { + updateLanded = true; + return { error: null }; + } + }), + delete: () => ({ match: async () => ({ error: null }) }), + select: () => ({ + match: () => ({ + maybeSingle: async () => ({ data: { name: 'remote-name' }, error: null }) + }) + }) + }) + }; + + const queue = new SyncQueue(client, { currentUserId: 'user-1' }); + await queue.enqueue({ + kind: 'update', + table: 'shopping_items', + match: { id: 'item-1' }, + patch: { name: 'local' }, + preImage: { name: 'old' } + }); + + expect(updateLanded).toBe(true); + // Op was removed from the queue (treated as success). + expect(await queue.pending()).toHaveLength(0); + }); +}); + describe('attachOnlineFlush — window online event triggers flush', () => { it('F-01: dispatching "online" calls flush() within a tick', async () => { const { client } = makeMockClient([{ error: null }]); diff --git a/apps/web/src/lib/sync/queue.ts b/apps/web/src/lib/sync/queue.ts index 7a87362..853cb12 100644 --- a/apps/web/src/lib/sync/queue.ts +++ b/apps/web/src/lib/sync/queue.ts @@ -36,6 +36,13 @@ export type UpdateOp = BaseOp & { table: 'shopping_items' | 'shopping_lists'; match: Record; patch: Record; + /** + * Fase 9.3 — the row state the client believed when it queued the + * UPDATE, restricted to the fields being patched. Used to detect + * last-write-wins conflicts at send time. Optional so legacy ops + * (and tests not exercising the conflict path) keep working. + */ + preImage?: Record; }; export type DeleteOp = BaseOp & { kind: 'delete'; @@ -49,6 +56,16 @@ export type NewOp = | Omit | Omit; +/** + * Maps an op's `table` to the entity_type value we persist in + * sync_conflicts. Keeping a small lookup avoids hard-coding the rule + * "strip the trailing s" which would break for irregular plurals. + */ +const ENTITY_TYPE_FOR_TABLE: Record = { + shopping_items: 'shopping_item', + shopping_lists: 'shopping_list' +}; + export const MAX_ATTEMPTS = 5; const DB_NAME = 'colectivo-sync'; const DB_VERSION = 1; @@ -59,9 +76,31 @@ type SupabaseQueryClient = { insert: (row: unknown) => { select: () => { single: () => Promise } }; update: (patch: unknown) => { match: (m: Record) => Promise }; delete: () => { match: (m: Record) => Promise }; + /** + * Fase 9.3 — used to read the remote pre-image before sending an + * UPDATE so we can detect a last-write-wins conflict. Optional + * because legacy callers that never enqueue a `preImage` op also + * never reach this branch. + */ + select?: (cols?: string) => { + match: (m: Record) => { + maybeSingle: () => Promise<{ data: Record | null; error: { message: string } | null }>; + }; + }; }; }; +/** + * Optional context the queue uses for last-write-wins conflict logging. + * Without `currentUserId` the queue skips conflict detection entirely — + * RLS on sync_conflicts requires `user_id = auth.uid()` so anonymous + * writes would fail anyway. + */ +export type QueueContext = { + currentUserId?: string | null; + collectiveId?: string | null; +}; + async function getDb(): Promise { return openDB(DB_NAME, DB_VERSION, { upgrade(db) { @@ -78,7 +117,17 @@ async function getDb(): Promise { * interface so tests can inject a mock. */ export class SyncQueue { - constructor(private readonly client: SupabaseQueryClient) {} + private readonly context: QueueContext; + + constructor(private readonly client: SupabaseQueryClient, context: QueueContext = {}) { + this.context = context; + } + + /** Replace the queue context (call when the user logs in / out). */ + setContext(context: QueueContext): void { + this.context.currentUserId = context.currentUserId ?? null; + this.context.collectiveId = context.collectiveId ?? null; + } /** * Persist the op to pending_ops, then attempt to send it to Supabase. @@ -163,10 +212,41 @@ export class SyncQueue { }; if (r.error && !isIdempotentSuccess(r.error)) throw new Error(r.error.message); } else if (op.kind === 'update') { + // Fase 9.3: best-effort last-write-wins conflict detection. + // Read the remote row first (only fields we're patching) and + // compare to the pre-image the caller captured when the op was + // queued. Any mismatch on a patched field = remote moved + // under us; log a sync_conflicts row and proceed with the + // UPDATE (last-write-wins). A failure to fetch / log never + // blocks the UPDATE — the conflict log is advisory. + let remoteSnapshot: Record | null = null; + if (op.preImage && this.client.from(op.table).select && this.context.currentUserId) { + try { + const cols = Object.keys(op.preImage).join(', '); + const sel = this.client.from(op.table).select?.(cols); + if (sel) { + const r = await sel.match(op.match).maybeSingle(); + if (!r.error) remoteSnapshot = r.data; + } + } catch { + /* swallow — best effort */ + } + } + const r = (await table.update(op.patch).match(op.match)) as { error: { message: string } | null; }; if (r.error) throw new Error(r.error.message); + + // After a successful UPDATE, fire-and-forget the conflict log. + if (remoteSnapshot && op.preImage) { + const diverged = Object.keys(op.preImage).some( + (k) => !deepEqual(remoteSnapshot![k], op.preImage![k]) + ); + if (diverged) { + await this.logConflict(op, remoteSnapshot); + } + } } else { const r = (await table.delete().match(op.match)) as { error: { message: string } | null; @@ -174,6 +254,54 @@ export class SyncQueue { if (r.error) throw new Error(r.error.message); } } + + /** + * Insert a sync_conflicts row for the given UPDATE op. Errors are + * swallowed — the table is append-only/best-effort and a logging + * failure must never break the user's sync. + */ + private async logConflict(op: UpdateOp, remoteSnapshot: Record): Promise { + const userId = this.context.currentUserId; + if (!userId) return; + const entityType = ENTITY_TYPE_FOR_TABLE[op.table]; + const entityId = (op.match.id ?? op.match['id']) as string | undefined; + if (!entityType || !entityId) return; + try { + await this.client + .from('sync_conflicts') + .insert({ + user_id: userId, + collective_id: this.context.collectiveId ?? null, + entity_type: entityType, + entity_id: entityId, + local_version: op.patch, + remote_version: remoteSnapshot, + resolution: 'remote_won' + }) + .select() + .single(); + } catch { + /* swallow */ + } + } +} + +/** Local deep-equality good enough for primitive / JSON payloads. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + if (typeof a !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const ak = Object.keys(a as object); + const bk = Object.keys(b as object); + if (ak.length !== bk.length) return false; + for (const k of ak) { + if (!deepEqual((a as Record)[k], (b as Record)[k])) { + return false; + } + } + return true; } /** diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte index f3e832a..3d00149 100644 --- a/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -6,6 +6,8 @@ import { login, isLoggingOut } from '$lib/auth'; import { getSupabase } from '$lib/supabase'; import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme'; + import { currentCollective } from '$lib/stores/collective'; + import { setSyncContext } from '$lib/sync'; import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte'; import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte'; import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte'; @@ -44,6 +46,17 @@ } }); + // Fase 9.3: keep the offline-sync queue's context (user_id + + // collective_id) in sync with the active session. Without this the + // queue's conflict-detection short-circuits silently (it needs a + // known user_id to satisfy sync_conflicts.user_id RLS). + $effect(() => { + setSyncContext({ + currentUserId: $currentUser?.id ?? null, + collectiveId: $currentCollective?.id ?? null + }); + }); + // Fase 9.1: cross-device theme sync. When the user logs in, read // public.users.theme and adopt it if it differs from the local // preference. The anti-FOUC inline script in app.html and initTheme() diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte index b13b91f..9353de7 100644 --- a/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -6,6 +6,7 @@ import Avatar from '$lib/components/Avatar.svelte'; import ImageCropper from '$lib/components/ImageCropper.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; + import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte'; import { languageTag, setLanguageTag } from '$lib/paraglide/runtime'; import * as m from '$lib/paraglide/messages'; import type { ThemePreference } from '$lib/theme'; @@ -275,6 +276,10 @@ + + +

diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index adfc6db..f595a7f 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/pg": "^8.11.10", + "fake-indexeddb": "^6.2.5", "typescript": "^5.7.3", "vite": "^6.0.7", "vitest": "^2.1.8" diff --git a/packages/test-utils/tests/sync-conflicts.test.ts b/packages/test-utils/tests/sync-conflicts.test.ts new file mode 100644 index 0000000..94c4045 --- /dev/null +++ b/packages/test-utils/tests/sync-conflicts.test.ts @@ -0,0 +1,149 @@ +/** + * SC-INT series: sync_conflicts wiring (Fase 9.3). + * + * The unit tests in apps/web/src/lib/sync/queue.test.ts cover the + * conflict-detection logic in isolation. This file exercises the live + * PostgREST path so we know: + * + * SC-INT-01: a user can insert a row attributed to themselves and read + * it back. + * SC-INT-02: a user cannot read another user's conflict rows (RLS). + * SC-INT-03: UPDATE / DELETE are rejected silently (no policy) so the + * table stays append-only at the API surface, not just at + * the schema level. + * + * The combination of these three is what makes the offline-queue's + * best-effort logging safe to enable in production. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; +import { closePool } from '../src/db-helpers.js'; +import { ANA_ID, BORJA_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; + +const admin = createAdminClient(); +const insertedConflictIds: string[] = []; + +afterAll(async () => { + if (insertedConflictIds.length > 0) { + await admin.from('sync_conflicts').delete().in('id', insertedConflictIds); + } + await closePool(); +}); + +describe('sync_conflicts — RLS + PostgREST contract', () => { + it('SC-INT-01: a user can insert their own conflict row and read it back', async () => { + const ana = await createClientAs(ANA_ID); + const entityId = '00000000-0000-0000-0000-0000000000a1'; + const { data, error } = await ana + .from('sync_conflicts') + .insert({ + user_id: ANA_ID, + collective_id: COLLECTIVE_ID, + entity_type: 'shopping_item', + entity_id: entityId, + local_version: { name: 'ana-local' }, + remote_version: { name: 'ana-remote' }, + resolution: 'remote_won' + }) + .select('id') + .single(); + expect(error).toBeNull(); + expect(data?.id).toBeTruthy(); + insertedConflictIds.push(data!.id); + + const { data: rows } = await ana + .from('sync_conflicts') + .select('user_id, entity_type, resolution') + .eq('id', data!.id); + expect(rows ?? []).toHaveLength(1); + expect(rows![0].user_id).toBe(ANA_ID); + expect(rows![0].resolution).toBe('remote_won'); + }); + + it('SC-INT-02: a user cannot read another user\'s conflict rows', async () => { + // Insert as Borja (RLS still has to allow the self-insert). + const borja = await createClientAs(BORJA_ID); + const { data, error } = await borja + .from('sync_conflicts') + .insert({ + user_id: BORJA_ID, + collective_id: COLLECTIVE_ID, + entity_type: 'shopping_item', + entity_id: '00000000-0000-0000-0000-0000000000b1', + local_version: { name: 'borja-only' }, + remote_version: { name: 'remote' }, + resolution: 'remote_won' + }) + .select('id') + .single(); + expect(error).toBeNull(); + insertedConflictIds.push(data!.id); + + // Ana cannot see it. + const ana = await createClientAs(ANA_ID); + const { data: visibleToAna } = await ana + .from('sync_conflicts') + .select('id') + .eq('id', data!.id); + expect(visibleToAna ?? []).toHaveLength(0); + }); + + it('SC-INT-03: a user cannot impersonate another via user_id at insert time', async () => { + const ana = await createClientAs(ANA_ID); + const { data, error } = await ana + .from('sync_conflicts') + .insert({ + user_id: BORJA_ID, // not me + entity_type: 'shopping_item', + entity_id: '00000000-0000-0000-0000-0000000000a2', + local_version: {}, + remote_version: {}, + resolution: 'remote_won' + }) + .select('id') + .single(); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + }); + + it('SC-INT-04: UPDATE / DELETE are denied silently (append-only)', async () => { + // Insert a row as Ana, then try to update + delete it as Ana. + const ana = await createClientAs(ANA_ID); + const { data: inserted, error } = await ana + .from('sync_conflicts') + .insert({ + user_id: ANA_ID, + entity_type: 'shopping_item', + entity_id: '00000000-0000-0000-0000-0000000000a3', + local_version: { v: 1 }, + remote_version: { v: 2 }, + resolution: 'remote_won' + }) + .select('id') + .single(); + expect(error).toBeNull(); + insertedConflictIds.push(inserted!.id); + + const { data: updated } = await ana + .from('sync_conflicts') + .update({ resolution: 'local_won' }) + .eq('id', inserted!.id) + .select('id'); + expect(updated ?? []).toHaveLength(0); // RLS returns 0 rows updated + + const { data: deleted } = await ana + .from('sync_conflicts') + .delete() + .eq('id', inserted!.id) + .select('id'); + expect(deleted ?? []).toHaveLength(0); + + // Row is still there. + const { data: stillThere } = await ana + .from('sync_conflicts') + .select('id, resolution') + .eq('id', inserted!.id) + .single(); + expect(stillThere?.resolution).toBe('remote_won'); + }); +}); diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index b158f93..0685c59 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -344,6 +344,55 @@ export interface Database { }; Relationships: []; }; + sync_conflicts: { + Row: { + id: string; + user_id: string; + collective_id: string | null; + entity_type: string; + entity_id: string; + local_version: Json; + remote_version: Json; + resolution: 'remote_won' | 'local_won'; + created_at: string; + }; + Insert: { + id?: string; + user_id: string; + collective_id?: string | null; + entity_type: string; + entity_id: string; + local_version: Json; + remote_version: Json; + resolution: 'remote_won' | 'local_won'; + created_at?: string; + }; + Update: { + id?: string; + user_id?: string; + collective_id?: string | null; + entity_type?: string; + entity_id?: string; + local_version?: Json; + remote_version?: Json; + resolution?: 'remote_won' | 'local_won'; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'sync_conflicts_user_id_fkey'; + columns: ['user_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'sync_conflicts_collective_id_fkey'; + columns: ['collective_id']; + referencedRelation: 'collectives'; + referencedColumns: ['id']; + } + ]; + }; item_frequency: { Row: { collective_id: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45ed41b..76a6c7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: '@types/pg': specifier: ^8.11.10 version: 8.20.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 typescript: specifier: ^5.7.3 version: 5.9.3 diff --git a/supabase/migrations/016_sync_conflicts.sql b/supabase/migrations/016_sync_conflicts.sql new file mode 100644 index 0000000..50c3de5 --- /dev/null +++ b/supabase/migrations/016_sync_conflicts.sql @@ -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. diff --git a/supabase/tests/010_sync_conflicts.sql b/supabase/tests/010_sync_conflicts.sql new file mode 100644 index 0000000..11a680d --- /dev/null +++ b/supabase/tests/010_sync_conflicts.sql @@ -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;