2026-05-18 17:43:46 -04:00
|
|
|
// Package db provides SQLite database initialization and schema management.
|
|
|
|
|
package db
|
2026-05-18 17:44:50 -04:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"database/sql"
|
|
|
|
|
_ "embed"
|
2026-05-23 09:01:21 -04:00
|
|
|
"fmt"
|
2026-05-18 17:44:50 -04:00
|
|
|
"log"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-05-23 08:50:21 -04:00
|
|
|
"strings"
|
2026-05-18 17:44:50 -04:00
|
|
|
|
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//go:embed migrations/001_create_tables.sql
|
|
|
|
|
var migration001 string
|
|
|
|
|
|
2026-05-23 09:01:21 -04:00
|
|
|
//go:embed migrations/002_dedup_unique_index.sql
|
|
|
|
|
var migration002 string
|
|
|
|
|
|
2026-05-18 17:44:50 -04:00
|
|
|
// 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,
|
2026-05-23 09:01:21 -04:00
|
|
|
// and runs all migrations using a schema_version table for tracking.
|
2026-05-18 17:44:50 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 09:01:21 -04:00
|
|
|
// 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 {
|
2026-05-18 17:44:50 -04:00
|
|
|
db.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 09:01:21 -04:00
|
|
|
// 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
|
2026-05-18 17:44:50 -04:00
|
|
|
}
|
2026-05-23 09:01:21 -04:00
|
|
|
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) {
|
2026-05-18 17:44:50 -04:00
|
|
|
log.Println("Migrations complete")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &DB{db}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 08:50:21 -04:00
|
|
|
// migrate executes a SQL migration string by splitting on semicolons.
|
2026-05-18 17:44:50 -04:00
|
|
|
func migrate(db *sql.DB, sql string) error {
|
|
|
|
|
statements := splitSQL(sql)
|
|
|
|
|
for _, stmt := range statements {
|
2026-05-23 08:50:21 -04:00
|
|
|
stmt = strings.TrimSpace(stmt)
|
2026-05-18 17:44:50 -04:00
|
|
|
if stmt == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 08:50:21 -04:00
|
|
|
// splitSQL splits a SQL string on semicolons, respecting quoted strings
|
|
|
|
|
// and stripping SQL line comments (--).
|
2026-05-18 17:44:50 -04:00
|
|
|
func splitSQL(sql string) []string {
|
2026-05-23 08:50:21 -04:00
|
|
|
// First, strip all line comments (--) to prevent them from swallowing
|
|
|
|
|
// subsequent SQL statements when newlines are collapsed.
|
|
|
|
|
sql = stripSQLLineComments(sql)
|
|
|
|
|
|
2026-05-18 17:44:50 -04:00
|
|
|
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 = ""
|
2026-05-23 08:50:21 -04:00
|
|
|
case '\r', '\n', '\t':
|
|
|
|
|
current += " "
|
2026-05-18 17:44:50 -04:00
|
|
|
default:
|
|
|
|
|
current += string(r)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-23 08:50:21 -04:00
|
|
|
if strings.TrimSpace(current) != "" {
|
2026-05-18 17:44:50 -04:00
|
|
|
stmts = append(stmts, current)
|
|
|
|
|
}
|
|
|
|
|
return stmts
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 08:50:21 -04:00
|
|
|
// 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++
|
2026-05-18 17:44:50 -04:00
|
|
|
}
|
2026-05-23 08:50:21 -04:00
|
|
|
// Replace comment with a newline (preserves statement boundaries)
|
|
|
|
|
result.WriteRune('\n')
|
|
|
|
|
continue
|
2026-05-18 17:44:50 -04:00
|
|
|
}
|
2026-05-23 08:50:21 -04:00
|
|
|
|
|
|
|
|
result.WriteRune(r)
|
|
|
|
|
i++
|
2026-05-18 17:44:50 -04:00
|
|
|
}
|
2026-05-23 08:50:21 -04:00
|
|
|
|
|
|
|
|
return result.String()
|
2026-05-18 17:44:50 -04:00
|
|
|
}
|