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

@@ -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}