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:
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Sync queue unit tests (Fase 2b.2).
|
||||
*
|
||||
* These describe the contract of the offline mutation queue that will live at
|
||||
* `apps/web/src/lib/sync/queue.ts`. They are skipped until that module exists.
|
||||
* When implementation lands, remove the `describe.skip` (NOT the inner skips)
|
||||
* and each test should fail red → pass green as features are built.
|
||||
*
|
||||
* Why they're here (not in apps/web): keeping all Vitest tests in one package
|
||||
* for `just test-integration`. If we ever add Vitest to apps/web directly, move
|
||||
* these files over and drop the cross-package import.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe.skip('sync queue — pending_ops contract (Fase 2b.2)', () => {
|
||||
it('Q-01: enqueue writes the op to pending_ops before the Supabase call', async () => {
|
||||
// Given a mocked idb + mocked supabase.from().insert()
|
||||
// When sync.enqueue({ op: "insert", table: "shopping_items", payload })
|
||||
// Then pending_ops should contain the op before the supabase call fires,
|
||||
// and the supabase call should only be awaited after the write.
|
||||
expect(true).toBe(true); // placeholder
|
||||
});
|
||||
|
||||
it('Q-02: successful sync removes the op from pending_ops', async () => {
|
||||
// Given an op in pending_ops
|
||||
// When the supabase call resolves with no error
|
||||
// Then the op should be removed from pending_ops
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('Q-03: failed sync keeps the op in pending_ops for retry', async () => {
|
||||
// Given an op in pending_ops
|
||||
// When the supabase call throws (network error)
|
||||
// Then the op remains, its `attempts` counter increments,
|
||||
// and the promise resolves (does NOT throw) — errors are deferred
|
||||
// so callers can keep working offline.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('Q-04: ops are processed in insertion order on flush', async () => {
|
||||
// Given three ops A, B, C in pending_ops in that order
|
||||
// When flush() runs
|
||||
// Then supabase calls happen in A, B, C order regardless of timing
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('sync flush — online event fallback (Fase 2b.2, Safari-safe)', () => {
|
||||
it('F-01: window "online" event triggers flush()', async () => {
|
||||
// Given pending ops and a mocked window
|
||||
// When dispatch Event("online")
|
||||
// Then flush() is called within 100ms
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('F-02: last-write-wins on conflict — remote updated_at > local', async () => {
|
||||
// Given a local op that would overwrite a remote row with a newer updated_at
|
||||
// When flush runs
|
||||
// Then the local op is DROPPED (not retried) and a sync_conflicts row is written
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('F-03: last-write-wins — local updated_at > remote, local wins', async () => {
|
||||
// Given a local op newer than the remote row
|
||||
// When flush runs
|
||||
// Then the local op is APPLIED normally, no conflict row
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user