Two new Series filters: failing, and the unverified Reader subset (#166)

This commit is contained in:
2026-08-22 23:43:47 +07:00
parent 5a32943528
commit 8a56907558
4 changed files with 224 additions and 14 deletions
+41 -12
View File
@@ -24,6 +24,8 @@ const (
SeriesFilterNoCover = "no_cover"
SeriesFilterReaderReport = "reader_report"
SeriesFilterFinished = "finished"
SeriesFilterFailing = "failing"
SeriesFilterUnverified = "unverified"
)
// SeriesFilter is one named filter predicate over the whole library. Site
@@ -54,6 +56,12 @@ const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_
// Reader behind them.
const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader`
// failureAnswer is the poll-failures row's answer (issue #165), kept apart
// from adminSeriesColumns like raisedByReaderAnswer: outcome and failing_since
// are not Series columns, and the COALESCE keeps the row scannable when the
// LEFT JOIN finds no failure row.
const failureAnswer = `COALESCE(f.outcome, ''), COALESCE(f.failing_since, 0)`
// seriesPageSize is the row read's page length. The tie-break in the query's
// ORDER BY is what makes this a stable page boundary — see SeriesPage.
const seriesPageSize = 50
@@ -83,6 +91,12 @@ type AdminSeries struct {
LatestCorrectedAt int64
ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum
// FailureOutcome is the outcome word of the Series' standing failure, ""
// when no failure row stands. FailingSince is when the run of failures
// began, zero with no row. Both come from the LEFT JOIN, not the Series
// row (issue #165: the row's existence is the state).
FailureOutcome string
FailingSince int64
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
// not finished — the same shape as the Correction stamp, and its own undo.
FinishedAt int64
@@ -119,7 +133,11 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
// a later repair), never-read-a-chapter and never-checked as disjoint halves
// (non-zero versus zero check stamp), stale, no cover, finished (the
// retirement stamp, read directly), and Reader-report.
// retirement stamp, read directly), Reader-report, and the failing pair —
// failing (the failure row exists, a chapter exists, and failing_since is
// past the cutoff) and unverified (the Reader-attributed subset of failing).
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
// chapter's IS NULL half, so the two are disjoint by construction.
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
// over the LEFT JOIN, where a bare WHERE has no row to test.
//
@@ -127,9 +145,11 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
// Series (`s.finished_at = 0` in each of the four): the clock-driven one —
// never-checked, stale, no-chapter, no-cover — keep ticking after the last
// Poll, so they would report a retired row as a problem no Poll is coming to
// fix; the three outcome-driven ones — no-URL, no-readers, Reader-report —
// read stored facts that simply stop arriving, so a finished Series needing a
// genuine repair still shows up under them.
// fix; the outcome-driven ones — no-URL, no-readers, Reader-report, failing,
// unverified — read stored facts that simply stop arriving, so a finished
// Series needing a genuine repair still shows up under them. The failing pair
// carries no finished guard for exactly that contrast: a failure row is a
// stored outcome, not a ticking clock.
//
// stale is the checked-but-old half of the stamp partition — because the
// verdict line wants "not checked in twelve hours" as one figure, and a never
@@ -157,6 +177,12 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
case SeriesFilterReaderReport:
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
case SeriesFilterFailing:
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1))
args = append(args, f.Cutoff)
case SeriesFilterUnverified:
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1)+` AND s.latest_raised_by IS NOT NULL`)
args = append(args, f.Cutoff)
case SeriesFilterFinished:
clauses = append(clauses, `s.finished_at > 0`)
case SeriesFilterNoReaders:
@@ -204,15 +230,17 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
base := len(args)
args = append(args, seriesPageSize, seriesPageSize*(f.Page-1))
rows, err := s.db.Query(`
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`,
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`, `+failureAnswer+`,
COUNT(b.reader_id) AS reader_count,
COUNT(*) OVER () AS filtered_total
FROM series s
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
`+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by,
f.outcome, f.failing_since
`+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
@@ -251,6 +279,7 @@ func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
SELECT s.site, s.kind
FROM series s
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
`+where+`
GROUP BY s.site, s.series_id, s.kind
`+having+`
@@ -273,11 +302,11 @@ func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
return out, rows.Err()
}
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer
// order, plus the query's reader_count and filtered_total columns, and returns
// the window total alongside the row. latest_chapter_num is NULL until first
// captured — the "never read a chapter" state. The Sighting-raiser column is
// never among the scanned columns.
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer +
// failureAnswer order, plus the query's reader_count and filtered_total
// columns, and returns the window total alongside the row. latest_chapter_num
// is NULL until first captured — the "never read a chapter" state. The
// Sighting-raiser column is never among the scanned columns.
func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
var (
a AdminSeries
@@ -288,7 +317,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
&a.RaisedByReader, &a.ReaderCount, &total,
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
); err != nil {
return AdminSeries{}, 0, err
}