Split Series from Bookmark, keeping the wire format flat #21

Closed
opened 2026-08-08 06:06:27 +07:00 by sulthan · 1 comment
Owner

Parent

#18

What to build

A Series becomes a thing in its own right, shared by everyone who tracks it, so it gets polled once instead of once per Bookmark. Facts about the work — title, cover, canonical URL, kind, Latest Chapter — move onto it; a Bookmark keeps only what differs between Readers.

Externally nothing changes: the JSON stays flat, the store joins on read and decomposes on write. That is what keeps the blast radius inside the store and the poller. See ADR-0003 and ADR-0004.

Acceptance criteria

  • A Series exists once per site and series ID and owns title, cover, canonical URL, kind, Latest Chapter and last-checked time.
  • A Bookmark holds only Progress, Favourite, Lifecycle bucket and updated_at.
  • GET /bookmarks and PUT /bookmarks/{key} emit and accept exactly the flat field set they did before — asserted by a test, not by inspection.
  • A PUT naming a Series that does not exist creates it from the client's title, cover and URL.
  • A PUT naming a Series that already exists ignores client-supplied title, cover and URL. Only the backend's own Poll updates them. This is a security boundary: scraped page content is attacker-controlled and the row is shared.
  • The poller walks Series rather than Bookmarks; a Series referenced by several Bookmarks is fetched once per due cycle.
  • The due queue orders by number of referencing Bookmarks descending, then least-recently-checked first.
  • A Series referenced by no Bookmark never appears in the due queue, and is not deleted.
  • Deleting the last Bookmark for a Series leaves the Series row behind; re-bookmarking it shows title and cover immediately rather than waiting for a Poll.
  • A Series row is still stamped before its fetch, so a broken series waits out its full cooldown instead of retrying every tick.
  • Batch size, stagger and interval are unchanged — outbound request rate must not increase.

Blocked by

#20

## Parent #18 ## What to build A Series becomes a thing in its own right, shared by everyone who tracks it, so it gets polled once instead of once per Bookmark. Facts about the work — title, cover, canonical URL, kind, Latest Chapter — move onto it; a Bookmark keeps only what differs between Readers. Externally nothing changes: the JSON stays flat, the store joins on read and decomposes on write. That is what keeps the blast radius inside the store and the poller. See ADR-0003 and ADR-0004. ## Acceptance criteria - [x] A Series exists once per site and series ID and owns title, cover, canonical URL, kind, Latest Chapter and last-checked time. - [x] A Bookmark holds only Progress, Favourite, Lifecycle bucket and `updated_at`. - [x] `GET /bookmarks` and `PUT /bookmarks/{key}` emit and accept exactly the flat field set they did before — asserted by a test, not by inspection. - [x] A `PUT` naming a Series that does not exist creates it from the client's title, cover and URL. - [x] A `PUT` naming a Series that already exists ignores client-supplied title, cover and URL. Only the backend's own Poll updates them. This is a security boundary: scraped page content is attacker-controlled and the row is shared. - [x] The poller walks Series rather than Bookmarks; a Series referenced by several Bookmarks is fetched once per due cycle. - [x] The due queue orders by number of referencing Bookmarks descending, then least-recently-checked first. - [x] A Series referenced by no Bookmark never appears in the due queue, and is not deleted. - [x] Deleting the last Bookmark for a Series leaves the Series row behind; re-bookmarking it shows title and cover immediately rather than waiting for a Poll. - [x] A Series row is still stamped before its fetch, so a broken series waits out its full cooldown instead of retrying every tick. - [x] Batch size, stagger and interval are unchanged — outbound request rate must not increase. ## Blocked by #20
sulthan added the ready-for-agent label 2026-08-08 06:06:27 +07:00
Author
Owner

Implemented on branch feat/db-change (commit 84291c3), all acceptance criteria met and test-asserted:

  • Series row keyed (site, series_id) owns title, cover, series_url, kind, latest chapter, latest_checked_at (migration 0002 backfills existing bookmarks).
  • Bookmark holds only progress, favourite, status, updated_at.
  • Flat wire format preserved — TestFlatWireFieldSet asserts the exact 15-field key set on both PUT and GET (ADR-0004).
  • PUT creates the series from client title/cover/URL when missing; on an existing series those three are ignored (only kind/latest-chapter are last-write-wins), enforced in Store.Upsert and tested at both store and API level (ADR-0003 security boundary).
  • Poller walks Series via DueForLatestCheck, ordered reader_count DESC, latest_checked_at ASC; shared series fetched once per cycle (TestRunOnceFetchesSharedSeriesOnce).
  • Orphan series never due, never deleted; deleting the last bookmark keeps the row and re-bookmarking shows title/cover immediately (TestDeleteKeepsSeriesRow).
  • Row stamped before fetch; batch/stagger/interval untouched.

go vet clean, full suite green. Reviewed on both axes (standards + spec) with zero actionable findings.

Implemented on branch feat/db-change (commit 84291c3), all acceptance criteria met and test-asserted: - Series row keyed (site, series_id) owns title, cover, series_url, kind, latest chapter, latest_checked_at (migration 0002 backfills existing bookmarks). - Bookmark holds only progress, favourite, status, updated_at. - Flat wire format preserved — TestFlatWireFieldSet asserts the exact 15-field key set on both PUT and GET (ADR-0004). - PUT creates the series from client title/cover/URL when missing; on an existing series those three are ignored (only kind/latest-chapter are last-write-wins), enforced in Store.Upsert and tested at both store and API level (ADR-0003 security boundary). - Poller walks Series via DueForLatestCheck, ordered reader_count DESC, latest_checked_at ASC; shared series fetched once per cycle (TestRunOnceFetchesSharedSeriesOnce). - Orphan series never due, never deleted; deleting the last bookmark keeps the row and re-bookmarking shows title/cover immediately (TestDeleteKeepsSeriesRow). - Row stamped before fetch; batch/stagger/interval untouched. go vet clean, full suite green. Reviewed on both axes (standards + spec) with zero actionable findings.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#21