diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 949b26036..345b268ae 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -35,7 +35,7 @@ The layering is **component → feature hook → store/slice → `repo/` → `ap - **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data. - **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/.ts`, not the god module. - **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache. -- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as `failed` or `conflict` holds back the later writes to the same `resource` + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers). +- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as `failed` or `conflict` holds back the later writes to the same `resource` + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). `flush()` takes the Web Lock `trek-mutation-flush:` so only one tab replays at a time (the in-tab flag alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers). ## Rules for new code diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index ff52ce302..e06dcd860 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -41,6 +41,33 @@ export function generateUUID(): string { } let _flushing = false + +/** + * Take the cross-tab flush lock, or learn that another tab holds it. Every open + * tab of the account shares the queue and runs its own triggers, and the + * _flushing flag only covers its own tab: two tabs read the same pending rows + * and replay each of them twice, leaning on the server's replay cache to answer + * the second. Resolves to the release function, or null when another tab is + * flushing (that flush picks the rows up). Where the Web Locks API is missing + * or refuses the request, the flush runs as before, guarded by the flag alone. + * The lock goes with the tab, so a tab killed mid-flush never strands it. + */ +function acquireFlushLock(): Promise<(() => void) | null> { + const noop = () => {} + const locks = typeof navigator === 'undefined' ? undefined : navigator.locks + if (!locks || typeof locks.request !== 'function') return Promise.resolve(noop) + return new Promise(resolve => { + locks + .request(`trek-mutation-flush:${offlineDb.name}`, { ifAvailable: true }, lock => { + if (!lock) { + resolve(null) + return undefined + } + return new Promise(release => resolve(release)) + }) + .catch(() => resolve(noop)) + }) +} // A row sits on 'syncing' only while its request is in flight, and the shared // axios instance times out at 8s. Anything still 'syncing' a minute later // belongs to a flush that never reached its catch — the tab was killed, the PWA @@ -216,6 +243,11 @@ export const mutationQueue = { async flush(): Promise { if (_flushing || isEffectivelyOffline() || !isAuthed()) return _flushing = true + const release = await acquireFlushLock() + if (!release) { + _flushing = false + return + } // tempId → realId learned during this flush, so a dependent edit/delete // queued against an offline-created entity (still holding the negative id) // can be rewritten to the server id before it is replayed. @@ -454,6 +486,7 @@ export const mutationQueue = { } } } finally { + release() _flushing = false } // A "mine wins" auto-resolution dropped its base token; one more pass now diff --git a/client/tests/unit/sync/mutationQueue.test.ts b/client/tests/unit/sync/mutationQueue.test.ts index 77ac4a847..ab1e3743e 100644 --- a/client/tests/unit/sync/mutationQueue.test.ts +++ b/client/tests/unit/sync/mutationQueue.test.ts @@ -714,3 +714,57 @@ describe('mutationQueue: writes to one entity keep their order around a parked o expect(await offlineDb.mutationQueue.count()).toBe(0); }); }); + +describe('mutationQueue.flush: one tab at a time', () => { + /** A Web Locks stand-in: one holder per name, `ifAvailable` answers null when taken. */ + function fakeLocks() { + const held = new Set(); + const names: string[] = []; + const request = vi.fn(async (name: string, _opts: { ifAvailable?: boolean }, cb: (lock: { name: string } | null) => unknown) => { + names.push(name); + if (held.has(name)) return cb(null); + held.add(name); + try { return await cb({ name }); } finally { held.delete(name); } + }); + Object.defineProperty(navigator, 'locks', { value: { request }, configurable: true }); + return { held, names, request }; + } + + afterEach(() => { + Reflect.deleteProperty(navigator, 'locks'); + }); + + it('leaves the queue to the tab that holds the lock', async () => { + const locks = fakeLocks(); + locks.held.add(`trek-mutation-flush:${offlineDb.name}`); + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + let sent = 0; + server.use(http.post('/api/trips/1/places', () => { sent++; return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 61 }) }); })); + + await mutationQueue.flush(); + expect(sent).toBe(0); + expect(await offlineDb.mutationQueue.get(id)).toMatchObject({ status: 'pending' }); + + // The other tab is done: the next trigger here takes the lock and sends it. + locks.held.clear(); + await mutationQueue.flush(); + expect(sent).toBe(1); + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + expect(locks.held.size).toBe(0); + }); + + it('flushes under the flag alone when the lock request is refused', async () => { + Object.defineProperty(navigator, 'locks', { + value: { request: vi.fn().mockRejectedValue(new DOMException('denied', 'SecurityError')) }, + configurable: true, + }); + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + server.use(http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 62 }) }))); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + }); +});