fix(offline): replay queued writes an interrupted flush left behind

A flush marks each row 'syncing' before it sends, and only ever picks up
rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA
does exactly this - and the row stays 'syncing' forever: it keeps counting
towards the pending badge, but nothing ever retries it, fails it or shows
it as a conflict. Anything queued behind it that depends on it later fails
with 'unresolved temp id'.

Stamp the row when it goes in flight and reclaim it on the next flush. The
axios instance times out at 8s, so a minute is well past anything that
could still be running, and the mutation id doubles as the idempotency key,
so replaying a write the server already applied is answered from its cache.
This commit is contained in:
Maurice
2026-08-23 09:12:26 +02:00
committed by Maurice
parent e91ad69c83
commit 39c8a22352
3 changed files with 68 additions and 2 deletions
+7
View File
@@ -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 {
+20 -2
View File
@@ -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).
@@ -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();