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

@@ -187,5 +187,8 @@
"settings_appearance": "Appearance", "settings_appearance": "Appearance",
"settings_theme_light": "Light", "settings_theme_light": "Light",
"settings_theme_dark": "Dark", "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"
} }

View File

@@ -187,5 +187,8 @@
"settings_appearance": "Apariencia", "settings_appearance": "Apariencia",
"settings_theme_light": "Claro", "settings_theme_light": "Claro",
"settings_theme_dark": "Oscuro", "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"
} }

View File

@@ -0,0 +1,124 @@
<script lang="ts">
/**
* Fase 9.3.3 — debug panel for the last 20 sync_conflicts rows owned
* by the current user. Gated on `import.meta.env.DEV` so it never
* ships in a production bundle; flip the prop to force-show for an
* eventual "secret toggle" without touching the gate.
*
* The component is presentational: pass in a `loader` that returns
* the rows so tests can inject a mock without going through Supabase.
*/
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import * as m from '$lib/paraglide/messages';
type ConflictRow = {
id: string;
entity_type: string;
entity_id: string;
local_version: unknown;
remote_version: unknown;
resolution: 'remote_won' | 'local_won';
created_at: string;
};
type Loader = (userId: string) => Promise<ConflictRow[]>;
const defaultLoader: Loader = async (userId) => {
const { data } = await getSupabase()
.from('sync_conflicts')
.select('id, entity_type, entity_id, local_version, remote_version, resolution, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(20);
return (data ?? []) as ConflictRow[];
};
let {
loader = defaultLoader,
forceShow = false
}: { loader?: Loader; forceShow?: boolean } = $props();
let rows = $state<ConflictRow[]>([]);
let loaded = $state(false);
// Hide in production unless `forceShow` is set. `import.meta.env.DEV`
// is statically replaced at build time. `$derived` re-evaluates when
// the prop changes (e.g. a parent toggles the secret unlock).
const visible = $derived(import.meta.env.DEV || forceShow);
onMount(async () => {
if (!visible || !$currentUser) {
loaded = true;
return;
}
try {
rows = await loader($currentUser.id);
} catch {
rows = [];
}
loaded = true;
});
function exportJson() {
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sync-conflicts-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
</script>
{#if visible}
<section data-testid="sync-conflicts-panel">
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.settings_sync_conflicts()}
</p>
{#if rows.length > 0}
<button
type="button"
onclick={exportJson}
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-800"
data-testid="sync-conflicts-export"
>
{m.settings_sync_conflicts_export()}
</button>
{/if}
</div>
{#if loaded && rows.length === 0}
<p class="text-sm text-text-secondary" data-testid="sync-conflicts-empty">
{m.settings_sync_conflicts_empty()}
</p>
{:else}
<ul class="space-y-2">
{#each rows as row (row.id)}
<li
data-testid="sync-conflict-row"
class="rounded-md border border-slate-200 bg-surface p-3 text-xs dark:border-slate-700"
>
<div class="mb-1 flex items-center justify-between text-text-secondary">
<span class="font-mono">{row.entity_type}</span>
<span>{new Date(row.created_at).toLocaleString()}</span>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<p class="text-text-muted">local</p>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.local_version, null, 0)}</pre>
</div>
<div>
<p class="text-text-muted">remote ({row.resolution})</p>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.remote_version, null, 0)}</pre>
</div>
</div>
</li>
{/each}
</ul>
{/if}
</section>
{/if}

View File

@@ -11,7 +11,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { getSupabase } from '$lib/supabase'; import { getSupabase } from '$lib/supabase';
import { pendingOpsCount } from '$lib/stores/syncStatus'; 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 singleton: SyncQueue | null = null;
let detachOnline: (() => void) | null = null; let detachOnline: (() => void) | null = null;
@@ -42,6 +42,15 @@ export async function refreshPending(): Promise<void> {
pendingOpsCount.set(ops.length); 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 * Enqueue-then-send convenience wrapper. Returns immediately on offline (op
* stays queued) and propagates the online-success/failure otherwise. * stays queued) and propagates the online-success/failure otherwise.

View File

@@ -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<string, unknown>) => {
calls.push({ table, kind: 'update', patch, match: m });
return { error: null };
}
}),
delete: () => ({
match: async (m: Record<string, unknown>) => {
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<string, unknown>;
remote_version: Record<string, unknown>;
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<string, unknown>) => {
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<string, unknown>) => {
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', () => { describe('attachOnlineFlush — window online event triggers flush', () => {
it('F-01: dispatching "online" calls flush() within a tick', async () => { it('F-01: dispatching "online" calls flush() within a tick', async () => {
const { client } = makeMockClient([{ error: null }]); const { client } = makeMockClient([{ error: null }]);

View File

@@ -36,6 +36,13 @@ export type UpdateOp = BaseOp & {
table: 'shopping_items' | 'shopping_lists'; table: 'shopping_items' | 'shopping_lists';
match: Record<string, unknown>; match: Record<string, unknown>;
patch: Record<string, unknown>; patch: Record<string, unknown>;
/**
* 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<string, unknown>;
}; };
export type DeleteOp = BaseOp & { export type DeleteOp = BaseOp & {
kind: 'delete'; kind: 'delete';
@@ -49,6 +56,16 @@ export type NewOp =
| Omit<UpdateOp, keyof BaseOp> | Omit<UpdateOp, keyof BaseOp>
| Omit<DeleteOp, keyof BaseOp>; | Omit<DeleteOp, keyof BaseOp>;
/**
* 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<UpdateOp['table'], string> = {
shopping_items: 'shopping_item',
shopping_lists: 'shopping_list'
};
export const MAX_ATTEMPTS = 5; export const MAX_ATTEMPTS = 5;
const DB_NAME = 'colectivo-sync'; const DB_NAME = 'colectivo-sync';
const DB_VERSION = 1; const DB_VERSION = 1;
@@ -59,9 +76,31 @@ type SupabaseQueryClient = {
insert: (row: unknown) => { select: () => { single: () => Promise<unknown> } }; insert: (row: unknown) => { select: () => { single: () => Promise<unknown> } };
update: (patch: unknown) => { match: (m: Record<string, unknown>) => Promise<unknown> }; update: (patch: unknown) => { match: (m: Record<string, unknown>) => Promise<unknown> };
delete: () => { match: (m: Record<string, unknown>) => Promise<unknown> }; delete: () => { match: (m: Record<string, unknown>) => Promise<unknown> };
/**
* 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<string, unknown>) => {
maybeSingle: () => Promise<{ data: Record<string, unknown> | 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<IDBPDatabase> { async function getDb(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, { return openDB(DB_NAME, DB_VERSION, {
upgrade(db) { upgrade(db) {
@@ -78,7 +117,17 @@ async function getDb(): Promise<IDBPDatabase> {
* interface so tests can inject a mock. * interface so tests can inject a mock.
*/ */
export class SyncQueue { 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. * 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); if (r.error && !isIdempotentSuccess(r.error)) throw new Error(r.error.message);
} else if (op.kind === 'update') { } 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<string, unknown> | 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 { const r = (await table.update(op.patch).match(op.match)) as {
error: { message: string } | null; error: { message: string } | null;
}; };
if (r.error) throw new Error(r.error.message); 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 { } else {
const r = (await table.delete().match(op.match)) as { const r = (await table.delete().match(op.match)) as {
error: { message: string } | null; error: { message: string } | null;
@@ -174,6 +254,54 @@ export class SyncQueue {
if (r.error) throw new Error(r.error.message); 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<string, unknown>): Promise<void> {
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<string, unknown>)[k], (b as Record<string, unknown>)[k])) {
return false;
}
}
return true;
} }
/** /**

View File

@@ -6,6 +6,8 @@
import { login, isLoggingOut } from '$lib/auth'; import { login, isLoggingOut } from '$lib/auth';
import { getSupabase } from '$lib/supabase'; import { getSupabase } from '$lib/supabase';
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme'; 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 DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte'; import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
import BottomTabBar from '$lib/components/layout/BottomTabBar.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 // 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 // public.users.theme and adopt it if it differs from the local
// preference. The anti-FOUC inline script in app.html and initTheme() // preference. The anti-FOUC inline script in app.html and initTheme()

View File

@@ -6,6 +6,7 @@
import Avatar from '$lib/components/Avatar.svelte'; import Avatar from '$lib/components/Avatar.svelte';
import ImageCropper from '$lib/components/ImageCropper.svelte'; import ImageCropper from '$lib/components/ImageCropper.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime'; import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import type { ThemePreference } from '$lib/theme'; import type { ThemePreference } from '$lib/theme';
@@ -275,6 +276,10 @@
</div> </div>
</section> </section>
<!-- Fase 9.3.3 — sync conflicts debug panel. Gated on
import.meta.env.DEV inside the component itself. -->
<SyncConflictsPanel />
<!-- Account — spacing creates the separation, not a border line --> <!-- Account — spacing creates the separation, not a border line -->
<section class="pt-8"> <section class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary"> <p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">

View File

@@ -19,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"fake-indexeddb": "^6.2.5",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^2.1.8" "vitest": "^2.1.8"

View File

@@ -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');
});
});

View File

@@ -344,6 +344,55 @@ export interface Database {
}; };
Relationships: []; 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: { item_frequency: {
Row: { Row: {
collective_id: string; collective_id: string;

3
pnpm-lock.yaml generated
View File

@@ -127,6 +127,9 @@ importers:
'@types/pg': '@types/pg':
specifier: ^8.11.10 specifier: ^8.11.10
version: 8.20.0 version: 8.20.0
fake-indexeddb:
specifier: ^6.2.5
version: 6.2.5
typescript: typescript:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3

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.

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;