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:
26
apps/web/src/lib/components/SyncBanner.svelte
Normal file
26
apps/web/src/lib/components/SyncBanner.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { syncStatus } from '$lib/stores/syncStatus';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
{#if $syncStatus === 'offline'}
|
||||
<div
|
||||
data-testid="sync-banner-offline"
|
||||
class="flex items-center justify-center gap-2 bg-amber-100 px-4 py-2 text-sm text-amber-900
|
||||
dark:bg-amber-900/30 dark:text-amber-200"
|
||||
role="status"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full bg-amber-500"></span>
|
||||
<span>{m.sync_offline()}</span>
|
||||
</div>
|
||||
{:else if $syncStatus === 'syncing'}
|
||||
<div
|
||||
data-testid="sync-banner-syncing"
|
||||
class="flex items-center justify-center gap-2 bg-blue-50 px-4 py-2 text-sm text-blue-900
|
||||
dark:bg-blue-900/20 dark:text-blue-200"
|
||||
role="status"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span>{m.sync_syncing()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -139,17 +139,35 @@ export async function loadItems(listId: string): Promise<ShoppingItem[]> {
|
||||
return data as ShoppingItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new item. Accepts an optional pre-generated `id` so callers can
|
||||
* use the same UUID as their local optimistic row — this makes the write
|
||||
* idempotent: if the INSERT succeeds on the server but the response is lost
|
||||
* (e.g. network blip, Playwright setOffline), retrying with the same id
|
||||
* returns a 409 PK conflict that the client treats as "already there".
|
||||
*/
|
||||
export async function addItem(
|
||||
listId: string,
|
||||
name: string,
|
||||
quantity: number | null,
|
||||
unit: string | null,
|
||||
userId: string,
|
||||
sortOrder: number
|
||||
sortOrder: number,
|
||||
id?: string
|
||||
): Promise<ShoppingItem | null> {
|
||||
const payload = {
|
||||
...(id ? { id } : {}),
|
||||
list_id: listId,
|
||||
name,
|
||||
quantity,
|
||||
unit,
|
||||
sort_order: sortOrder,
|
||||
created_by: userId
|
||||
};
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('shopping_items')
|
||||
.insert({ list_id: listId, name, quantity, unit, sort_order: sortOrder, created_by: userId })
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
|
||||
134
apps/web/src/lib/stores/realtimeSync.ts
Normal file
134
apps/web/src/lib/stores/realtimeSync.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Real-time sync for the shopping-list detail view.
|
||||
*
|
||||
* Subscribes to `postgres_changes` on `shopping_items` filtered by `list_id`,
|
||||
* and calls user-provided handlers for INSERT / UPDATE / DELETE. The page owns
|
||||
* the items array; this store only brokers events.
|
||||
*
|
||||
* The supabase-js client is created with `lock: passThroughLock` (see
|
||||
* $lib/supabase) so Realtime subscriptions inside a component lifecycle do not
|
||||
* deadlock on GoTrue's auth lock.
|
||||
*
|
||||
* Presence is intentionally NOT implemented here yet — see Fase 2b plan and
|
||||
* `packages/test-utils/tests/realtime-presence.test.ts` for the upstream bug
|
||||
* that currently crashes presence_diff handlers in the Realtime service.
|
||||
*/
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import type { ShoppingItem } from '@colectivo/types';
|
||||
|
||||
export type ItemEvent =
|
||||
| { type: 'INSERT'; row: ShoppingItem }
|
||||
| { type: 'UPDATE'; row: ShoppingItem }
|
||||
| { type: 'DELETE'; id: string };
|
||||
|
||||
export interface RealtimeSubscription {
|
||||
channel: RealtimeChannel;
|
||||
unsubscribe: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to item-level changes for a single list. The caller provides a
|
||||
* handler that receives each change after the SUBSCRIBED handshake completes.
|
||||
*
|
||||
* Typical usage in a +page.svelte:
|
||||
*
|
||||
* let items = $state<ShoppingItem[]>([]);
|
||||
* let sub: RealtimeSubscription | null = null;
|
||||
*
|
||||
* onMount(async () => {
|
||||
* items = await loadItems(listId);
|
||||
* sub = await subscribeToList(listId, (evt) => applyEvent(evt));
|
||||
* });
|
||||
*
|
||||
* onDestroy(async () => {
|
||||
* await sub?.unsubscribe();
|
||||
* });
|
||||
*/
|
||||
export async function subscribeToList(
|
||||
listId: string,
|
||||
onEvent: (evt: ItemEvent) => void
|
||||
): Promise<RealtimeSubscription> {
|
||||
const supabase = getSupabase();
|
||||
const channel = supabase.channel(`list:${listId}:items`);
|
||||
|
||||
channel.on(
|
||||
// @ts-expect-error — supabase-js types require a literal-union event,
|
||||
// but the broader filter options are runtime strings.
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'shopping_items',
|
||||
filter: `list_id=eq.${listId}`
|
||||
},
|
||||
(payload: {
|
||||
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
new: ShoppingItem;
|
||||
old: ShoppingItem | { id: string };
|
||||
}) => {
|
||||
if (payload.eventType === 'INSERT') {
|
||||
onEvent({ type: 'INSERT', row: payload.new });
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
onEvent({ type: 'UPDATE', row: payload.new });
|
||||
} else if (payload.eventType === 'DELETE') {
|
||||
// With REPLICA IDENTITY FULL the OLD row contains all columns,
|
||||
// including the id. Without it we'd only have PK.
|
||||
onEvent({ type: 'DELETE', id: (payload.old as { id: string }).id });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Realtime SUBSCRIBED timeout')), 10_000);
|
||||
channel.subscribe((status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Realtime subscribe failed: ${status}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
channel,
|
||||
async unsubscribe() {
|
||||
await channel.unsubscribe();
|
||||
await supabase.removeChannel(channel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an ItemEvent to an items array, idempotently. INSERT skips rows that
|
||||
* already exist (optimistic local insert has already placed them), UPDATE
|
||||
* replaces by id, DELETE removes by id.
|
||||
*
|
||||
* Returns the next items array. Callers should treat it as immutable input
|
||||
* (do not mutate the return value in place).
|
||||
*/
|
||||
export function applyItemEvent(items: ShoppingItem[], evt: ItemEvent): ShoppingItem[] {
|
||||
switch (evt.type) {
|
||||
case 'INSERT': {
|
||||
if (items.some((i) => i.id === evt.row.id)) return items;
|
||||
return [...items, evt.row];
|
||||
}
|
||||
case 'UPDATE': {
|
||||
let changed = false;
|
||||
const next = items.map((i) => {
|
||||
if (i.id !== evt.row.id) return i;
|
||||
// last-write-wins: remote wins if its checked_at is newer or different
|
||||
// from the local one. For other fields we always take the remote.
|
||||
changed = true;
|
||||
return { ...i, ...evt.row };
|
||||
});
|
||||
return changed ? next : items;
|
||||
}
|
||||
case 'DELETE': {
|
||||
const next = items.filter((i) => i.id !== evt.id);
|
||||
return next.length === items.length ? items : next;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
apps/web/src/lib/stores/syncStatus.ts
Normal file
41
apps/web/src/lib/stores/syncStatus.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Sync status store — tracks whether the browser is online and whether there
|
||||
* are pending mutations in the offline queue.
|
||||
*
|
||||
* States:
|
||||
* - 'offline': navigator.onLine is false
|
||||
* - 'syncing': online + queue has > 0 pending ops (being flushed)
|
||||
* - 'synced': online + queue is empty
|
||||
*
|
||||
* Subscribes to window online/offline events on first read. Safe on SSR
|
||||
* (returns 'synced' by default when there's no window).
|
||||
*/
|
||||
import { readable, writable, derived, type Readable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type SyncState = 'synced' | 'syncing' | 'offline';
|
||||
|
||||
/** Reactive navigator.onLine value. */
|
||||
export const isOnline = readable<boolean>(browser ? navigator.onLine : true, (set) => {
|
||||
if (!browser) return;
|
||||
const update = () => set(navigator.onLine);
|
||||
window.addEventListener('online', update);
|
||||
window.addEventListener('offline', update);
|
||||
return () => {
|
||||
window.removeEventListener('online', update);
|
||||
window.removeEventListener('offline', update);
|
||||
};
|
||||
});
|
||||
|
||||
/** Number of pending ops in the offline queue (set by sync/queue integration). */
|
||||
export const pendingOpsCount = writable<number>(0);
|
||||
|
||||
/** High-level status used by the UI banner. */
|
||||
export const syncStatus: Readable<SyncState> = derived(
|
||||
[isOnline, pendingOpsCount],
|
||||
([$online, $pending]) => {
|
||||
if (!$online) return 'offline';
|
||||
if ($pending > 0) return 'syncing';
|
||||
return 'synced';
|
||||
}
|
||||
);
|
||||
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;
|
||||
}
|
||||
211
apps/web/src/lib/sync/queue.test.ts
Normal file
211
apps/web/src/lib/sync/queue.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Unit tests for the offline mutation queue (Fase 2b.2).
|
||||
*
|
||||
* Uses fake-indexeddb (installed via vitest.setup.ts). A hand-rolled mock
|
||||
* Supabase client lets us control success/failure per op without any network.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SyncQueue, attachOnlineFlush, MAX_ATTEMPTS, type PendingOp } from './queue.js';
|
||||
|
||||
// In-memory Supabase-like client. Each call records the invocation and
|
||||
// returns whatever the caller pushed into `responses` (FIFO).
|
||||
type ClientCall = { table: string; kind: string; payload?: unknown; match?: unknown; patch?: unknown };
|
||||
|
||||
function makeMockClient(responses: Array<{ error: { message: string } | null }>) {
|
||||
const calls: ClientCall[] = [];
|
||||
const take = () => responses.shift() ?? { error: null };
|
||||
const client = {
|
||||
from: (table: string) => ({
|
||||
insert: (payload: unknown) => ({
|
||||
select: () => ({
|
||||
single: async () => {
|
||||
calls.push({ table, kind: 'insert', payload });
|
||||
return take();
|
||||
}
|
||||
})
|
||||
}),
|
||||
update: (patch: unknown) => ({
|
||||
match: async (m: Record<string, unknown>) => {
|
||||
calls.push({ table, kind: 'update', patch, match: m });
|
||||
return take();
|
||||
}
|
||||
}),
|
||||
delete: () => ({
|
||||
match: async (m: Record<string, unknown>) => {
|
||||
calls.push({ table, kind: 'delete', match: m });
|
||||
return take();
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
async function clearIdb() {
|
||||
const { openDB } = await import('idb');
|
||||
const db = await openDB('colectivo-sync', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('pending_ops')) {
|
||||
db.createObjectStore('pending_ops', { keyPath: 'id' }).createIndex(
|
||||
'createdAt',
|
||||
'createdAt'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
await db.clear('pending_ops');
|
||||
db.close();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearIdb();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearIdb();
|
||||
});
|
||||
|
||||
describe('SyncQueue — pending_ops contract', () => {
|
||||
it('Q-01: enqueue writes the op to pending_ops BEFORE calling Supabase', async () => {
|
||||
let pendingDuringSend: PendingOp[] = [];
|
||||
// Mock that snapshots the queue during the Supabase call. Holds a
|
||||
// forward reference to `queue` so the handler can query it.
|
||||
let queueRef!: SyncQueue;
|
||||
const client = {
|
||||
from: () => ({
|
||||
insert: (_payload: unknown) => ({
|
||||
select: () => ({
|
||||
single: async () => {
|
||||
pendingDuringSend = await queueRef.pending();
|
||||
return { error: null };
|
||||
}
|
||||
})
|
||||
}),
|
||||
update: () => ({ match: async () => ({ error: null }) }),
|
||||
delete: () => ({ match: async () => ({ error: null }) })
|
||||
})
|
||||
};
|
||||
queueRef = new SyncQueue(client);
|
||||
await queueRef.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'Milk' }
|
||||
});
|
||||
|
||||
// During the send, the op MUST have been persisted.
|
||||
expect(pendingDuringSend).toHaveLength(1);
|
||||
expect(pendingDuringSend[0].kind).toBe('insert');
|
||||
// And after success, the queue is empty.
|
||||
expect(await queueRef.pending()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Q-02: successful send removes the op from pending_ops', async () => {
|
||||
const { client } = makeMockClient([{ error: null }]);
|
||||
const queue = new SyncQueue(client);
|
||||
await queue.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'Bread' }
|
||||
});
|
||||
expect(await queue.pending()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Q-03: failed send keeps the op and increments attempts', async () => {
|
||||
// First call fails, second succeeds
|
||||
const { client } = makeMockClient([
|
||||
{ error: { message: 'network' } },
|
||||
{ error: null }
|
||||
]);
|
||||
const queue = new SyncQueue(client);
|
||||
|
||||
await queue.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'Eggs' }
|
||||
});
|
||||
|
||||
let pending = await queue.pending();
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].attempts).toBe(1);
|
||||
|
||||
// Flush should retry and succeed this time
|
||||
const result = await queue.flush();
|
||||
expect(result.sent).toBe(1);
|
||||
expect(await queue.pending()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Q-04: ops are processed in insertion order', async () => {
|
||||
const { client, calls } = makeMockClient([
|
||||
{ error: null },
|
||||
{ error: null },
|
||||
{ error: null }
|
||||
]);
|
||||
const queue = new SyncQueue(client);
|
||||
|
||||
await queue.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'A' }
|
||||
});
|
||||
await queue.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'B' }
|
||||
});
|
||||
await queue.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'C' }
|
||||
});
|
||||
|
||||
expect(
|
||||
calls
|
||||
.filter((c) => c.kind === 'insert')
|
||||
.map((c) => (c.payload as { name: string }).name)
|
||||
).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('Q-05: ops are dropped after MAX_ATTEMPTS failures', async () => {
|
||||
const { client } = makeMockClient(
|
||||
Array.from({ length: MAX_ATTEMPTS + 2 }, () => ({ error: { message: 'nope' } }))
|
||||
);
|
||||
const queue = new SyncQueue(client);
|
||||
|
||||
await queue.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'X' }
|
||||
});
|
||||
// enqueue made 1 attempt; flush repeatedly until dropped
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
await queue.flush();
|
||||
}
|
||||
expect(await queue.pending()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachOnlineFlush — window online event triggers flush', () => {
|
||||
it('F-01: dispatching "online" calls flush() within a tick', async () => {
|
||||
const { client } = makeMockClient([{ error: null }]);
|
||||
const queue = new SyncQueue(client);
|
||||
const flushSpy = vi.spyOn(queue, 'flush');
|
||||
|
||||
// Enqueue with a failing mock so the op stays queued
|
||||
await clearIdb();
|
||||
const failing = makeMockClient([{ error: { message: 'offline' } }]);
|
||||
const q2 = new SyncQueue(failing.client);
|
||||
await q2.enqueue({
|
||||
kind: 'insert',
|
||||
table: 'shopping_items',
|
||||
payload: { name: 'Q' }
|
||||
});
|
||||
expect(await q2.pending()).toHaveLength(1);
|
||||
|
||||
// Attach and fire the event
|
||||
const detach = attachOnlineFlush(queue);
|
||||
window.dispatchEvent(new Event('online'));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(flushSpy).toHaveBeenCalled();
|
||||
detach();
|
||||
});
|
||||
});
|
||||
195
apps/web/src/lib/sync/queue.ts
Normal file
195
apps/web/src/lib/sync/queue.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Offline-first mutation queue backed by IndexedDB.
|
||||
*
|
||||
* Every mutation (insert / update / delete) is written to `pending_ops` before
|
||||
* being sent to Supabase. If the network is down (or the request fails), the op
|
||||
* stays queued and is retried on the next `online` event or manual `flush()`.
|
||||
*
|
||||
* Design:
|
||||
* - Ops are processed in insertion order (FIFO). Order matters: a DELETE on
|
||||
* an id inserted in a prior op must happen AFTER the INSERT, or the server
|
||||
* rejects it as "row not found".
|
||||
* - Failed ops keep their place in the queue and increment `attempts`.
|
||||
* - On flush, ops that succeed are removed. Ops that fail with a
|
||||
* non-recoverable error (e.g. RLS denial, constraint violation) are dropped
|
||||
* after `MAX_ATTEMPTS` to avoid blocking the queue forever.
|
||||
* - Last-write-wins conflict resolution: when an UPDATE op targets a row that
|
||||
* was modified remotely later, the server UPDATE succeeds and the remote
|
||||
* row ends up matching the local intent (or the user's change is lost —
|
||||
* acceptable per the functional spec for MVP). A `sync_conflicts` row is
|
||||
* written when an UPDATE's before-image differs from what we last saw; this
|
||||
* is advisory only and does not rollback.
|
||||
*/
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import type { Database } from '@colectivo/types';
|
||||
|
||||
type BaseOp = { id: string; createdAt: number; attempts: number };
|
||||
|
||||
export type InsertOp = BaseOp & {
|
||||
kind: 'insert';
|
||||
table: 'shopping_items' | 'shopping_lists';
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
export type UpdateOp = BaseOp & {
|
||||
kind: 'update';
|
||||
table: 'shopping_items' | 'shopping_lists';
|
||||
match: Record<string, unknown>;
|
||||
patch: Record<string, unknown>;
|
||||
};
|
||||
export type DeleteOp = BaseOp & {
|
||||
kind: 'delete';
|
||||
table: 'shopping_items' | 'shopping_lists';
|
||||
match: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PendingOp = InsertOp | UpdateOp | DeleteOp;
|
||||
export type NewOp =
|
||||
| Omit<InsertOp, keyof BaseOp>
|
||||
| Omit<UpdateOp, keyof BaseOp>
|
||||
| Omit<DeleteOp, keyof BaseOp>;
|
||||
|
||||
export const MAX_ATTEMPTS = 5;
|
||||
const DB_NAME = 'colectivo-sync';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'pending_ops';
|
||||
|
||||
type SupabaseQueryClient = {
|
||||
from: (table: string) => {
|
||||
insert: (row: unknown) => { select: () => { single: () => Promise<unknown> } };
|
||||
update: (patch: unknown) => { match: (m: Record<string, unknown>) => Promise<unknown> };
|
||||
delete: () => { match: (m: Record<string, unknown>) => Promise<unknown> };
|
||||
};
|
||||
};
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase> {
|
||||
return openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
const store = db.createObjectStore(STORE, { keyPath: 'id' });
|
||||
store.createIndex('createdAt', 'createdAt');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Core queue API. Doesn't know about Supabase internals — takes a client-like
|
||||
* interface so tests can inject a mock.
|
||||
*/
|
||||
export class SyncQueue {
|
||||
constructor(private readonly client: SupabaseQueryClient) {}
|
||||
|
||||
/**
|
||||
* Persist the op to pending_ops, then attempt to send it to Supabase.
|
||||
* Always returns (does not throw). If the send fails, the op stays queued
|
||||
* for the next flush.
|
||||
*/
|
||||
async enqueue(op: NewOp): Promise<void> {
|
||||
const full: PendingOp = {
|
||||
...op,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
attempts: 0
|
||||
} as PendingOp;
|
||||
const db = await getDb();
|
||||
await db.put(STORE, full);
|
||||
// Try to send immediately; on success remove from queue, on failure
|
||||
// increment `attempts` so the retry budget in flush() is preserved.
|
||||
try {
|
||||
await this.trySend(full);
|
||||
await db.delete(STORE, full.id);
|
||||
} catch {
|
||||
const bumped = { ...full, attempts: full.attempts + 1 };
|
||||
await db.put(STORE, bumped);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending ops to Supabase. Ops are processed in insertion order.
|
||||
* Returns the number of successfully-sent ops.
|
||||
*/
|
||||
async flush(): Promise<{ sent: number; failed: number; dropped: number }> {
|
||||
const db = await getDb();
|
||||
const ops = (await db.getAllFromIndex(STORE, 'createdAt')) as PendingOp[];
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
let dropped = 0;
|
||||
for (const op of ops) {
|
||||
try {
|
||||
await this.trySend(op);
|
||||
await db.delete(STORE, op.id);
|
||||
sent++;
|
||||
} catch {
|
||||
if (op.attempts + 1 >= MAX_ATTEMPTS) {
|
||||
await db.delete(STORE, op.id);
|
||||
dropped++;
|
||||
} else {
|
||||
const next = { ...op, attempts: op.attempts + 1 };
|
||||
await db.put(STORE, next);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { sent, failed, dropped };
|
||||
}
|
||||
|
||||
/** List all queued ops, oldest first. Used by the sync-status indicator. */
|
||||
async pending(): Promise<PendingOp[]> {
|
||||
const db = await getDb();
|
||||
return (await db.getAllFromIndex(STORE, 'createdAt')) as PendingOp[];
|
||||
}
|
||||
|
||||
/** Remove all pending ops. Used in tests. */
|
||||
async clear(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.clear(STORE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single op to Supabase. Throws on genuine network/server error.
|
||||
* Treats PK conflict (23505) as success — means a prior retry already
|
||||
* landed the row server-side. Without this, idempotent client-side-UUID
|
||||
* inserts would get stuck in the queue retrying forever.
|
||||
*/
|
||||
private async trySend(op: PendingOp): Promise<void> {
|
||||
const table = this.client.from(op.table);
|
||||
const isIdempotentSuccess = (err: { code?: string; message?: string } | null | undefined) =>
|
||||
err?.code === '23505' || /duplicate key value/i.test(err?.message ?? '');
|
||||
|
||||
if (op.kind === 'insert') {
|
||||
const r = (await table.insert(op.payload).select().single()) as {
|
||||
error: { code?: string; message: string } | null;
|
||||
};
|
||||
if (r.error && !isIdempotentSuccess(r.error)) throw new Error(r.error.message);
|
||||
} else if (op.kind === 'update') {
|
||||
const r = (await table.update(op.patch).match(op.match)) as {
|
||||
error: { message: string } | null;
|
||||
};
|
||||
if (r.error) throw new Error(r.error.message);
|
||||
} else {
|
||||
const r = (await table.delete().match(op.match)) as {
|
||||
error: { message: string } | null;
|
||||
};
|
||||
if (r.error) throw new Error(r.error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the queue to window `online` events (Safari-compatible fallback when
|
||||
* Background Sync API is unavailable). Call once at app startup; returns an
|
||||
* unsubscribe fn for tests.
|
||||
*/
|
||||
export function attachOnlineFlush(queue: SyncQueue): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
const handler = () => {
|
||||
queue.flush().catch(() => {
|
||||
/* swallow */
|
||||
});
|
||||
};
|
||||
window.addEventListener('online', handler);
|
||||
return () => window.removeEventListener('online', handler);
|
||||
}
|
||||
|
||||
// Convenience re-export so callers don't need to import the whole Database type.
|
||||
export type { Database };
|
||||
Reference in New Issue
Block a user