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>
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for the offline mutation queue (Fase 2b.2).
|
|
*
|
|
* Uses fake-indexeddb (installed via vitest.setup.ts). A hand-rolled mock
|
|
* Supabase client lets us control success/failure per op without any network.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { SyncQueue, attachOnlineFlush, MAX_ATTEMPTS, type PendingOp } from './queue.js';
|
|
|
|
// In-memory Supabase-like client. Each call records the invocation and
|
|
// returns whatever the caller pushed into `responses` (FIFO).
|
|
type ClientCall = { table: string; kind: string; payload?: unknown; match?: unknown; patch?: unknown };
|
|
|
|
function makeMockClient(responses: Array<{ error: { message: string } | null }>) {
|
|
const calls: ClientCall[] = [];
|
|
const take = () => responses.shift() ?? { error: null };
|
|
const client = {
|
|
from: (table: string) => ({
|
|
insert: (payload: unknown) => ({
|
|
select: () => ({
|
|
single: async () => {
|
|
calls.push({ table, kind: 'insert', payload });
|
|
return take();
|
|
}
|
|
})
|
|
}),
|
|
update: (patch: unknown) => ({
|
|
match: async (m: Record<string, unknown>) => {
|
|
calls.push({ table, kind: 'update', patch, match: m });
|
|
return take();
|
|
}
|
|
}),
|
|
delete: () => ({
|
|
match: async (m: Record<string, unknown>) => {
|
|
calls.push({ table, kind: 'delete', match: m });
|
|
return take();
|
|
}
|
|
})
|
|
})
|
|
};
|
|
return { client, calls };
|
|
}
|
|
|
|
async function clearIdb() {
|
|
const { openDB } = await import('idb');
|
|
const db = await openDB('colectivo-sync', 1, {
|
|
upgrade(db) {
|
|
if (!db.objectStoreNames.contains('pending_ops')) {
|
|
db.createObjectStore('pending_ops', { keyPath: 'id' }).createIndex(
|
|
'createdAt',
|
|
'createdAt'
|
|
);
|
|
}
|
|
}
|
|
});
|
|
await db.clear('pending_ops');
|
|
db.close();
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await clearIdb();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await clearIdb();
|
|
});
|
|
|
|
describe('SyncQueue — pending_ops contract', () => {
|
|
it('Q-01: enqueue writes the op to pending_ops BEFORE calling Supabase', async () => {
|
|
let pendingDuringSend: PendingOp[] = [];
|
|
// Mock that snapshots the queue during the Supabase call. Holds a
|
|
// forward reference to `queue` so the handler can query it.
|
|
let queueRef!: SyncQueue;
|
|
const client = {
|
|
from: () => ({
|
|
insert: (_payload: unknown) => ({
|
|
select: () => ({
|
|
single: async () => {
|
|
pendingDuringSend = await queueRef.pending();
|
|
return { error: null };
|
|
}
|
|
})
|
|
}),
|
|
update: () => ({ match: async () => ({ error: null }) }),
|
|
delete: () => ({ match: async () => ({ error: null }) })
|
|
})
|
|
};
|
|
queueRef = new SyncQueue(client);
|
|
await queueRef.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'Milk' }
|
|
});
|
|
|
|
// During the send, the op MUST have been persisted.
|
|
expect(pendingDuringSend).toHaveLength(1);
|
|
expect(pendingDuringSend[0].kind).toBe('insert');
|
|
// And after success, the queue is empty.
|
|
expect(await queueRef.pending()).toHaveLength(0);
|
|
});
|
|
|
|
it('Q-02: successful send removes the op from pending_ops', async () => {
|
|
const { client } = makeMockClient([{ error: null }]);
|
|
const queue = new SyncQueue(client);
|
|
await queue.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'Bread' }
|
|
});
|
|
expect(await queue.pending()).toHaveLength(0);
|
|
});
|
|
|
|
it('Q-03: failed send keeps the op and increments attempts', async () => {
|
|
// First call fails, second succeeds
|
|
const { client } = makeMockClient([
|
|
{ error: { message: 'network' } },
|
|
{ error: null }
|
|
]);
|
|
const queue = new SyncQueue(client);
|
|
|
|
await queue.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'Eggs' }
|
|
});
|
|
|
|
let pending = await queue.pending();
|
|
expect(pending).toHaveLength(1);
|
|
expect(pending[0].attempts).toBe(1);
|
|
|
|
// Flush should retry and succeed this time
|
|
const result = await queue.flush();
|
|
expect(result.sent).toBe(1);
|
|
expect(await queue.pending()).toHaveLength(0);
|
|
});
|
|
|
|
it('Q-04: ops are processed in insertion order', async () => {
|
|
const { client, calls } = makeMockClient([
|
|
{ error: null },
|
|
{ error: null },
|
|
{ error: null }
|
|
]);
|
|
const queue = new SyncQueue(client);
|
|
|
|
await queue.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'A' }
|
|
});
|
|
await queue.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'B' }
|
|
});
|
|
await queue.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'C' }
|
|
});
|
|
|
|
expect(
|
|
calls
|
|
.filter((c) => c.kind === 'insert')
|
|
.map((c) => (c.payload as { name: string }).name)
|
|
).toEqual(['A', 'B', 'C']);
|
|
});
|
|
|
|
it('Q-05: ops are dropped after MAX_ATTEMPTS failures', async () => {
|
|
const { client } = makeMockClient(
|
|
Array.from({ length: MAX_ATTEMPTS + 2 }, () => ({ error: { message: 'nope' } }))
|
|
);
|
|
const queue = new SyncQueue(client);
|
|
|
|
await queue.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'X' }
|
|
});
|
|
// enqueue made 1 attempt; flush repeatedly until dropped
|
|
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
await queue.flush();
|
|
}
|
|
expect(await queue.pending()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
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', () => {
|
|
it('F-01: dispatching "online" calls flush() within a tick', async () => {
|
|
const { client } = makeMockClient([{ error: null }]);
|
|
const queue = new SyncQueue(client);
|
|
const flushSpy = vi.spyOn(queue, 'flush');
|
|
|
|
// Enqueue with a failing mock so the op stays queued
|
|
await clearIdb();
|
|
const failing = makeMockClient([{ error: { message: 'offline' } }]);
|
|
const q2 = new SyncQueue(failing.client);
|
|
await q2.enqueue({
|
|
kind: 'insert',
|
|
table: 'shopping_items',
|
|
payload: { name: 'Q' }
|
|
});
|
|
expect(await q2.pending()).toHaveLength(1);
|
|
|
|
// Attach and fire the event
|
|
const detach = attachOnlineFlush(queue);
|
|
window.dispatchEvent(new Event('online'));
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
expect(flushSpy).toHaveBeenCalled();
|
|
detach();
|
|
});
|
|
});
|