Merge ticket/90-lnw-stale-row-repair (#90)

This commit is contained in:
2026-08-11 13:08:23 +07:00
2 changed files with 341 additions and 3 deletions
+133 -3
View File
@@ -244,6 +244,102 @@
return ADAPTERS.find((a) => a.site === site) || null;
}
// A stale lightnovelworld row is one keyed under the Chapter Slug its
// address was built from, while the page's pointer names a different
// Series. All three storage sites must move together: the cache row alone
// leaves a queued write replaying under a key whose row no longer exists
// (that write is silently lost), and a last-checked timestamp left behind
// re-fetches the repaired row on the next visit. A repair must never cost a
// Reader the chapter they were tracking, so when a row already holds the
// repaired key the two rows merge and the farther-ahead progress wins.
// Pure: no storage access, no module state, no clock.
function repairLnwStaleRow(list, queue, lastChecked, page) {
if (
!page ||
page.site !== "lightnovelworld" ||
!page.chapterSlug ||
!page.seriesId ||
page.chapterSlug === page.seriesId
) {
return { list, queue, lastChecked };
}
const oldKey = "lightnovelworld:" + page.chapterSlug;
const newKey = "lightnovelworld:" + page.seriesId;
const stale = list.find((b) => b.key === oldKey);
if (!stale) return { list, queue, lastChecked };
let listOut;
const existing = list.find((b) => b.key === newKey);
if (existing) {
// Duplicate case (spec user story 2): one row must survive, and it is
// the one already under the repaired key — with the stale row's
// progress carried across when it is ahead, favourite OR'd, and the
// stronger lifecycle bucket kept (finished > archived > reading, so a
// merge can never silently un-archive or un-finish a row).
const merged = Object.assign({}, existing);
if (
existing.last_chapter_num == null ||
(stale.last_chapter_num != null && stale.last_chapter_num > existing.last_chapter_num)
) {
merged.last_chapter = stale.last_chapter;
merged.last_chapter_num = stale.last_chapter_num;
merged.last_chapter_url = stale.last_chapter_url;
}
merged.favorite = !!(existing.favorite || stale.favorite);
const rank = (s) => ({ finished: 2, archived: 1, reading: 0 }[s || "reading"] || 0);
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
} else {
listOut = list.map((b) =>
b.key === oldKey
? Object.assign({}, b, {
key: newKey,
series_id: page.seriesId,
series_url: page.seriesUrl,
})
: b
);
}
let queueOut = queue;
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;
if (oldKey in lastChecked) {
lastCheckedOut = Object.assign({}, lastChecked);
// Max, not overwrite: a timestamp already under the repaired key must
// not be rolled back to an older one.
lastCheckedOut[newKey] = Math.max(lastCheckedOut[newKey] || 0, lastCheckedOut[oldKey]);
delete lastCheckedOut[oldKey];
}
return { list: listOut, queue: queueOut, lastChecked: lastCheckedOut };
}
// Highest chapter the site lists, or null when the markup yields nothing.
// seriesId is only consulted by adapters whose pages carry other series'
// chapter links; the rest ignore it.
@@ -1243,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();
@@ -1258,9 +1355,41 @@
}
}
// 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. 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 null;
}
state.list = out.list;
reindex(); // byKey answers under the old key until rebuilt
saveCache(state.list);
queue.splice(0, queue.length, ...out.queue); // closures hold this array instance
saveQueue(queue);
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 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);
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();
applyLnwStaleRowRepair();
render();
maybeAutoUpdate();
maybeCaptureLatestOnSeriesPage();
@@ -1373,13 +1502,14 @@
function init() {
buildUI();
state.page = detect();
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();
@@ -1620,7 +1750,7 @@
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
// ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe };
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, repairLnwStaleRow };
}
// ============================================================
+208
View File
@@ -47,6 +47,7 @@ const {
lightnovelworld,
kindOf,
maxChapter,
repairLnwStaleRow,
} = require("../novel-bookmark.user.js");
function loc(href) {
@@ -331,6 +332,213 @@ test("latestChapterFromAnchors returns null when nothing matches", () => {
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
});
// ============================================================
// repairLnwStaleRow — the row migration (spec seam 3)
// ============================================================
// A row as bookmarkCurrent builds it, keyed under the invented Chapter Slug
// identity the old adapter derived from the chapter address.
function staleRow(over) {
return Object.assign(
{
key: "lightnovelworld:my-longevity-simulation",
site: "lightnovelworld",
kind: "novel",
series_id: "my-longevity-simulation",
title: "My Longevity Simulation",
series_url: "https://lightnovelworld.net/novel/my-longevity-simulation/",
last_chapter: "Chapter 7",
last_chapter_num: 7,
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-7/",
updated_at: 1000,
},
over
);
}
// The divergent novel from research §3: the address's slug is a Chapter Slug,
// the page pointer names the real Series.
function lnwPage(over) {
return Object.assign(
{
type: "chapter",
site: "lightnovelworld",
seriesId: "immortality-simulator",
chapterSlug: "my-longevity-simulation",
seriesUrl: "https://lightnovelworld.net/novel/immortality-simulator/",
},
over
);
}
test("repairLnwStaleRow rewrites the row, the queue entry and the last-checked map together", () => {
const list = [staleRow()];
const queue = [
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: false, attempts: 2 },
];
const lastChecked = { "lightnovelworld:my-longevity-simulation": 12345 };
const out = repairLnwStaleRow(list, queue, lastChecked, lnwPage());
assert.equal(out.list.length, 1);
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
assert.equal(out.list[0].series_id, "immortality-simulator");
assert.equal(out.list[0].series_url, "https://lightnovelworld.net/novel/immortality-simulator/");
assert.deepEqual(out.queue, [
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 2 },
]);
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 12345 });
});
test("repairLnwStaleRow keeps Progress, Favourite and Lifecycle bucket on the rewritten row", () => {
const stale = staleRow({
last_chapter: "Chapter 42",
last_chapter_num: 42,
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
favorite: true,
status: "archived",
updated_at: 777,
});
const out = repairLnwStaleRow([stale], [], {}, lnwPage());
const b = out.list[0];
assert.equal(b.last_chapter, "Chapter 42");
assert.equal(b.last_chapter_num, 42);
assert.equal(
b.last_chapter_url,
"https://lightnovelworld.net/my-longevity-simulation-chapter-42/"
);
assert.equal(b.favorite, true);
assert.equal(b.status, "archived");
assert.equal(b.updated_at, 777);
assert.equal(b.title, "My Longevity Simulation");
});
test("repairLnwStaleRow changes nothing when the Chapter Slug and Series slug agree", () => {
const list = [
staleRow({ key: "lightnovelworld:a-will-eternal", series_id: "a-will-eternal" }),
];
const queue = [{ key: "lightnovelworld:a-will-eternal", op: "put", sendStatus: true, attempts: 1 }];
const lastChecked = { "lightnovelworld:a-will-eternal": 99 };
const out = repairLnwStaleRow(
list,
queue,
lastChecked,
lnwPage({
chapterSlug: "a-will-eternal",
seriesId: "a-will-eternal",
seriesUrl: "https://lightnovelworld.net/novel/a-will-eternal/",
})
);
assert.equal(out.list, list); // same references, nothing rewritten
assert.equal(out.queue, queue);
assert.equal(out.lastChecked, lastChecked);
});
test("repairLnwStaleRow changes nothing when no row sits under the old key", () => {
const list = [
staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
}),
];
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
const out = repairLnwStaleRow(list, [], lastChecked, lnwPage());
assert.equal(out.list, list);
assert.equal(out.queue.length, 0);
assert.equal(out.lastChecked, lastChecked);
});
test("repairLnwStaleRow ignores a page that carries no Chapter Slug", () => {
const list = [
staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
}),
];
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
// A series page (chapterSlug null) and another site both stay untouched.
const series = repairLnwStaleRow(list, [], lastChecked, lnwPage({ chapterSlug: null }));
const other = repairLnwStaleRow(list, [], lastChecked, {
type: "chapter",
site: "novelfull",
seriesId: "x",
});
assert.equal(series.list, list);
assert.equal(other.list, list);
});
test("repairLnwStaleRow still moves the row and the last-checked map when the queue has no entry", () => {
const out = repairLnwStaleRow(
[staleRow()],
[],
{ "lightnovelworld:my-longevity-simulation": 5 },
lnwPage()
);
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
assert.deepEqual(out.queue, []);
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 5 });
});
test("repairLnwStaleRow carries a queued change to the repaired identity with its marker intact", () => {
const queue = [
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
];
const out = repairLnwStaleRow([staleRow()], queue, {}, lnwPage());
assert.deepEqual(out.queue, [
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
]);
});
test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the farther-ahead progress", () => {
const canonical = staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
last_chapter: "Chapter 5",
last_chapter_num: 5,
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-5/",
favorite: false,
});
const stale = staleRow({
last_chapter: "Chapter 42",
last_chapter_num: 42,
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
favorite: true,
status: "archived",
});
const queue = [
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 },
];
const out = repairLnwStaleRow([canonical, stale], queue, {}, lnwPage());
assert.equal(out.list.length, 1);
const merged = out.list[0];
assert.equal(merged.key, "lightnovelworld:immortality-simulator");
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 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: true, attempts: 3 },
]);
});
test("repairLnwStaleRow does not regress progress when the repaired-key row is ahead", () => {
const canonical = staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
last_chapter: "Chapter 100",
last_chapter_num: 100,
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-100/",
});
const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42 });
const out = repairLnwStaleRow([canonical, stale], [], {}, lnwPage());
assert.equal(out.list.length, 1);
assert.equal(out.list[0].last_chapter_num, 100);
});
// ============================================================
// kindOf
// ============================================================