From c63ec5e2858bcbde46349771a5119fc0b3b300a2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 11:57:06 +0200 Subject: [PATCH] fix(offline): a flush asked for during a running pass gets its own pass An online edit that has to queue behind an older write to the same entity starts a flush so it goes out right after that write. When the older write was already in flight, flush() returned at once on the in-tab flag, and the running pass had read its pending rows before the new one existed. The edit then sat in the queue until the next trigger (the 30 second tick, a visibility change or a reconnect), so collaborators saw it late and a refresh in that window showed the older server value. A flush called while a pass runs now asks that pass for one more round, which it starts once it is done, next to the existing "mine wins" re-run. Each pass clears the request when it starts, so the extra rounds stay bounded by the calls made during a pass. --- client/src/repo/assignmentRepo.test.ts | 22 ++++++++++++++++++++++ client/src/sync/mutationQueue.ts | 25 +++++++++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/client/src/repo/assignmentRepo.test.ts b/client/src/repo/assignmentRepo.test.ts index 546e24de1..91682effa 100644 --- a/client/src/repo/assignmentRepo.test.ts +++ b/client/src/repo/assignmentRepo.test.ts @@ -120,6 +120,28 @@ describe('an online edit of a visit with an older write still queued', () => { expect(sentTimes()).toEqual(['07:00', '09:00']) }) + it('goes out once the older one lands even when that one was already on its way', async () => { + await queueFirst('pending') + // The older time is in flight when the new one is made: the running pass has + // already read its rows, so it must run once more for the new one. + let edited: Promise | undefined + vi.mocked(apiClient.request).mockImplementation(async ({ data }) => { + const sent = (data as typeof first).place_time + if (sent === '07:00') { + edited = assignmentRepo.setTimes(9, assignment, second) + await edited + } + return { data: { assignment: { ...assignment, assignment_time: sent } } } + }) + + await mutationQueue.flush() + + expect(edited).toBeDefined() + expect(assignmentsApi.updateTime).not.toHaveBeenCalled() + expect(sentTimes()).toEqual(['07:00', '09:00']) + expect(await offlineDb.mutationQueue.count()).toBe(0) + }) + it('holds a day clear behind an older clear of the same day', async () => { await assignmentRepo.clearDay(9, 1) await offlineDb.mutationQueue.toCollection().modify({ status: 'failed', attempts: 8 }) diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index c16539dbe..a2193cdcc 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -41,6 +41,10 @@ export function generateUUID(): string { } let _flushing = false +// Set when flush() is called while a pass is already running. That pass read its +// pending rows when it began, so a write queued since then waits for one more +// pass, which the running one starts once it is done. +let _flushAgain = false /** * Take the cross-tab flush lock, waiting while another tab holds it. Every open @@ -255,7 +259,8 @@ export const mutationQueue = { /** * Start a flush for a write that joined the queue while online (see * mustQueue). It goes out as soon as the write it waits for is through, not - * at the next trigger. Offline this does nothing. + * at the next trigger: when a flush is already running, that flush runs one + * more pass once it is done (see flush). Offline this does nothing. */ sendSoon(): void { if (isEffectivelyOffline()) return @@ -270,10 +275,19 @@ export const mutationQueue = { * failed once it has failed MAX_SERVER_ERROR_ATTEMPTS times. A write parked as * failed or as a conflict holds back the later writes to the same entity, so * they keep their order whatever the user decides about it. + * + * A call that arrives while a pass is running returns at once, but asks that + * pass for one more round: the running pass read the pending rows when it + * began and would not see a write queued since. */ async flush(): Promise { - if (_flushing || isEffectivelyOffline() || !isAuthed()) return + if (isEffectivelyOffline() || !isAuthed()) return + if (_flushing) { + _flushAgain = true + return + } _flushing = true + _flushAgain = false const release = await acquireFlushLock() // tempId → realId learned during this flush, so a dependent edit/delete // queued against an offline-created entity (still holding the negative id) @@ -518,8 +532,10 @@ export const mutationQueue = { } // A "mine wins" auto-resolution dropped its base token; one more pass now // overwrites the server unconditionally. Bounded: the retried write carries - // no token, so it cannot 409 for the same reason. - if (needsRetry && !isEffectivelyOffline()) { + // no token, so it cannot 409 for the same reason. A flush asked for while + // this pass ran gets its pass here too. Bounded as well: the flag is only set + // by a call made during a pass, and every pass clears it when it starts. + if ((needsRetry || _flushAgain) && !isEffectivelyOffline()) { await this.flush() } }, @@ -633,6 +649,7 @@ export const mutationQueue = { /** Reset internal flushing flag and timestamp counters — useful in tests. */ _resetFlushing(): void { _flushing = false + _flushAgain = false _lastTs = 0 _lastTempId = 0 },