Orphan removal: one Series at a time, with the foreign key as the guard #155
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Parent
Spec #135.
What to build
Removing a Bookmark leaves the Series behind and nothing deletes one, so the row outlives every relationship to it: no Reader can reach it, no Poll visits it, and it still owns a Cover file on a small disk. The dashboard now shows the owner these rows; this ticket gives them a way to be rid of one.
An owner action, one at a time, orphans only. Remove sits on the Series list row and on the detail page, and both render it only at a zero Reader count. Rejected: a permanently-disabled or always-present Remove the database refuses — a dead control is one the owner learns to ignore. Rejected: a bulk sweep over the orphan filter — an unbounded delete over rows the owner has not read individually is a different risk class, and the orphan set is short by construction, since a Reader has to drop the last Bookmark.
The database is the guard. A plain delete of the series row by its composite key. The bookmarks-to-series foreign key has deliberately no cascade, so it refuses while any Bookmark exists — which is the definition of orphan. No
NOT EXISTSpre-check: it would add a read-then-write window and a second copy of the definition that can drift. A foreign-key violation is translated at the handler into "a Reader has bookmarked this Series again", with the row re-rendered at its new count, never a 500.Cover bytes go through the guarded reclamation helper, so removing one Series can never blank another's artwork. The whole sequence is: delete the series row, then guard-check, then remove the file, then delete the covers row — the guard cannot pass while the series row still points at the address.
Confirm-gated, with the in-place confirm row on the danger wash, because this is the one action here that pulls a row out of the list. The confirm copy states only what is certain: "Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row." Byte deletion is not promised — the guard decides. Earlier draft copy claiming it removes the Series "for every Reader" and deletes its Cover bytes is wrong twice over.
Not a one-way door, and the page says so: the next PUT re-inserts the series row and an Acquisition or a Poll refetches the Cover. Removing an orphan discards a row nothing reads, not history.
No "shared with N other series" disclosure — it costs a query per confirm-row render and buys a fact the owner cannot act on.
Responses. The list row answers with the removed row's fragment plus a second out-of-band snippet re-rendering the N series · label heading: the row and the count are one fact, and a heading still reading 1 after the last orphan is gone is a lie the owner must reload to clear. The filter select's option counts stay stale until the next navigation — eight snippets per delete to keep one number honest is not worth it. The detail page redirects to the orphan list: the subject no longer exists, a refresh would 404, and the list is where the owner was headed.
The route joins the admin route list behind the owner gate and caps its form body the way the API path caps bodies.
Acceptance criteria
POST /admin/series/{key}/removeis owner-gated by joining the admin route listBlocked by
Landed on
spec-135as a--no-ffmerge ofticket/155-orphan-removal(bc64a1d).Implemented:
(*Store).RemoveSeriesas a plain parameterized DELETE by the composite key with noNOT EXISTSpre-check - the no-cascadebookmarks_series_fkis the guard, and its violation is translated into the package sentinelErrSeriesHasBookmarksso no driver type escapes the store. The row's cover address is read before the delete and reclaimed after it through #154'sReclaimCover, unchanged: the helper's guard cannot pass while the series row still points at the address, so the order is load-bearing. A reclamation failure is logged and the removal still succeeds.POST /admin/series/{key}/removejoined toadminRoutes, so owner-gating is by route registration and the existing enumeration test covers it. One handler, two callers, branching on HX-Target the wayadminSeriesPolldoes: the list row answers with the removed row's fragment plus an out-of-band re-render of theN seriesheading, because the row and the count are one fact and a heading still reading 1 after the last orphan is gone is a lie the owner would have to reload to clear; the detail page navigates to the orphan list, since the subject no longer exists and a refresh would 404. The filter select's option counts stay stale until the next navigation, as decided. Body capped withhttp.MaxBytesReaderat 64 KB, malformed key 400, unknown Series 404, generic errors to the client with detail to the log.Remove renders only at a zero Reader count, on both the list row and the detail page. A removal that races a fresh bookmark renders "a Reader has bookmarked this Series again" with the fresh count, not a 500.
Confirm gate:
hx-confirmcarrying the copy verbatim - "Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row." Byte deletion is not promised, because the guard decides.hx-confirmrather than the in-place.confirm-rowbecauseadmin.htmldoes not loadfilter.js, so nothing on an admin page can toggle that row, and the alternatives were a second copy oftoggleConfirmRowor inline script in the shell - both refused. It is also the existing precedent on the admin surface:readers.htmlgates both of its destructive actions the same way. Destruction uses--danger, never--ember.One naming note: the redirect targets the Series list's real wire constant for the orphan filter,
no_readers(store.SeriesFilterNoReaders) - the "orphan" in the ticket is the domain word, not the query value.One pre-existing test superseded:
TestSeriesListRowShapeasserted an orphan row's action cell was empty. It now asserts exactly one Remove.The full backend suite is green on the merged base: 594 tests, 10 packages.
No blocking concerns handed back.