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>
85 lines
2.8 KiB
TypeScript
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;
|
|
}
|