From bbe04b1e8103ac17e89abaa5a7335b0b99dd2052 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:04:35 +0700 Subject: [PATCH 1/8] feat(userscript): retry queue storage and status-carrying API errors --- userscript/manga-bookmark.user.js | 106 +++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index ee2d1d8..e5bf6a3 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -265,6 +265,14 @@ return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {}); } + // The retry queue treats a 400 differently from a dropped connection, so the + // status has to survive the throw. A network failure leaves it undefined. + function httpError(what, status) { + const e = new Error(what + " " + status); + e.status = status; + return e; + } + async function apiGet() { const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() }); if (!res.ok) throw new Error("GET /bookmarks " + res.status); @@ -283,7 +291,7 @@ headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body), }); - if (!res.ok) throw new Error("PUT /bookmarks " + res.status); + if (!res.ok) throw httpError("PUT /bookmarks", res.status); return res.json(); } @@ -292,7 +300,8 @@ method: "DELETE", headers: authHeaders(), }); - if (!res.ok) throw new Error("DELETE /bookmarks " + res.status); + // 404 means the row is already gone, which is what the caller wanted. + if (!res.ok && res.status !== 404) throw httpError("DELETE /bookmarks", res.status); } // ============================================================ @@ -338,6 +347,99 @@ return b.status || "reading"; } + // ============================================================ + // Retry queue + // + // A failed write is not rolled back and not lost: the key is parked here and + // replayed when the backend is next reachable. Entries carry no payload — the + // body is read from state.byKey at send time, because state.list *is* the + // desired state and is already persisted in mangabm:cache. One entry per key, + // so two writes to the same series cannot replay out of order, an + // archive-then-unarchive collapses to whatever the cache now says, and a + // queued DELETE replaces a queued PUT rather than racing it. + // ============================================================ + + const QUEUE_KEY = "mangabm:queue"; + const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count + const QUEUE_MAX_ATTEMPTS = 10; + + let authFailed = false; // a 401 was seen; retrying cannot help until the token changes + + function loadQueue() { + try { + const raw = localStorage.getItem(QUEUE_KEY); + return raw ? JSON.parse(raw) : []; + } catch (e) { + return []; + } + } + + function saveQueue(q) { + try { + localStorage.setItem(QUEUE_KEY, JSON.stringify(q)); + } catch (e) { + /* quota / private mode — ignore */ + } + } + + const queue = loadQueue(); + + function queueCount() { + return queue.length; + } + + function queueGet(key) { + return queue.find((e) => e.key === key) || null; + } + + function titleFor(key) { + const b = state.byKey[key]; + return (b && (b.title || b.series_id)) || key; + } + + // Oldest goes first when the cap is hit: the newest entry is the action the + // user just took, and dropping that is the bug this queue exists to fix. + function queuePush(entry) { + if (queue.length >= QUEUE_MAX) { + const evicted = queue.shift(); + toast("Sync queue full — dropped " + titleFor(evicted.key), true); + } + queue.push(entry); + return entry; + } + + function queueDrop(key) { + const i = queue.findIndex((e) => e.key === key); + if (i >= 0) { + queue.splice(i, 1); + saveQueue(queue); + } + } + + // Records a failed write and classifies why it failed. A 400 is a payload the + // server will never accept, so it is dropped now instead of being retried ten + // times; a 401 is the wrong token, so the entry is kept untouched and the + // drain gives up until the token changes. + function queueEnqueue(key, op, sendStatus, err) { + const status = err && err.status; + if (status === 400) { + queueDrop(key); + toast("Couldn't sync " + titleFor(key) + " — change lost", true); + return; + } + const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 }); + e.op = op; // a delete replaces a put, and a put replaces a delete + e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped + if (status === 401) { + authFailed = true; + } else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) { + queueDrop(key); + toast("Gave up syncing " + titleFor(key), true); + return; + } + saveQueue(queue); + } + // ============================================================ // Mutations (optimistic: update UI/cache first, then sync) // ============================================================ -- 2.52.0 From 0615617ebea2739eabf5bf4b5277b54240b87b8f Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:08:24 +0700 Subject: [PATCH 2/8] feat(userscript): route every write through the retry queue --- userscript/manga-bookmark.user.js | 79 +++++++++++++++++++------------ 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index e5bf6a3..8a5a2fb 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -444,6 +444,45 @@ // Mutations (optimistic: update UI/cache first, then sync) // ============================================================ + // Every write goes through here, queued or not, so there is one place that + // talks to the API and one place that decides `sendStatus`. That flag is + // sticky: while an archive is pending for a key, a later progress write to the + // same key still carries the bucket — otherwise the server would answer with + // the old status and the adopted row would silently un-archive the series. + async function pushBookmark(key, sendStatus) { + const pending = queueGet(key); + const withStatus = sendStatus || (pending ? pending.sendStatus : false); + const bm = state.byKey[key]; + if (!bm) { + queueDrop(key); // removed locally in the meantime — nothing left to send + return true; + } + try { + // ponytail: last-write-wins, so a replay can overwrite a newer server + // value (a poller-written latest_chapter, or progress from another + // device). Single user, self-healing on the next poll — revisit only if + // this ever runs multi-user. + upsertLocal(await apiPut(key, bm, { sendStatus: withStatus })); + queueDrop(key); + render(); + return true; + } catch (e) { + queueEnqueue(key, "put", withStatus, e); + return false; + } + } + + async function pushDelete(key) { + try { + await apiDelete(key); + queueDrop(key); + return true; + } catch (e) { + queueEnqueue(key, "delete", false, e); + return false; + } + } + async function bookmarkCurrent() { const p = state.page; if (p.type !== "series" && p.type !== "chapter") return; @@ -502,14 +541,8 @@ async function syncUpsert(bm, okMsg) { upsertLocal(bm); // optimistic render(); - try { - const saved = await apiPut(bm.key, bm); - upsertLocal(saved); // adopt server updated_at - render(); - toast(okMsg); - } catch (e) { - toast("Offline — saved locally, will retry", true); - } + if (await pushBookmark(bm.key, false)) toast(okMsg); + else toast("Saved — will sync when online", true); } async function toggleFavorite(key) { @@ -521,13 +554,7 @@ }); upsertLocal(bm); // optimistic render(); - try { - const saved = await apiPut(bm.key, bm); - upsertLocal(saved); - render(); - } catch (e) { - toast("Offline — saved locally, will retry", true); - } + if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", true); } // Archive parks a series: it leaves All and Favourites but the server keeps @@ -537,16 +564,14 @@ const existing = state.byKey[key]; if (!existing) return; const next = statusOf(existing) === "archived" ? "reading" : "archived"; - const bm = Object.assign({}, existing, { status: next }); - upsertLocal(bm); + upsertLocal(Object.assign({}, existing, { status: next })); render(); - try { - const saved = await apiPut(bm.key, bm, { sendStatus: true }); - upsertLocal(saved); // adopt the stored row: the server owns updated_at - render(); + // The only write with an opinion about the bucket, so the only one that + // sends `status` at all. + if (await pushBookmark(key, true)) { toast(next === "archived" ? "Archived" : "Back in your list"); - } catch (e) { - toast("Archive failed — retry when online", true); + } else { + toast((next === "archived" ? "Archived" : "Restored") + " — will sync when online", true); } } @@ -623,12 +648,8 @@ async function removeBookmark(key) { removeLocal(key); // optimistic render(); - try { - await apiDelete(key); - toast("Removed"); - } catch (e) { - toast("Offline — remove will retry", true); - } + if (await pushDelete(key)) toast("Removed"); + else toast("Removed — will sync when online", true); } // Dwell timer: don't advance progress the instant a newer chapter opens (guards -- 2.52.0 From 4b863d5d2ee47818c3e508ee64e4af1f33250124 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:11:26 +0700 Subject: [PATCH 3/8] feat(userscript): drain the retry queue on navigate and on reconnect --- userscript/manga-bookmark.user.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 8a5a2fb..fb0c8dd 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -440,6 +440,31 @@ saveQueue(queue); } + let draining = false; + + // Replays everything owed. Cheap in the normal case — it returns on the first + // line when the queue is empty, which is why it can hang off navigation. A + // 401 stops the whole pass: the token is wrong, so the next entry would fail + // the same way, and the queue is left intact so fixing the token fixes it. + async function drain() { + if (draining || queue.length === 0) return; + draining = true; + authFailed = false; + try { + for (const e of queue.slice()) { + if (e.op === "delete") await pushDelete(e.key); + else await pushBookmark(e.key, e.sendStatus); + if (authFailed) { + toast("Sync auth failed — check the token", true); + break; + } + } + } finally { + draining = false; + } + render(); + } + // ============================================================ // Mutations (optimistic: update UI/cache first, then sync) // ============================================================ @@ -1018,6 +1043,9 @@ // Asura is client-routed, so full loads are rare — checking here too is // what keeps its bookmarks current. The throttle still caps the rate. backgroundRefreshLatest(); + // Drain only, not a full refresh: Asura may go a long time without a + // reload, and an extra GET per client-side route change is not wanted. + drain(); } // Framework-agnostic URL-change watcher: patch history + poll as a fallback, @@ -1053,6 +1081,7 @@ state.page = detect(); render(); installNavWatcher(); + window.addEventListener("online", drain); // signal returned while the page stayed open // Sync first: both auto-record and the latest-chapter checks below need to // know which series are bookmarked and how fresh they are. refresh().then(() => { -- 2.52.0 From 6eb29902b1715f879fd2090f059c26a41ef0524e Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:13:58 +0700 Subject: [PATCH 4/8] feat(userscript): keep pending writes over a refreshed list --- userscript/manga-bookmark.user.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index fb0c8dd..fc5c498 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -465,6 +465,27 @@ render(); } + // Keys the server has not heard about yet must survive a fetched list, or the + // card the user just changed silently flaps back — the exact bug this queue + // exists to fix. Reads state.byKey, so it must run *before* setList replaces + // it. + function overlayPending(list) { + if (queue.length === 0) return list; + const out = list.filter((b) => { + const e = queueGet(b.key); + return !(e && e.op === "delete"); + }); + for (const e of queue) { + if (e.op !== "put") continue; + const local = state.byKey[e.key]; + if (!local) continue; + const i = out.findIndex((b) => b.key === e.key); + if (i >= 0) out[i] = local; + else out.push(local); + } + return out; + } + // ============================================================ // Mutations (optimistic: update UI/cache first, then sync) // ============================================================ @@ -1025,9 +1046,10 @@ // ============================================================ async function refresh() { + await drain(); // push what we owe before adopting the server's view of it try { const list = await apiGet(); - setList(list); + setList(overlayPending(list)); render(); } catch (e) { render(); // fall back to cache -- 2.52.0 From 51bf123cab1456c626a3fa3d76617c35ef7378c2 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:17:12 +0700 Subject: [PATCH 5/8] feat(userscript): show pending writes in the panel nav --- userscript/manga-bookmark.user.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index fc5c498..5b7d483 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -782,6 +782,7 @@ }); root.getElementById("backdrop").addEventListener("click", togglePanel); root.getElementById("closeBtn").addEventListener("click", togglePanel); + root.getElementById("pending").addEventListener("click", () => drain()); for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { root.getElementById(id).addEventListener("click", () => { @@ -934,6 +935,11 @@ root.getElementById("panel").classList.toggle("open", panelOpen); root.getElementById("backdrop").classList.toggle("open", panelOpen); + // Silent while everything is synced; honest the moment something is stuck. + const pending = root.getElementById("pending"); + pending.hidden = queueCount() === 0; + pending.textContent = "⟳ " + queueCount() + " pending"; + // Context header for the current page. const ctx = root.getElementById("context"); ctx.innerHTML = ""; @@ -1132,6 +1138,7 @@ 🌐 Web Asura Demonic +
@@ -1192,6 +1199,8 @@ white-space: nowrap; } .chip:active { opacity: .8; } + .chip.pending { background: #4c1d95; border: none; cursor: pointer; } + .chip.pending[hidden] { display: none; } #context { padding: 12px 16px; border-bottom: 1px solid #33333d; display: flex; flex-direction: column; gap: 8px; -- 2.52.0 From a9ddf3d80464a7c0555414795f3ba914361ee1cf Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:20:43 +0700 Subject: [PATCH 6/8] docs: record the userscript retry queue --- CLAUDE.md | 17 +++++++++++++++-- userscript/manga-bookmark.user.js | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dfc7fcb..9768195 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,11 +78,24 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache) 1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. 2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback. 3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. -4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS +4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so a + failed mutation is parked in `localStorage` (`mangabm:queue`) and replayed on + the next navigation, reconnect, or `refresh()`. Entries are markers + (`{key, op, sendStatus, attempts}`), never payloads — the body is read from + the cache at send time, so one entry per key gives ordering and coalescing for + free. `sendStatus` is **sticky**: while an archive is pending, later writes to + that key keep carrying the bucket, which is what stops a successful + in-between write from silently un-archiving the series. `refresh()` drains + before it fetches and overlays anything still pending, so the list never + flaps. A 400 drops the entry, a 401 aborts the pass and keeps the queue, and + transient failures retry to a cap of 10. Latest-chapter writes deliberately + stay out of the queue. See + `docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`. +5. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS (critical on mobile). Three tabs (All / Favourites / Archived) and a row of link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG block next to `API_BASE`. -5. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices). +6. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices). ### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 5b7d483..efc9be8 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Manga Bookmark Sync // @namespace mangabm -// @version 1.3.0 +// @version 1.4.0 // @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @author you // @match https://asuracomic.net/* -- 2.52.0 From cfcac48df2d8ba6922d0b65c2dec48309edfa1ec Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:33:37 +0700 Subject: [PATCH 7/8] fix(userscript): close the un-archive hole and the drain interleaving races Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 107 +++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 25 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index efc9be8..5cae976 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -416,6 +416,17 @@ } } + // Parks a write without counting it as a failure. Used when the key is already + // on the wire: the write is owed, but nothing went wrong, so it must not spend + // one of the ten attempts. + function queueDefer(key, op, sendStatus) { + const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 }); + e.op = op; // a delete replaces a put, and a put replaces a delete + e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped + saveQueue(queue); + return e; + } + // Records a failed write and classifies why it failed. A 400 is a payload the // server will never accept, so it is dropped now instead of being retried ten // times; a 401 is the wrong token, so the entry is kept untouched and the @@ -427,9 +438,7 @@ toast("Couldn't sync " + titleFor(key) + " — change lost", true); return; } - const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 }); - e.op = op; // a delete replaces a put, and a put replaces a delete - e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped + const e = queueDefer(key, op, sendStatus); if (status === 401) { authFailed = true; } else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) { @@ -440,29 +449,52 @@ saveQueue(queue); } - let draining = false; + // Keys with a request on the wire right now, mapped to "another write arrived + // while this one was flying". A second write to the same key is never sent + // concurrently — the two responses would race to own the row — it is parked in + // the queue instead and a later drain replays it. The flight already in the + // air must then leave that entry alone and not adopt its own now-stale + // response, or it would undo the write the user just made. + const inFlight = new Map(); + + let draining = null; // the pass in progress, so a second caller awaits it // Replays everything owed. Cheap in the normal case — it returns on the first // line when the queue is empty, which is why it can hang off navigation. A // 401 stops the whole pass: the token is wrong, so the next entry would fail // the same way, and the queue is left intact so fixing the token fixes it. - async function drain() { - if (draining || queue.length === 0) return; - draining = true; - authFailed = false; - try { - for (const e of queue.slice()) { - if (e.op === "delete") await pushDelete(e.key); - else await pushBookmark(e.key, e.sendStatus); - if (authFailed) { - toast("Sync auth failed — check the token", true); - break; + // + // Returns the in-flight pass when one is already running, so refresh()'s + // `await drain()` really does wait for what we owe instead of racing a drain + // that onNavigate or the online listener started unawaited. + function drain() { + if (queue.length === 0) return Promise.resolve(); + if (draining) return draining; + const pass = (async () => { + authFailed = false; + try { + for (const e of queue.slice()) { + if (e.op === "delete") await pushDelete(e.key); + else await pushBookmark(e.key, e.sendStatus); + if (authFailed) { + toast("Sync auth failed — check the token", true); + break; + } } + } finally { + render(); } - } finally { - draining = false; - } - render(); + })(); + // Swallowed, not surfaced: drain is called unawaited from onNavigate and the + // online listener, and an uncaught rejection on a page we do not control is + // a console error nobody can act on. Every real sync failure is already + // toasted and queued by pushBookmark/pushDelete. + draining = pass + .catch(() => {}) + .finally(() => { + draining = null; + }); + return draining; } // Keys the server has not heard about yet must survive a fetched list, or the @@ -503,29 +535,51 @@ queueDrop(key); // removed locally in the meantime — nothing left to send return true; } + if (inFlight.has(key)) { + queueDefer(key, "put", withStatus); // see inFlight — parked, not sent + inFlight.set(key, true); // supersedes the flight already in the air + return false; + } + inFlight.set(key, false); + let ok = false; try { // ponytail: last-write-wins, so a replay can overwrite a newer server // value (a poller-written latest_chapter, or progress from another // device). Single user, self-healing on the next poll — revisit only if // this ever runs multi-user. - upsertLocal(await apiPut(key, bm, { sendStatus: withStatus })); - queueDrop(key); - render(); - return true; + const saved = await apiPut(key, bm, { sendStatus: withStatus }); + if (!inFlight.get(key)) { + upsertLocal(saved); + queueDrop(key); + } + ok = true; } catch (e) { queueEnqueue(key, "put", withStatus, e); - return false; + } finally { + inFlight.delete(key); } + // Outside the try: a throw in render() is a UI bug, not a write failure, and + // must not re-queue a write that already landed. + if (ok) render(); + return ok; } async function pushDelete(key) { + if (inFlight.has(key)) { + queueDefer(key, "delete", false); // see inFlight — parked, not sent + inFlight.set(key, true); // supersedes the flight already in the air + return false; + } + inFlight.set(key, false); try { await apiDelete(key); - queueDrop(key); + if (!inFlight.get(key)) queueDrop(key); return true; } catch (e) { queueEnqueue(key, "delete", false, e); return false; + } finally { + inFlight.delete(key); } } @@ -635,6 +689,9 @@ }); upsertLocal(bm); render(); + // A queued write owns this row; the drain sends latest_chapter + // with it, carrying the correct bucket. + if (queueGet(bm.key)) return; try { const saved = await apiPut(bm.key, bm); upsertLocal(saved); -- 2.52.0 From 66eb3d2a988d44f4729a2c6c7588af095a161daa Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 17:38:49 +0700 Subject: [PATCH 8/8] fix(userscript): don't let a failing in-flight write clobber a deferred entry Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 5cae976..2e3107e 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -554,7 +554,15 @@ } ok = true; } catch (e) { - queueEnqueue(key, "put", withStatus, e); + // Same guard as the success path: a newer write is parked for this key, so + // classifying *this* failure would clobber its op or, on a 400, drop it + // outright. Its own drain reports its own outcome. The one thing that must + // still carry across is sendStatus — this flight may have been the archive + // replay, and losing its flag here would narrow the parked write into a + // silent un-archive. + const parked = inFlight.get(key) ? queueGet(key) : null; + if (parked) queueDefer(key, parked.op, withStatus); + else queueEnqueue(key, "put", withStatus, e); } finally { inFlight.delete(key); } @@ -576,7 +584,7 @@ if (!inFlight.get(key)) queueDrop(key); return true; } catch (e) { - queueEnqueue(key, "delete", false, e); + if (!inFlight.get(key)) queueEnqueue(key, "delete", false, e); // see pushBookmark return false; } finally { inFlight.delete(key); -- 2.52.0