Cross-Series admin read model, with the privacy boundary in the projection #140
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?
Parent
Spec #134.
What to build
Every read of a Series today is scoped to one Reader, so the owner cannot ask library-wide questions: how many Series have no Cover, which ones has the poller never read a chapter from, which ones does nobody hold any more. Those rows accumulate -- removing a Bookmark leaves the Series behind and nothing ever deletes one. This ticket lands the two store reads that answer those questions, and the privacy boundary that keeps them anonymous. No page changes here; it is verified at the store seam.
A filter value object (Site, Library kind, filter name, a caller-supplied staleness cutoff, a 1-based page) feeds two methods: a row read returning rows plus the filtered total, and a per-Site aggregate. Two methods, not one -- the landing page reads only the aggregate and must not pay for fifty rows it discards. The filter vocabulary is shared by being one set of named predicate constants, not by being one method.
Eight filter names: no series URL, never read a chapter, no Readers, never checked, not checked in the window, no cover, latest from a Reader's report, and the absent/unknown case meaning all Series. Ordered permanent-and-fixable first, and labelled as the repair they need rather than as the SQL they are -- the owner arrives to act, not to admire a predicate. Two notes that are easy to get wrong: "no series URL" means an empty URL only (the host-fails-the-fetch-gate case 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 is a repair, and belongs to a later spec); and "never read a chapter" and "never checked" are disjoint by construction (non-zero versus zero check stamp), so the two counts never double-report a row.
The row query is a LEFT JOIN to Bookmarks, not a JOIN: orphans are exactly what hygiene has to find, and the Lane's join hides them. A zero Reader count is the orphan marker, and it is an anonymous Series-level fact. Reader count is a plain count of every Bookmark on the Series, which knowingly disagrees with the two Lane queries for as long as the finished Lifecycle bucket exists -- a later spec removes that bucket, after which the definitions are the same set. Recorded rather than hidden.
The tie-break is mandatory. Every unpollable Series has a zero check stamp, so ordering on that column alone gives no stable page boundary and rows repeat or vanish across pages. 50 rows a page; the filtered total comes from a window count in the same query, because window functions run after grouping and before the limit, so one where-clause cannot disagree with a second copy of itself.
The privacy boundary is the projection, not a template. The existing Series column list carries the Reader id that raised a Sighting, 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. The admin row type has no field for it and that column never leaves the store package. A boolean answering "a Reader's report set this number" is computed in SQL instead, so the owner learns which Series to look at and nothing about which Reader.
Also lands the two schema pieces the read model and the next tickets need, as an expand step: a
series (latest_checked_at)index (the table has only its primary key today -- this supports the default order and is a judgement, not a measurement, since a grouped query over a join may ignore it; re-time on real data before adding a second), and aforce_poll_atcolumn defaulting to zero, unused until the Forced Poll ticket wires it.Acceptance criteria
go test ./...greenBlocked by
None — can start immediately.