#90: repair stale lnw rows in place on the next chapter visit

A row keyed under the Chapter Slug its address was built from, while the
page pointer names a different Series, is stale. Rewrite its key, identity
and address in the cache, the retry-queue entry (if any) and the per-device
last-checked map together, then sync through the queue-backed path so an
offline repair parks and replays. Pure transform exported for the harness;
wired before render() at both detect() call sites. A row already under the
repaired key merges, keeping the farther-ahead progress, favourite OR'd and
the stronger lifecycle bucket.
This commit is contained in:
2026-08-11 12:56:17 +07:00
parent e864770eb8
commit 8c172e3579
2 changed files with 317 additions and 1 deletions
+207
View File
@@ -47,6 +47,7 @@ const {
lightnovelworld,
kindOf,
maxChapter,
repairLnwStaleRow,
} = require("../novel-bookmark.user.js");
function loc(href) {
@@ -331,6 +332,212 @@ 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 queue marker per key: the repaired-key entry wins, the stale-key one
// is dropped rather than replaying the write twice.
assert.deepEqual(out.queue, [
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 },
]);
});
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
// ============================================================