Offline retry queue for userscript writes #5
Reference in New Issue
Block a user
Delete Branch "feat/offline-retry-queue"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The bug
Every mutation in the userscript is optimistic: it writes
state.listand themangabm:cachecopy, re-renders, then PUTs. If the PUT fails, nothing rolls back and nothing retries. The cache now asserts something the server has never heard of, until the next successfulGET /bookmarkssilently overwrites it.Walked end to end: phone loses signal mid-read, user taps Archive, the card moves to Archived and looks saved.
apiPutthrows. Local state is not rolled back. Signal returns, a later navigation callsrefresh()→apiGet()→setList(), which replacesstate.listwholesale. The series is back in All. No toast, no explanation, minutes later.Four of the five call sites already toasted a promise of a retry that did not exist. This makes the existing copy honest rather than adding a new promise.
The shape
Markers, not payloads. Every mutation already builds and PUTs the whole desired row, and
state.list(mirrored intomangabm:cache) already is the desired state. So the queue stores only{key, op, sendStatus, attempts}inlocalStorageundermangabm:queue; the body is read fromstate.byKeyat send time. That collapses four hard questions at once:reading". Nothing to merge.One write path.
pushBookmark/pushDeleteare the only way a user-facing mutation reaches the API — not a fallback bolted onto eachcatch. That distinction is the whole point; see below.Drain triggers, all cheap when the queue is empty (
drain()returns on its first line): head ofrefresh(),onNavigate(drain only, not a full refresh — Asura is client-routed and an extra GET per route change is not wanted), awindowonlinelistener, and tapping the pending chip.Visibility. A
⟳ N pendingchip in the existing#navrow, hidden entirely when the queue is empty. Silent convergence in the happy path; honest the moment something is stuck.Failure classes: a
400drops the entry and says so; a401aborts the whole pass and keeps the queue intact (fixing the token fixes everything); a404on DELETE is treated as success; network errors and5xxretry to a cap of 10 attempts. Every dropped write is announced — a queue that fails permanently and says nothing is the same class of bug being fixed.The sticky-
sendStatushole this closesAn empty
statuson the wire means "keep the stored bucket" server-side. A queue bolted onto each mutation'scatchhas a hole:{X, put, sendStatus: true}is queued.syncUpsertPUTs withsendStatus: false→ succeeds →upsertLocal(saved)adopts a server row that still saysreading.Routing every write through
pushBookmark— which ORs in any pendingsendStatusand only ever widens it, never narrows it — is what closes that. A replayed progress write still omitsstatus; a replayed archive still carries it.refresh()drains before it fetches, thenoverlayPending()re-applies anything still pending over the fetched list beforesetListreplacesstate.byKey, so the card the user just changed never flaps back.applyLatestChapterIfChangeddeliberately stays out of the queue: it is background information the user never asked for, the server-side poller learns the same fact independently, andbackgroundRefreshLatestalready retries on a 4h throttle. Queueing it would let a stale locallatest_chapteroverwrite a fresher poller value on replay.Fixes from the final review (commits 6-8)
The whole-branch review found one Critical and two Important defects that only appear across commit boundaries:
applyLatestChapterIfChangedPUTs withoutsendStatus, so the server stripsstatus, returns the storedreading, andupsertLocal(saved)writes that over a pending archive.onNavigaterunsmaybeCaptureLatestOnSeriesPage()beforedrain()with no await between them, so this was deterministic on any series-page visit, not a race — and the drain then sentstatus:"reading"explicitly, making it permanent. Fixed with a singleif (queueGet(bm.key)) return;guard: the write stays unqueued as designed, it just no longer adopts a server row while a write is pending.latest_chapterstill reaches the server via the drain, carrying the correct bucket.drainingguarded drain-vs-drain but not drain-vs-mutation. A tap during an in-flight same-key PUT started a second concurrent write; whichever response landed second won, and the loser'squeueDropcould delete the entry the tap had just parked. Fixed with per-key in-flight tracking: a write for a key already in flight defers (parks a queue entry, sends nothing, returnsfalseso the caller still toasts), and the landing flight suppresses its ownupsertLocal/queueDropwhen superseded — including on its failure path, so a failing flight cannot clobber a parkedop:"delete"and resurrect a removed bookmark.drain()returnedundefinedwhile already draining, sorefresh()'sawait drain()was a silent no-op and could adopt a pre-write list, flapping the card at boot. It now returns the in-flight promise. The empty-queue fast path is unchanged and still an immediate return.Also:
render()moved out ofpushBookmark'stry(a render throw was re-queueing an already-successful write), and unawaiteddrain()rejections are swallowed.The backend is untouched
No file under
backend/is in this diff. Theupdated_atrule and the empty-status keep rule stay solely inStore.Upsert; nothing client-side duplicates or works around them. A replayed PUT is an ordinary late write under the project's existing last-write-wins model. Regression check:go test ./...isok,CGO_ENABLED=0 go build ./...succeeds.Two accepted losses, marked with
ponytail:comments at the replay site: alatest_chapterthe poller learned while the client was offline can be overwritten by the client's older value (self-healing on the poller's next cooldown), and read progress made on another device between the failed write and the replay can be overwritten (single-user deployment).Verification status — read this before merging
node --checkpasses and every commit was reviewed, but the 15-row manual DevTools checklist has NOT been run. There is no test infrastructure for the userscript, and by design it gains none here — a pasted copy of the logic in a scratch node script would drift from the real file the moment either changed. The author is shipping to prod and verifying there.The rows most worth checking first, because each maps to a specific defect the review caught:
init.op:"delete"→ after reconnecting, X must not reappear.onNavigate.Known limitations, deliberately not fixed here
400drops the entry and toasts, but local state keeps asserting the lost change until the next successfulGET.401on a live tap toasts "will sync when online" rather than the auth message; the user learns the truth on the next drain.mangabm:queue— the queue is read once at boot and each save writes the whole array. Same idiom as the pre-existingsaveCache; low risk on mobile Bromite.🤖 Generated with Claude Code