Cross-Series read model and the privacy boundary #116

Closed
opened 2026-08-17 15:22:42 +07:00 by sulthan · 6 comments
Owner

Part of #114

Question

What does the admin Series list read, and what must it refuse to show?

There is no generic all-Series query today: store.List/Get are per-readerID, and
DueForLatestCheck (backend/internal/store/store.go) is the only cross-reader join. It already
computes an anonymous readerCount, which is the shape this view should follow.

To decide:

  • The store method's signature and the columns it returns, and whether it reuses seriesColumns
    or needs its own projection. Only compile-time constants may be concatenated into query text.
  • Sorting, filtering, and paging. A library of unknown size behind one owner-only page still
    needs a bound; whether that is a page size, a hard cap, or a search box is open.
  • The anonymous Reader count's exact definition: all bookmarks, or only non-finished ones (which
    is what DueForLatestCheck and EligibleSeriesCount mean by eligible)?
  • The attribution collision. series.latest_raised_by is an FK to readers and
    latest_sighted_at says when a Reader's report last stood in for a Poll (ADR-0011). That is
    per-Reader identity on a shared row, and the privacy boundary says Series-level facts plus
    counts only. Does the Series view show that a Latest Chapter was Sighting-raised without
    saying by whom, show the Reader, or omit it entirely — and if it is hidden, how does the owner
    investigate a Series whose Latest Chapter looks wrong?

Added by #115

The read model has a second consumer: the landing page's stats block needs one grouped
aggregate over series — per Site, and library-wide — counting no cover (cover_address = ''),
never checked (latest_checked_at = 0 with a series_url), stale over 24h, unpollable
(series_url = ''), and Sighting-raised (latest_sighted_at > latest_checked_at), plus the
manga/novel split. One COUNT(…) FILTER (WHERE …) … GROUP BY site pass, not one query per
figure. EligibleSeriesCount is the wrong shape for it: one Site per call, one number back.

Filter state is URL-addressable (/admin/series?filter=no-cover), so each filter this ticket
supports must be nameable in a query string.

Part of #114 ## Question What does the admin Series list read, and what must it refuse to show? There is no generic all-Series query today: `store.List`/`Get` are per-`readerID`, and `DueForLatestCheck` (`backend/internal/store/store.go`) is the only cross-reader join. It already computes an anonymous `readerCount`, which is the shape this view should follow. To decide: - The store method's signature and the columns it returns, and whether it reuses `seriesColumns` or needs its own projection. Only compile-time constants may be concatenated into query text. - Sorting, filtering, and paging. A library of unknown size behind one owner-only page still needs a bound; whether that is a page size, a hard cap, or a search box is open. - The anonymous Reader count's exact definition: all bookmarks, or only non-finished ones (which is what `DueForLatestCheck` and `EligibleSeriesCount` mean by eligible)? - **The attribution collision.** `series.latest_raised_by` is an FK to `readers` and `latest_sighted_at` says when a Reader's report last stood in for a Poll (ADR-0011). That is per-Reader identity on a shared row, and the privacy boundary says Series-level facts plus counts only. Does the Series view show that a Latest Chapter was Sighting-raised without saying by whom, show the Reader, or omit it entirely — and if it is hidden, how does the owner investigate a Series whose Latest Chapter looks wrong? ## Added by #115 The read model has a **second consumer**: the landing page's stats block needs one grouped aggregate over `series` — per Site, and library-wide — counting no cover (`cover_address = ''`), never checked (`latest_checked_at = 0` with a `series_url`), stale over 24h, unpollable (`series_url = ''`), and Sighting-raised (`latest_sighted_at > latest_checked_at`), plus the manga/novel split. One `COUNT(…) FILTER (WHERE …) … GROUP BY site` pass, not one query per figure. `EligibleSeriesCount` is the wrong shape for it: one Site per call, one number back. Filter state is URL-addressable (`/admin/series?filter=no-cover`), so each filter this ticket supports must be nameable in a query string.
sulthan added the wayfinder:grilling label 2026-08-17 15:22:42 +07:00
sulthan self-assigned this 2026-08-17 16:13:36 +07:00
Author
Owner

Resolution

Two store methods, a dedicated row type that cannot carry Reader identity, one URL-addressable
filter enum, and a 12-hour staleness threshold. The privacy boundary is enforced by the
projection, not by a template.

Surface

// SeriesFilter selects the admin Series page. Filter is one of the names below;
// an unknown name filters nothing. StaleBefore is a caller-supplied epoch-ms
// cutoff, so the store stays clockless like DueForLatestCheck.
type SeriesFilter struct {
	Site        string // "" = every Site
	Kind        string // "" = both Libraries
	Filter      string // "", no-cover, unchecked, stale, unpollable, sighting-raised, orphan
	StaleBefore int64
	Page        int    // 1-based
}

func (s *Store) AdminSeries(f SeriesFilter) (rows []SeriesRow, total int, err error)
func (s *Store) SeriesStats(staleBefore int64) ([]SiteStats, error)

Two methods, not one: the landing page reads only the aggregate and must not pay for 50 rows it
discards. The filter vocabulary is shared by being one set of named predicate constants, not by
being one method.

SeriesRow — the privacy boundary as a type

store.Series is not reused. seriesColumns (store.go:202) carries s.latest_raised_by, and
scanSeries lands it on Series.LatestRaisedBy, so reusing it would put Reader identity in the
admin handler and leave the boundary resting on a template that happens not to print a field.
SeriesRow has no field for it:

type SeriesRow struct {
	Site, SeriesID   string
	Title, Kind      string
	LatestChapter    string
	LatestChapterNum *float64
	LatestCheckedAt  int64
	SightingRaised   bool // latest_sighted_at > latest_checked_at
	HasCover         bool // cover_address <> ''
	Pollable         bool // series_url <> ''
	ReaderCount      int
}

SightingRaised answers the attribution question: the owner learns that a reader's Sighting, not
a Poll, set this Latest Chapter, and learns nothing about which Reader. It is computed in SQL so
no caller repeats the comparison. latest_raised_by never leaves the store package. Guard it the
way api_test.go:597-600 guards latest_checked_at against bookmarkColumns: a test asserting the
new column constant does not mention latest_raised_by.

Key() derives site:series_id as everywhere else — the route key from #115.

The row query

SELECT <adminSeriesColumns>, COUNT(b.reader_id) AS reader_count, COUNT(*) OVER () AS total
  FROM series s
  LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
 WHERE ($1::text = '' OR s.site = $1) AND ($2::text = '' OR s.kind = $2) [AND <predicate>]
 GROUP BY s.site, s.series_id, s.title, s.kind, s.latest_chapter, s.latest_chapter_num,
          s.latest_checked_at, s.latest_sighted_at, s.cover_address, s.series_url
[HAVING COUNT(b.reader_id) = 0]
 ORDER BY s.latest_checked_at ASC, s.site, s.series_id
 LIMIT 50 OFFSET $n
  • LEFT JOIN, not JOIN. store.Delete removes a Bookmark and leaves the series row, and
    nothing deletes one, so orphan Series accumulate. The Lane's HAVING COUNT(*) > 0 hides them;
    the dashboard's third job is Library-wide hygiene, so it must not. reader_count = 0 is the
    orphan marker, and it is an anonymous Series-level fact. Removal is its own ticket, #125.
  • Reader count is a plain COUNT, not COUNT(*) FILTER (WHERE b.status <> 'finished'). It
    counts every Bookmark on the Series. This knowingly disagrees with the two Lane queries for as
    long as the finished Lifecycle bucket exists; #124 removes that bucket, after which the two
    definitions are the same set. Recording the divergence rather than hiding it: until #124 lands,
    a Series every Reader finished shows a non-zero count and is never Polled.
  • Tie-break is mandatory. Every unpollable Series has latest_checked_at = 0, so ordering on
    that column alone gives no stable page boundary and rows would repeat or vanish across pages.
  • Predicates are compile-time constants selected by the enum; the enum value never reaches
    query text. Site and Kind are $N parameters.
?filter= predicate
no-cover s.cover_address = ''
unchecked s.latest_checked_at = 0 AND s.series_url <> ''
stale s.latest_checked_at > 0 AND s.latest_checked_at <= $staleBefore
unpollable s.series_url = ''
sighting-raised s.latest_sighted_at > s.latest_checked_at
orphan HAVING COUNT(b.reader_id) = 0
unknown or absent none

unpollable means series_url = '' only. The second unpollable case — a series_url whose
host fails the pin in fetchableSeriesURL (poller.go:555) — is invisible to SQL, needs the Site
registry in Go, and would break both the count and the pager if filtered after the read. It
belongs to #121, which repairs a wrong series_url; it is a repair, not a hygiene count.

An empty series_url is reachable and permanent: the column defaults to '', Upsert writes
title/series_url/cover only when the row is brand new (store.go:814, 858-869), and no Poll
ever writes it. So a PUT that omitted it creates a Series no client action can fix.

Paging and the total

50 rows per page, ?page= 1-based. The total comes from COUNT(*) OVER () in the same query:
window functions run after GROUP BY/HAVING and before LIMIT, so the figure counts the
filtered groups, and one WHERE clause cannot disagree with a second copy of itself.

Safety, since the window count vanishes on an empty page: the handler treats zero rows with
Page > 1 as an over-run and re-reads at page 1. That costs a second query only in the case
that would otherwise show a blank list with no pager.

Staleness

staleAfter = 12 * time.Hour, a constant in the web package, passed down as StaleBefore.
Twelve hours, not 24: the owner checks once in the day and once at night, and each check should
cover the interval since the last one. This supersedes the "unchecked over 24h" figure in
#115's landing verdict line.
It is a human threshold, deliberately not a multiple of
defaultRest, and the landing page reads the database, not the poller, so it cannot follow the
Lane's pace anyway.

Aggregate

SeriesStats is one pass: GROUP BY s.site with COUNT(*) FILTER (WHERE …) per hygiene class
plus the manga/novel split. Library-wide totals are summed in Go over five Sites — ROLLUP would
add a NULL-Site row every scanner has to special-case. The orphan count needs the Bookmark side,
so the query joins a grouped subquery (LEFT JOIN (SELECT site, series_id FROM bookmarks GROUP BY site, series_id) b USING (site, series_id)) and counts FILTER (WHERE b.site IS NULL), rather
than putting a subquery inside a FILTER. Landing-page counts include orphans: they are exactly
what needs attention.

Index

Migration 0012: CREATE INDEX IF NOT EXISTS series_latest_checked_at_idx ON series (latest_checked_at);
series has only its primary key today (no migration creates an index). This one supports the
default order and is a judgement, not a measurement — a GROUP BY over a join may ignore it.
Revisit with a timing on real data before adding a second.

Pushed onto other tickets

  • #118 — the filter vocabulary is fixed here (seven names). #118 decides labels, which counts
    the stats block links, and how a filtered list presents itself.
  • #121 — owns the host-pin unfetchable case and series_url repair.
  • #124 — owns the Reader-count divergence and will add one filter value and one predicate.
  • #125 — new: whether an orphan Series can be removed, and what that does to its Cover bytes.
## Resolution Two store methods, a dedicated row type that cannot carry Reader identity, one URL-addressable filter enum, and a 12-hour staleness threshold. The privacy boundary is enforced by the projection, not by a template. ### Surface ```go // SeriesFilter selects the admin Series page. Filter is one of the names below; // an unknown name filters nothing. StaleBefore is a caller-supplied epoch-ms // cutoff, so the store stays clockless like DueForLatestCheck. type SeriesFilter struct { Site string // "" = every Site Kind string // "" = both Libraries Filter string // "", no-cover, unchecked, stale, unpollable, sighting-raised, orphan StaleBefore int64 Page int // 1-based } func (s *Store) AdminSeries(f SeriesFilter) (rows []SeriesRow, total int, err error) func (s *Store) SeriesStats(staleBefore int64) ([]SiteStats, error) ``` Two methods, not one: the landing page reads only the aggregate and must not pay for 50 rows it discards. The filter vocabulary is shared by being one set of named predicate constants, not by being one method. ### SeriesRow — the privacy boundary as a type `store.Series` is not reused. `seriesColumns` (store.go:202) carries `s.latest_raised_by`, and `scanSeries` lands it on `Series.LatestRaisedBy`, so reusing it would put Reader identity in the admin handler and leave the boundary resting on a template that happens not to print a field. `SeriesRow` has no field for it: ```go type SeriesRow struct { Site, SeriesID string Title, Kind string LatestChapter string LatestChapterNum *float64 LatestCheckedAt int64 SightingRaised bool // latest_sighted_at > latest_checked_at HasCover bool // cover_address <> '' Pollable bool // series_url <> '' ReaderCount int } ``` `SightingRaised` answers the attribution question: the owner learns that a reader's Sighting, not a Poll, set this Latest Chapter, and learns nothing about which Reader. It is computed in SQL so no caller repeats the comparison. `latest_raised_by` never leaves the store package. Guard it the way api_test.go:597-600 guards `latest_checked_at` against `bookmarkColumns`: a test asserting the new column constant does not mention `latest_raised_by`. `Key()` derives `site:series_id` as everywhere else — the route key from #115. ### The row query ``` SELECT <adminSeriesColumns>, COUNT(b.reader_id) AS reader_count, COUNT(*) OVER () AS total FROM series s LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id WHERE ($1::text = '' OR s.site = $1) AND ($2::text = '' OR s.kind = $2) [AND <predicate>] GROUP BY s.site, s.series_id, s.title, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_sighted_at, s.cover_address, s.series_url [HAVING COUNT(b.reader_id) = 0] ORDER BY s.latest_checked_at ASC, s.site, s.series_id LIMIT 50 OFFSET $n ``` - **LEFT JOIN, not JOIN.** `store.Delete` removes a Bookmark and leaves the `series` row, and nothing deletes one, so orphan Series accumulate. The Lane's `HAVING COUNT(*) > 0` hides them; the dashboard's third job is Library-wide hygiene, so it must not. `reader_count = 0` *is* the orphan marker, and it is an anonymous Series-level fact. Removal is its own ticket, #125. - **Reader count is a plain `COUNT`,** not `COUNT(*) FILTER (WHERE b.status <> 'finished')`. It counts every Bookmark on the Series. This knowingly disagrees with the two Lane queries for as long as the finished Lifecycle bucket exists; #124 removes that bucket, after which the two definitions are the same set. Recording the divergence rather than hiding it: until #124 lands, a Series every Reader finished shows a non-zero count and is never Polled. - **Tie-break is mandatory.** Every unpollable Series has `latest_checked_at = 0`, so ordering on that column alone gives no stable page boundary and rows would repeat or vanish across pages. - **Predicates are compile-time constants** selected by the enum; the enum value never reaches query text. Site and Kind are `$N` parameters. | `?filter=` | predicate | | --- | --- | | `no-cover` | `s.cover_address = ''` | | `unchecked` | `s.latest_checked_at = 0 AND s.series_url <> ''` | | `stale` | `s.latest_checked_at > 0 AND s.latest_checked_at <= $staleBefore` | | `unpollable` | `s.series_url = ''` | | `sighting-raised` | `s.latest_sighted_at > s.latest_checked_at` | | `orphan` | `HAVING COUNT(b.reader_id) = 0` | | unknown or absent | none | `unpollable` means **`series_url = ''` only**. The second unpollable case — a `series_url` whose host fails the pin in `fetchableSeriesURL` (poller.go:555) — is invisible to SQL, needs the Site registry in Go, and would break both the count and the pager if filtered after the read. It belongs to #121, which repairs a wrong `series_url`; it is a repair, not a hygiene count. An empty `series_url` is reachable and permanent: the column defaults to `''`, `Upsert` writes `title`/`series_url`/`cover` only when the row is brand new (store.go:814, 858-869), and no Poll ever writes it. So a PUT that omitted it creates a Series no client action can fix. ### Paging and the total 50 rows per page, `?page=` 1-based. The total comes from `COUNT(*) OVER ()` in the same query: window functions run after `GROUP BY`/`HAVING` and before `LIMIT`, so the figure counts the filtered groups, and one `WHERE` clause cannot disagree with a second copy of itself. Safety, since the window count vanishes on an empty page: the handler treats zero rows with `Page > 1` as an over-run and re-reads at page 1. That costs a second query only in the case that would otherwise show a blank list with no pager. ### Staleness `staleAfter = 12 * time.Hour`, a constant in the web package, passed down as `StaleBefore`. Twelve hours, not 24: the owner checks once in the day and once at night, and each check should cover the interval since the last one. **This supersedes the "unchecked over 24h" figure in #115's landing verdict line.** It is a human threshold, deliberately not a multiple of `defaultRest`, and the landing page reads the database, not the poller, so it cannot follow the Lane's pace anyway. ### Aggregate `SeriesStats` is one pass: `GROUP BY s.site` with `COUNT(*) FILTER (WHERE …)` per hygiene class plus the manga/novel split. Library-wide totals are summed in Go over five Sites — `ROLLUP` would add a NULL-Site row every scanner has to special-case. The orphan count needs the Bookmark side, so the query joins a grouped subquery (`LEFT JOIN (SELECT site, series_id FROM bookmarks GROUP BY site, series_id) b USING (site, series_id)`) and counts `FILTER (WHERE b.site IS NULL)`, rather than putting a subquery inside a `FILTER`. Landing-page counts include orphans: they are exactly what needs attention. ### Index Migration `0012`: `CREATE INDEX IF NOT EXISTS series_latest_checked_at_idx ON series (latest_checked_at);` `series` has only its primary key today (no migration creates an index). This one supports the default order and is a judgement, not a measurement — a `GROUP BY` over a join may ignore it. Revisit with a timing on real data before adding a second. ### Pushed onto other tickets - **#118** — the filter vocabulary is fixed here (seven names). #118 decides labels, which counts the stats block links, and how a filtered list presents itself. - **#121** — owns the host-pin unfetchable case and `series_url` repair. - **#124** — owns the Reader-count divergence and will add one filter value and one predicate. - **#125** — new: whether an orphan Series can be removed, and what that does to its Cover bytes.
Author
Owner

From #119 (closed): series gains force_poll_at bigint NOT NULL DEFAULT 0 in migration 0013. SeriesRow projects it and exposes a derived PollPending (force_poll_at > latest_checked_at) so the admin row can render check requested <age> ago. The unchecked and stale filters are unaffected — a Forced Poll never writes latest_checked_at, which is exactly why the flag is its own column rather than a zeroed timestamp.

From #119 (closed): `series` gains `force_poll_at bigint NOT NULL DEFAULT 0` in migration 0013. **SeriesRow projects it** and exposes a derived `PollPending` (`force_poll_at > latest_checked_at`) so the admin row can render `check requested <age> ago`. The `unchecked` and `stale` filters are unaffected — a Forced Poll never writes `latest_checked_at`, which is exactly why the flag is its own column rather than a zeroed timestamp.
Author
Owner

Amendment from #117 (closed):

  • staleAfter becomes ownerWindow = 12 * time.Hour, one constant feeding both the staleness
    cutoff specified here and #117's per-Site outcome window. Same 12h, same rationale (one look by
    day, one by night, each covering the interval since the last); two differently-named 12h
    constants on one page is how they drift apart. StaleBefore in SeriesFilter is unchanged —
    the store stays clockless.
  • The filter set has a hole this ticket could not see: latest_checked_at is stamped before
    the fetch (poller.go:428), so it means attempted. A Series broken for a month is
    unchecked-clean, stale-clean and unpollable-clean. #127 owns the fix (per-Series outcome
    columns plus an eighth filter name).
Amendment from #117 (closed): - **`staleAfter` becomes `ownerWindow = 12 * time.Hour`**, one constant feeding both the staleness cutoff specified here and #117's per-Site outcome window. Same 12h, same rationale (one look by day, one by night, each covering the interval since the last); two differently-named 12h constants on one page is how they drift apart. `StaleBefore` in `SeriesFilter` is unchanged — the store stays clockless. - **The filter set has a hole this ticket could not see**: `latest_checked_at` is stamped *before* the fetch (poller.go:428), so it means *attempted*. A Series broken for a month is `unchecked`-clean, `stale`-clean and `unpollable`-clean. #127 owns the fix (per-Series outcome columns plus an eighth filter name).
Author
Owner

Amendment from #118 (closed):

  • An eighth filter name, no-chapter, joins the enum as another compile-time predicate
    constant: s.latest_chapter_num IS NULL AND s.latest_checked_at > 0 AND s.series_url <> '' — a
    Series the poller has attempted and never once read a chapter number from. Disjoint from
    unchecked by construction (latest_checked_at = 0 vs > 0), so the two counts never
    double-report a row.
  • SeriesStats gains the matching COUNT(*) FILTER (WHERE ...) column, so the landing block can
    show and link it. One more FILTER in the pass that already exists — no new query shape.
  • The seven query-string names specified here are the API and are unchanged; #118 only adds display
    labels beside them.
  • The sighting-raised filter carries more weight than this ticket gave it: #118 rejected a
    "Latest Chapter went backwards" filter, leaving sighting-raised as the only lens on a Latest
    Chapter that may be wrong.
Amendment from #118 (closed): - **An eighth filter name**, `no-chapter`, joins the enum as another compile-time predicate constant: `s.latest_chapter_num IS NULL AND s.latest_checked_at > 0 AND s.series_url <> ''` — a Series the poller has attempted and never once read a chapter number from. Disjoint from `unchecked` by construction (`latest_checked_at = 0` vs `> 0`), so the two counts never double-report a row. - `SeriesStats` gains the matching `COUNT(*) FILTER (WHERE ...)` column, so the landing block can show and link it. One more `FILTER` in the pass that already exists — no new query shape. - The seven query-string names specified here are the API and are unchanged; #118 only adds display labels beside them. - The `sighting-raised` filter carries more weight than this ticket gave it: #118 rejected a "Latest Chapter went backwards" filter, leaving `sighting-raised` as the only lens on a Latest Chapter that may be wrong.
Author
Owner

Amendment from #124 (closed):

  • The ReaderCount divergence you recorded is resolved. #124 deletes the finished Lifecycle bucket and both Lane queries drop HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0, so your plain COUNT and the set the Lanes poll are now the same set, exactly as you predicted.
  • SeriesRow gains FinishedAt int64 (projected the way #119's amendment added force_poll_at), so the list row can show the state and the detail page can offer the undo.
  • A tenth predicate constant, finished → s.finished_at > 0, and SeriesStats gains the matching COUNT(*) FILTER (WHERE finished_at > 0).
  • Four existing predicates gain AND s.finished_at = 0: stale, unchecked, no-cover, no-chapter. A finished Series stops being checked, so without the guard it ages into stale for ever. unpollable, orphan and sighting-raised are untouched — each is still a real repair on a finished row.
  • bookmarkColumns gains a derived reader-facing finished bool (s.finished_at > 0), a plain label with no filtering or ordering effect. finished_at itself never appears in a client-visible write path, and Upsert's explicit INSERT INTO series (...) column list is what enforces that — the same mechanism that already protects cover.
Amendment from #124 (closed): - **The `ReaderCount` divergence you recorded is resolved.** #124 deletes the finished Lifecycle bucket and both Lane queries drop `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0`, so your plain `COUNT` and the set the Lanes poll are now the same set, exactly as you predicted. - **`SeriesRow` gains `FinishedAt int64`** (projected the way #119's amendment added `force_poll_at`), so the list row can show the state and the detail page can offer the undo. - **A tenth predicate constant**, `finished` → `s.finished_at > 0`, and `SeriesStats` gains the matching `COUNT(*) FILTER (WHERE finished_at > 0)`. - **Four existing predicates gain `AND s.finished_at = 0`**: `stale`, `unchecked`, `no-cover`, `no-chapter`. A finished Series stops being checked, so without the guard it ages into `stale` for ever. `unpollable`, `orphan` and `sighting-raised` are untouched — each is still a real repair on a finished row. - `bookmarkColumns` gains a derived reader-facing `finished bool` (`s.finished_at > 0`), a plain label with no filtering or ordering effect. `finished_at` itself never appears in a client-visible write path, and `Upsert`'s explicit `INSERT INTO series (...)` column list is what enforces that — the same mechanism that already protects `cover`.
Author
Owner

Amendment from #127 (closed):

  • AdminSeries gains LEFT JOIN poll_failures f USING (site, series_id) (migration 0017), and SeriesRow gains two fields: the outcome word and failing_since. Nothing on series was added — the row's existence in poll_failures is the failure state, so there is no success sentinel to project.
  • The filter vocabulary is now eleven names, not seven: #118 added no-chapter (eighth), #124 finished (tenth), and #127 adds failing (ninth) and unverified (eleventh). failing = the joined row exists AND s.latest_chapter_num IS NOT NULL AND f.failing_since older than ownerWindow; unverified = s.latest_raised_by IS NOT NULL plus the failing test.
  • The join is what makes unverified free, and it is why #127 rejected storing that pair as a column: with the two facts in two tables, a stored copy would need five writers for a value the join computes.
Amendment from #127 (closed): - **`AdminSeries` gains `LEFT JOIN poll_failures f USING (site, series_id)`** (migration 0017), and **`SeriesRow` gains two fields**: the outcome word and `failing_since`. Nothing on `series` was added — the row's *existence* in `poll_failures` is the failure state, so there is no success sentinel to project. - **The filter vocabulary is now eleven names**, not seven: #118 added `no-chapter` (eighth), #124 `finished` (tenth), and #127 adds `failing` (ninth) and `unverified` (eleventh). `failing` = the joined row exists `AND s.latest_chapter_num IS NOT NULL AND f.failing_since` older than `ownerWindow`; `unverified` = `s.latest_raised_by IS NOT NULL` plus the `failing` test. - The join is what makes `unverified` free, and it is why #127 rejected storing that pair as a column: with the two facts in two tables, a stored copy would need five writers for a value the join computes.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#116