diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 02328f250..a072bf761 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -51,6 +51,13 @@ export interface QueuedMutation { conflictServer?: unknown; /** When the conflict was detected (for ordering / display). */ conflictAt?: number; + /** + * When the row was marked 'syncing'. Only meaningful while it is — the flush + * that set it clears the row on success or moves it off 'syncing' on failure. + * A stamp that outlives its flush is how a killed tab is recognised on the + * next one (see mutationQueue's STUCK_SYNCING_MS). + */ + syncingSince?: number; } export interface SyncMeta { diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 43112821b..ab7035107 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -39,6 +39,13 @@ export function generateUUID(): string { } let _flushing = false +// 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 +// was evicted, the device slept mid-request. flush() only ever selects +// 'pending', so without recovery that row is stranded: counted in the badge, +// never replayed, and blocking every dependent behind it. +const STUCK_SYNCING_MS = 60_000 // Monotonically increasing timestamp so same-millisecond enqueues // still get a deterministic FIFO order when sorted by createdAt. let _lastTs = 0 @@ -121,14 +128,25 @@ export const mutationQueue = { // without its base token, so one more pass overwrites the server cleanly. let needsRetry = false try { + // Reclaim what an interrupted flush left behind before picking up work. + // Replaying is safe: the mutation id doubles as the X-Idempotency-Key, so + // a write the server already applied is answered from its replay cache. + const stuckBefore = Date.now() - STUCK_SYNCING_MS + await offlineDb.mutationQueue + .where('status') + .equals('syncing') + .filter(m => (m.syncingSince ?? 0) < stuckBefore) + .modify(m => { m.status = 'pending'; m.syncingSince = undefined }) + const pending = await offlineDb.mutationQueue .where('status') .equals('pending') .sortBy('createdAt') for (const mutation of pending) { - // Mark as syncing so UI can show progress - await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' }) + // Mark as syncing so UI can show progress. The stamp is what lets the + // next flush tell an in-flight row from one a killed tab abandoned. + await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing', syncingSince: Date.now() }) // Resolve a temp-id reference now that earlier CREATEs in this flush // may have completed (FIFO order guarantees the CREATE ran first). diff --git a/client/tests/unit/sync/mutationQueue.test.ts b/client/tests/unit/sync/mutationQueue.test.ts index 8e7868acf..19cfc6a5d 100644 --- a/client/tests/unit/sync/mutationQueue.test.ts +++ b/client/tests/unit/sync/mutationQueue.test.ts @@ -241,6 +241,47 @@ describe('mutationQueue.flush — offline guard', () => { // ── pending / pendingCount ──────────────────────────────────────────────────── +describe('mutationQueue.flush — interrupted flush recovery', () => { + it('replays a row an earlier flush abandoned on syncing', async () => { + let seen = 0; + server.use( + http.post('/api/trips/1/places', () => { + seen += 1; + return HttpResponse.json({ place: buildPlace({ id: 42 }) }); + }), + ); + + // What a killed tab leaves behind: marked syncing, stamped long enough ago + // that no live request could still be in flight. + const id = generateUUID(); + await offlineDb.mutationQueue.add({ + id, tripId: 1, method: 'POST', url: '/trips/1/places', + body: { name: 'Eiffel Tower' }, resource: 'places', + createdAt: Date.now() - 600_000, status: 'syncing', attempts: 0, lastError: null, + syncingSince: Date.now() - 600_000, + }); + + await mutationQueue.flush(); + + expect(seen).toBe(1); + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + }); + + it('leaves a freshly marked syncing row alone (a concurrent flush owns it)', async () => { + const id = generateUUID(); + await offlineDb.mutationQueue.add({ + id, tripId: 1, method: 'POST', url: '/trips/1/places', + body: { name: 'Eiffel Tower' }, resource: 'places', + createdAt: Date.now(), status: 'syncing', attempts: 0, lastError: null, + syncingSince: Date.now(), + }); + + await mutationQueue.flush(); + + expect((await offlineDb.mutationQueue.get(id))!.status).toBe('syncing'); + }); +}); + describe('mutationQueue.pending', () => { it('returns pending mutations for a trip', async () => { const id1 = generateUUID();