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:
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.10",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^2.1.8"
|
||||
|
||||
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: [];
|
||||
};
|
||||
sync_conflicts: {
|
||||
Row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
collective_id: string | null;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
local_version: Json;
|
||||
remote_version: Json;
|
||||
resolution: 'remote_won' | 'local_won';
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
collective_id?: string | null;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
local_version: Json;
|
||||
remote_version: Json;
|
||||
resolution: 'remote_won' | 'local_won';
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
collective_id?: string | null;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
local_version?: Json;
|
||||
remote_version?: Json;
|
||||
resolution?: 'remote_won' | 'local_won';
|
||||
created_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'sync_conflicts_user_id_fkey';
|
||||
columns: ['user_id'];
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'sync_conflicts_collective_id_fkey';
|
||||
columns: ['collective_id'];
|
||||
referencedRelation: 'collectives';
|
||||
referencedColumns: ['id'];
|
||||
}
|
||||
];
|
||||
};
|
||||
item_frequency: {
|
||||
Row: {
|
||||
collective_id: string;
|
||||
|
||||
Reference in New Issue
Block a user