review: clear stale attribution, gate numberless PUTs, cite the ADR

A Poll finding a higher number now clears series.latest_raised_by: the value
it stores is its own, so the next Poll must not credit that Reader with an
agreement they did not earn, nor charge them for a retraction of a number
they never reported.

A PUT carrying no chapter is no Sighting and defers nothing. The ceiling now
counts the Site's own rest rather than defaultRest. The userscripts PUT an
unchanged read too - the case the whole mechanism exists for was the one they
never sent.

ADR-0011 carries its citations and states honestly when a Reader's marks take
effect.
This commit is contained in:
2026-08-16 16:36:31 +07:00
parent 56afb9f237
commit 2b597921f4
13 changed files with 2959 additions and 1003 deletions
+24 -14
View File
@@ -905,27 +905,37 @@
}
}
// Records the newest chapter a site has published. Silent: this fires from
// Reports the newest chapter a site has published. Silent: this fires from
// page visits and background checks the user did not ask for, and it never
// reorders the list — updated_at is a candidate the server discards unless
// reading progress moved.
async function applyLatestChapterIfChanged(existing, latest) {
//
// An unchanged number is still sent. It is the read that lets the backend
// skip its own poll of this series (a Sighting, issue #103), so the common
// case — visiting a series with nothing new — is exactly the one worth
// reporting. Only the local write and the re-render are skipped.
async function reportLatestChapter(existing, latest) {
if (!existing || !latest) return;
if (existing.latest_chapter_num === latest.num) return;
const bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
const changed = existing.latest_chapter_num !== latest.num;
let bm = existing;
if (changed) {
bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
}
// A queued write owns this row; the drain sends latest_chapter
// with it, carrying the correct bucket.
if (queueGet(bm.key)) return;
try {
const saved = await apiPut(bm.key, bm);
upsertLocal(saved);
render();
if (changed) {
upsertLocal(saved);
render();
}
} catch (e) {
/* offline — the local cache still shows it, retried on a later visit */
}
@@ -937,7 +947,7 @@
if (p.type !== "series") return;
const existing = state.byKey[keyOf(p)];
if (!existing) return;
applyLatestChapterIfChanged(
reportLatestChapter(
existing,
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
);
@@ -977,7 +987,7 @@
const html = await res.text();
latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
}
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
await reportLatestChapter(state.byKey[bm.key] || bm, latest);
} catch (e) {
/* offline or blocked — try again after the throttle window */
}
+24 -14
View File
@@ -809,27 +809,37 @@
}
}
// Records the newest chapter a site has published. Silent: this fires from
// Reports the newest chapter a site has published. Silent: this fires from
// page visits and background checks the user did not ask for, and it never
// reorders the list — updated_at is a candidate the server discards unless
// reading progress moved.
async function applyLatestChapterIfChanged(existing, latest) {
//
// An unchanged number is still sent. It is the read that lets the backend
// skip its own poll of this series (a Sighting, issue #103), so the common
// case — visiting a series with nothing new — is exactly the one worth
// reporting. Only the local write and the re-render are skipped.
async function reportLatestChapter(existing, latest) {
if (!existing || !latest) return;
if (existing.latest_chapter_num === latest.num) return;
const bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
const changed = existing.latest_chapter_num !== latest.num;
let bm = existing;
if (changed) {
bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
}
// A queued write owns this row; the drain sends latest_chapter
// with it, carrying the correct bucket.
if (queueGet(bm.key)) return;
try {
const saved = await apiPut(bm.key, bm);
upsertLocal(saved);
render();
if (changed) {
upsertLocal(saved);
render();
}
} catch (e) {
/* offline — the local cache still shows it, retried on a later visit */
}
@@ -841,7 +851,7 @@
if (p.type !== "series") return;
const existing = state.byKey[keyOf(p)];
if (!existing) return;
applyLatestChapterIfChanged(
reportLatestChapter(
existing,
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
);
@@ -883,7 +893,7 @@
if (!res.ok) continue;
const html = await res.text();
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
await reportLatestChapter(state.byKey[bm.key] || bm, latest);
} catch (e) {
/* offline or blocked — try again after the throttle window */
}