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:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
124
apps/web/src/lib/components/SyncConflictsPanel.svelte
Normal file
124
apps/web/src/lib/components/SyncConflictsPanel.svelte
Normal 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}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }]);
|
||||||
|
|||||||
@@ -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,8 +76,30 @@ 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, {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
149
packages/test-utils/tests/sync-conflicts.test.ts
Normal file
149
packages/test-utils/tests/sync-conflicts.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
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