Files
yellowjacket/cmd/indexbuild/staleschema_test.go
T
yonluandClaude Opus 5 eb059a3d71 fix(database): retire a table whose shape the schema moved past
`applySchema` is CREATE ... IF NOT EXISTS and there is no migration
chain, so a *changed* table never migrates: the statement silently
no-ops against the old shape. Two plans had already landed on that, and
neither showed up in a test because a fresh install is perfectly
healthy.

- 014 added `total_tracks` to explore_index and to `indexRowFields`,
  the projection every explore read uses, so every search, browse,
  artist page and album page failed with "no such column: total_tracks"
  on any database that already had a catalog.
- 013 reshaped audio_files, so applySchema could not run at all and the
  app did not open.

staleshape.go runs before applySchema and drops what disagrees, so the
create is a create. It parses sql/schemas/ for the expectation rather
than writing the column list down a second time, and it notices a
changed *type* as well as a missing column — 013 moved mbid TEXT to
BLOB, which no ALTER could express and which SQLite will not coerce, so
a query against 16 raw bytes returns no rows rather than an error.

Only Authored tables are exempt. Cache is rebuildable by definition,
Owned is what a rescan rebuilds (plan 013's stated "delete and
rescan"), and a table the schema no longer describes at all goes too --
013 left seven behind plus schema_migrations.

Three things in it are load-bearing, and each was a bug first:

- The parser read `UNIQUE(mbid)` as a column, which made a healthy
  catalog look stale. That would have retired it on every launch and
  cost every user an artifact download per start.
- The drops are one transaction with defer_foreign_keys. Those legacy
  tables reference each other, so any order fails on whichever goes
  first; turning foreign keys off instead would suppress
  playlist_tracks.audio_file_id's ON DELETE SET NULL and leave entries
  pointing at ids a rescan reissues to *different songs*. Nulled
  entries are empty; stale ones are wrong, and wrong quietly.
- The order is sorted, so a failure reproduces. Map order is random,
  and the foreign-key bug passed its own regression test on two runs in
  three until the order was fixed.

Verified against a real pre-013 install: it opens, its 22 playlists
survive, 1,887 linked playlist entries become 0 rather than dangling,
and the legacy tables are swept.

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

128 lines
3.5 KiB
Go

//go:build indexbuild
package main
import (
"context"
"database/sql"
"log/slog"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
"yellowjacket/backend/database"
"yellowjacket/backend/system"
)
// TestRetireLibraryTables is the CI failure written down.
//
// The index job's persistent volume held a database from before plan
// 013 reshaped audio_files, so every run died applying an index to a
// column the old table does not have. The catalog in the same file is
// a day of downloading to rebuild and has to survive the repair.
func TestRetireLibraryTables(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
t.Setenv("YJ_HOME", t.TempDir())
dataDir, err := system.GetUserDataDirPath()
if err != nil {
t.Fatalf("resolve data dir: %v", err)
}
dbPath := filepath.Join(dataDir, "yj.db")
// Build the real thing, then put it back into the shape the volume
// was actually in: a pre-013 audio_files, and a table the schema
// stopped describing at all.
if _, err := database.NewDB(logger); err != nil {
t.Fatalf("first open: %v", err)
}
exec(t, dbPath, `
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
VALUES (1, randomblob(16), 'A Catalog Row', 'An Artist', randomblob(16));
DROP TABLE audio_files;
CREATE TABLE audio_files (
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL UNIQUE,
recording_id INTEGER
);
CREATE TABLE recordings (id INTEGER PRIMARY KEY, title TEXT);
`)
// This used to assert the symptom -- that the schema cannot be
// applied over a table whose shape has moved on -- because at the
// time nothing repaired it and only this job did. The app-side
// repair (backend/database/staleshape.go) now retires a stale
// non-authored table before applySchema meets it, so opening
// succeeds and the symptom no longer reproduces from here.
//
// That does not make retireLibraryTables redundant, and the rest of
// this test is why: the app-side repair only removes what is *stale*,
// while this database wants its library half gone entirely, healthy
// or not, because nothing here scans, plays or authors.
if _, err := database.NewDB(logger); err != nil {
t.Fatalf("the app-side repair should have opened this: %v", err)
}
if err := retireLibraryTables(context.Background(), logger); err != nil {
t.Fatalf("retireLibraryTables: %v", err)
}
for _, table := range []string{"audio_files", "recordings"} {
if count(t, dbPath, sqliteMasterQuery(table)) != 0 {
t.Errorf("%s survived; the schema cannot be applied over it", table)
}
}
if _, err := database.NewDB(logger); err != nil {
t.Fatalf("open after retiring stale tables: %v", err)
}
// The catalog is why the volume exists.
if got := count(t, dbPath, "SELECT COUNT(*) FROM explore_index"); got != 1 {
t.Errorf("explore_index rows = %d, want 1 (the catalog must survive)", got)
}
}
func sqliteMasterQuery(name string) string {
return `SELECT COUNT(*) FROM sqlite_master WHERE name = '` + name + `'`
}
func exec(t *testing.T, dbPath, statements string) {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.Exec(statements); err != nil {
t.Fatalf("exec: %v", err)
}
}
func count(t *testing.T, dbPath, query string) int {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = db.Close() }()
var n int
if err := db.QueryRow(query).Scan(&n); err != nil {
t.Fatalf("%s: %v", query, err)
}
return n
}