Offline retry queue for userscript writes #5
@@ -265,6 +265,14 @@
|
|||||||
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
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() {
|
async function apiGet() {
|
||||||
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
||||||
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
||||||
@@ -283,7 +291,7 @@
|
|||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify(body),
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +300,8 @@
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: authHeaders(),
|
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";
|
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)
|
// Mutations (optimistic: update UI/cache first, then sync)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user