Series-level finished state, and the fate of the finished Lifecycle bucket #124
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #114
Question
Does a Series itself become finished, and does the reader-facing finished Lifecycle bucket go away?
Raised while resolving #116. The owner wants to mark a Series finished so that its Poll Lane
never Polls it again, and wants the Reader to keep only reading and archived.
Today
finishedis a Lifecycle bucket on a Bookmark (bookmarks.status, one ofreading/archived/finished). It is a per-Reader value, but the Poll Lane reads it as if it were
a fact about the Series:
DueForLatestCheckandEligibleSeriesCount(backend/internal/store/store.go:972, 1010) both require
COUNT(*) FILTER (WHERE b.status<>'finished') > 0. So one Reader who finishes a Series doesnot stop the Poll, but every Reader who finishes it does. That is a Series-level effect
assembled from per-Reader votes.
CONTEXT.md defines the Lifecycle bucket as three mutually exclusive states of a Bookmark.
A Series that a Site has completed is a fact about the Series, like Latest Chapter, and the
glossary says a Reader cannot change facts about a Series. The current model puts the fact in
the wrong place.
To decide:
series, or something else.status<>'finished'test in the two Lane queries, and whether anarchived Bookmark still keeps a Series eligible for a Poll.
today.
api.handlersrejects an unknownstatuswith 400, and the userscripts and the webUI both offer the bucket, so removal reaches the userscript, the web UI, the API validation
and a migration.
Reader sees any sign of it.
Consequences for the map: the admin Series list (#116) gains one filter value and one
predicate; the hygiene counts (#118) must not call a finished Series stale; the action itself
is an intervention in the sense of #119 — a flag the Lane reads, never a command sent to it.
Resolution
Finished becomes a fact about the Series, written only by the owner, and the reader-facing
Lifecycle bucket is deleted. The Reader vote that could stop a Poll is gone. Recorded as
ADR-0012 (
docs/adr/0012-finished-belongs-to-the-series.md) — the migration is one-wayand destroys a user-facing bucket, so it needed one.
The column
series.finished_at bigint NOT NULL DEFAULT 0, migration 0016. Epoch ms, zero meansnot finished — the same shape as #121's
latest_corrected_at, and it doubles as the undo(write 0) and as the "since when" the detail page prints. No side table (one Series row
already exists), and not on #119's
poll_lanes(that grain is per-Site).Owner writes it, and nothing else ever does. No adapter, no Reader, no Poll. A
machine-written finish fails invisibly: the Series stops being Polled, so nothing
contradicts the mistake afterwards, and #127's failure lens cannot see it either because
there are no attempts left to fail.
The two Lane queries
The
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0clause is deleted fromboth. The plain
JOIN bookmarksalready answers "does any Reader hold this"; whether toPoll becomes
finished_at = 0. Archived keeps polling, unchanged, for the reason already atstore.go:956.DueForLatestCheck—WHEREgains(s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at),and
s.finished_atjoins theGROUP BYlist alongside the other series columns.EligibleSeriesCount—WHEREgainss.finished_at = 0, with no force clause. Thisasymmetry is deliberate: that count is the divisor in the Lane's pace (
min(gap, hour/eligible)),so admitting a forced Series would make one press speed up every other fetch on the Site.
Amends #119. Its
HAVING (… <> 'finished' … OR force)shape is void, and one of the threewaiting rules it named as overridable — "the finished-only bucket" — is replaced by
finished_at. Its rationale transfers verbatim: the owner asking is direct evidence someonecares about a Series the owner shelved. Everything #119 said a Forced Poll never overrides
is untouched.
Servicing a forced request does not clear
finished_at. Nothing writesforce_poll_atback to zero; pending is derived (
force_poll_at > latest_checked_at) and self-clears whencheckOnestampslatest_checked_atbefore the fetch (poller.go:422-431). At thatinstant nothing has been read, so clearing the finish would act on zero evidence and convert
a one-off check into permanently resumed polling. Same principle #127 already took from
#121: only a real success resets state the owner did not write. Un-finishing is an explicit
action.
Consequence to accept: on a Site where everything is finished,
EligibleSeriesCounthits 0and #117 logs the pass as
nothing-eligible— a Lane that declined and said why, not astall. Correct as-is, no new handling.
The Lifecycle bucket is removed
CONTEXT.mdnow defines it as two states, reading or archived. Removal reaches:store.StatusFinished(store.go:185) and the doc comment atstore.go:49-50, deleted.api/handlers.go:79-83— the special-case 400 ("can only be set from the web UI") goes;finishedis simply an invalid status like any other.web.go:322(tab filter),web.go:488'suiStatusswitch (two values),app.html:74-77(the Finished tab),
chrome.html:39-44,list.html:17-18,card.html:35-37(badge),card.html:74-80(finish button) andcard.html:119-130(its confirm row).novel-bookmark.user.js:283— the three-way merge rank (finished 2 / archived 1 / reading 0)drops to two values; both userscripts' comments about the rejected value go with it.
Migration 0016 seeds before it flips, and the order is load-bearing:
The seed reads the buckets, so it must run first. This carries today's behaviour across the
cutover unchanged — every Series not being Polled yesterday is still not being Polled
tomorrow — while reinterpreting it as one owner decision instead of a Reader vote. It
knowingly over-approximates in the owner's favour (a Series everyone merely shelved is
declared finished, undone in one click) rather than seeding nothing and silently resuming
polling on a set nobody chose to resume.
Admin surface
/admin/series/{key}), never the list row. The decision needs thefacts that page shows — Latest Chapter, when it last moved, whether the page still reads —
and the 50-row grid's neighbouring ghost is a harmless Check now. #122's row shape is
unchanged; the row still displays the state.
.confirm-row(thecard.html:58-59rule:every move out goes through a confirm, a reversal fires straight away). Un-finish fires
instantly, like Restore (
card.html:67-72). Neither is--danger— nothing is destroyed— so
--patinaper #122, and never--ember.SeriesRowgainsFinishedAt int64, the way #119's amendment addedforce_poll_at.Filters —
finishedis the tenth nameAgainst the recommendation to add none; the owner wants finished Series findable as a list.
?filter=finisheds.finished_at > 0SeriesStatsgains the matchingCOUNT(*) FILTER (WHERE finished_at > 0); #118'szero-renders-the-digit-unlinked rule applies unchanged. Amends #116 (tenth predicate
constant) and #118 (tenth
<select>option with its count);failingstays #127's ninth.Four hygiene predicates gain
AND s.finished_at = 0:stale,unchecked,no-cover,no-chapter. Without the guards a finished Series ages intostalefor ever and neverleaves, breaking #118's "an empty hygiene list is good news".
unpollable,orphanandsighting-raisedare left alone — each is still a genuine repair on a finished row.Readers see a label, and only a label
bookmarkColumnsgains a derivedfinished bool(s.finished_at > 0) on the flat Bookmark(ADR-0004). It reuses the badge markup and
--mosstoken that removing the bucket frees(
card.html:35-37,style.css:87), and renders in both userscripts viael(..., {text})—never
{html}. No tab, no filter, no reordering, no effect on the ember. A Readermid-way through a finished Series keeps it exactly where they left it; the badge only
explains why nothing new will arrive.
A bool, not the timestamp: the date the owner pressed a button is an operations fact whose
only consumer is
/admin.Read-only inbound, enforced by omission. Clients PUT the whole flat object back
(
novel-bookmark.user.js:1390-1396), so a cache written this morning would carryfinished: falsetonight and un-finish the Series.Upsert'sINSERT INTO series(
store.go:858-867) names its columns explicitly andfinished_atis not among them —the same mechanism that already protects
cover(store.go:850-852).Glossary
Three edits, applied:
Lifecycle bucketis two states;Forced Poll's "a Series onlyfinished Readers hold" becomes "a Series the owner marked Finished"; new Finished Series
term.
Not decided here — the completion-marker hint
The owner wants the server to notice a Site's own completion marker and surface
"potentially finished" in the dashboard, with no false positives and the owner still the
only writer. That needs a live check of what each of six Sites publishes, plus containment
rules that cannot be designed before we know which signals exist. Split into two child
tickets rather than guessed at here. This ticket's boundary is the part that binds them:
a machine hint may never write
finished_at.Pushed onto other tickets
HAVINGshape void; the finished override moves tofinished_at; force is inthe worklist query and not in the pacing count.
SeriesRow.FinishedAt; tenth predicate constant;SeriesStatscount; theknowingly-divergent
ReaderCountnow agrees with the Lanes, as that ticket predicted.<select>option labelled Finished, ordered last; four predicates gainthe
finished_at = 0guard.--patina); list rows unchanged but display the state.stale/unchecked/no-chapter, sofailing's threshold only ever sees Series still being attempted.Readers), and this ticket deletes no bytes.
sulthan referenced this issue2026-08-20 20:32:21 +07:00