Offline retry queue for userscript writes #5

Merged
sulthan merged 8 commits from feat/offline-retry-queue into main 2026-07-27 17:52:17 +07:00
Showing only changes of commit 0615617ebe - Show all commits
+50 -29
View File
@@ -444,6 +444,45 @@
// Mutations (optimistic: update UI/cache first, then sync) // 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() { async function bookmarkCurrent() {
const p = state.page; const p = state.page;
if (p.type !== "series" && p.type !== "chapter") return; if (p.type !== "series" && p.type !== "chapter") return;
@@ -502,14 +541,8 @@
async function syncUpsert(bm, okMsg) { async function syncUpsert(bm, okMsg) {
upsertLocal(bm); // optimistic upsertLocal(bm); // optimistic
render(); render();
try { if (await pushBookmark(bm.key, false)) toast(okMsg);
const saved = await apiPut(bm.key, bm); else toast("Saved — will sync when online", true);
upsertLocal(saved); // adopt server updated_at
render();
toast(okMsg);
} catch (e) {
toast("Offline — saved locally, will retry", true);
}
} }
async function toggleFavorite(key) { async function toggleFavorite(key) {
@@ -521,13 +554,7 @@
}); });
upsertLocal(bm); // optimistic upsertLocal(bm); // optimistic
render(); render();
try { if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", true);
const saved = await apiPut(bm.key, bm);
upsertLocal(saved);
render();
} catch (e) {
toast("Offline — saved locally, will retry", true);
}
} }
// Archive parks a series: it leaves All and Favourites but the server keeps // Archive parks a series: it leaves All and Favourites but the server keeps
@@ -537,16 +564,14 @@
const existing = state.byKey[key]; const existing = state.byKey[key];
if (!existing) return; if (!existing) return;
const next = statusOf(existing) === "archived" ? "reading" : "archived"; const next = statusOf(existing) === "archived" ? "reading" : "archived";
const bm = Object.assign({}, existing, { status: next }); upsertLocal(Object.assign({}, existing, { status: next }));
upsertLocal(bm);
render(); render();
try { // The only write with an opinion about the bucket, so the only one that
const saved = await apiPut(bm.key, bm, { sendStatus: true }); // sends `status` at all.
upsertLocal(saved); // adopt the stored row: the server owns updated_at if (await pushBookmark(key, true)) {
render();
toast(next === "archived" ? "Archived" : "Back in your list"); toast(next === "archived" ? "Archived" : "Back in your list");
} catch (e) { } else {
toast("Archive failed — retry when online", true); toast((next === "archived" ? "Archived" : "Restored") + " — will sync when online", true);
} }
} }
@@ -623,12 +648,8 @@
async function removeBookmark(key) { async function removeBookmark(key) {
removeLocal(key); // optimistic removeLocal(key); // optimistic
render(); render();
try { if (await pushDelete(key)) toast("Removed");
await apiDelete(key); else toast("Removed — will sync when online", true);
toast("Removed");
} catch (e) {
toast("Offline — remove will retry", true);
}
} }
// Dwell timer: don't advance progress the instant a newer chapter opens (guards // Dwell timer: don't advance progress the instant a newer chapter opens (guards