Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
7.8 KiB
002 — Data lifecycle architecture
Status: completed (first tranche); follow-ups tracked below Branch: main Created: 2026-07-26
Problem
An audit of asset and row cleanup found five leaks, four of which shared
one root cause: deletion logic was hand-written per call site and lived
far from the thing being deleted. RemoveLibrary knew about ten tables
because someone enumerated them once; migration 32 added an eleventh and
nothing noticed. Files written by explore had no cleanup counterpart
anywhere. A function that evicted expired cache rows was written and
never called.
Findings, in severity order:
RemoveLibrarywas broken for any scanned library.tagging_itemsholdsFOREIGN KEY(library_id) REFERENCES libraries(id)with noON DELETEclause and was never cleared, soDELETE FROM librariesfailed withFOREIGN KEY constraint failed (787)and rolled back the whole removal. Every scanned library hastagging_itemsrows (the scan upserts one per album folder), so this fired on essentially every real removal.RemoveLibraryhad zero test coverage.- Artist images were never deleted by anything. No
os.Removeinexplore, noDELETE FROM artist_imagesin the codebase. Unbounded in the number of artists ever browsed in Explore, most of whom are not in the library. - Cover art size variants leaked on removal. Only the base
cover_art.file_pathwas unlinked; the_sm/_md/_lgfiles beside it are derived filenames, not rows, so three files per cover survived. http_cachewas never pruned.Cache.Evict()existed with no callers. Reads filter onexpires_at, so expired rows were inert but accumulated for the life of the install.- Cover-art proxy cache was never pruned. No eviction, no size cap.
Approach
Rather than patch five holes, classify the data so the class of bug becomes hard to write. Everything persisted falls on two axes — regenerability and cost of regeneration — which collapse to four kinds:
| Kind | Regenerable? | Deletion policy |
|---|---|---|
| Owned — projection of the user's files | Yes, by rescan | Follows the files |
| Authored — user-created, no other copy | No | Explicit user action only |
| Derived — computed from owned | Yes, cheaply | Free; must never block owned deletion |
| Cache — network or dump sourced | Yes, expensively | TTL/age eviction, never cascade |
The classification is not just vocabulary — it produces the right fix for each finding. Finding 1 is derived data acting as a referential parent of owned data, which the taxonomy makes categorically illegal. Finding 2 is cache data that never needed owner-linked cleanup at all; it wants age eviction. Finding 3 is derived data that must be swept against a live set rather than tracked individually.
A Go interface was considered and rejected: the only polymorphic consumer is the janitor, the substrates have nothing in common (SQL rows, an FTS virtual table, a view, three directories of JPEGs, a 900 MB index), and provenance is a static fact better enforced by package boundaries than by methods an implementation may lie about. A declarative catalog gets the same benefit for a tenth of the cost.
What shipped
backend/datamap — the catalog. Every table, view, and asset
directory declared with its Kind, its Lifetime (cascade, set-null,
swept, retained), and a note explaining the classification. Plain data
with no service dependencies, so tests can assert it against a live
schema. FTS5 shadow tables resolve to their parent.
Tests that give it teeth (backend/datamap/datamap_test.go):
TestCatalogCoversSchema— every table insqlite_masteris claimed by exactly one entry. A new table fails the build until somebody states what it is and how it dies.TestCatalogHasNoStaleEntries— the reverse, catching drift.TestNoActionForeignKeysAreDeclaredSwept— aNO ACTIONforeign key blocks its parent's deletion, so its table must declareswept. This is the exact shape of finding 1, now caught at CI time.TestLifetimesMatchSchema— declared cascade/set-null must match what SQLite actually enforces.TestAuthoredCascadesAreDeliberate— authored data is unrecoverable, so a cascade onto it needs an explicit exemption.
backend/maintenance — the janitor. A registry of named jobs with
per-job minimum intervals, run at startup-idle and on a 6h tick. Policies
follow the taxonomy: derived data sweeps against a live set, cache data
ages out. Registered in one place (app.go: startJanitor) so the full set
of janitorial work is a single visible list.
Jobs: http-cache-evict (6h), covers-sweep (24h, live set from
cover_art expanded via CoverArtFileSet), artist-images-sweep (24h,
keeps art for library artists indefinitely, evicts browsed-artist art
after 90d), cover-art-proxy-sweep (24h, 30d age eviction).
The covers sweep refuses to act on an empty live set — that means the query failed to see the table, not that every cover is garbage.
Leak tests (backend/library/leak_test.go) — driven by the catalog
rather than a hardcoded list, so new tables are covered the moment they
are catalogued:
TestRemoveLibraryLeavesNoOwnedOrDerivedRows— removing the only library leaves no owned or derived rows, except those instaleToleratedwith a written reason.TestRemoveLibraryPreservesAuthoredData— authored data survives.TestSweptTablesAreActuallySwept— a table declaringsweptthat nothing sweeps is caught.
All three were verified to fail when the finding-1 fix is reverted.
Fixes — tagging_items cleared inside the removal transaction
(crud.go step 17); CoverArtFileSet expands originals to variants and
the legacy _thumb name; Cache.Evict logic moved into a registered job.
Incidental: Library.emit — runtime.EventsEmit calls log.Fatalf
on a context without a Wails runtime, which killed the test binary and
made the whole package untestable. All ten emits in the package now route
through a nil-safe helper. This also removes a real crash risk for
background workers that outlive their context.
Follow-ups
audio_files is a mixed-kind table. play_count, last_played, and
tag_status are authored data living in an owned table. Orphan
cleanup treats the whole row as regenerable, which is why renaming a file
destroys its play count — the row is deleted and re-imported fresh. This
is the strongest argument for splitting authored per-track state into its
own table keyed by something more stable than a path. Related: an
audio-stream content hash (excluding tag blocks, so it survives
retagging) would let a rename be recognised as the same file. Deliberately
out of scope here; it is a schema change plus a rename-detection pass, not
a cleanup fix.
Cascade adoption. Fourteen of nineteen foreign keys are NO ACTION.
Converting them to CASCADE would delete a lot of hand-written orphan
sweeps, but SQLite cannot add ON DELETE via ALTER TABLE — each needs
the 12-step table rebuild. Note the ordering constraint: cascades delete
rows silently, so any code that collects file paths before deleting rows
(as RemoveLibrary does for cover art) breaks under cascade. Mark-and-
sweep must land first; the two compose, cascade plus path-collection does
not.
Consolidate the ten orphan sweeps. DELETE ... WHERE id NOT IN (...)
appears ten times across crud.go, dbsync.go, smartplaylist.go, and
database.go. One shared sweepOrphans(tx) would shrink the surface where
a new table can be forgotten. Worth doing opportunistically rather than as
a big-bang refactor.
Storage settings pane. The catalog knows every table and directory and its kind; the janitor already computes bytes freed. A settings pane showing per-kind disk usage with "clear cache" and "rebuild derived data" buttons is now mostly a UI job.