From 6f44512ebc77d92c800f13f9ebf1b5f76906b4da Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 9 Mar 2026 09:27:02 -0400 Subject: [PATCH] =?UTF-8?q?docs(10):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=20schema=20&=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 7 +- .../phases/10-schema-migration/10-01-PLAN.md | 592 ++++++++++++++++++ .../phases/10-schema-migration/10-02-PLAN.md | 592 ++++++++++++++++++ 3 files changed, 1189 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/10-schema-migration/10-01-PLAN.md create mode 100644 .planning/phases/10-schema-migration/10-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ea8244c..77a4383 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -62,7 +62,10 @@ Plans: 2. An existing user's database is migrated on first launch: their single directory becomes a named library, all existing audio_files get that library_id, and everything works without any user action 3. The `playlist_tracks` table supports nullable `audio_file_id` with phantom metadata columns — the schema is ready for phantom track preservation 4. All migration operations complete atomically — a crash mid-migration leaves the database unchanged (not half-migrated) -**Plans:** TBD +**Plans:** 2 plans +Plans: +- [ ] 10-01-PLAN.md — Schema definitions + Migration 6 (libraries table, library_id FK, phantom columns, track_metadata VIEW, backup, TOML migration) +- [ ] 10-02-PLAN.md — sqlc queries for libraries + updated playlist phantom queries + migration integration tests ### Phase 11: Per-Library Scan Pipeline **Goal:** Users can scan individual libraries independently with proper sequential coordination @@ -112,7 +115,7 @@ Plans: | 7. Backend Performance | v1.0 | 2/2 | Complete | 2026-03-05 | | 8. Frontend Performance & UX | v1.0 | 4/4 | Complete | 2026-03-05 | | 9. Scan Cancellation & Keyboard Shortcuts | v1.1 | 5/5 | Complete | 2026-03-07 | -| 10. Schema & Migration | v1.1 | 0/? | Not started | - | +| 10. Schema & Migration | v1.1 | 0/2 | Planned | - | | 11. Per-Library Scan Pipeline | v1.1 | 0/? | Not started | - | | 12. Library CRUD & Data Integrity | v1.1 | 0/? | Not started | - | | 13. Library Views & Phantom Tracks | v1.1 | 0/? | Not started | - | diff --git a/.planning/phases/10-schema-migration/10-01-PLAN.md b/.planning/phases/10-schema-migration/10-01-PLAN.md new file mode 100644 index 0000000..2c35052 --- /dev/null +++ b/.planning/phases/10-schema-migration/10-01-PLAN.md @@ -0,0 +1,592 @@ +--- +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` + diff --git a/.planning/phases/10-schema-migration/10-02-PLAN.md b/.planning/phases/10-schema-migration/10-02-PLAN.md new file mode 100644 index 0000000..ba4f2ec --- /dev/null +++ b/.planning/phases/10-schema-migration/10-02-PLAN.md @@ -0,0 +1,592 @@ +--- +phase: 10-schema-migration +plan: 02 +type: execute +wave: 2 +depends_on: + - 10-01 +files_modified: + - backend/database/sql/queries/libraries.sql + - backend/database/sql/queries/audio_files.sql + - backend/database/sql/queries/playlists.sql + - backend/database/sql/sqlcgen/db.go + - backend/database/sql/sqlcgen/models.go + - backend/database/sql/sqlcgen/querier.go + - backend/database/sql/sqlcgen/libraries.sql.go + - backend/database/sql/sqlcgen/audio_files.sql.go + - backend/database/sql/sqlcgen/playlists.sql.go + - backend/database/testhelper.go + - backend/database/database_test.go +autonomous: true +requirements: + - LIB-04 + - LIB-05 + +must_haves: + truths: + - "sqlc-generated queries exist for library CRUD (create, get, list, delete)" + - "Playlist track queries handle nullable audio_file_id and phantom columns" + - "Audio file queries accept library_id parameter" + - "Migration tests verify upgrade path from v5 to v6" + - "Migration tests verify fresh database creates correct schema" + - "Migration tests verify TOML config is read and default library created" + - "Test helper NewTestDB creates v6 schema including libraries table" + artifacts: + - path: "backend/database/sql/queries/libraries.sql" + provides: "sqlc query definitions for libraries CRUD" + contains: "CreateLibrary" + - path: "backend/database/sql/queries/playlists.sql" + provides: "Updated playlist queries with phantom column support" + contains: "phantom_title" + - path: "backend/database/sql/sqlcgen/libraries.sql.go" + provides: "Generated Go code for library queries" + contains: "func.*CreateLibrary" + - path: "backend/database/database_test.go" + provides: "Migration 6 integration tests" + contains: "TestMigration6" + key_links: + - from: "backend/database/sql/queries/libraries.sql" + to: "backend/database/sql/schemas/_libraries.sql" + via: "sqlc schema awareness" + pattern: "libraries" + - from: "backend/database/database_test.go" + to: "backend/database/database.go migration6" + via: "NewTestDB runs all migrations" + pattern: "runMigrations" +--- + + +Add sqlc query definitions for the new schema, regenerate Go code, and write migration integration tests. + +Purpose: Plan 01 created the schema and migration. This plan makes the new tables usable via type-safe sqlc queries, updates existing playlist queries for phantom support, and verifies the migration works correctly on both fresh and existing databases. + +Output: sqlc queries + generated code for libraries and updated playlists + comprehensive migration tests + + + +@/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/phases/10-schema-migration/10-01-SUMMARY.md + + + + +From backend/database/sql/schemas/_libraries.sql (created by Plan 01): +```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 +); +``` + +From backend/database/sql/schemas/playlist_tracks.sql (updated by Plan 01): +```sql +CREATE TABLE IF NOT EXISTS playlist_tracks ( + id INTEGER PRIMARY KEY, + playlist_id INTEGER NOT NULL, + audio_file_id INTEGER, -- nullable for phantom tracks + 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 +); +``` + +From backend/database/sql/schemas/audio_files.sql (updated by Plan 01): +```sql +-- Now includes: library_id int NOT NULL DEFAULT 0 +-- FK: FOREIGN KEY(library_id) REFERENCES libraries(id) +-- Index: idx_audio_files_library_id +``` + +From backend/database/database.go (updated by Plan 01): +```go +func runMigrations(ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string) error +func backupDatabase(dbPath string, logger *slog.Logger) (string, error) +func migration6MultiLibrary(ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string) error +``` + +From backend/database/sqlc.yaml: +```yaml +version: "2" +sql: + - name: "yellowjacket" + engine: "sqlite" + queries: "./sql/queries" + schema: "./sql/schemas" + gen: + go: + package: "sqlcgen" + out: "./sql/sqlcgen" +``` + +Existing sqlc query patterns from playlists.sql: +```sql +-- name: AddPlaylistTrack :one +INSERT INTO playlist_tracks (playlist_id, audio_file_id, position) VALUES (?, ?, ?) +RETURNING *; + +-- name: GetPlaylistTracksWithMetadata :many +SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position, + af.file_path, af.length_milliseconds, ... +FROM playlist_tracks pt +JOIN audio_files af ON pt.audio_file_id = af.id +... +``` + +Existing test patterns from testhelper.go: +```go +func NewTestDB(t *testing.T) *DB // runs all schemas + migrations +``` + +Existing test patterns from search_test.go: +```go +func seedSearchData(t *testing.T, db *DB) // creates full entity graph +``` + + + + + + + Task 1: Add sqlc queries for libraries and update playlist queries for phantom support + + backend/database/sql/queries/libraries.sql + backend/database/sql/queries/audio_files.sql + backend/database/sql/queries/playlists.sql + backend/database/sql/sqlcgen/db.go + backend/database/sql/sqlcgen/models.go + backend/database/sql/sqlcgen/querier.go + backend/database/sql/sqlcgen/libraries.sql.go + backend/database/sql/sqlcgen/audio_files.sql.go + backend/database/sql/sqlcgen/playlists.sql.go + + +**1. Create `backend/database/sql/queries/libraries.sql` (NEW FILE):** + +Define the core CRUD queries for the libraries table. These will be consumed by Phase 12 (Library CRUD API) but the type-safe generated code is needed now for migration tests and any early usage. + +```sql +-- name: CreateLibrary :one +INSERT INTO libraries (name, path) VALUES (?, ?) +RETURNING *; + +-- name: GetLibrary :one +SELECT * FROM libraries WHERE id = ? LIMIT 1; + +-- name: GetLibraryByPath :one +SELECT * FROM libraries WHERE path = ? LIMIT 1; + +-- name: GetAllLibraries :many +SELECT * FROM libraries ORDER BY name; + +-- name: UpdateLibraryName :exec +UPDATE libraries SET name = ? WHERE id = ?; + +-- name: DeleteLibrary :exec +DELETE FROM libraries WHERE id = ?; + +-- name: CountLibraries :one +SELECT COUNT(*) AS count FROM libraries; +``` + +**2. Update `backend/database/sql/queries/playlists.sql`:** + +The existing queries need updates for the new playlist_tracks schema: + +a) **`AddPlaylistTrack`** — Add phantom metadata columns to the INSERT. The caller populates phantom data eagerly on every insert (per user decision): +```sql +-- name: AddPlaylistTrack :one +INSERT INTO playlist_tracks ( + playlist_id, audio_file_id, position, + phantom_title, phantom_artist, phantom_album, + phantom_duration_ms, phantom_genre, phantom_cover_art_path +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; +``` + +b) **`GetPlaylistTracks`** — Change JOIN to LEFT JOIN on audio_files (audio_file_id is now nullable). Include phantom columns in output so callers can display either live or phantom data: +```sql +-- name: GetPlaylistTracks :many +SELECT pt.id, pt.playlist_id, pt.audio_file_id, pt.position, + COALESCE(af.file_path, '') AS file_path, + pt.phantom_title, pt.phantom_artist, pt.phantom_album, + pt.phantom_duration_ms, pt.phantom_genre, pt.phantom_cover_art_path +FROM playlist_tracks pt +LEFT JOIN audio_files af ON pt.audio_file_id = af.id +WHERE pt.playlist_id = ? +ORDER BY pt.position; +``` + +c) **`GetPlaylistTracksWithMetadata`** — Same LEFT JOIN change, and include phantom fallback columns. When audio_file_id is NULL (phantom), the live metadata JOINs return NULL and callers use phantom_* columns instead: +```sql +-- name: GetPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + COALESCE(af.file_path, '') AS file_path, + COALESCE(af.length_milliseconds, 0) AS length_milliseconds, + COALESCE(r.name, pt.phantom_title, '') AS title, + COALESCE(ac.text, pt.phantom_artist, '') AS artist, + COALESCE(rg.name, pt.phantom_album, '') AS album, + COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, + CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom +FROM playlist_tracks pt +LEFT 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 +WHERE pt.playlist_id = ? +ORDER BY pt.position; +``` + +d) **`GetAllPlaylistTracksWithMetadata`** — Same LEFT JOIN and phantom fallback pattern, without WHERE clause. + +e) **`IsTrackInPlaylist`** — Change JOIN to LEFT JOIN (audio_file_id may be NULL for phantom tracks). + +f) **`RemovePlaylistTrackByPath`** — Change subquery JOIN to handle nullable audio_file_id. + +g) **`GetPlaylistTrackFilePaths`** — Change to LEFT JOIN, filter out NULLs: +```sql +-- name: GetPlaylistTrackFilePaths :many +SELECT COALESCE(af.file_path, '') AS file_path +FROM playlist_tracks pt +LEFT JOIN audio_files af ON pt.audio_file_id = af.id +WHERE pt.playlist_id = ? AND pt.audio_file_id IS NOT NULL +ORDER BY pt.position; +``` + +**3. Update `backend/database/sql/queries/audio_files.sql`:** + +Add a query to get audio files filtered by library: +```sql +-- name: GetAudioFilesByLibrary :many +SELECT * FROM audio_files WHERE library_id = ?; + +-- name: CountAudioFilesByLibrary :one +SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?; +``` + +**4. Regenerate sqlc code:** + +Run from `backend/database/`: +```bash +go generate ./... +``` + +This regenerates all files in `sql/sqlcgen/` from the updated schemas and queries. + +**5. Fix any compilation errors** in the generated code or in callers of the changed query signatures (particularly `AddPlaylistTrack` which now has 9 parameters instead of 3). Check all callers: +- `backend/playlist/playlist.go` — calls `AddPlaylistTrack`. Update to pass phantom metadata. +- Any other callers of changed queries. + +For `AddPlaylistTrack` callers: pass the phantom metadata alongside the audio_file_id. The caller should resolve the metadata at insert time (eager population per user decision). Look at how `playlist.go` currently calls it and add the phantom fields. For now, populate phantom data from the track metadata that the caller already has available. + +**IMPORTANT:** The playlist package's `AddTrack`/`AddTracks` methods need to resolve phantom metadata before inserting. Look at how `GetPlaylistTracksWithMetadata` resolves metadata — the same JOIN pattern should be used to fetch phantom data before insert. Or simpler: the caller already has the file path → look up metadata from DB → pass as phantom columns. + +Create a helper query to resolve phantom metadata for a given audio_file_id: +```sql +-- name: GetTrackPhantomMetadata :one +SELECT + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + af.length_milliseconds AS duration_ms, + 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 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 cover_art ca ON rg.cover_art_id = ca.id +WHERE af.id = ?; +``` + +Add this to `playlists.sql`. + +After regenerating, verify compilation: +```bash +cd backend && go build ./... +``` + +Fix any broken callers of `AddPlaylistTrack` — the signature change from 3 args to 9 args will cause compile errors in the playlist package. Update each caller to: +1. Look up phantom metadata via `GetTrackPhantomMetadata` query +2. Pass all 9 params to `AddPlaylistTrack` + + + cd backend/database && go generate ./... && cd ../.. && go build ./... && go vet ./... + + + - `libraries.sql` query file exists with 7 CRUD queries + - `playlists.sql` updated with phantom column support in all track queries + - `audio_files.sql` has library-filtered query + - sqlc regenerated successfully (all files in sql/sqlcgen/ updated) + - `AddPlaylistTrack` callers updated for new 9-param signature + - `GetTrackPhantomMetadata` helper query exists for eager phantom population + - `go build ./...` passes from project root + + + + + Task 2: Migration integration tests and NewTestDB update + + backend/database/testhelper.go + backend/database/database_test.go + + +Write integration tests that verify migration 6 works correctly on both fresh and existing databases. Also update `NewTestDB` for the new schema. + +**1. Update `testhelper.go`:** + +The `NewTestDB` helper runs all schemas + migrations. Since migration 6 reads a TOML config file, and the test helper uses `:memory:` database with no file path, the migration will skip the TOML reading (existingDir = ""). The test helper needs to handle the updated `runMigrations` signature that now takes `dbPath`: + +```go +// Pass empty string for dbPath — in-memory DBs don't need backup. +if err := runMigrations(ctx, db, slog.Default(), ""); err != nil { + t.Fatalf("could not run migrations: %v", err) +} +``` + +The backup function should no-op when dbPath is empty. Verify this is handled in the migration 6 code (Plan 01 should have handled it — if not, add a guard). + +Also add a `NewTestDBWithLibrary` helper that creates a test DB with a pre-populated library, useful for tests in other packages: + +```go +// NewTestDBWithLibrary returns a test DB with a library row pre-inserted. +// Returns the DB and the library ID. +func NewTestDBWithLibrary(t *testing.T, name, path string) (*DB, int64) { + t.Helper() + db := NewTestDB(t) + lib, err := db.Queries.CreateLibrary(db.Ctx, sqlcgen.CreateLibraryParams{ + Name: name, + Path: path, + }) + if err != nil { + t.Fatalf("could not create test library: %v", err) + } + return db, lib.ID +} +``` + +**2. Create/update `database_test.go`:** + +Write these test cases: + +a) **TestMigration6FreshDB** — Verify that a fresh database (no prior data) creates all expected tables including libraries, and that the schema matches expectations: +```go +func TestMigration6FreshDB(t *testing.T) { + db := NewTestDB(t) + + // Verify libraries table exists + var tableCount int + err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='libraries'").Scan(&tableCount) + // assert tableCount == 1 + + // Verify audio_files has library_id column + // Query PRAGMA table_info(audio_files), check for library_id + + // Verify playlist_tracks has phantom columns and nullable audio_file_id + // Query PRAGMA table_info(playlist_tracks), check columns + + // Verify track_metadata VIEW includes library_id + // Query PRAGMA table_info(track_metadata), check for library_id — wait, VIEWs don't work with table_info + // Instead: SELECT sql FROM sqlite_master WHERE name='track_metadata' + // Assert contains 'library_id' + + // Verify user_version is current (>= 6) + var version int + err = db.QueryRow("PRAGMA user_version").Scan(&version) + // assert version >= 6 + + // Verify libraries table is empty on fresh DB + count, err := db.Queries.CountLibraries(db.Ctx) + // assert count == 0 +} +``` + +b) **TestMigration6LibraryQueries** — Verify CRUD operations on libraries table work: +```go +func TestMigration6LibraryQueries(t *testing.T) { + db := NewTestDB(t) + + // Create a library + lib, err := db.Queries.CreateLibrary(db.Ctx, sqlcgen.CreateLibraryParams{ + Name: "Music", + Path: "/home/user/Music", + }) + // assert lib.Name == "Music", lib.Path == "/home/user/Music" + // assert lib.ID > 0 + + // Get by ID + got, err := db.Queries.GetLibrary(db.Ctx, lib.ID) + // assert got matches lib + + // Get by path + gotByPath, err := db.Queries.GetLibraryByPath(db.Ctx, "/home/user/Music") + // assert gotByPath matches lib + + // Unique path constraint + _, err = db.Queries.CreateLibrary(db.Ctx, sqlcgen.CreateLibraryParams{ + Name: "Duplicate", + Path: "/home/user/Music", + }) + // assert IsUniqueViolation(err) + + // List libraries + libs, err := db.Queries.GetAllLibraries(db.Ctx) + // assert len(libs) == 1 + + // Update name + err = db.Queries.UpdateLibraryName(db.Ctx, sqlcgen.UpdateLibraryNameParams{ + Name: "My Music", + ID: lib.ID, + }) + // Verify name changed + + // Delete + err = db.Queries.DeleteLibrary(db.Ctx, lib.ID) + count, _ := db.Queries.CountLibraries(db.Ctx) + // assert count == 0 +} +``` + +c) **TestMigration6PhantomPlaylistTracks** — Verify playlist tracks work with phantom columns: +```go +func TestMigration6PhantomPlaylistTracks(t *testing.T) { + db, libID := NewTestDBWithLibrary(t, "Test", "/test/music") + + // Create prerequisite data: file_type, recording, audio_file + // (use pattern from existing seedSearchData or seedAudioFiles) + + // Create playlist + playlist, _ := db.Queries.CreatePlaylist(db.Ctx, "Test Playlist") + + // Add track with phantom metadata (eager population) + track, err := db.Queries.AddPlaylistTrack(db.Ctx, sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlist.ID, + AudioFileID: sql.NullInt64{Int64: audioFileID, Valid: true}, + Position: 0, + PhantomTitle: sql.NullString{String: "Test Song", Valid: true}, + PhantomArtist: sql.NullString{String: "Test Artist", Valid: true}, + PhantomAlbum: sql.NullString{String: "Test Album", Valid: true}, + PhantomDurationMs: sql.NullInt64{Int64: 180000, Valid: true}, + PhantomGenre: sql.NullString{String: "Rock", Valid: true}, + PhantomCoverArtPath: sql.NullString{String: "", Valid: false}, + }) + // assert track created + + // Delete the audio_file — should SET NULL on audio_file_id + // (not CASCADE delete the playlist_track) + _, err = db.ExecContext("DELETE FROM audio_files WHERE id = ?", audioFileID) + + // Verify playlist track still exists with NULL audio_file_id + tracks, _ := db.Queries.GetPlaylistTracksWithMetadata(db.Ctx, playlist.ID) + // assert len(tracks) == 1 + // assert tracks[0].AudioFileID is NULL/invalid + // assert tracks[0].Title == "Test Song" (from phantom) + // assert tracks[0].IsPhantom == 1 +} +``` + +d) **TestMigration6AudioFilesLibraryFK** — Verify library_id FK enforcement: +```go +func TestMigration6AudioFilesLibraryFK(t *testing.T) { + db, libID := NewTestDBWithLibrary(t, "Test", "/test") + + // Insert audio_file with valid library_id — should succeed + // Insert audio_file with invalid library_id (999) — should fail FK check + + // Count files by library + count, _ := db.Queries.CountAudioFilesByLibrary(db.Ctx, libID) + // assert count == 1 +} +``` + +e) **TestMigration6TrackMetadataViewHasLibraryID** — Verify the VIEW includes library_id: +```go +func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) { + db, libID := NewTestDBWithLibrary(t, "Test", "/test") + // Insert an audio file with test data + // Query track_metadata VIEW + // Verify library_id column is present and has correct value +} +``` + +**Test patterns to follow:** +- Use `NewTestDB(t)` or `NewTestDBWithLibrary(t, ...)` for setup +- Use `t.Helper()` in helpers +- Use `t.Context()` — NOT `context.Background()` +- Table-driven subtests where appropriate +- Use `database.IsUniqueViolation(err)` for constraint checks +- Follow existing test naming convention: `Test{Feature}{Behavior}` + + + cd backend/database && go test -v -run "TestMigration6" -count=1 ./... + + + - `NewTestDB` updated for new runMigrations signature (passes empty dbPath) + - `NewTestDBWithLibrary` helper exists for tests needing a pre-created library + - TestMigration6FreshDB verifies all tables, columns, and VIEW exist + - TestMigration6LibraryQueries verifies CRUD and unique constraint + - TestMigration6PhantomPlaylistTracks verifies SET NULL FK + phantom metadata preservation + - TestMigration6AudioFilesLibraryFK verifies FK enforcement + - TestMigration6TrackMetadataViewHasLibraryID verifies VIEW includes library_id + - All tests pass + + + + + + +- `go generate ./...` succeeds in backend/database +- `go build ./...` succeeds from project root +- `go test ./backend/database/... -count=1` — all tests pass including new migration tests +- `go test ./backend/playlist/... -count=1` — playlist package still compiles and tests pass (updated AddPlaylistTrack callers) +- `golangci-lint run ./backend/...` — no new lint errors + + + +- All 7 library CRUD queries generated and working +- Playlist queries correctly handle phantom tracks (nullable audio_file_id, phantom columns) +- Audio file queries support library filtering +- Migration tests verify both fresh install and upgrade paths +- SET NULL FK behavior verified: deleting audio_file preserves playlist_track with phantom metadata +- NewTestDBWithLibrary helper available for downstream test usage + + + +After completion, create `.planning/phases/10-schema-migration/10-02-SUMMARY.md` +