package store import ( "database/sql" "fmt" "strconv" "strings" ) // 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. 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" 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 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; the age-based filters read it, the store never does Page int // 1-based page of the row read; default 1 } // adminSeriesColumns is the owner's library-wide Series projection in // scanAdminSeries order. It is the privacy boundary: a Series' row carries // the Reader id that raised its Latest Chapter (latest_raised_by), and that id // must never leave the store package — so the projection does not select it, // 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` // raisedByReaderAnswer answers "did a Reader's report set this number" without // naming which Reader. Kept apart from adminSeriesColumns so the column list — // the shape scanAdminSeries is fed — stays free of the Sighting-raiser // identity, and the owner learns which rows to act on and nothing about the // 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 // AdminSeries is one Series as the owner's library-wide view sees it: a // Series-level fact plus an anonymous Reader count. ReaderCount being zero is // the orphan marker. RaisedByReader is the only trace of the Sighting // mechanism here; the Reader id behind it never reaches this type. type AdminSeries struct { Site string SeriesID string Title string SeriesURL string CoverAddress string // "" = no Cover yet Kind string LatestChapter string LatestChapterNum *float64 // nil until first captured LatestCheckedAt int64 // ForcePollAt is the owner's "check now" request stamp (issue #146), zero // meaning never asked. Pending is derived, never stored: a request is // pending while ForcePollAt is newer than LatestCheckedAt. ForcePollAt int64 // LatestCorrectedAt is the correction stamp (issue #149): non-zero means // the Latest Chapter is the owner's, zero means never corrected. The // provenance line (#152) derives from it, so the zero-means-never meaning // is load-bearing. 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 } // SeriesPage is one page of the owner's filtered Series list plus the count // of every Series matching the same filter — a window number, not the page's // len, so the landing page's figure and the list heading come from one query. type SeriesPage struct { Rows []AdminSeries Total int } // SiteSeriesShape is one Site's share of the Series matching a filter: how // many, and the manga/novel split. One grouped pass, then library-wide totals // are summed in Go over the rows — the landing page's per-Site table reads // this and never pays for the rows the list discards. type SiteSeriesShape struct { Site string Total int Manga int Novel int } // Key returns the canonical identity in bookmark-key form (":"). func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID } // adminFilter maps a filter's named predicate to its compile-time WHERE and // HAVING clauses and their bound parameters — the name never reaches query // text, and Site and Kind bind as parameters. Shared by the row read and the // per-Site aggregate so the two cannot disagree on what a filter means. // // The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch- // 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), 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 — // 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 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 // checked Series is already counted on its own "waiting"/never-checked // filter, folding it in would double-report it. The landing page computes the // inclusive number as stale + never-checked. func adminFilter(f SeriesFilter) (where, having string, args []any, err error) { var clauses []string if f.Kind != "" { args = append(args, f.Kind) clauses = append(clauses, "s.kind = $"+strconv.Itoa(len(args))) } switch f.Name { case "", SeriesFilterAll: case SeriesFilterNoURL: clauses = append(clauses, `s.series_url = ''`) case SeriesFilterNoChapter: clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL AND s.finished_at = 0`) case SeriesFilterNeverChecked: clauses = append(clauses, `s.latest_checked_at = 0 AND s.finished_at = 0`) case SeriesFilterStale: clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)+` AND s.finished_at = 0`) args = append(args, f.Cutoff) case SeriesFilterNoCover: 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: having = `HAVING COUNT(b.reader_id) = 0` default: return "", "", nil, fmt.Errorf("unknown series filter %q", f.Name) } if len(clauses) > 0 { where = "WHERE " + strings.Join(clauses, " AND ") } return where, having, args, nil } // SeriesPage returns one page of the Series matching the filter, least // recently checked first. The LEFT JOIN to Bookmarks is what surfaces the // orphans that hygiene has to find — an inner join would hide them, exactly // as the Lane's join does. Every bookmark keeps its Series polled now that // finished is a Series flag, so this plain ReaderCount agrees with the Lane // queries (issue #157). // // The tie-break is mandatory, not decorative: every unpollable Series shares a // zero check stamp, so ordering on that column alone gives no stable page // boundary and rows would repeat or vanish across pages. (site, series_id) is // the primary key, hence total. The filtered total is a window count in the // same query — window functions run after grouping and before the limit, so // one where-clause cannot disagree with a second copy of itself. func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) { where, having, args, err := adminFilter(f) if err != nil { return SeriesPage{}, err } if f.Page < 1 { f.Page = 1 } // Site narrowing is the row read's own; the aggregate must see every Site. if f.Site != "" { args = append(args, f.Site) clause := "s.site = $" + strconv.Itoa(len(args)) if where == "" { where = "WHERE " + clause } else { where += " AND " + clause } } base := len(args) args = append(args, seriesPageSize, seriesPageSize*(f.Page-1)) rows, err := s.db.Query(` 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, 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...) if err != nil { return SeriesPage{}, fmt.Errorf("query series page: %w", err) } defer rows.Close() out := SeriesPage{} for rows.Next() { a, total, err := scanAdminSeries(rows.Scan) if err != nil { return SeriesPage{}, fmt.Errorf("scan series page: %w", err) } out.Rows = append(out.Rows, a) out.Total = total } return out, rows.Err() } // SeriesShapes returns each Site's share of the Series matching the filter, // one grouped pass. Site and Page are row-read concerns and are ignored; the // Landing page reads this per Site and sums the totals in Go for the // library-wide figure. func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) { where, having, args, err := adminFilter(f) if err != nil { return nil, err } rows, err := s.db.Query(` SELECT site, COUNT(*) AS total, COUNT(*) FILTER (WHERE kind = 'manga') AS manga, COUNT(*) FILTER (WHERE kind = 'novel') AS novel FROM ( 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+` ) shape GROUP BY site ORDER BY site`, args...) if err != nil { return nil, fmt.Errorf("query series shapes: %w", err) } defer rows.Close() out := []SiteSeriesShape{} for rows.Next() { var sh SiteSeriesShape if err := rows.Scan(&sh.Site, &sh.Total, &sh.Manga, &sh.Novel); err != nil { return nil, fmt.Errorf("scan series shape: %w", err) } out = append(out, sh) } return out, rows.Err() } // 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 latestChapterNum sql.NullFloat64 total int ) 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.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total, ); err != nil { return AdminSeries{}, 0, err } if latestChapterNum.Valid { a.LatestChapterNum = &latestChapterNum.Float64 } return a, total, nil }