A newly bookmarked Series acquires its Cover at creation (#59) #68
Reference in New Issue
Block a user
Delete Branch "feat/59-cover-at-creation"
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?
Closes #59.
Part of spec #55, and the ticket that fixes the reported bug #47. Architecture:
docs/adr/0007-backend-hosts-cover-bytes.md. Does not close #47 or #55.What changed
A Reader bookmarks a Series nobody holds yet — the exact case in #47 — and within seconds the list shows its artwork instead of a broken image. The first Bookmark to create a Series fires
Store.OnSeriesCreatedafter commit, and the newlatest.Acquirerturns that into one series-page fetch that yields both the Latest Chapter and the cover URL. The bytes go through the gated cover fetcher from #57 and are stored content-addressed through #56, so the wire carries an absolute URL on this deployment's own origin — never a third-party address, and never one that 404s.Store
0009_series_cover_address.sqladdsseries.cover_address. The two facts are now split:series.coveris the third-party source address the bytes came from (the acquisition path's dedupe key),series.cover_addressis the SHA-256 they are stored under. An emptycover_addressis precisely what "no Cover yet" means, which is the distinction both the API and the UI depend on.SetSeriesCoverwrites the address only after the bytes are on disk, so the wire can never name an object that is not there.CoverWireURLbuildsPUBLIC_BASE_URL + /covers/<sha256>for every scanned row, and returns""for a blank address.Upsert'sINSERTand itsDO UPDATE. A client-supplied cover cannot reach the shared Series row on any path, not just the creation path.Opennow rejects a base URL that is not an absolutehttp(s)origin:PUBLIC_BASE_URL=bookmarks.example.comwould otherwise start cleanly and emit addresses no browser can load.Acquisition
internal/latest/acquire.go: one fetch, gated by the poller's ownfetchableSeriesURL(aseries_urlarrives in a client-supplied PUT body, so without the gate a token-holder chooses what the server fetches from its own network position).latest_checked_at, so the poller does not refetch the same page a tick later.Wire and route
GET /covers/{address}serves the bytes publicly and uncredentialed withCache-Control: public, max-age=604800, immutable. The address is gated by a^[0-9a-f]{64}$pattern and cross-checked against a pure function of itself before any filesystem read, so no request shaped like a traversal reaches disk.PUT /bookmarks/{key}still accepts acoverfield and discards it, permanently. Rejecting it would break every installed userscript the moment this deploys, and ADR-0004's compatibility argument depends on those scripts continuing to work. The decode site says so in place of a TODO nobody intends to keep.store.CoverContentTypecanonicalises comix's non-standardimage/jpgtoimage/jpeg, so one image cannot land under two spellings. This one was found by the live smoke test, not by reading.Config
PUBLIC_BASE_URLis new and required (cover URLs must go out absolute — the userscript renders them on third-party origins, where a relative path resolves against the Site). Documented in.env.example,docker-compose.yml(:?so compose fails too),DEPLOY.mdandbackend/AGENTS.md.Acceptance criteria
All twelve of #59's criteria are met; the checklist on the issue is ticked with the evidence.
Verification
go test ./...green (Docker-backed Postgres suite).comix:n8we-dungeons-and-crayonsproduced"cover": "http://127.0.0.1:8099/covers/8ce74d80…"and"latest_chapter": "Chapter 81"within seconds of the PUT;curlon that address returned200,Content-Type: image/jpeg,Cache-Control: public, max-age=604800, immutable, and a 280x420 JPEG. That run is what surfaced theimage/jpgcontent type.gofromAcquireturnsTestAcquireDoesNotBlockTheWritered.Reviewed
Both axes of
/code-reviewwere run against this diff before commit. Their findings that were actionable here are folded in: the concurrency bound, the shutdown tie, thePUBLIC_BASE_URLvalidation, the missinglatest_checked_atstamp, and a test that could not fail.Known sequencing
A kagane/novelfull Series created between this deploy and #62 has no cover source at all: the acquisition skips those Sites and
Upsertno longer persists the userscript-scraped address. This is #59's stated boundary rather than a defect, but it is a user-visible gap on two Sites and should order #62 accordingly.A Reader who bookmarks a Series nobody holds yet no longer waits out the poll queue for its artwork: the first Bookmark to create a Series fires Store.OnSeriesCreated, and latest.Acquirer turns that into a single series-page fetch yielding both the Latest Chapter and the cover URL. The bytes are fetched through the gated cover fetcher and stored content-addressed, so the wire carries an absolute URL on this deployment's own origin (ADR-0007) - never a third-party address and never one that 404s. - series.cover_address (migration 0009) splits the third-party source address the bytes came from (series.cover) from the content address they are stored under. A blank cover_address is what "no Cover yet" means, so the wire field is empty until real bytes exist. - GET /covers/{address} serves the bytes publicly and uncredentialed, immutable-cached; the address is gated by a 64-hex pattern and cross-checked against a pure function of itself before any filesystem read. - Client-sent cover values are decoded and discarded permanently: the cover columns are absent from Upsert's INSERT and its DO UPDATE, so no request value can reach the shared Series row (extends ADR-0003's "ignored after creation" to "ignored always", keeps ADR-0004's flat wire so installed userscripts keep working). - Acquisition is asynchronous and log-and-drop: the Reader's write neither blocks on nor fails with a third-party Site. It is bounded by a two-slot semaphore, cancelled at shutdown, and stamps latest_checked_at so the poller does not refetch the same page a tick later. - store.CoverContentType canonicalises comix's non-standard "image/jpg" to "image/jpeg", so one image cannot land under two spellings. - PUBLIC_BASE_URL is a new required setting; Open rejects anything that is not an absolute http(s) origin, since a bare hostname would start cleanly and emit addresses no browser can load. Verified against a live backend: bookmarking a comix series produced a 280x420 JPEG served from /covers/<sha256> with the immutable cache header, and the web UI card renders that address.