Files
mangaBookmark/docs/adr/0013-commands-through-the-database.md
T
sulthan 889f0f3f38 Admin dashboard: pages, Lane observability, poll pass log, per-Series intervention (#134) (#148)
Spec #134, all ten tickets. Closes #134.

## What ships

The admin surface becomes four bookmarkable addresses behind one nav row, and Lane observability stops dying with the process.

- **#138** `/admin` splits into Overview, Lanes, Readers, Series, each a real route with the active tab underlined.
- **#139** `poll_passes` and `poll_lanes` land as durable tables with their store surface.
- **#140** cross-Series admin read model, with the privacy boundary in the projection: the Reader id that raised a Latest Chapter never leaves the store package.
- **#141** the poller records exactly one pass row per exit, with a skip reason and outcome counts.
- **#142** Series list: eight hygiene filters, Site and Library narrowing, paging — all of it in the query string, so a filtered list is a bookmark.
- **#143** Overview: a three-state verdict line and a stats block where every non-zero figure links to the list that counts it.
- **#144** per-Series detail page, keyed by the `site:series_id` composite the rest of the system already uses.
- **#145** the Lanes page reads the database; the in-memory Lane state, `web.LaneReporter` and `latest.Status` are deleted.
- **#146** Forced Poll: *Check now* stamps `series.force_poll_at` and never commands the poller.
- **#147** pause and resume one Site's Lane, with a mandatory 1h/6h/24h expiry.

## Shape of the design

Two decisions carry the rest. **Commands go through the database, never at the poller**: both *Check now* and a Lane pause write a row the next pass reads, so they survive a restart and the whole surface stays testable with no poller running. And **pending is derived, never stored** — the request stamp being newer than the check stamp — which self-clears on the check stamp with no second write and no sweeper, because the check stamp is written before the fetch.

ADRs: `docs/adr/0012-persisted-lane-state.md`, `docs/adr/0013-commands-through-the-database.md`.

## Verification

`go test ./...` green on the merged base (`264839e`), all packages, Docker-backed. `gofmt -l` and `go vet` clean.

Every ticket was reviewed on both axes (`cr-spec` + `cr-standards`) before merge.

## Known, non-blocking

- **#143** the verdict ignores never-reported Lanes when other Lanes have reported, and the per-Site table lists Sites that have Series rather than the whole registry. The ticket prose asks for eight hygiene figures per Site; the design mock and the landed `.tbl.sites` grid both say six columns, and the mock won.
- **#146** two `SeriesPage` scans per press instead of a keyed read — `ponytail:`-commented in-tree with the upgrade path.
- **#147** a paused Site with no pass row yet renders no row and so no control, since the Lanes page lists Sites that have passed.
- **#141** a sibling browser Lane declining at the top of a pass records as `sidecar-down`. Specified deliberately; the later spec in this series settles it.

Reviewed-on: #148
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-22 08:27:19 +07:00

3.0 KiB

ADR-0013: Commands through the database

Date: 2026-08-22 Status: accepted

Decision

Owner interventions are facts about rows, never commands to the poller. Check now (POST /admin/series/{key}/poll) writes one stamp — series.force_poll_at, unix ms, zero meaning never asked (the column landed in migration 0014) — and the poller's next pass reads it through Store.DueForLatestCheck. The control never signals the running process, so a request survives a restart, and the whole surface is testable with no poller running at all.

Pending is derived, never stored: a Series is pending while force_poll_at > latest_checked_at. It self-clears with no second write and no sweeper because the check stamp is written before the fetch (the same "attempted" discipline as ADR-0010) — the first attempt ends the pending state whatever the attempt returns. There is no expiry: a request the Lane never reaches keeps ageing in the UI, and an old pending marker is itself the evidence that a Lane is stuck. Writing again re-stamps the request time; the write is idempotent.

Queue-jump rules. A forced Series overrides exactly three gates in the due query: the rest cutoff, the Sighting-deferral clause and the finished-only bucket, and it sorts to the front of the queue (ORDER BY forced DESC, reader_count DESC, latest_checked_at ASC). It never overrides an empty series_url (nothing to fetch), the Bookmarks join (a Series no Reader holds has no consumer for the result), the Lane's refusal backoff, the sidecar-down skip, or the Lane's gap — the last three are poller-side gates the query cannot see and must not. The one pass-level gate a forced Series does open is the browser wake threshold: a human asking wakes a sleeping Chrome, where the thresholds exist to stop the machine waking itself for one unattended check. If the home machine is off, nothing happens and the request ages visibly, which is correct.

Rejected: zeroing the check stamp as the force signal. It would corrupt the never-checked and stale counts the landing page exists to show, and make a pending marker impossible.

Why

A stuck-looking Series previously waited for its turn in the Lane's hour, and there was no way to ask for one check sooner. A direct poller command would have been lost on every restart and untestable without a running poller; a row the poller already reads is neither. Deriving pending from the two stamps keeps the flag honest across restarts and makes the mechanism two column writes and three query clauses instead of a state machine.

Constraints

  • The finished-status clause the force flag overrides is today's Lifecycle test; a later spec in this series deletes it wholesale rather than amending it, so the clause stays as it stands.
  • The control is unconfirmed (it takes nothing away) and renders no .confirm-row; it is hidden on a Series with no series_url and on an orphan — the same pair the due query refuses to override.
  • The answer to a press is the freshly rendered row, so the figures describe the state after the press.