129 lines
3.5 KiB
Go
129 lines
3.5 KiB
Go
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
|
|
||
|
|
// Package database manages the SQLite connection, PRAGMAs, and schema migrations.
|
||
|
|
package database
|
||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"embed"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
_ "modernc.org/sqlite"
|
||
|
|
)
|
||
|
|
|
||
|
|
//go:embed migrations/*.sql
|
||
|
|
var migrationsFS embed.FS
|
||
|
|
|
||
|
|
// Open creates a new SQLite database connection with recommended PRAGMAs
|
||
|
|
// for WAL mode, foreign keys, and performance tuning.
|
||
|
|
func Open(dbPath string) (*sql.DB, error) {
|
||
|
|
// Ensure the directory for the database file exists.
|
||
|
|
dir := filepath.Dir(dbPath)
|
||
|
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||
|
|
return nil, fmt.Errorf("create db directory: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
db, err := sql.Open("sqlite", dbPath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply recommended PRAGMAs. These must be set per-connection, and since
|
||
|
|
// database/sql may open multiple connections, we use ConnInitHook via DSN
|
||
|
|
// parameters where possible. However, journal_mode persists at the db level.
|
||
|
|
pragmas := []string{
|
||
|
|
"PRAGMA journal_mode = WAL",
|
||
|
|
"PRAGMA busy_timeout = 5000",
|
||
|
|
"PRAGMA synchronous = NORMAL",
|
||
|
|
"PRAGMA foreign_keys = ON",
|
||
|
|
"PRAGMA cache_size = -20000",
|
||
|
|
}
|
||
|
|
for _, p := range pragmas {
|
||
|
|
if _, err := db.Exec(p); err != nil {
|
||
|
|
db.Close()
|
||
|
|
return nil, fmt.Errorf("set pragma %q: %w", p, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// SQLite works best with a single writer connection.
|
||
|
|
db.SetMaxOpenConns(1)
|
||
|
|
|
||
|
|
slog.Info("database opened", "path", dbPath)
|
||
|
|
return db, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Migrate runs all pending SQL migrations in order. Migrations are embedded
|
||
|
|
// SQL files named with a numeric prefix (e.g. 001_initial.sql). Each migration
|
||
|
|
// runs within a transaction.
|
||
|
|
func Migrate(db *sql.DB) error {
|
||
|
|
// Create the migrations tracking table if it doesn't exist.
|
||
|
|
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
|
|
version TEXT PRIMARY KEY,
|
||
|
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||
|
|
)`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("create schema_migrations table: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Read all migration files.
|
||
|
|
entries, err := migrationsFS.ReadDir("migrations")
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("read migrations dir: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort by filename to ensure correct order.
|
||
|
|
sort.Slice(entries, func(i, j int) bool {
|
||
|
|
return entries[i].Name() < entries[j].Name()
|
||
|
|
})
|
||
|
|
|
||
|
|
for _, entry := range entries {
|
||
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
name := entry.Name()
|
||
|
|
|
||
|
|
// Check if this migration has already been applied.
|
||
|
|
var count int
|
||
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", name).Scan(&count); err != nil {
|
||
|
|
return fmt.Errorf("check migration %s: %w", name, err)
|
||
|
|
}
|
||
|
|
if count > 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
// Read and execute the migration in a transaction.
|
||
|
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
tx, err := db.Begin()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("begin tx for %s: %w", name, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := tx.Exec(string(content)); err != nil {
|
||
|
|
tx.Rollback()
|
||
|
|
return fmt.Errorf("execute migration %s: %w", name, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", name); err != nil {
|
||
|
|
tx.Rollback()
|
||
|
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := tx.Commit(); err != nil {
|
||
|
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
slog.Info("applied migration", "version", name)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|