--- phase: 10-schema-migration plan: 01 type: execute wave: 1 depends_on: [] files_modified: - backend/database/sql/schemas/libraries.sql - backend/database/sql/schemas/audio_files.sql - backend/database/sql/schemas/playlist_tracks.sql - backend/database/sql/schemas/track_metadata_view.sql - backend/database/database.go autonomous: true requirements: - DATA-01 - DATA-04 - LSCAN-05 must_haves: truths: - "Fresh database creates libraries table with name, path, created_at columns" - "Fresh database creates audio_files with library_id FK column" - "Fresh database creates playlist_tracks with nullable audio_file_id and phantom metadata columns" - "Fresh database creates track_metadata VIEW including library_id" - "Existing v5 database is migrated to v6 atomically — backup created first, all changes in transaction" - "Existing audio_files rows get library_id pointing to the auto-created default library" - "Migration reads TOML DirectoryPath to create the default library row" artifacts: - path: "backend/database/sql/schemas/libraries.sql" provides: "Libraries table DDL for fresh installs" contains: "CREATE TABLE IF NOT EXISTS libraries" - path: "backend/database/sql/schemas/audio_files.sql" provides: "Updated audio_files DDL with library_id FK" contains: "library_id" - path: "backend/database/sql/schemas/playlist_tracks.sql" provides: "Updated playlist_tracks DDL with nullable audio_file_id and phantom columns" contains: "phantom_title" - path: "backend/database/sql/schemas/track_metadata_view.sql" provides: "Updated VIEW with library_id in SELECT" contains: "af.library_id" - path: "backend/database/database.go" provides: "migration6MultiLibrary function + backup logic" contains: "migration6MultiLibrary" key_links: - from: "backend/database/database.go" to: "backend/database/sql/schemas/libraries.sql" via: "embedded SQL schema execution in NewDB" pattern: "schemas.ReadDir.*sql/schemas" - from: "backend/database/database.go migration6" to: "TOML config file" via: "system.GetUserConfigDirPath + toml decode" pattern: "toml\\.Decode" --- Create the database schema definitions and migration 6 for multi-library support. Purpose: This is the foundational schema change that all subsequent multi-library phases depend on. Fresh installs get the new schema directly; existing databases are migrated atomically with a pre-migration backup. Output: Updated SQL schema files for fresh databases + migration 6 implementation in database.go @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/10-schema-migration/10-CONTEXT.md @.planning/research/ARCHITECTURE.md @.planning/research/PITFALLS.md From backend/database/database.go: ```go // DB wraps the SQLite database connection and queries. type DB struct { db *sql.DB Ctx context.Context Queries *sqlcgen.Queries logger *slog.Logger } // NewDB opens the database and applies schema migrations. func NewDB(logger *slog.Logger) (*DB, error) // runMigrations applies incremental schema changes using SQLite's // PRAGMA user_version as the version tracker. func runMigrations(ctx context.Context, db *sql.DB, logger *slog.Logger) error // isDuplicateColumnErr returns true when the error is SQLite's // "duplicate column name" error. func isDuplicateColumnErr(err error) bool // Current migration count: 5 (user_version = 5) // Migration 5 pattern: table rebuild with FK OFF, DROP VIEW, rebuild, recreate VIEW, FK ON ``` From backend/database/sql/schemas/audio_files.sql (current): ```sql CREATE TABLE IF NOT EXISTS audio_files ( id integer PRIMARY KEY, file_path text NOT NULL UNIQUE, length_milliseconds int NOT NULL, file_type_id int NOT NULL, recording_id int NOT NULL, sample_rate int NOT NULL DEFAULT 0, bit_depth int NOT NULL DEFAULT 0, channels int NOT NULL DEFAULT 0, bitrate int NOT NULL DEFAULT 0, file_size int NOT NULL DEFAULT 0, basename text NOT NULL DEFAULT '', FOREIGN KEY(file_type_id) REFERENCES file_types(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); ``` From backend/database/sql/schemas/playlist_tracks.sql (current): ```sql CREATE TABLE IF NOT EXISTS playlist_tracks ( id INTEGER PRIMARY KEY, playlist_id INTEGER NOT NULL, audio_file_id INTEGER NOT NULL, position INTEGER NOT NULL, FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE ); ``` From backend/database/sql/schemas/queue_tracks.sql (current — CASCADE stays): ```sql CREATE TABLE IF NOT EXISTS queue_tracks ( id INTEGER PRIMARY KEY, audio_file_id INTEGER NOT NULL, position INTEGER NOT NULL, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE ); ``` From backend/library/config.go: ```go type Config struct { DirectoryPath Directory `toml:"DirectoryPath"` ScanConcurrency ScanConcurrency `toml:"ScanConcurrency"` } ``` From backend/system/userdata.go: ```go func GetUserDataDirPath() (string, error) func GetUserConfigDirPath() (string, error) ``` Task 1: Update SQL schema files for fresh installs backend/database/sql/schemas/libraries.sql backend/database/sql/schemas/audio_files.sql backend/database/sql/schemas/playlist_tracks.sql backend/database/sql/schemas/track_metadata_view.sql Create the schema files that define the target state for fresh database installs. These files are executed via `go:embed` in `NewDB()` — they use `CREATE TABLE IF NOT EXISTS` / `CREATE VIEW IF NOT EXISTS` so they're idempotent. **1. Create `libraries.sql` (NEW FILE):** ```sql 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 ); ``` Per user decision: minimal table — name, path, created_at only. No scan metadata columns (Phase 11 adds those). No scan_concurrency column (global default fallback for now). **2. Update `audio_files.sql`:** Add `library_id` column with FK to libraries table. For fresh databases the column should be `NOT NULL` with no DEFAULT (fresh installs always create a library first). However, since the CREATE TABLE runs before any libraries exist, use `DEFAULT 0` to allow the table creation to succeed — the migration and scan pipeline will always set the correct value. Add after the `basename` column: ```sql library_id int NOT NULL DEFAULT 0, ``` Add FK constraint: ```sql FOREIGN KEY(library_id) REFERENCES libraries(id) ``` Add index after the table: ```sql CREATE INDEX IF NOT EXISTS idx_audio_files_library_id ON audio_files(library_id); ``` **3. Update `playlist_tracks.sql`:** Change `audio_file_id` from `NOT NULL` to nullable (remove NOT NULL). Change FK from `ON DELETE CASCADE` to `ON DELETE SET NULL`. Add phantom metadata columns with NULL defaults: ```sql CREATE TABLE IF NOT EXISTS playlist_tracks ( 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 ); ``` Keep the existing indexes on playlist_id and audio_file_id. **4. Update `track_metadata_view.sql`:** Add `af.library_id` to the SELECT list — insert it after `af.file_size` (last column). The JOIN structure stays identical: ```sql af.file_size, af.library_id FROM audio_files af ``` **IMPORTANT:** The `libraries.sql` file must sort BEFORE `audio_files.sql` alphabetically so it's executed first (the FK depends on it). Verify: "libraries" < "audio_files" — NO, "a" < "l" so audio_files runs first. This is a problem because audio_files references libraries. Solutions: - Rename to `001_libraries.sql` — but this changes naming convention - Use the migration to handle existing DBs and rely on SQLite's deferred FK check for fresh DBs — since `PRAGMA foreign_keys = ON` is set AFTER schema files run? No — PRAGMAs run BEFORE schemas in `NewDB()`. Actually, check the code: `applyPRAGMAs()` runs `PRAGMA foreign_keys = ON` before schema files execute. So `audio_files.sql` will fail FK check if `libraries` table doesn't exist yet. The fix: name the file so it sorts before audio_files. Use `_libraries.sql` (underscore sorts before 'a' in ASCII). Or better: just create the libraries table inside audio_files.sql as a preceding statement? No, that's messy. Best approach: Name the file so it executes first. Files are read from embedded FS sorted by `ReadDir` (alphabetical). Prefix: `00_libraries.sql` ensures it runs before any other schema. This is clean and explicit. Actually — re-read the `NewDB()` code. It iterates `dirEntries` from `ReadDir` which returns entries sorted by name. So we need `libraries.sql` to sort before `audio_files.sql`. Since 'l' > 'a', we can't use `libraries.sql` directly. Use `_libraries.sql` (underscore = 0x5F sorts after uppercase letters in ASCII but before lowercase... actually in Go's `ReadDir`, entries are sorted case-sensitively). Since all existing files are lowercase, and '_' (0x5F) < 'a' (0x61), `_libraries.sql` will sort first. Wait — there are other options. The simplest: just temporarily disable FK checks in `NewDB` during schema file execution, then re-enable. But that changes production behavior. Simplest correct approach: just make the FK constraint on audio_files use a CREATE INDEX rather than inline FK, and handle the FK check in the migration. But that loses FK enforcement on fresh DBs. Actually the cleanest fix: since `libraries.sql` needs to exist before `audio_files.sql`, and Go's `ReadDir` sorts alphabetically, and `_` < `a`: name it `_libraries.sql`. This runs before all other schema files. Alternative: just don't add the FK constraint to the CREATE TABLE DDL and instead add it during migration / via a separate step. But SQLite doesn't support ALTER TABLE ADD CONSTRAINT. Go with `_libraries.sql`. It's a minor naming convention change but ensures correct execution order. cd backend/database && go build ./... - `_libraries.sql` exists with CREATE TABLE IF NOT EXISTS libraries - `audio_files.sql` includes library_id column and FK - `playlist_tracks.sql` has nullable audio_file_id, SET NULL FK, and all 6 phantom columns - `track_metadata_view.sql` includes af.library_id in SELECT - Package compiles successfully Task 2: Implement migration 6 and pre-migration backup backend/database/database.go Add migration 6 to the `runMigrations()` function in `database.go`. This is the most complex migration yet — follow the established patterns from migration 5 (table rebuild with FK OFF). **Step 1: Add backup function.** Create `backupDatabase()` function that copies the database file before migration 6 runs. Per user decision: timestamp-based naming (e.g., `yj.db.bak.20260309`), no automatic cleanup, logged at INFO level. ```go // 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") // Use io.Copy from source to destination // Log at INFO: "database backup created", "path", backupPath // Return backupPath, nil on success } ``` The `dbPath` must be passed to `runMigrations`. Update the signature: ```go func runMigrations(ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string) error ``` Update the call site in `NewDB()` to pass `sqliteDBFilePath`. **Step 2: Add migration 6 block in runMigrations.** After the `version < 5` block, add: ```go // Migration 6: multi-library support. if version < 6 { if err := migration6MultiLibrary( ctx, db, logger, dbPath, ); err != nil { return err } } ``` **Step 3: Implement `migration6MultiLibrary()` function.** This is a large function — follow migration 5's pattern. The steps MUST execute in this exact order inside a single transaction (DATA-04: atomic): ```go 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. backupPath, err := backupDatabase(dbPath, logger) // Handle error — if backup fails, abort migration. logger.Info("pre-migration backup created", "path", backupPath) // 2. Read TOML config to get existing library directory. // Use system.GetUserConfigDirPath() to find config.toml. // Parse ONLY the [Library] section to get DirectoryPath. // If no config or no DirectoryPath, existingDir = "" (fresh install). configDir, err := system.GetUserConfigDirPath() // Read config.toml, decode [Library].DirectoryPath // Use a minimal struct: struct{ Library struct{ DirectoryPath string } } // 3. Disable FK checks for table rebuild. _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = OFF") // 4. Create libraries table. _, 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 ) `) // 5. Insert default library from TOML (if existingDir is not empty). var defaultLibID int64 if existingDir != "" { // Derive library name from directory basename. // e.g., "/home/user/Music" -> "Music" libName := filepath.Base(existingDir) result, err := db.ExecContext(ctx, "INSERT INTO libraries (name, path) VALUES (?, ?)", libName, existingDir, ) defaultLibID, _ = result.LastInsertId() logger.Info("migrated existing library", "name", libName, "path", existingDir, "id", defaultLibID, ) } // 6. Add library_id column to audio_files. // Use DEFAULT with the actual library ID so existing rows are backfilled. // Per P1: NOT NULL column added via ALTER TABLE requires DEFAULT. 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 ... } } // 7. Create index on library_id. _, err = db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_audio_files_library_id ON audio_files(library_id) `) // 8. Drop track_metadata VIEW (references audio_files which we're about to rebuild playlist_tracks against). _, err = db.ExecContext(ctx, "DROP VIEW IF EXISTS track_metadata") // 9. Rebuild playlist_tracks for SET NULL FK + phantom columns. // Per P2: audit ALL CASCADE FKs — playlist_tracks changes to SET NULL, // queue_tracks keeps CASCADE (ephemeral). _, 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 ) `) // Copy existing data (phantom columns get NULL). _, 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 `) // Drop old table. _, err = db.ExecContext(ctx, "DROP TABLE playlist_tracks") // Rename. _, err = db.ExecContext(ctx, "ALTER TABLE playlist_tracks_new RENAME TO playlist_tracks") // Recreate indexes. _, err = db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id) `) _, err = db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id ON playlist_tracks(audio_file_id) `) // 10. Backfill phantom metadata on existing playlist_tracks from audio_files JOINs. // Per user decision: eager population — fill metadata now, not lazily. _, 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 `) // 11. Recreate track_metadata VIEW with library_id. _, 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 `) // 12. Re-enable FK checks. _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON") // 13. Remove music_directory from TOML config. // Read the full config, nil out the Library.DirectoryPath, write back. // Per user decision: old key ignored if still present (no crash). // Use BurntSushi/toml for read/write consistency. // Only do this if existingDir was non-empty (migration actually ran). if existingDir != "" { removeLibraryDirFromTOML(configDir, logger) } // 14. Set version. _, err = db.ExecContext(ctx, "PRAGMA user_version = 6") logger.Info("migration 6 complete") return nil } ``` **Step 4: Implement `removeLibraryDirFromTOML()` helper.** Read the TOML file, set DirectoryPath to empty string, write back. Use the same `os.WriteFile` with `0o644` permissions pattern from the config package. If the file doesn't exist or the section is missing, no-op (per user decision: old config key ignored). **IMPORTANT notes for the executor:** - Import `path/filepath` for `filepath.Base()` and `time` for backup timestamp. - Import `io` for `io.Copy` in backup function. - Import `os` for file operations. - Import `github.com/BurntSushi/toml` for TOML read/write in migration. - Add `// SAFETY:` comments on all hand-crafted SQL (consistent with Phase 6 convention). - The backup runs OUTSIDE the transaction (you can't copy a file inside a SQL transaction). The migration SQL steps should be wrapped in a transaction for atomicity. Use `db.BeginTx()` around steps 3-12. - Actually, PRAGMA foreign_keys cannot run inside a transaction. Structure: backup → PRAGMA FK OFF → BEGIN TX → steps 4-11 → COMMIT → PRAGMA FK ON → PRAGMA user_version = 6. - Wait — PRAGMA user_version also can't run inside a transaction reliably on all SQLite versions. Follow migration 5's pattern: no explicit transaction, just sequential statements with PRAGMA FK OFF/ON wrapping. - For fresh installs with no TOML config: existingDir="" and defaultLibID=0. The ALTER TABLE ADD COLUMN with DEFAULT 0 is fine — there are no audio_files rows on a fresh install anyway. The schema files handle fresh DB creation. - The `library_id NOT NULL DEFAULT 0` on audio_files in the schema file means fresh-install audio_files don't require a library to exist yet. The scan pipeline (Phase 11) will set library_id correctly. DEFAULT 0 is a placeholder that won't satisfy the FK constraint, but since `PRAGMA foreign_keys` only checks on INSERT/UPDATE, and the CREATE TABLE runs before any data, this is safe. Actually, that FK constraint with DEFAULT 0 is problematic. If FK checks are on and someone inserts a row without a library, it'll fail. For fresh installs the scan pipeline (Phase 11) will always set a real library_id. But to be safe, DON'T add a FK constraint in the CREATE TABLE for audio_files — add it only via the migration where we control the value. Wait, no — we want FK enforcement on fresh DBs too. Better approach: Use `DEFAULT 1` in the schema file — but library ID 1 may not exist on fresh installs. Actually for fresh installs per user decision: "empty libraries table, user adds their first library when they want to scan." So there's no library to FK-reference. The scan pipeline in Phase 11 will create a library first, then scan. The safest approach: keep the FK constraint and `NOT NULL DEFAULT 0` in the schema file. Since `PRAGMA foreign_keys = ON` is set, any INSERT into audio_files without a valid library_id will fail — which is correct behavior. The DEFAULT 0 only matters for the ALTER TABLE ADD COLUMN during migration where it backfills existing rows. We immediately set all rows to the correct library_id in the same migration. Wait — for the ALTER TABLE ADD COLUMN in migration 6, the DEFAULT value must match the actual library ID. That's `defaultLibID` (dynamic). So the schema file's DEFAULT 0 is fine for CREATE TABLE (fresh DBs), and the migration uses a dynamic DEFAULT. One more thing: on fresh DBs, audio_files will have `library_id INTEGER NOT NULL DEFAULT 0` with a FK to libraries. If someone tries to INSERT an audio_file with library_id=0 and no library with id=0 exists, the FK check will fail. This is actually CORRECT — you must create a library first. Good. Let the executor figure out the exact DEFAULT handling. The key instruction is clear. cd backend/database && go build ./... && go vet ./... - `runMigrations` signature updated to accept dbPath - `backupDatabase()` creates timestamped copy of .db file - `migration6MultiLibrary()` implements all 14 steps in order - TOML DirectoryPath is read and used to create default library - Library name derived from directory basename - playlist_tracks rebuilt with SET NULL FK and 6 phantom columns - Phantom metadata backfilled from audio_files JOINs on existing rows - track_metadata VIEW recreated with library_id column - TOML config cleaned up (DirectoryPath removed after migration) - All hand-crafted SQL has SAFETY comments - Package compiles and passes vet - `go build ./...` passes from project root - `go vet ./...` passes from backend/database - No linting errors on new code: `golangci-lint run ./backend/database/...` - Fresh database creates all tables including libraries and updated audio_files/playlist_tracks - Migration 6 function exists with complete implementation - Backup function creates timestamped database copy - All schema changes follow established migration patterns - TOML config reading works for default library creation After completion, create `.planning/phases/10-schema-migration/10-01-SUMMARY.md`