// Package db provides SQLite database initialization and schema management. package db import ( "database/sql" _ "embed" "fmt" "log" "os" "path/filepath" _ "modernc.org/sqlite" ) //go:embed migrations/001_create_tables.sql var migration001 string //go:embed migrations/002_dedup_unique_index.sql var migration002 string // DB wraps the sql.DB with connection-level settings. type DB struct { *sql.DB } // Open opens the SQLite database at the given path, enables WAL mode, // and runs all migrations using a schema_version table for tracking. func Open(path string) (*DB, error) { // Ensure the directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } db, err := sql.Open("sqlite", path) if err != nil { return nil, err } // Enable WAL for concurrent read/write performance if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { db.Close() return nil, err } // Enable foreign keys if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { db.Close() return nil, err } // Ensure schema_version table exists for migration tracking if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil { db.Close() return nil, err } // Read current schema version (0 if table is empty) var currentVersion int if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤tVersion); err != nil { db.Close() return nil, err } // Migration definitions: ordered list of (version, sql) type migration struct { version int sql string } migrations := []migration{ {1, migration001}, {2, migration002}, } for _, m := range migrations { if currentVersion >= m.version { continue } log.Printf("Running migration %d for %s...", m.version, path) if err := migrate(db, m.sql); err != nil { db.Close() return nil, fmt.Errorf("migration %d: %w", m.version, err) } if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil { db.Close() return nil, fmt.Errorf("record migration %d: %w", m.version, err) } log.Printf("Migration %d complete", m.version) } if currentVersion < len(migrations) { log.Println("Migrations complete") } return &DB{db}, nil } // migrate executes a SQL migration string. func migrate(db *sql.DB, sql string) error { // Split on semicolons to handle multiple statements statements := splitSQL(sql) for _, stmt := range statements { stmt = stripWhitespace(stmt) if stmt == "" { continue } if _, err := db.Exec(stmt); err != nil { return err } } return nil } // splitSQL splits a SQL string on semicolons, respecting quoted strings. func splitSQL(sql string) []string { var stmts []string var current string inQuote := false quoteChar := rune(0) for _, r := range sql { if inQuote { current += string(r) if r == quoteChar { inQuote = false } continue } switch r { case '"', '\'', '`': inQuote = true quoteChar = r current += string(r) case ';': stmts = append(stmts, current) current = "" default: current += string(r) } } if len(current) > 0 { stmts = append(stmts, current) } return stmts } // stripWhitespace removes leading/trailing whitespace and normalizes newlines. func stripWhitespace(s string) string { result := "" runningSpace := false for _, r := range s { if r == ' ' || r == '\t' || r == '\n' || r == '\r' { if !runningSpace { result += " " runningSpace = true } } else { result += string(r) runningSpace = false } } return result }