Files
yellowjacket/.planning/plans/completed/013-database-audit.md
yonluandClaude Opus 5 e7748f1fd5
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s
feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.

## The local library is shaped like files, not like MusicBrainz

`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.

- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
  `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
  `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
  orphaned recordings, 216 release groups and 260 artists that library
  carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
  view, one row type, one mapper. Nine hand-rolled copies had drifted
  far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
  instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
  shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
  are squashed away, along with the drift between them that had sqlc
  generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
  had been assembling the old FK chain each in its own order.

## The catalog stores its ids as bytes

`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.

- `backend/explore/mbid.go` is the only place the encoding is known;
  everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
  rather than silently returning no rows, since SQLite does not coerce
  between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
  on the way in, so the artifact already published keeps working and no
  format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
  list, and `TestStoredEncodingRoundTrips` sweeps every read path.

## An album page that says how much of the album is yours

- One question, asked once: is there a file. `filePaths` is filled by a
  single batched lookup when the tracklist settles, and the badge, the
  Play count, the dimmed rows and every menu item read it — replacing
  four claims of decreasing confidence that could show a green tick on
  an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
  groups) and on `audio_files` from tags that have always carried it:
  a complete MBID-matched album now makes no catalog call at all, where
  it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
  and the version list marks the release you own rather than standing a
  synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
  one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
  instead of the owned ones wearing a green tick and a legend.

## Caches and cover art get ceilings

- Only the three tiers of a cover are stored; the full-resolution copy
  nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
  URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
  the same install held art for 5,770 artists in a 1,301-artist
  library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
  directory, so it deleted the rows that were the only record of the
  files it left behind. `explore.ArtistImageDir` is that layout's one
  definition now.

## The autotag queue asks whether there is work

`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.

## Phantom playlist tracks resolve in place

An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.

## Playing a track plays the list it is in

Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-16 13:58:15 -04:00

29 KiB
Raw Permalink Blame History

013 — The database audit

Status: complete (2026-08-16). R1R10 landed, the album page that prompted the audit with them, and the one part of R5 that ships in the artifact — a per-release-group track denominator — landed as plan 014. The audit below is unchanged from when it was written — the measurements describe the old shape and are the reason for the new one. Branch: none Created: 2026-08-15 Supersedes: the four-part album-page fix sketched in conversation (it survives, reduced, as R1 and R3 below) Related: 010 (owned albums offline), 011 (owned artists' discography), 012 (API call audit), 002 (data lifecycle)


Method

Every number here is measured against the real 25,966-track library at ~/.local/share/yellowjacket/yj.db (copied read-only), not against a fixture and not inferred from the code. Where a claim rests on a capability rather than a count — "sqlc can do X" — it was executed, not assumed.

The brief: efficiency and simplicity — the minimum required to achieve our featureset, with fewer lines and a smaller database as evidence rather than as the goal. Two named sources of confusion to resolve: local versus remote versions of a thing, files versus tracks, and indexed versus live lookups. One added constraint: avoid hitting APIs by storing intelligently, without a ridiculous base install.


The measurements

The database is 1.00 GB, and 78% of it is one table

object size rows
explore_index 383 MB 2,052,200
its five indexes + UNIQUE(mbid) 395 MB
its two FTS tables 85 MB 2,052,200 + 96,451
recordings 38 MB (27 MB of it lyrics) 26,778
lyrics_index 18 MB 24,294
artist_metadata 12 MB 7,673
http_cache 9 MB 2,930
audio_files 5 MB 25,966
everything else < 10 MB

The local library — the part that is the user's — is about 50 MB. The catalog and its indexes are 780 MB.

Inside explore_index, half the bytes are three text columns

column bytes note
mbid 70 MB 36-char text; 16 bytes as a blob
artist_mbid 70 MB same, and it is a foreign key in disguise
caa_release_mbid 62 MB same
entity_type 18 MB three distinct values, stored as words
title / artist_name / release_name 74 MB real data

Five columns are declared, shipped in the artifact, selected in every query, and empty: aliases (0 rows), sort_name (0), disambiguation (0), country (69 rows of 2.05 M), artist_type (72). aliases is additionally a column in both FTS tables, so the tokenizer indexes nothing, twice.

Two 50 MB indexes have a WHERE clause that excludes 0.3% of rows

idx_explore_title_lower (53 MB) and idx_explore_artist_lower (48 MB) are WHERE popularity > 0. 2,046,645 of 2,052,200 rows satisfy that. They are full indexes wearing a partial index's clothes, and they exist to serve one exact-match tier (ExactMatches, searchindex.go:1298) that the champion FTS — 96,451 rows, 2 MB — already covers the popular half of.

The local library models many-to-many relationships that are all 1:1

claim measured
recordings with more than one file 0
recordings in more than one release group 0
artist credits with more than one artist 3 of 2,823
files sharing a recording 0

recordings (26,778) is one row per file. release_group_recordings (26,778) is one row per file. artist_credit (2,823) and artist_credit_artist (2,826) differ by three.

…and it leaks rows that outlive the files

orphan count
recordings with no audio_files row 812 (218 carry MBIDs)
release_groups with no file underneath 216
artists credited on no file 260
explore_index rows flagged in_library with no file behind them 129 recordings, 2 release groups, 1 artist

That last row is the bug reported today, in the user's own data.

The query surface

surface count
sqlc queries 235 (7,850 generated Go lines)
raw SQL call sites outside sqlc 188
bound IPC methods 272
X / XByLibrary query twins 14 (8 of them exposed as separate bindings)
copies of the "one row per file with its metadata" projection 9, plus the view that already defines it

mapTrackRow takes 22 positional arguments and is called from 9 places, because each duplicated query generates its own row struct.

The data directory is 8.5 GB — the database is the small part

path size of which
artist-images/ 5.4 GB 4,125 MB is candidate images no code path reads; 1,222 MB is primaries + tiers for 5,770 artists in a library with 1,301
covers/ 1.4 GB 1,134 MB is originals; all three rendered tiers together are 110 MB
ffmpeg/ 283 MB bundled binary
yj.db 1.0 GB above
yj.db.bak + .bak.20260309 452 MB nothing deletes these
art caches (cover-art-cache, artist-image-cache) 81 MB catalog art, fine

The 4.1 GB of unreachable artist candidates is the bug CLAUDE.md records as fixed; this install still carries it, so the janitor jobs have never run here. Worth confirming they run at all before declaring that one closed.


The diagnosis

Everything below is downstream of one thing.

There are three different notions of "a track" in this app, and the code keeps asking the wrong one.

  1. A file — a row in audio_files. The only thing that is unambiguously yours: it has a path, it plays.
  2. A local entity — a row in recordings / release_groups / artists. Created by a scan from a file, but with an independent lifetime: nothing deletes it when the file goes, and retagging a file creates a new one and abandons the old (library.go:1722 repoints audio_files.recording_id at a fresh recording; pruneOrphanedMetadata only runs on the scan's deleted-file branch, library.go:982). This is where the 812 orphans come from — and autotagging is the machine that makes them.
  3. A catalog entity — a row in explore_index, downloaded, global, identical for every user.

"Is this mine" is asked of (2) almost everywhere, and answered by (1) whenever the user actually does something:

  • LibraryMBIDIndex.CheckMBIDs (librarymbid.go:64) is literally SELECT mbid FROM recordings WHERE mbid IN (…). It sets inLibrary on every catalog tracklist.
  • pruneStaleLocalCrossReferences (searchindex.go:2480) clears explore_index.in_library when the recordings row disappears — not when the file does. Hence 129 phantom "you own this" rows.
  • albumLibraryStatus() in explore-album-details.ts ORs four claims of decreasing confidence, none of which is "a file exists".
  • But GetFilePathsByRecordingMBIDs, which every action goes through, joins audio_files. It is the only one that tells the truth.

So a retagged file leaves behind a recording carrying the old MBID; the catalog matches that MBID; the row renders owned, undimmed, with a Play button; and every action on it fails with "could not be found in your library" — on a fully-tagged library. The user's instinct that the check is fragile is correct, and the fragility is not the live lookup. The live lookup is the only part that is right.

The same confusion explains "files vs tracks" and "local vs remote": tables (2) exist to be a local mirror of the catalog's shape, so a "track" is sometimes a file, sometimes a mirror row, sometimes a catalog row, and the three are joined by MBID — a key that two of the three can lack or lie about.


Findings and recommendations

R1 — Ownership is "a file exists". Say it once, in SQL.

Cheap, immediate, and it fixes the reported bug.

  • CheckMBIDs' recordings and release_groups branches gain a join to audio_files. (artists too, via credit.)
  • pruneStaleLocalCrossReferences tests for a file, not for a local row.
  • pruneOrphanedMetadata runs after the retag path as well as the delete path — or, better, is deleted along with the tables that need it (R2).
  • One-shot cleanup of the 812/216/260 existing orphans at open.

Effect: 129 lying rows in this library become honest; the class cannot recur while (2) exists.

R2 — Collapse the MusicBrainz-shaped local schema into a file-shaped one

The big one. It is what makes R1 structural rather than a patch.

The local model imitates MusicBrainz's normalization — artist_credit is an MB concept — for a dataset in which every relationship it models is 1:1 (measured above). The cost of that imitation:

  • 5 tables (recordings, release_group_recordings, artist_credit, artist_credit_artist, release_to_rg — the last has 0 rows and no schema-file writer) and ~12 indexes.
  • A 6-way join in every read, including a MIN(release_group_id) subquery repeated in 11 places to undo a many-to-many that never happens, and a "first credited artist" subquery in 9 to undo another (the row-multiplication bug class documented at length in CLAUDE.md, which serves 3 rows).
  • An orphan-cleanup subsystem (GetOrphaned*IDs ×3, Count*References ×2, pruneOrphanedMetadata) that exists only because these rows can outlive their file — and which does not actually work (812 orphans).
  • The entire phantom-ownership class above.

Proposed shape:

audio_files   id, path, library_id, …, title, track_no, disc_no, year,
              composer, comment, artist_credit TEXT, artist_id→artists,
              album_id→albums, recording_mbid, modified_at, …
albums        id, name, artist_id, mbid, year, original_year,
              cover_art_id, total_tracks…      (genuinely many files→1)
artists       id, name, mbid                    (genuinely many→1)
genres + file_genres                            (genuinely many↔many:
                                                 107k rows / 26k files)

artist_credit survives as text on the file (display: "A feat. B") plus artist_id (the primary artist, for grouping) — which is everything the UI does with it today, minus the join that multiplies rows.

Effect: a row exists iff a file exists, so R1 becomes a foreign key rather than a rule anyone can forget. Removes 5 tables, ~12 indexes, ~30 sqlc queries, the orphan subsystem, both repeated subqueries, and the AUTOMATIC COVERING INDEX SQLite builds on every library load. Estimated 1,500 to 2,500 lines across backend/library, backend/database/sql/* and sqlcgen.

Cost: one real migration of user data (not an ADD COLUMN), and it touches autotag, tagwriter, playlist matching and the explore xref. This is the item to sequence carefully; everything else is independent of it.

R3 — One projection, one row type, one mapper

track_metadata (the view) already is the canonical "one row per file" definition, and only the raw-SQL search paths use it (search.go, lyrics_search.go). Every sqlc query re-implements it — 9 copies, which have already drifted: the view prefers rg.original_year for year, GetAllTracksWithFullMetadata uses r.year. The same library shows a different year depending on which screen you are on.

Verified, not assumed: sqlc generates cleanly against the view — SELECT * FROM track_metadata WHERE … yields one TrackMetadatum struct with correct types (run during this audit).

And the 14 X/XByLibrary twins collapse into one query each:

WHERE (CAST(sqlc.arg(library_id) AS INTEGER) = 0
       OR library_id = CAST(sqlc.arg(library_id) AS INTEGER))

Measured cost of the collapse: none. Scoped-with-OR 23 ms, scoped direct 21 ms, unscoped 145 ms over the full 26k rows.

Effect: 14 queries, 8 bindings, 8 frontend branches, 9 row structs → 1, 9 call sites of a 22-argument mapper → 1. Roughly 2,000 generated lines and 300 hand-written ones, and the year inconsistency cannot exist.

R4 — Put explore_index on a diet (~200 MB, no feature loss)

change saved
mbid, artist_mbid, caa_release_mbid as 16-byte blobs ~110 MB in the table
…and the same keys in UNIQUE(mbid) (99 MB) and idx_explore_index_artist_mbid (131 MB) ~70100 MB
entity_type → INTEGER 18 MB + index
drop aliases, sort_name, disambiguation (0 rows); reconsider country/artist_type (69/72 rows) small bytes, real clarity — and one fewer empty FTS column
make the two LOWER() indexes' partial predicate mean something (popularity >= championPopThreshold OR in_library), or retire the tier onto the champion FTS up to 101 MB

Better still for artist_mbid: it is a foreign key spelled as text. An integer reference to the artist row is 8 bytes instead of 36 and makes the 131 MB index a fraction of its size.

Also worth separating: in_library, local_*_id, is_similar and discog_fetched are personalization stored inside the shipped catalog table, which is why the artifact import has to merge by explicit column list and why artist_enrichment had to become its own table for exactly this reason. Measured: in_library and local_*_id IS NOT NULL agree on every one of 2,052,200 rows — they are the same fact stored twice. A library_xref(mbid, kind, local_id) side table would make the catalog table purely the artifact and delete the merge-by-column-list rule.

R5 — Ask the network less, without a bigger install

Present state (from musicbrainz.go:17-27): search 24 h, entity 7 days, releases 90 days. MusicBrainz entity data changes on the order of never for the fields we read, and 251 of 2,930 cache rows are already expired on this install — so a fully-populated artist page re-fetches itself weekly, forever.

  • Raise cacheTTLEntity to a year (or drop expiry and revalidate in the background). Cost: bytes already stored. Benefit: the steady-state network cost of browsing your own library goes to roughly zero.
  • Ship a per-release-group total_tracks in the artifact. 010 correctly rejects shipping tracklists (the per-artist track budget would truncate them, and "Play 7 of 9" for a twelve-track album is a confident lie). But the denominator is one small integer per release group — 400,677 rows, ~2 bytes — and it is exactly what albumLibraryStatus/ownership() needs to say complete / incomplete / unknown for a catalog album with no local tags. Tiny, honest, and it does not depend on coverage.
  • Keep 010's per-user backfill for the tracklists themselves; this does not replace it, it shrinks what it has to cover.
  • http_cache has no size bound and no vacuum beyond expiry. Give it a ceiling.

R6 — The 5.3 GB on disk that no feature needs

  • 4,125 MB of artist candidate images that nothing reads (the documented bug — but the janitors have not run on this install; verify they run at all).
  • Artist images exist for 5,770 artists in a 1,301-artist library. Fetching art for artists you do not own is the same "prefetch everything" instinct as the discography backfill 011 corrected.
  • 1,134 MB of cover originals versus 110 MB for all three rendered tiers. Nothing renders the original; and it is re-derivable from the audio file itself, which is on disk by definition. Keep _lg as the largest and drop originals — that is 1.1 GB with no visible change.
  • yj.db.bak (394 MB) and yj.db.bak.20260309 (58 MB) accumulate with nothing to clean them.

This is the largest single win available and it does not touch the schema.

R7 — Redundant indexes and dead columns

Five indexes are prefixes of an existing UNIQUE/PK and can be dropped outright (they cost write time on every insert):

idx_recording_genres_recording_idUNIQUE(recording_id, genre_id) · idx_similar_artist_map_sourcePK(source, similar) · idx_artist_credit_artist_artist_idUNIQUE(artist_id, credit_id) · idx_artist_metadata_mbidPK(mbid, source) · idx_artist_images_mbidUNIQUE(artist_mbid, source, source_url).

Dead data:

  • recordings.genre — populated on 25,619 rows at every scan and read by nothing. Every genre read goes through recording_genres + genres. Write-only column.
  • release_groups.total_tracks / total_discs — 0 rows populated; the feature that needed them put the number on release_group_recordings instead.
  • release_to_rg — 0 rows, no writer in any schema file.
  • libraries.sql carries a doc comment about download_requests, pasted from another file. Small, but it is the kind of drift the two-file schema rule exists to catch.

R8 — One genuine N+1

mixCandidates (explore/mix.go:181) issues GetGenreNamesByFilePath per candidate path, inside a loop over similar artists, inside a loop over seed artists. Twenty seeds × twenty similar × thirty paths is 12,000 single-row queries for one mix. It is one query with an IN clause, or one query for the whole weighted set. (mixSeedProfile above it is the same shape, bounded by seed size.)

Nothing else in the tree matches this pattern — a scan of every query issued inside a loop turned up 72 candidates and this is the only real one.

R9 — The IPC surface has internals in it

Bound and reachable from the frontend today: AcquirePipelineLock, ReleasePipelineLock, SetJobRegistry, SetScanHooks, SetRescanHooks, SetRemovalHooks, MusicBrainz, CAALimiter, PopulateLocalCrossReferences. v3's generator binds every exported method; these want to be unexported or moved off the service type. Free lines, and one less way to wedge the app from a console.

R10 — The test DB is not the shape production runs

NewTestDB shares one in-memory connection and leaves readDB nil, so reader() returns the writer. That is why the read-pool write bug (documented in CLAUDE.md) reached a user, and why TestNoWritesOnTheReadPool had to be a tree-walk instead of a test. Giving the test DB two handles over one shared in-memory file would let that be an ordinary test.


What I recommend leaving alone

  • The download subsystem (requests / downloads / items). Three tables, clean lifetimes, well argued in the schema comments. The download_wants table in this install is the pre-rename name; the rename migration will clear it on next launch.
  • The champion FTS. 96k rows, 2 MB, a real latency tier.
  • The dual write/read handle, WAL, and the persist-writer queues. These are recent, measured, and correct.
  • File paths as the frontend's identity for a track. Integer ids would be cheaper over IPC, but CLAUDE.md's argument (an index goes stale on re-sort/refilter, a path does not) is right, and the cost is bounded.
  • Storing lyrics locally (27 MB + 18 MB index for 24k tracks). That is the API-avoidance trade working exactly as intended.

What landed (2026-08-15 / 16)

The third pass: the album page, which is where the report came from

The audit started from a user report — a fully-tagged library saying "not in your library", on hover rather than on click — and R1 fixed the half of that which lives in SQL. The other half was the page: ownership was four claims OR'd into a tick, and the context menu asked the backend per row, as the menu opened.

explore-album-details now resolves the displayed tracklist's file paths once, from updated(), into one filePaths map that the badge, the Play count, the dimmed rows and every menu item read. The synthesised local tracks carry their own FilePath, so a library album costs no lookup at all; a catalog tracklist costs one batched GetFilePathsByRecordingMBIDs. catalogScope() no longer returns 'library' here — that was the second complaint in the same report, and the artist page keeps it because a library-only artist really is missing sections.

Two bugs fell out of doing it this way, and neither is the one that was reported:

  • The render loop. Guarding the lookup on filePaths (answered) rather than on askedFor (asked) re-requests every unowned MBID forever, because an unowned MBID never lands in the map.
  • "No release data available" over a tracklist held in memory. loadLocalTracks rebuilt the version list only when catalog releases existed, but the "Your Library" entry is synthesised from the local tracks — so the no-releases case was the one case it skipped. Nothing caught it because the old ownership check answered from the local album id and never needed the tracklist to exist.

The second pass: R5R10

before after
the two exact-match indexes 101 MB 3 MB (predicate narrowed to the champion set; plan unchanged, measured)
cover art on disk original + 3 tiers 3 tiers — 1,134 MB of a 1.4 GB directory was the original, and nothing rendered it
browsed artist art 90-day expiry, no ceiling expiry plus a 256 MB budget, oldest evicted first; owned artists never in it
MusicBrainz entity TTL 7 days 1 year, with a 128 MB ceiling on the response cache
redundant indexes 5 0 (3 dropped here, 2 went with their tables)
internal methods on the IPC surface 24 0 (//wails:ignore; 272 → 248 bound methods)
test DB one handle, readDB nil two handles, the shape production runs

The catalog line is R4, finished the day after: MBIDs stored as 16 raw bytes and entity types as codes, measured by converting the real 2,052,200-row catalog through the shipped schema. It needed no artifact rebuild — the importer asks the artifact which encoding it carries and converts the older text form on the way in. Plan 014 has the detail.

Two of those repaid immediately. Giving the test database its own read pool caught three tests writing through it on the first run — the exact bug class that reached a user as "attempt to write a readonly database" and that TestNoWritesOnTheReadPool had to walk the source tree to find. And the artist-image sweep's own test turned out to seed an artists row with no file and call it owned: the phantom this whole audit is about, sitting in the fixture of the test that guards it.

One finding in this audit was wrong. aliases, sort_name, disambiguation, country and artist_type are not dead columns. They are empty on that install because the artist-enrichment pass had barely run (which is finding 011's subject), but indexOneArtist writes all five, and aliases is an FTS column that makes an artist findable by alias. They stay.

The first pass: R2, carrying R1 and R3

R2 shipped with R1 and R3 inside it, because the collapse made them free rather than separate work. No migration: fresh installs only, by the user's decision, so sql/migrations/ went with it.

before after
local tables 9 5 (audio_files, albums, artists, genres, file_genres)
sqlc queries 235 185
generated Go 7,850 6,023
bound IPC methods 272 264
copies of the track projection 9 + the view the view
X/XByLibrary query twins 14 0
migration files + runner 7 + ~120 lines 0
net 5,070 lines across 122 files

Gone: recordings, release_group_recordings, artist_credit, artist_credit_artist, pruneOrphanedMetadata's four sweeps, RemoveLibrary's eight, mapTrackRow's 22 positional arguments, and 340 lines of tagwriter/dbsync.go that existed to relink and then un-orphan those tables.

Ownership is now a file in every one of the places that used to ask a metadata table: CheckMBIDs, collectLibraryEntities, pruneStaleLocalCrossReferences and GetFilePathsByRecordingMBIDs.

Three things found on the way, each written down where it can be hit again (CLAUDE.md, references/schema-change.md):

  • sqlc's parameter rewriter is byte-offset based, so one em dash in a query comment corrupts generation into SELECid.
  • sqlc.slice and sqlc.arg do not compose — slice expansion renumbers, so GetFilePathsByAlbums([1,2], 0) read album id 2 as the library id. Caught by a test, not by a type.
  • release_to_rg looked dead and was not: 0 rows on any ordinary install, because only a local indexbuild fills it, and the daily incremental refresh reads it. Restored.

Verified: make lint (3 configurations), go test ./... plus the indexbuild and dev tag passes, tsc --noEmit, make ui-test (768), and a new end-to-end test that scans the real fixture library and asserts no row outlives its file (TestScan_FixtureLibraryLeavesNothingBehind).


Sequence

Revised 2026-08-15, after the compatibility constraint was lifted: breaking changes are acceptable and the schema may be squashed. That inverts the order — R2 was last only because of the migration, and it subsumes R1 (ownership becomes a foreign key) and reshapes R3 (the projection is defined over the new tables). Doing R1 and R3 against the old shape first would be work thrown away.

  1. R2 — the schema collapse, with the rebuild below. It carries R1 and R3 with it.
  2. R6 — reclaim the 5.3 GB on disk; confirm the janitors run.
  3. R7 / R9 / R8 / R10 — the small correctness and hygiene items.
  4. R4 — the explore_index diet. Artifact rebuild + format bump.
  5. R5 — cache TTLs (trivial) and the shipped denominator (rides along with R4's artifact change).

"Break everything" has a floor, and it is not the schema

Reshaping tables freely is fine. Dropping the database is not, and the numbers say so — a wipe-and-rescan would destroy:

count why a rescan does not restore it
files marked user_confirmed 25,014 the user's autotag review decisions
reviewed tagging folders (confirmed/skipped) 2,109 ditto, plus every skipped becomes pending again
rows in recordings.lyrics 24,294 an unknown share came from LRCLIB, not from tags — re-fetching them is precisely the API traffic we are trying to avoid
playlists / playlist tracks 22 / 1,917 Authored; nothing else has them

So the change ships as a one-shot in-place rebuild: create the new tables, INSERT … SELECT across, drop the old ones, in a single transaction at open. Seconds on 26k rows, ~40 lines of SQL, no migration chain and no rollback path — which is the freedom that was actually being asked for. sql/migrations/ gets squashed into sql/schemas/ at the same time (NOTES.md already blesses this pre-1.0).

Two tables are classified as one Kind and hold another

backend/datamap already encodes what is safe to lose (Owned and Derived rebuild from the files; Cache is expensive; Authored is irreplaceable). The audit found two places where the column disagrees with the table's entry, which is exactly why a wipe looked cheaper than it is:

  • audio_files.tag_status — the table is Owned (a projection of the files), but user_confirmed / user_skipped_permanent are Authored: a decision the user made that exists nowhere else.
  • recordings.lyrics — the table is Owned, but lyrics fetched by the LRCLIB backfill are Cache, and nothing records which of the 24,294 rows came from a tag and which from the network.

The new schema fixes both by construction: lyrics move to their own MBID-keyed table with a source column (so they survive any rebuild of the owned tables, and the provenance question becomes answerable), and tag_status' authored values are carried across explicitly rather than recomputed.

Expected outcome if all of it lands: database ~1.0 GB → ~0.75 GB, data directory 8.5 GB → ~2.5 GB, sqlc queries 235 → ~180, generated Go 7,850 → ~5,000, bound methods 272 → ~255, and — the part that matters — one definition of "this is mine" that a file either satisfies or does not.

The open questions, answered

  1. R2's migration — the user's call, and it was "just assume this new version will only be installed by a new user". So there is no in-place rebuild and no chain: sql/schemas/ is the whole description. An existing YJ_HOME does not open (its audio_files has recording_id and none of the tag columns, and CREATE TABLE IF NOT EXISTS cannot add them) — delete and rescan, and rebuild any seed with make sandbox-seed.
  2. R4's artifact format — no break was needed. The importer asks the artifact what it carries rather than trusting a version, so the published text-form artifact still imports. Plan 014 has it.
  3. Yes, the janitors run. Runner.Start calls RunDue immediately and lastRun is in-memory, so every launch runs everything due. The 4.1 GB survived because OrphanedArtistImagesJob joined a bare MBID onto a sharded directory — deleting the rows and leaving the files, which is worse than not running — and because StrayArtistImageFilesJob did not exist. Both are fixed; it was a bug report, not a cleanup.

Measured on the finished refactor

expected actual
sqlc queries ~180 185
generated Go ~5,000 6,024
bound methods ~255 248
explore_index + indexes 780 MB → 405 MB

The one recommendation not taken

R4's "better still" for artist_mbid: an integer reference to the artist row (8 bytes) rather than the 16 raw bytes it now stores. It is a further ~30 MB on idx_explore_index_artist_mbid, and the reason to stop short is that the artifact carries MBIDs and not local ids, so the import would have to resolve every row against a table it is in the middle of filling. Worth its own argument, not a footnote to this one.