// Package db provides SQLite database initialization and schema management. package db import ( "database/sql" _ "embed" "fmt" "log" "os" "path/filepath" "strings" _ "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 by splitting on semicolons. func migrate(db *sql.DB, sql string) error { statements := splitSQL(sql) for _, stmt := range statements { stmt = strings.TrimSpace(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 // and stripping SQL line comments (--). func splitSQL(sql string) []string { // First, strip all line comments (--) to prevent them from swallowing // subsequent SQL statements when newlines are collapsed. sql = stripSQLLineComments(sql) 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 = "" case '\r', '\n', '\t': current += " " default: current += string(r) } } if strings.TrimSpace(current) != "" { stmts = append(stmts, current) } return stmts } // stripSQLLineComments removes all -- single-line comments from SQL text. func stripSQLLineComments(sql string) string { var result strings.Builder i := 0 runes := []rune(sql) for i < len(runes) { r := runes[i] // Check for -- comment start if r == '-' && i+1 < len(runes) && runes[i+1] == '-' { // Skip to end of line i += 2 for i < len(runes) && runes[i] != '\n' && runes[i] != '\r' { i++ } // Replace comment with a newline (preserves statement boundaries) result.WriteRune('\n') continue } result.WriteRune(r) i++ } return result.String() }