Implements spec #137 (spec 4 of 4 from wayfinder map #114). Closes #137. Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch. ## Summary - #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate. - #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion. - #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition. - #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing. - #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier. - #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree. `cd backend && go test ./...` green on the merged branch (8 packages). Reviewed-on: #174 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #174.
This commit is contained in:
@@ -9,32 +9,38 @@ import (
|
||||
|
||||
// Series filter names (issue #140): the seven repair filters are ordered
|
||||
// permanent-then-fixable — the repairs nothing will ever undo first, the
|
||||
// ones a Poll can make right after. SeriesFilterFinished is not part of
|
||||
// that ordering: a finished Series is a deliberate state, not a repair, so
|
||||
// it sits last, informational. A name is the repair a row needs, not the
|
||||
// SQL that finds it; the values are the wire form the Series list URL
|
||||
// carries (#142). "all" is the absent and unknown case: every Series.
|
||||
// ones a Poll can make right after. SeriesFilterFinished and
|
||||
// SeriesFilterSiteCompleted are not part of that ordering: a finished
|
||||
// Series is a deliberate state and a Site-completed one is the Site's own
|
||||
// marker, not repairs, so the pair rides the tail, informational. A name is
|
||||
// the repair a row needs, not the SQL that finds it; the values are the
|
||||
// wire form the Series list URL carries (#142). "all" is the absent and
|
||||
// unknown case: every Series.
|
||||
const (
|
||||
SeriesFilterAll = "all"
|
||||
SeriesFilterNoURL = "no_series_url"
|
||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||
SeriesFilterNoReaders = "no_readers"
|
||||
SeriesFilterNeverChecked = "never_checked"
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
SeriesFilterAll = "all"
|
||||
SeriesFilterNoURL = "no_series_url"
|
||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||
SeriesFilterNoReaders = "no_readers"
|
||||
SeriesFilterNeverChecked = "never_checked"
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
SeriesFilterSiteCompleted = "site_completed"
|
||||
SeriesFilterFailing = "failing"
|
||||
SeriesFilterUnverified = "unverified"
|
||||
)
|
||||
|
||||
// SeriesFilter is one named filter predicate over the whole library. Site
|
||||
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
|
||||
// staleness boundary the "stale" filter compares against, supplied by the
|
||||
// caller's clock — the store has no clock; Page is 1-based.
|
||||
// staleness boundary the age-based filters — "stale" and the failing pair —
|
||||
// compare against, supplied by the caller's clock — the store has no clock;
|
||||
// Page is 1-based.
|
||||
type SeriesFilter struct {
|
||||
Site string // "" = every Site
|
||||
Kind string // "" = both library buckets' series
|
||||
Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll
|
||||
Cutoff int64 // unix ms; "stale" reads it, the store never does
|
||||
Cutoff int64 // unix ms; the age-based filters read it, the store never does
|
||||
Page int // 1-based page of the row read; default 1
|
||||
}
|
||||
|
||||
@@ -45,7 +51,7 @@ type SeriesFilter struct {
|
||||
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
||||
const adminSeriesColumns = `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_corrected_at, s.finished_at, s.site_completed_at`
|
||||
|
||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||
@@ -54,6 +60,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,9 +95,19 @@ 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
|
||||
// SiteCompletedAt is when the last successful Poll read saw the Site's own
|
||||
// completed value, zero meaning it did not (issue #168). The Site's marker,
|
||||
// never a Lifecycle decision: the owner's Finish is the only retirement.
|
||||
SiteCompletedAt int64
|
||||
}
|
||||
|
||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||
@@ -119,17 +141,26 @@ 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), site completed (the Site's marker, 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.
|
||||
//
|
||||
// The clock-versus-outcome split decides which predicates exclude finished
|
||||
// Series (`s.finished_at = 0` in each of the four): the clock-driven one —
|
||||
// Series (`s.finished_at = 0` in each of the five): the clock-driven ones —
|
||||
// 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; site-completed's stamp is the Site's, and it keeps standing after the
|
||||
// owner retires the row, so a finished Series would be reported as work
|
||||
// nobody is going to do. 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,8 +188,16 @@ 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 SeriesFilterSiteCompleted:
|
||||
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
|
||||
case SeriesFilterNoReaders:
|
||||
having = `HAVING COUNT(b.reader_id) = 0`
|
||||
default:
|
||||
@@ -204,15 +243,18 @@ 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.site_completed_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 +293,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 +316,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
|
||||
@@ -287,8 +330,8 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
||||
if err := scan(
|
||||
&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.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.SiteCompletedAt,
|
||||
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
|
||||
); err != nil {
|
||||
return AdminSeries{}, 0, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user