backend: remove orphan series from the admin (ticket #155)
(*Store).RemoveSeries deletes one series row by (site, series_id) via a
plain parameterized DELETE; a bookmarks_series_fk violation outside
23503 is translated into the ErrSeriesHasBookmarks sentinel so no
driver type escapes the store. The caller reads the row's cover
address before the delete and reclaims it after: ReclaimCover's guard
cannot pass while a series row still points at the address.
POST /admin/series/{key}/remove answers the list row with the removed
row's fragment plus the heading re-rendered out of band at the fresh
count (HX-Reswap: delete removes the row through the same button that
swaps the refusal back in), and navigates from the detail page to the
No-Readers list (HX-Redirect for htmx, a 303 for plain clients). A
removal that races a fresh Bookmark is a refusal, not a 500: the row
re-renders at its new count with the fact spelled out. The control
renders only at zero Reader count on both surfaces, gated by hx-confirm
with the brief's copy.
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
@@ -1082,6 +1083,37 @@ func (s *Store) Delete(readerID int64, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pgForeignKeyViolation is the SQLSTATE the driver surfaces when a Bookmark
|
||||
// row refuses a Series delete (bookmarks_series_fk). pgconn exports no named
|
||||
// constant for it, so the store names it here.
|
||||
const pgForeignKeyViolation = "23503"
|
||||
|
||||
// ErrSeriesHasBookmarks is RemoveSeries' refusal: a Reader still holds the
|
||||
// Series, so the owner's removal must not reach past that record. The
|
||||
// delete is the check — no NOT EXISTS pre-check that can race the insert —
|
||||
// and the driver's foreign-key violation is translated here so no driver
|
||||
// type escapes the store (issue #155).
|
||||
var ErrSeriesHasBookmarks = errors.New("series has bookmarks")
|
||||
|
||||
// RemoveSeries deletes one Series row by (site, series_id). It is refused
|
||||
// while any Bookmark references the row; deleting an absent key is not an
|
||||
// error, matching Delete. The caller owns the stranded Cover: read the row's
|
||||
// cover_address before the delete and call ReclaimCover after it — the
|
||||
// helper's guard cannot pass while the series row still points at the
|
||||
// address, so the order is the sequence, not a preference.
|
||||
func (s *Store) RemoveSeries(site, seriesID string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM series WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgForeignKeyViolation {
|
||||
return ErrSeriesHasBookmarks
|
||||
}
|
||||
return fmt.Errorf("remove series %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordLanePass appends one pass and prunes every older row in the same
|
||||
// transaction. retainBefore is supplied by the poller's clock.
|
||||
func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
|
||||
|
||||
@@ -2340,3 +2340,112 @@ func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) {
|
||||
t.Fatal("covers row after failed unlink is gone; want it left for a retry")
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSeries is the orphan removal (#155): one Series, one delete, refused
|
||||
// by the database while any Bookmark points at it. The store translates the
|
||||
// foreign-key violation into its own sentinel so no driver type escapes, and
|
||||
// the caller reaps the stranded Cover through ReclaimCover.
|
||||
func TestRemoveSeriesRemovesOrphanAndReclaimsCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("orphan the series: %v", err)
|
||||
}
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("old-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
// The caller's sequence: the row is deleted first, then the address is
|
||||
// reclaimed — the guard cannot pass while the row still points at it.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover: %v", err)
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&one); err != sql.ErrNoRows {
|
||||
t.Fatalf("series row after remove = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(addr); err != nil || ok {
|
||||
t.Fatalf("covers row after remove = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("sharded file after remove = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal is the whole point of the sentinel: a Series a Reader still
|
||||
// holds is not removed, its row is untouched and its Cover keeps serving.
|
||||
func TestRemoveSeriesRefusedWhileBookmarked(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/kept.jpg", []byte("kept-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("kept-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); !errors.Is(err, ErrSeriesHasBookmarks) {
|
||||
t.Fatalf("RemoveSeries on a bookmarked series = %v, want ErrSeriesHasBookmarks", err)
|
||||
}
|
||||
var held int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&held); err != nil {
|
||||
t.Fatal("series row after refusal is gone; want it untouched")
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "kept-art" {
|
||||
t.Fatalf("cover after refusal = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series sharing its Cover address with a second Series is removed while
|
||||
// the artwork stays readable through CoverByAddress: the series row stops
|
||||
// referencing the address first, so ReclaimCover's guard passes for this
|
||||
// caller without touching the shared bytes (ADR-0014).
|
||||
func TestRemoveSeriesSparesSharedCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
|
||||
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("orphan solo: %v", err)
|
||||
}
|
||||
if err := store.Delete(store.OwnerID(), "asura:second"); err != nil {
|
||||
t.Fatalf("orphan second: %v", err)
|
||||
}
|
||||
const src = "https://cdn.example/covers/shared.jpg"
|
||||
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover on solo: %v", err)
|
||||
}
|
||||
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover on second: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("shared-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
// The guard spares the shared bytes even though this caller reclaims.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover over a shared address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("shared bytes after remove = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after remove: %v, want present", err)
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "second").Scan(&one); err != nil {
|
||||
t.Fatal("the second series row vanished with the first")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an absent key removes nothing and is not an error, matching the
|
||||
// Delete precedent — the handler's own lookups turn the absent case into the
|
||||
// 404 before the store ever sees it.
|
||||
func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.RemoveSeries("asura", "ghost"); err != nil {
|
||||
t.Fatalf("RemoveSeries on a missing key = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user