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

@@ -6,6 +6,8 @@
import { login, isLoggingOut } from '$lib/auth';
import { getSupabase } from '$lib/supabase';
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 MobileTopBar from '$lib/components/layout/MobileTopBar.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
// public.users.theme and adopt it if it differs from the local
// preference. The anti-FOUC inline script in app.html and initTheme()

View File

@@ -6,6 +6,7 @@
import Avatar from '$lib/components/Avatar.svelte';
import ImageCropper from '$lib/components/ImageCropper.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages';
import type { ThemePreference } from '$lib/theme';
@@ -275,6 +276,10 @@
</div>
</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 -->
<section class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">