7a0c190ebe
Written while scoping #18. CONTEXT.md pins the ubiquitous language (Series, Reader, Bookmark, Progress, Latest Chapter, Poll) that the schema split and the ordering rule are argued in; the four ADRs record the decisions that follow from it, starting with Postgres over SQLite. Refs #18
45 lines
2.4 KiB
Markdown
45 lines
2.4 KiB
Markdown
# Series is a shared entity, and only the Poll may update it
|
|
|
|
Status: accepted
|
|
|
|
Facts about a Series that are true regardless of who is reading — title, cover, canonical
|
|
URL, Latest Chapter — moved off the Bookmark onto a shared `series` row keyed
|
|
`(site, series_id)`. A Bookmark now holds only what differs between Readers: Progress,
|
|
Favourite, Lifecycle bucket. Fifty Readers tracking one Series produce fifty Bookmarks
|
|
and one Series, so the Series is polled once rather than fifty times.
|
|
|
|
## Why
|
|
|
|
The poller checks at most 84 series/hour (batch 14 per 10-minute tick). With ~50 Readers
|
|
holding ~30 Series each, polling per Bookmark means a 1,500-item sweep — roughly 18 hours
|
|
against a configured 1-hour cooldown, quietly breaking the New Chapter signal that is the
|
|
product's reason to exist. Deduplicating to distinct Series cuts the sweep several-fold,
|
|
and because the Series row now knows how many Readers hold it, the poll queue is ordered
|
|
`reader_count DESC, latest_checked_at ASC` — popular Series stay fresh and the long tail
|
|
absorbs the shortfall. That ordering is only expressible because the split happened.
|
|
|
|
Raising throughput instead was rejected: sweeping 400 Series hourly needs the stagger
|
|
cut from 20s to ~9s, doubling request rate against sites that already bot-score the
|
|
single VPS IP.
|
|
|
|
## Only the Poll writes Series fields
|
|
|
|
A client may supply `title`, `cover` and `series_url` only when creating a Series nobody
|
|
has bookmarked yet. After that, client-supplied values are ignored; only the backend's
|
|
own fetch updates them.
|
|
|
|
This is a security boundary, not tidiness. Those values are scraped from third-party
|
|
pages, which `AGENTS.md` requires be treated as attacker-controlled. Before the split, a
|
|
hostile or compromised site could corrupt exactly one Reader's row. After it, the same
|
|
write lands on a row every Reader sees — one Reader's browser becomes a write path into
|
|
everyone else's UI, and a cover URL can point anywhere. The backend's own fetch is the
|
|
higher-trust source: its network, its parser, no third-party JavaScript in the path.
|
|
|
|
## Consequences
|
|
|
|
- `Store.Upsert` decomposes one incoming flat body across two tables and enforces the
|
|
ownership rule at that seam.
|
|
- The `updated_at` ordering rule stays on the Bookmark, where Progress lives. Unchanged.
|
|
- Per-Reader title overrides are deliberately not supported; they would reintroduce the
|
|
duplication this removes.
|