Files
yellowjacket/.planning/phases/10-schema-migration/10-01-PLAN.md
T

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
10-schema-migration 01 execute 1
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
true
DATA-01
DATA-04
LSCAN-05
truths artifacts key_links
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
path provides contains
backend/database/sql/schemas/libraries.sql Libraries table DDL for fresh installs CREATE TABLE IF NOT EXISTS libraries
path provides contains
backend/database/sql/schemas/audio_files.sql Updated audio_files DDL with library_id FK library_id
path provides contains
backend/database/sql/schemas/playlist_tracks.sql Updated playlist_tracks DDL with nullable audio_file_id and phantom columns phantom_title
path provides contains
backend/database/sql/schemas/track_metadata_view.sql Updated VIEW with library_id in SELECT af.library_id
path provides contains
backend/database/database.go migration6MultiLibrary function + backup logic migration6MultiLibrary
from to via pattern
backend/database/database.go backend/database/sql/schemas/libraries.sql embedded SQL schema execution in NewDB schemas.ReadDir.*sql/schemas
from to via pattern
backend/database/database.go migration6 TOML config file system.GetUserConfigDirPath + toml decode 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

<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.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:

// 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):

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):

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):

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:

type Config struct {
    DirectoryPath   Directory       `toml:"DirectoryPath"`
    ScanConcurrency ScanConcurrency `toml:"ScanConcurrency"`
}

From backend/system/userdata.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):

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:

  library_id int NOT NULL DEFAULT 0,

Add FK constraint:

  FOREIGN KEY(library_id) REFERENCES libraries(id)

Add index after the table:

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:

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:

    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.

// 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:

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:

// 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):

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/...`

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/10-schema-migration/10-01-SUMMARY.md`