package database import ( "database/sql" "errors" "fmt" "strings" "time" _ "github.com/mattn/go-sqlite3" ) // DB wraps sql.DB with migration support. type DB struct { conn *sql.DB } // ErrArtistNotFound is returned by artist lookups when no row matches the given // ID. It is a sentinel so callers (e.g. the web UI) can distinguish "missing" // from other errors. var ErrArtistNotFound = errors.New("artist not found") // ErrReleaseNotFound is returned by SetReleaseIgnored when no external_release // row matches the given RGID (e.g. it was pruned by a concurrent re-sync). It // is a sentinel so callers (e.g. the web UI) can treat it as benign. var ErrReleaseNotFound = errors.New("release not found") // New opens a SQLite database at dbPath and runs schema migrations. func New(dbPath string) (*DB, error) { // The _foreign_keys=on DSN parameter enables foreign key enforcement on // EVERY connection in the pool. A one-off "PRAGMA foreign_keys=ON" executed // on the pooled *sql.DB only applies to the first connection and is lost on // connections opened later by the pool, silently disabling the safety net. // A plain ":memory:" database is private to the connection that opened it, // so a pool of N connections would give N separate empty databases and // migrations would appear missing on some. Limiting the pool to a single // connection keeps one in-memory database per New() call, which is correct // for both tests (isolated) and the single-process production service. // Append the foreign_keys pragma via net/url so a caller-supplied path that // already contains a query string is not silently broken. dsn := dbPath if !strings.Contains(dsn, "?") { dsn += "?" } else { dsn += "&" } dsn += "_foreign_keys=on" conn, err := sql.Open("sqlite3", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } if dbPath == ":memory:" { conn.SetMaxOpenConns(1) } // Enable WAL mode for better concurrent read performance. if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil { conn.Close() return nil, fmt.Errorf("set WAL mode: %w", err) } // Set busy timeout to handle concurrent write contention. if _, err := conn.Exec("PRAGMA busy_timeout=5000"); err != nil { conn.Close() return nil, fmt.Errorf("set busy timeout: %w", err) } db := &DB{conn: conn} if err := db.migrate(); err != nil { conn.Close() return nil, fmt.Errorf("migrate: %w", err) } return db, nil } // Close closes the database connection. func (db *DB) Close() error { return db.conn.Close() } // Conn returns the underlying sql.DB for use by other packages. func (db *DB) Conn() *sql.DB { return db.conn } // Begin starts a new database transaction. func (db *DB) Begin() (*sql.Tx, error) { return db.conn.Begin() } // migrate runs all pending schema migrations in order. func (db *DB) migrate() error { // Create the migrations tracking table first, unconditionally. if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS _migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP );`); err != nil { return fmt.Errorf("create migrations table: %w", err) } migrations := []struct { name string sql string }{ { name: "001_create_artist_settings", sql: `CREATE TABLE IF NOT EXISTS artist_settings ( id TEXT PRIMARY KEY, name TEXT NOT NULL, ignore_singles BOOLEAN DEFAULT 0, ignore_compilations BOOLEAN DEFAULT 0, monitored BOOLEAN DEFAULT 1 );`, }, { name: "002_create_external_releases", sql: `CREATE TABLE IF NOT EXISTS external_releases ( rgid TEXT PRIMARY KEY, artist_id TEXT NOT NULL REFERENCES artist_settings(id), title TEXT NOT NULL, type TEXT, release_date TEXT, is_ignored BOOLEAN DEFAULT 0 );`, }, { name: "003_create_local_albums", sql: `CREATE TABLE IF NOT EXISTS local_albums ( id TEXT PRIMARY KEY, artist_id TEXT NOT NULL REFERENCES artist_settings(id), title TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_local_albums_artist_id ON local_albums(artist_id);`, }, { name: "004_create_notifications_sent", sql: `CREATE TABLE IF NOT EXISTS notifications_sent ( rgid TEXT NOT NULL REFERENCES external_releases(rgid), sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (rgid, sent_at) );`, }, { name: "005_add_cached_at_to_external_releases", sql: `ALTER TABLE external_releases ADD COLUMN cached_at DATETIME;`, }, { name: "006_add_secondary_types_to_external_releases", sql: `ALTER TABLE external_releases ADD COLUMN secondary_types TEXT;`, }, { name: "007_index_external_releases_artist_id", sql: `CREATE INDEX IF NOT EXISTS idx_external_releases_artist_id ON external_releases(artist_id);`, }, { name: "008_add_mbid_to_artist_settings", sql: `ALTER TABLE artist_settings ADD COLUMN mbid TEXT;`, }, { name: "009_add_last_synced_to_artist_settings", sql: `ALTER TABLE artist_settings ADD COLUMN last_synced DATETIME;`, }, } for _, m := range migrations { applied, err := db.isMigrationApplied(m.name) if err != nil { return fmt.Errorf("check migration %s: %w", m.name, err) } if applied { continue } tx, err := db.conn.Begin() if err != nil { return fmt.Errorf("begin transaction for migration %s: %w", m.name, err) } if _, err := tx.Exec(m.sql); err != nil { tx.Rollback() return fmt.Errorf("apply migration %s: %w", m.name, err) } if _, err := tx.Exec("INSERT INTO _migrations (name) VALUES (?)", m.name); err != nil { tx.Rollback() return fmt.Errorf("record migration %s: %w", m.name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", m.name, err) } } return nil } // isMigrationApplied checks whether a migration with the given name has already been applied. func (db *DB) isMigrationApplied(name string) (bool, error) { var count int err := db.conn.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count) if err != nil { return false, err } return count > 0, nil } // ArtistSettings represents a row in the artist_settings table. type ArtistSettings struct { ID string `json:"id"` Name string `json:"name"` MBID string `json:"mbid"` IgnoreSingles bool `json:"ignore_singles"` IgnoreCompilations bool `json:"ignore_compilations"` Monitored bool `json:"monitored"` LastSynced time.Time `json:"last_synced"` } // LocalAlbum represents a row in the local_albums table. type LocalAlbum struct { ID string `json:"id"` ArtistID string `json:"artist_id"` Title string `json:"title"` } // ExternalRelease represents a row in the external_releases table. type ExternalRelease struct { RGID string `json:"rgid"` ArtistID string `json:"artist_id"` Title string `json:"title"` Type string `json:"type"` ReleaseDate string `json:"release_date"` IsIgnored bool `json:"is_ignored"` CachedAt time.Time `json:"cached_at"` SecondaryTypes []string `json:"secondary_types"` } // NotificationSent represents a row in the notifications_sent table. type NotificationSent struct { RGID string `json:"rgid"` SentAt time.Time `json:"sent_at"` }