From 1179f56c3680112692e71e8dc7ce946446fa8a8a Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 9 Mar 2026 09:41:08 -0400 Subject: [PATCH] feat(10-01): implement migration 6 and pre-migration backup - Add backupDatabase() for timestamped .db file backup before migration - Add migration6MultiLibrary() with all 14 steps: FK OFF, create libraries table, insert default library from TOML config, add library_id to audio_files, rebuild playlist_tracks with SET NULL FK and 6 phantom columns, backfill phantom metadata, recreate track_metadata VIEW with library_id, FK ON, clean TOML config - Add readLibraryDirFromTOML() and removeLibraryDirFromTOML() helpers - Update runMigrations signature to accept dbPath for backup - Add sentinel library row in NewTestDB for FK constraint satisfaction --- backend/database/database.go | 497 ++++++++++++++++++++++++++++++++- backend/database/testhelper.go | 11 +- 2 files changed, 506 insertions(+), 2 deletions(-) diff --git a/backend/database/database.go b/backend/database/database.go index 65b13b2..f54d0f7 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -6,11 +6,16 @@ import ( "database/sql" "embed" "fmt" + "io" "io/fs" "log/slog" + "os" "path" + "path/filepath" "strings" + "time" + "github.com/BurntSushi/toml" _ "modernc.org/sqlite" // Register sqlite driver. "yellowjacket/backend/database/sql/sqlcgen" @@ -93,7 +98,7 @@ func NewDB(logger *slog.Logger) (*DB, error) { // Run versioned schema migrations for columns that cannot be // added with CREATE TABLE IF NOT EXISTS on existing databases. - if err := runMigrations(dbCtx, db, logger); err != nil { + if err := runMigrations(dbCtx, db, logger, sqliteDBFilePath); err != nil { return nil, fmt.Errorf( "could not run schema migrations: %w", err, ) @@ -171,6 +176,7 @@ func runMigrations( ctx context.Context, db *sql.DB, logger *slog.Logger, + dbPath string, ) error { var version int @@ -297,6 +303,15 @@ func runMigrations( } } + // Migration 6: multi-library support. + if version < 6 { + if err := migration6MultiLibrary( + ctx, db, logger, dbPath, + ); err != nil { + return err + } + } + return nil } @@ -657,3 +672,483 @@ func isDuplicateColumnErr(err error) bool { err.Error(), "duplicate column name", ) } + +// backupDatabase copies the database file to a timestamped backup +// before running a destructive migration. Returns the backup path. +func backupDatabase( + dbPath string, logger *slog.Logger, +) (string, error) { + backupPath := dbPath + ".bak." + time.Now().Format("20060102") + + src, err := os.Open(dbPath) + if err != nil { + return "", fmt.Errorf( + "could not open database for backup: %w", err, + ) + } + + defer func() { _ = src.Close() }() + + dst, err := os.Create(backupPath) + if err != nil { + return "", fmt.Errorf( + "could not create backup file: %w", err, + ) + } + + defer func() { _ = dst.Close() }() + + if _, err := io.Copy(dst, src); err != nil { + return "", fmt.Errorf( + "could not copy database to backup: %w", err, + ) + } + + logger.Info("database backup created", "path", backupPath) + + return backupPath, nil +} + +// migration6MultiLibrary adds multi-library support: creates the +// libraries table, adds library_id FK to audio_files, rebuilds +// playlist_tracks with SET NULL FK and phantom metadata columns, +// and recreates the track_metadata VIEW with library_id. +// +// SAFETY: Hand-crafted SQL for schema migration. +func migration6MultiLibrary( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, + dbPath string, +) error { + logger.Info("applying migration 6: multi-library support") + + // 1. Backup database BEFORE any changes (skip for in-memory DBs). + if dbPath != "" && dbPath != ":memory:" { + if _, err := backupDatabase(dbPath, logger); err != nil { + return fmt.Errorf( + "migration 6: backup failed: %w", err, + ) + } + } + + // 2. Read TOML config to get existing library directory. + existingDir := readLibraryDirFromTOML(logger) + + // 3. Disable FK checks for table rebuild. + // SAFETY: PRAGMA foreign_keys cannot run inside a transaction. + if _, err := db.ExecContext( + ctx, "PRAGMA foreign_keys = OFF", + ); err != nil { + return fmt.Errorf( + "migration 6: could not disable foreign keys: %w", + err, + ) + } + + // 4. Create libraries table. + // SAFETY: Hand-crafted DDL for new table. + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS libraries ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + return fmt.Errorf( + "migration 6: could not create libraries table: %w", + err, + ) + } + + // 5. Insert default library from TOML (if existingDir is not empty). + var defaultLibID int64 + + if existingDir != "" { + libName := filepath.Base(existingDir) + + // SAFETY: Hand-crafted INSERT for migrated default library. + result, err := db.ExecContext(ctx, + "INSERT INTO libraries (name, path) VALUES (?, ?)", + libName, existingDir, + ) + if err != nil { + return fmt.Errorf( + "migration 6: could not insert default library: %w", + err, + ) + } + + defaultLibID, _ = result.LastInsertId() + + logger.Info("migrated existing library", + "name", libName, + "path", existingDir, + "id", defaultLibID, + ) + } + + // 6. Add library_id column to audio_files. + // SAFETY: ALTER TABLE ADD COLUMN with dynamic DEFAULT for backfill. + stmt := fmt.Sprintf( + "ALTER TABLE audio_files ADD COLUMN library_id INTEGER NOT NULL DEFAULT %d", + defaultLibID, + ) + + if _, err := db.ExecContext(ctx, stmt); err != nil { + if !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 6: could not add library_id column: %w", + err, + ) + } + } + + // 7. Create index on library_id. + // SAFETY: Hand-crafted index for FK performance. + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_audio_files_library_id + ON audio_files(library_id) + `); err != nil { + return fmt.Errorf( + "migration 6: could not create library_id index: %w", + err, + ) + } + + // 8. Drop track_metadata VIEW before table rebuild. + if _, err := db.ExecContext( + ctx, "DROP VIEW IF EXISTS track_metadata", + ); err != nil { + return fmt.Errorf( + "migration 6: could not drop track_metadata VIEW: %w", + err, + ) + } + + // 9. Rebuild playlist_tracks for SET NULL FK + phantom columns. + // SAFETY: Table rebuild — playlist_tracks changes to SET NULL, + // queue_tracks keeps CASCADE (ephemeral, not rebuilt). + + // SAFETY: Hand-crafted DDL for rebuilt playlist_tracks. + if _, err := db.ExecContext(ctx, ` + CREATE TABLE playlist_tracks_new ( + id INTEGER PRIMARY KEY, + playlist_id INTEGER NOT NULL, + audio_file_id INTEGER, + position INTEGER NOT NULL, + phantom_title TEXT, + phantom_artist TEXT, + phantom_album TEXT, + phantom_duration_ms INTEGER, + phantom_genre TEXT, + phantom_cover_art_path TEXT, + FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, + FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL + ) + `); err != nil { + return fmt.Errorf( + "migration 6: could not create playlist_tracks_new: %w", + err, + ) + } + + // Copy existing data (phantom columns get NULL). + // SAFETY: Hand-crafted INSERT-SELECT for data migration. + if _, err := db.ExecContext(ctx, ` + INSERT INTO playlist_tracks_new (id, playlist_id, audio_file_id, position) + SELECT id, playlist_id, audio_file_id, position FROM playlist_tracks + `); err != nil { + return fmt.Errorf( + "migration 6: could not copy playlist_tracks data: %w", + err, + ) + } + + // Drop old table. + if _, err := db.ExecContext( + ctx, "DROP TABLE playlist_tracks", + ); err != nil { + return fmt.Errorf( + "migration 6: could not drop old playlist_tracks: %w", + err, + ) + } + + // Rename. + if _, err := db.ExecContext(ctx, + "ALTER TABLE playlist_tracks_new RENAME TO playlist_tracks", + ); err != nil { + return fmt.Errorf( + "migration 6: could not rename playlist_tracks_new: %w", + err, + ) + } + + // Recreate indexes. + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id + ON playlist_tracks(playlist_id) + `); err != nil { + return fmt.Errorf( + "migration 6: could not create playlist_id index: %w", + err, + ) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id + ON playlist_tracks(audio_file_id) + `); err != nil { + return fmt.Errorf( + "migration 6: could not create audio_file_id index: %w", + err, + ) + } + + // 10. Backfill phantom metadata on existing playlist_tracks + // from audio_files JOINs. Eager population per user decision. + // SAFETY: Hand-crafted UPDATE-FROM-SELECT for phantom backfill. + if _, err := db.ExecContext(ctx, ` + UPDATE playlist_tracks SET + phantom_title = sub.title, + phantom_artist = sub.artist, + phantom_album = sub.album, + phantom_duration_ms = sub.duration, + phantom_genre = sub.genre, + phantom_cover_art_path = sub.cover_art_path + FROM ( + SELECT + pt.id AS pt_id, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + af.length_milliseconds AS duration, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(ca.file_path, '') AS cover_art_path + FROM playlist_tracks pt + JOIN audio_files af ON pt.audio_file_id = af.id + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id + ) sub + WHERE playlist_tracks.id = sub.pt_id + `); err != nil { + return fmt.Errorf( + "migration 6: could not backfill phantom metadata: %w", + err, + ) + } + + // 11. Recreate track_metadata VIEW with library_id. + // SAFETY: Hand-crafted VIEW recreation matching schema file. + if _, err := db.ExecContext(ctx, ` + CREATE VIEW IF NOT EXISTS track_metadata AS + SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size, + af.library_id + FROM audio_files af + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN file_types ft ON af.file_type_id = ft.id + `); err != nil { + return fmt.Errorf( + "migration 6: could not recreate track_metadata VIEW: %w", + err, + ) + } + + // 12. Re-enable FK checks. + if _, err := db.ExecContext( + ctx, "PRAGMA foreign_keys = ON", + ); err != nil { + return fmt.Errorf( + "migration 6: could not re-enable foreign keys: %w", + err, + ) + } + + // 13. Remove DirectoryPath from TOML config (libraries table + // is now the source of truth). + if existingDir != "" { + removeLibraryDirFromTOML(logger) + } + + // 14. Set version. + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 6", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 6: %w", err, + ) + } + + logger.Info("migration 6 complete") + + return nil +} + +// readLibraryDirFromTOML reads the TOML config file and returns +// the Library.DirectoryPath value, or "" if not configured. +func readLibraryDirFromTOML(logger *slog.Logger) string { + configDir, err := system.GetUserConfigDirPath() + if err != nil { + logger.Debug( + "could not get config dir for TOML read", + "err", err, + ) + + return "" + } + + configPath := path.Join(configDir, "config.toml") + + data, err := os.ReadFile(configPath) + if err != nil { + logger.Debug( + "could not read config.toml", + "path", configPath, + "err", err, + ) + + return "" + } + + // Minimal struct to extract only the Library.DirectoryPath field. + var cfg struct { + Library struct { + DirectoryPath string `toml:"DirectoryPath"` + } `toml:"Library"` + } + + if _, err := toml.Decode(string(data), &cfg); err != nil { + logger.Debug( + "could not parse config.toml", + "path", configPath, + "err", err, + ) + + return "" + } + + return cfg.Library.DirectoryPath +} + +// removeLibraryDirFromTOML reads the TOML config, removes the +// Library.DirectoryPath field, and writes the config back. This +// ensures the libraries table is the sole source of truth after +// migration. +func removeLibraryDirFromTOML(logger *slog.Logger) { + configDir, err := system.GetUserConfigDirPath() + if err != nil { + logger.Warn( + "could not get config dir for TOML cleanup", + "err", err, + ) + + return + } + + configPath := path.Join(configDir, "config.toml") + + data, err := os.ReadFile(configPath) + if err != nil { + logger.Warn( + "could not read config.toml for cleanup", + "path", configPath, + "err", err, + ) + + return + } + + // Parse the full config as a generic map to preserve all fields. + var cfg map[string]any + + if _, err := toml.Decode(string(data), &cfg); err != nil { + logger.Warn( + "could not parse config.toml for cleanup", + "err", err, + ) + + return + } + + // Remove DirectoryPath from [Library] section. + if lib, ok := cfg["Library"].(map[string]any); ok { + delete(lib, "DirectoryPath") + + // If Library section is now empty, remove it entirely. + if len(lib) == 0 { + delete(cfg, "Library") + } + } + + // Write updated config back. + out, err := toml.Marshal(cfg) + if err != nil { + logger.Warn( + "could not marshal updated config.toml", + "err", err, + ) + + return + } + + if err := os.WriteFile(configPath, out, 0o644); err != nil { + logger.Warn( + "could not write updated config.toml", + "path", configPath, + "err", err, + ) + + return + } + + logger.Info( + "removed Library.DirectoryPath from config.toml", + "path", configPath, + ) +} diff --git a/backend/database/testhelper.go b/backend/database/testhelper.go index b07f869..358a24c 100644 --- a/backend/database/testhelper.go +++ b/backend/database/testhelper.go @@ -57,10 +57,19 @@ func NewTestDB(t *testing.T) *DB { } } - if err := runMigrations(ctx, db, slog.Default()); err != nil { + if err := runMigrations(ctx, db, slog.Default(), ":memory:"); err != nil { t.Fatalf("could not run migrations: %v", err) } + // Insert a sentinel library row at id=0 so audio_files inserts + // using the DEFAULT library_id=0 satisfy the FK constraint. + if _, err := db.ExecContext( + ctx, + "INSERT INTO libraries (id, name, path) VALUES (0, 'Test', '/test')", + ); err != nil { + t.Fatalf("could not insert test library: %v", err) + } + queries := sqlcgen.New(db) t.Cleanup(func() { _ = db.Close() })