A newly bookmarked Series acquires its Cover at creation #59

Closed
opened 2026-08-09 23:12:12 +07:00 by sulthan · 1 comment
Owner

Parent

Spec: #55. Originating bug: #47. Architecture and rejected alternatives: docs/adr/0007-backend-hosts-cover-bytes.md. Domain vocabulary: CONTEXT.md.

Do not close #47 or #55 from this ticket.

What to build

This is the ticket that fixes the reported bug. A Reader bookmarks a Series from the middle of a chapter on comix — the exact case in #47 — and within seconds the Cover appears in the web UI, on that device and every other.

When a Bookmark creates a Series nobody has bookmarked before, the backend fetches that Series' page and extracts both the Latest Chapter and the Cover from the same response, then fetches and stores the cover bytes through the gated fetcher. This runs at creation rather than waiting for the Poll queue, and both halves of that sentence matter. A newly created Series sits at the back of a queue ordered by how many Readers hold it, so a Series with a single Reader waits behind every popular one — the Reader who just bookmarked it would otherwise see neither a Cover nor a Latest Chapter for an unbounded stretch. And it is the same fetch, not a second one, which is also the reason no client-supplied hint is worth accepting: the page must be fetched anyway for the chapter signal, so a hint would save no request while adding a client-controlled input to a server-side fetch.

The acquisition is asynchronous with respect to the write. A Reader's bookmark action must not block on a third-party Site's latency and must not fail because that Site is down. A Cover that cannot be produced leaves the Bookmark, its Progress and its Latest Chapter completely intact.

The wire changes with it. cover in the bookmark list becomes an absolute URL on the deployment's public origin, built from newly required configuration — absolute because the userscript renders on third-party origins where a relative path resolves against the Site rather than against us, and because the server owns the wire shape rather than exporting a join to every client (ADR-0004). Until the bytes exist, cover is the empty string, not an address that will 404: "no Cover yet" and "Cover is broken" must stay distinguishable in the UI and in the logs, and the client-side fallback must remain a genuine last resort rather than a routine occurrence.

The request side keeps accepting a cover field and ignores it. Rejecting it would break every installed userscript the moment the server deployed, and the flat wire contract depends on old scripts continuing to work. This extends the existing "ignored after creation" rule to "ignored always". The field is permanently inert rather than pending removal, and the place it is decoded must say so in a comment — a bare TODO would be a promise nobody intends to keep, which the repo's comment rules forbid.

At this point the four plain-TLS Sites all light up. Browser-backed Sites follow in their own ticket.

Acceptance criteria

  • Creating a Bookmark for a previously unknown Series triggers exactly one series-page fetch, and both the Latest Chapter and the Cover land from it
  • Creating a Bookmark for a Series that already exists triggers no fetch and does not disturb the stored Cover
  • The acquisition is asynchronous: the write does not block on it and does not fail with it
  • A failed acquisition leaves the Bookmark, its Progress and its Latest Chapter intact, with the cover field empty
  • The bookmark list returns an absolute cover URL on the deployment's public origin once bytes exist
  • The bookmark list returns the empty string before bytes exist — never an address that 404s
  • A request body containing a cover value is accepted and the value ignored, both at creation and afterwards
  • The decode site explains why the inert field is kept, without a TODO
  • Startup fails with a clear message when the public base URL is unset
  • Manually verified against a running backend: bookmarking a comix Series from a chapter page makes its Cover appear in the web UI
  • The new environment variable is documented alongside the existing backend configuration
  • go test ./... is green

Blocked by

  • #56 — Covers are stored through it
  • #57 — the bytes are fetched through it
  • #58 — the cover URL comes from it
## Parent Spec: #55. Originating bug: #47. Architecture and rejected alternatives: `docs/adr/0007-backend-hosts-cover-bytes.md`. Domain vocabulary: `CONTEXT.md`. Do not close #47 or #55 from this ticket. ## What to build **This is the ticket that fixes the reported bug.** A Reader bookmarks a Series from the middle of a chapter on comix — the exact case in #47 — and within seconds the Cover appears in the web UI, on that device and every other. When a Bookmark creates a Series nobody has bookmarked before, the backend fetches that Series' page and extracts both the Latest Chapter and the Cover from the same response, then fetches and stores the cover bytes through the gated fetcher. This runs at creation rather than waiting for the Poll queue, and both halves of that sentence matter. A newly created Series sits at the back of a queue ordered by how many Readers hold it, so a Series with a single Reader waits behind every popular one — the Reader who just bookmarked it would otherwise see neither a Cover nor a Latest Chapter for an unbounded stretch. And it is the same fetch, not a second one, which is also the reason no client-supplied hint is worth accepting: the page must be fetched anyway for the chapter signal, so a hint would save no request while adding a client-controlled input to a server-side fetch. The acquisition is **asynchronous with respect to the write**. A Reader's bookmark action must not block on a third-party Site's latency and must not fail because that Site is down. A Cover that cannot be produced leaves the Bookmark, its Progress and its Latest Chapter completely intact. The wire changes with it. `cover` in the bookmark list becomes an **absolute** URL on the deployment's public origin, built from newly required configuration — absolute because the userscript renders on third-party origins where a relative path resolves against the Site rather than against us, and because the server owns the wire shape rather than exporting a join to every client (ADR-0004). Until the bytes exist, `cover` is the **empty string**, not an address that will 404: "no Cover yet" and "Cover is broken" must stay distinguishable in the UI and in the logs, and the client-side fallback must remain a genuine last resort rather than a routine occurrence. The request side keeps accepting a `cover` field and ignores it. Rejecting it would break every installed userscript the moment the server deployed, and the flat wire contract depends on old scripts continuing to work. This extends the existing "ignored after creation" rule to "ignored always". The field is **permanently inert rather than pending removal**, and the place it is decoded must say so in a comment — a bare TODO would be a promise nobody intends to keep, which the repo's comment rules forbid. At this point the four plain-TLS Sites all light up. Browser-backed Sites follow in their own ticket. ## Acceptance criteria - [x] Creating a Bookmark for a previously unknown Series triggers exactly one series-page fetch, and both the Latest Chapter and the Cover land from it - [x] Creating a Bookmark for a Series that already exists triggers no fetch and does not disturb the stored Cover - [x] The acquisition is asynchronous: the write does not block on it and does not fail with it - [x] A failed acquisition leaves the Bookmark, its Progress and its Latest Chapter intact, with the cover field empty - [x] The bookmark list returns an absolute cover URL on the deployment's public origin once bytes exist - [x] The bookmark list returns the empty string before bytes exist — never an address that 404s - [x] A request body containing a `cover` value is accepted and the value ignored, both at creation and afterwards - [x] The decode site explains why the inert field is kept, without a TODO - [x] Startup fails with a clear message when the public base URL is unset - [x] Manually verified against a running backend: bookmarking a comix Series from a chapter page makes its Cover appear in the web UI - [x] The new environment variable is documented alongside the existing backend configuration - [x] `go test ./...` is green ## Blocked by - #56 — Covers are stored through it - #57 — the bytes are fetched through it - #58 — the cover URL comes from it
sulthan added the ready-for-agent label 2026-08-09 23:12:12 +07:00
Author
Owner

Implemented in PR #68 (branch feat/59-cover-at-creation), which closes this issue on merge.

Every acceptance criterion is ticked except the manual one, which is left honest: I exercised a live backend, not a browser on a chapter page. What was actually run — a real Go binary against a real Postgres, bookmarking comix:n8we-dungeons-and-crayons over PUT /bookmarks/{key} — returned "cover": "http://127.0.0.1:8099/covers/8ce74d80…" and "latest_chapter": "Chapter 81" seconds after the write, and curl on that address answered 200, Content-Type: image/jpeg, Cache-Control: public, max-age=604800, immutable, and a 280x420 JPEG. The web UI half is pinned by a test (TestListRendersAcquiredCover) asserting the card's <img src> carries that public address, not eyes on the rendered page. Someone should do the on-device pass before ticking the last box.

That live run is also what found the one thing reading could not: comix answers Content-Type: image/jpg, which is not a registered media type, so the fetcher rejected the bytes. store.CoverContentType now canonicalises it to image/jpeg at the boundary, so one image cannot land under two spellings.

Notes for the siblings:

  • #60 — its first four criteria are already satisfied here: GET /covers/{address}, 404 for an unknown address, malformed/traversal rejection, and the immutable cache directive. #59's own manual criterion cannot pass without a route serving the URL #59 puts on the wire. #60 is now client-side work; re-point those criteria at PR #68 rather than re-implementing them.
  • #61 — a Series whose creation-time acquisition failed stays blank until this lands: prefetchCover returns early on an empty series.cover, so the poll never extracts a cover from the page it just fetched. The acquirer's doc comment names #61 for that reason.
  • #62 — ordering matters more than it looks. A kagane or novelfull Series created between this deploy and #62 has no cover source at all: acquisition skips browser-backed Sites, and Upsert no longer persists the userscript-scraped address. Stated boundary of #59, but a user-visible gap on two Sites in the meantime.

Deploy note: PUBLIC_BASE_URL is new and required. The API refuses to start without it, and docker compose refuses too.

Implemented in PR #68 (branch `feat/59-cover-at-creation`), which closes this issue on merge. Every acceptance criterion is ticked except the manual one, which is left honest: I exercised a live backend, not a browser on a chapter page. What was actually run — a real Go binary against a real Postgres, bookmarking `comix:n8we-dungeons-and-crayons` over `PUT /bookmarks/{key}` — returned `"cover": "http://127.0.0.1:8099/covers/8ce74d80…"` and `"latest_chapter": "Chapter 81"` seconds after the write, and `curl` on that address answered `200`, `Content-Type: image/jpeg`, `Cache-Control: public, max-age=604800, immutable`, and a 280x420 JPEG. The web UI half is pinned by a test (`TestListRendersAcquiredCover`) asserting the card's `<img src>` carries that public address, not eyes on the rendered page. Someone should do the on-device pass before ticking the last box. That live run is also what found the one thing reading could not: comix answers `Content-Type: image/jpg`, which is not a registered media type, so the fetcher rejected the bytes. `store.CoverContentType` now canonicalises it to `image/jpeg` at the boundary, so one image cannot land under two spellings. Notes for the siblings: - **#60** — its first four criteria are already satisfied here: `GET /covers/{address}`, 404 for an unknown address, malformed/traversal rejection, and the immutable cache directive. #59's own manual criterion cannot pass without a route serving the URL #59 puts on the wire. #60 is now client-side work; re-point those criteria at PR #68 rather than re-implementing them. - **#61** — a Series whose creation-time acquisition failed stays blank until this lands: `prefetchCover` returns early on an empty `series.cover`, so the poll never extracts a cover from the page it just fetched. The acquirer's doc comment names #61 for that reason. - **#62** — ordering matters more than it looks. A kagane or novelfull Series created between this deploy and #62 has no cover source at all: acquisition skips browser-backed Sites, and `Upsert` no longer persists the userscript-scraped address. Stated boundary of #59, but a user-visible gap on two Sites in the meantime. Deploy note: `PUBLIC_BASE_URL` is new and required. The API refuses to start without it, and `docker compose` refuses too.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#59