feat(fase-2b): Realtime sync + offline queue + Modo Compra
Fase 2b closes the critical-path product differentiator. Two sessions on the
same list see each other's changes in real time, mutations survive network
drops and sync when reconnected, and a dedicated full-screen shopping view
optimises the in-store experience.
2b.1 Realtime
- `$lib/stores/realtimeSync.ts` wraps `postgres_changes` with an `applyItemEvent`
reducer (INSERT/UPDATE/DELETE → next items[]) and a `subscribeToList` helper
that awaits the SUBSCRIBED handshake before returning
- `/lists/[id]` subscribes in onMount, unsubscribes in onDestroy. Uses a
`pendingTempIds` set to deduplicate own-mutation echoes against optimistic
rows (matches by name+created_by+sort_order)
- Client-generated UUIDs for new inserts make the path idempotent: if the
server response is lost and we retry, the second POST hits PK-conflict
which the queue treats as success
- CHECKED section div now carries role="listitem" so Playwright locators
follow the item across sections
2b.2 Offline queue
- `$lib/sync/queue.ts` — SyncQueue class backed by IndexedDB (idb), FIFO flush,
MAX_ATTEMPTS=5 retry budget, PK-conflict-as-success short-circuit
- `$lib/sync/index.ts` — app-wide singleton, window.online listener flushes
automatically, hydrateSyncState() at page load restores pendingOpsCount
- `$lib/stores/syncStatus.ts` — derived store (offline | syncing | synced)
tracking navigator.onLine + queue depth
- SyncBanner component renders the offline/syncing indicator in the detail
and session views
- handleAdd enqueues on failure instead of reverting, so an offline mutation
keeps its optimistic row and syncs on reconnect
2b.3 Modo Compra
- `/lists/[id]/session/+page.svelte` — full-screen overlay (fixed inset-0
z-50) that covers the sidebar while keeping the normal auth routing.
56×56 toggles, flip animation shuffling items between TO BUY / CHECKED,
Finish Shopping confirmation modal → completeList → goto('/lists')
- Link from `/lists/[id]` header (data-testid=start-session) with the
new `list_start_session` message
Testing infra
- apps/web gets its own Vitest config (jsdom + fake-indexeddb/auto) and a
new `pnpm test:unit` script. `just test-all` now chains pgTAP → integration
→ unit → e2e so a single command is the gate.
- packages/test-utils/tests/sync-queue.test.ts (placeholder scaffold) removed —
replaced by `apps/web/src/lib/sync/queue.test.ts` co-located with the module
Totals: 103 tests green
16 pgTAP
59 Vitest integration (in packages/test-utils)
6 Vitest unit (in apps/web — the SyncQueue)
22 Playwright E2E (5 auth + 4 lists + 6 items + 2 realtime + 2 offline + 3 session)
2 skipped (realtime-presence — upstream bug, unchanged)
Documented in plan/fase-2b and CLAUDE.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
75
apps/web/src/lib/sync/index.ts
Normal file
75
apps/web/src/lib/sync/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 } 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user