Orphan removal: one Series at a time, with the foreign key as the guard #155

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

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 EXISTS pre-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}/remove is owner-gated by joining the admin route list
  • Remove is rendered only at a zero Reader count, on both the list row and the detail page
  • Removing an orphan deletes the series row and reclaims its Cover bytes through the guarded helper
  • A removal that races a fresh bookmark renders "a Reader has bookmarked this Series again" with the fresh count, not a 500
  • The list response carries the re-rendered heading count out of band
  • The detail page redirects to the orphan list after removal
  • The action is confirm-gated with the in-place confirm row, and the copy promises only the certain effects
  • The form body is capped the way the API path caps bodies

Blocked by

  • #154 — the guarded reclamation helper; this is its second caller.
## 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 EXISTS` pre-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}/remove` is owner-gated by joining the admin route list - [ ] *Remove* is rendered only at a zero Reader count, on both the list row and the detail page - [ ] Removing an orphan deletes the series row and reclaims its Cover bytes through the guarded helper - [ ] A removal that races a fresh bookmark renders "a Reader has bookmarked this Series again" with the fresh count, not a 500 - [ ] The list response carries the re-rendered heading count out of band - [ ] The detail page redirects to the orphan list after removal - [ ] The action is confirm-gated with the in-place confirm row, and the copy promises only the certain effects - [ ] The form body is capped the way the API path caps bodies ## Blocked by - #154 — the guarded reclamation helper; this is its second caller.
sulthan added the ready-for-agent label 2026-08-22 08:39:23 +07:00
sulthan self-assigned this 2026-08-22 09:38:04 +07:00
Author
Owner

Landed on spec-135 as a --no-ff merge of ticket/155-orphan-removal (bc64a1d).

Implemented: (*Store).RemoveSeries as a plain parameterized DELETE by the composite key with no NOT EXISTS pre-check - the no-cascade bookmarks_series_fk is the guard, and its violation is translated into the package sentinel ErrSeriesHasBookmarks so no driver type escapes the store. The row's cover address is read before the delete and reclaimed after it through #154's ReclaimCover, 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}/remove joined to adminRoutes, so owner-gating is by route registration and the existing enumeration test covers it. One handler, two callers, branching on HX-Target the way adminSeriesPoll does: the list row answers with the removed row's fragment plus an out-of-band re-render of the N series heading, 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 with http.MaxBytesReader at 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-confirm carrying 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-confirm rather than the in-place .confirm-row because admin.html does not load filter.js, so nothing on an admin page can toggle that row, and the alternatives were a second copy of toggleConfirmRow or inline script in the shell - both refused. It is also the existing precedent on the admin surface: readers.html gates 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: TestSeriesListRowShape asserted 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.

Landed on `spec-135` as a `--no-ff` merge of `ticket/155-orphan-removal` (bc64a1d). Implemented: `(*Store).RemoveSeries` as a plain parameterized DELETE by the composite key with no `NOT EXISTS` pre-check - the no-cascade `bookmarks_series_fk` is the guard, and its violation is translated into the package sentinel `ErrSeriesHasBookmarks` so no driver type escapes the store. The row's cover address is read before the delete and reclaimed after it through #154's `ReclaimCover`, 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}/remove` joined to `adminRoutes`, so owner-gating is by route registration and the existing enumeration test covers it. One handler, two callers, branching on HX-Target the way `adminSeriesPoll` does: the list row answers with the removed row's fragment plus an out-of-band re-render of the `N series` heading, 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 with `http.MaxBytesReader` at 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-confirm` carrying 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-confirm` rather than the in-place `.confirm-row` because `admin.html` does not load `filter.js`, so nothing on an admin page can toggle that row, and the alternatives were a second copy of `toggleConfirmRow` or inline script in the shell - both refused. It is also the existing precedent on the admin surface: `readers.html` gates 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: `TestSeriesListRowShape` asserted 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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#155