diff --git a/userscript/novel-bookmark.user.js b/userscript/novel-bookmark.user.js index 84c38b9..0241f11 100644 --- a/userscript/novel-bookmark.user.js +++ b/userscript/novel-bookmark.user.js @@ -303,17 +303,29 @@ } let queueOut = queue; - if (queue.some((e) => e.key === oldKey)) { - queueOut = queue - .map((e) => { - if (e.key !== oldKey) return e; - // An entry already under the repaired key wins: both markers read - // the same row from the cache at send time, so keeping both would - // replay one write twice. - if (queue.some((x) => x.key === newKey)) return null; - return Object.assign({}, e, { key: newKey }); - }) - .filter(Boolean); + const oldEntry = queue.find((e) => e.key === oldKey); + if (oldEntry) { + const survivor = queue.find((x) => x.key === newKey); + if (!survivor) { + queueOut = queue.map((e) => (e.key === oldKey ? Object.assign({}, e, { key: newKey }) : e)); + } else { + // Both keys hold a marker and one row survives, so the two collapse + // into one entry under the repaired key (the queue's one-entry-per-key + // invariant). sendStatus is sticky — an archive intent from either + // marker survives, the queue's own rule — and the worse attempts + // count wins. The stale-key marker's op is dropped: the row it + // described is retired by the merge itself. + queueOut = queue + .filter((e) => e.key !== oldKey && e.key !== newKey) + .concat([ + { + key: newKey, + op: survivor.op, + sendStatus: survivor.sendStatus || oldEntry.sendStatus, + attempts: Math.max(survivor.attempts || 0, oldEntry.attempts || 0), + }, + ]); + } } let lastCheckedOut = lastChecked; @@ -1327,7 +1339,8 @@ // Refresh + navigation // ============================================================ - async function refresh() { + async function refresh(awaitFirst) { + if (awaitFirst) await awaitFirst; // repair sync lands before we adopt the server's view of it await drain(); // push what we owe before adopting the server's view of it loading = true; render(); @@ -1345,12 +1358,16 @@ // Runs the repair on every navigation, silently: rewrites a stale // lightnovelworld row to the page's discovered identity, persists all three // sites, and syncs through the queue-backed path so an offline repair parks - // and replays later. No toast — the Reader never asked for this. When the - // transform returns its inputs unchanged there is nothing to do. - function repairStaleLnwRow() { + // and replays later. No toast — the Reader never asked for this. Returns + // the sync promise (or null when nothing changed) so init can hold the + // server view until the repair has landed. When the transform returns its + // inputs unchanged there is nothing to do. + function applyLnwStaleRowRepair() { const lastChecked = loadLastChecked(); const out = repairLnwStaleRow(state.list, queue, lastChecked, state.page); - if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) return; + if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) { + return null; + } state.list = out.list; reindex(); // byKey answers under the old key until rebuilt saveCache(state.list); @@ -1359,16 +1376,20 @@ saveLastChecked(out.lastChecked); // Queue-backed sync, outcome swallowed: the repaired row is PUT (with its // bucket when archived — the only status a userscript write may send) and - // the old server row is deleted so no duplicate survives on the wire. + // the old server bookmark is deleted so no duplicate survives on the + // wire. The delete parks under the retired key while offline — the + // teardown of the old identity, not a new record of the Chapter Slug. const key = keyOf(state.page); - pushBookmark(key, statusOf(state.byKey[key]) === "archived").catch(() => {}); - pushDelete("lightnovelworld:" + state.page.chapterSlug).catch(() => {}); + return Promise.all([ + pushBookmark(key, statusOf(state.byKey[key]) === "archived"), + pushDelete("lightnovelworld:" + state.page.chapterSlug), + ]).catch(() => {}); } let lastUrl = location.href; function onNavigate() { state.page = detect(); - repairStaleLnwRow(); + applyLnwStaleRowRepair(); render(); maybeAutoUpdate(); maybeCaptureLatestOnSeriesPage(); @@ -1481,14 +1502,14 @@ function init() { buildUI(); state.page = detect(); - repairStaleLnwRow(); + const repair = applyLnwStaleRowRepair(); render(); installNavWatcher(); installLongPress(); 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(() => { + refresh(repair).then(() => { maybeAutoUpdate(); maybeCaptureLatestOnSeriesPage(); backgroundRefreshLatest(); diff --git a/userscript/test/novel-logic.test.js b/userscript/test/novel-logic.test.js index e181d28..61380b1 100644 --- a/userscript/test/novel-logic.test.js +++ b/userscript/test/novel-logic.test.js @@ -516,10 +516,11 @@ test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the f assert.equal(merged.last_chapter_num, 42); // the stale row is ahead — progress must not be lost assert.equal(merged.favorite, true); // favourite survives from either row assert.equal(merged.status, "archived"); // the stronger bucket survives - // One queue marker per key: the repaired-key entry wins, the stale-key one - // is dropped rather than replaying the write twice. + // One marker under the repaired key: sendStatus is sticky (the archive + // intent from the stale-key marker survives) and the worse attempts count + // wins — the queue's own coalescing rules. assert.deepEqual(out.queue, [ - { key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 }, + { key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 }, ]); });