Files
collective-lists/apps/web/src/lib/sync/index.ts
Oier Bravo Urtasun ed556ce245 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>
2026-05-18 01:31:35 +02:00

85 lines
2.8 KiB
TypeScript

/**
* App-wide sync queue singleton.
*
* One SyncQueue per browser tab, initialised lazily on first access. Pass
* mutations through `enqueue*` helpers below so they're persisted to IDB
* BEFORE the network call — that way a lost connection doesn't lose work.
*
* On window 'online' we flush the queue. Before returning from a mutation
* helper we also update the pendingOpsCount store so the banner can react.
*/
import { browser } from '$app/environment';
import { getSupabase } from '$lib/supabase';
import { pendingOpsCount } from '$lib/stores/syncStatus';
import { SyncQueue, attachOnlineFlush, type NewOp, type QueueContext } from './queue';
let singleton: SyncQueue | null = null;
let detachOnline: (() => void) | null = null;
export function getQueue(): SyncQueue {
if (!singleton) {
// The SyncQueue's constructor parameter is structurally compatible with
// supabase-js's query builder surface. Cast through unknown rather than
// introducing a shared interface for a single integration point.
singleton = new SyncQueue(getSupabase() as unknown as ConstructorParameters<typeof SyncQueue>[0]);
if (browser) {
detachOnline = attachOnlineFlush(singleton);
// Refresh the pendingOpsCount whenever we flush.
const originalFlush = singleton.flush.bind(singleton);
singleton.flush = async (...args: Parameters<typeof originalFlush>) => {
const r = await originalFlush(...args);
await refreshPending();
return r;
};
}
}
return singleton;
}
export async function refreshPending(): Promise<void> {
if (!singleton) return;
const ops = await singleton.pending();
pendingOpsCount.set(ops.length);
}
/**
* Update the queue's "who am I / which collective" context. Called from
* the top-level layout when auth + active collective resolve, so the
* conflict logger (Fase 9.3) attributes rows correctly.
*/
export function setSyncContext(ctx: QueueContext): void {
getQueue().setContext(ctx);
}
/**
* Enqueue-then-send convenience wrapper. Returns immediately on offline (op
* stays queued) and propagates the online-success/failure otherwise.
*/
export async function enqueueOp(op: NewOp): Promise<void> {
const q = getQueue();
await q.enqueue(op);
await refreshPending();
}
/**
* Called at app startup (from a top-level layout) to hydrate the
* pendingOpsCount store from IDB so the banner reflects leftover ops.
*/
export async function hydrateSyncState(): Promise<void> {
if (!browser) return;
const q = getQueue();
const ops = await q.pending();
pendingOpsCount.set(ops.length);
// If we booted up with pending ops AND we're online, flush immediately.
if (ops.length > 0 && navigator.onLine) {
await q.flush();
}
}
// For tests that want to tear down the singleton.
export function __resetSyncForTests(): void {
detachOnline?.();
singleton = null;
detachOnline = null;
}