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) // ============================================================