diff --git a/internal/store/store.go b/internal/store/store.go index fd2fb15..64a4e42 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -6,6 +6,7 @@ import ( "database/sql" "fmt" "path/filepath" + "strings" "time" _ "modernc.org/sqlite" @@ -119,7 +120,7 @@ var migrations = []string{ // undo must then leave that target alone. `ALTER TABLE operations ADD COLUMN link_preexisted INTEGER NOT NULL DEFAULT 0;`, - // 6-10 belong to features that were removed again (tag editing, + // 6-11 belong to features that were removed again (tag editing, // organizing, the step-by-step fixer). They only add columns, which are // unused now, and stay so databases that already ran them keep the same // numbering and future migrations are never skipped. @@ -141,10 +142,22 @@ var migrations = []string{ `ALTER TABLE pairs ADD COLUMN library_type TEXT NOT NULL DEFAULT 'music'; ALTER TABLE pairs ADD COLUMN fixer_state TEXT NOT NULL DEFAULT '';`, - // 11: opt-in automatic removal of empty slave folders after each scan. + // 11: slave-only songs whose album or artist the master has (removed). + `ALTER TABLE matches ADD COLUMN missing TEXT NOT NULL DEFAULT '';`, + + // 12: opt-in automatic removal of empty slave folders after each scan. + // For a few hours this was numbered 11, so databases that ran it under + // that number already have the column; see addsExistingColumn. `ALTER TABLE pairs ADD COLUMN auto_cleanup_empty INTEGER NOT NULL DEFAULT 0;`, } +// addsExistingColumn reports whether a migration failed only because the +// single column it adds is already there, so it counts as applied. +func addsExistingColumn(m string, err error) bool { + return strings.Count(strings.TrimSpace(m), ";") == 1 && strings.Contains(m, "ADD COLUMN") && + strings.Contains(err.Error(), "duplicate column name") +} + // SchemaVersion is the newest database schema this build knows. func SchemaVersion() int { return len(migrations) } @@ -178,7 +191,7 @@ func (s *Store) migrate() error { if err != nil { return err } - if _, err := tx.Exec(migrations[i]); err != nil { + if _, err := tx.Exec(migrations[i]); err != nil && !addsExistingColumn(migrations[i], err) { tx.Rollback() return fmt.Errorf("migration %d: %w", i+1, err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a71ac26..a78856b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,6 +2,8 @@ package store import ( "context" + "database/sql" + "path/filepath" "reflect" "strings" "testing" @@ -57,3 +59,45 @@ func TestMatchLetters(t *testing.T) { } } } + +// Databases reached schema 11 two ways: the removed "missing from master" +// column, or (briefly) auto_cleanup_empty numbered 11. Both must open with +// every current column, keeping their pairs. +func TestMigrateFromEitherSchema11(t *testing.T) { + for name, eleven := range map[string]string{ + "missing": `ALTER TABLE matches ADD COLUMN missing TEXT NOT NULL DEFAULT '';`, + "auto cleanup": `ALTER TABLE pairs ADD COLUMN auto_cleanup_empty INTEGER NOT NULL DEFAULT 0;`, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", "file:"+filepath.Join(dir, "playlink.db")) + if err != nil { + t.Fatal(err) + } + old := append(append([]string{}, migrations[:10]...), eleven, + `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions, created_at) VALUES ('Music', '/m', '/s', '/q', 'flac', 1)`) + stmts := []string{`CREATE TABLE schema_version (v INTEGER NOT NULL)`, `INSERT INTO schema_version (v) VALUES (11)`} + for _, m := range append(stmts, old...) { + if _, err := db.Exec(m); err != nil { + t.Fatal(err) + } + } + db.Close() + + st, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer st.DB.Close() + ps, err := st.Pairs(context.Background()) + if err != nil || len(ps) != 1 || ps[0].Name != "Music" { + t.Fatalf("pairs = %+v, %v", ps, err) + } + var v int + st.DB.QueryRow(`SELECT MAX(v) FROM schema_version`).Scan(&v) + if v != SchemaVersion() { + t.Fatalf("schema %d, want %d", v, SchemaVersion()) + } + }) + } +}