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

@@ -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', () => {
it('F-01: dispatching "online" calls flush() within a tick', async () => {
const { client } = makeMockClient([{ error: null }]);