188 lines
5.6 KiB
Go
188 lines
5.6 KiB
Go
// Package db provides SQLite database setup, migrations, and snapshots
|
|
// for Metacircular services.
|
|
//
|
|
// All databases are opened with the standard Metacircular pragmas (WAL mode,
|
|
// foreign keys, busy timeout) and restrictive file permissions (0600).
|
|
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite" // SQLite driver (pure Go, no CGo).
|
|
)
|
|
|
|
// Open opens or creates a SQLite database at path with the standard
|
|
// Metacircular pragmas:
|
|
//
|
|
// PRAGMA journal_mode = WAL;
|
|
// PRAGMA foreign_keys = ON;
|
|
// PRAGMA busy_timeout = 5000;
|
|
//
|
|
// The file is created with 0600 permissions (owner read/write only).
|
|
// The parent directory is created if it does not exist.
|
|
//
|
|
// Open returns a standard [*sql.DB] — no wrapper types. Services use it
|
|
// directly with database/sql.
|
|
func Open(path string) (*sql.DB, error) {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return nil, fmt.Errorf("db: create directory %s: %w", dir, err)
|
|
}
|
|
|
|
// Pre-create the file with restrictive permissions if it does not exist.
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
f, createErr := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec // path is caller-provided config, not user input
|
|
if createErr != nil {
|
|
return nil, fmt.Errorf("db: create file %s: %w", path, createErr)
|
|
}
|
|
_ = f.Close()
|
|
}
|
|
|
|
database, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db: open %s: %w", path, err)
|
|
}
|
|
|
|
pragmas := []string{
|
|
"PRAGMA journal_mode = WAL",
|
|
"PRAGMA foreign_keys = ON",
|
|
"PRAGMA busy_timeout = 5000",
|
|
}
|
|
for _, p := range pragmas {
|
|
if _, execErr := database.Exec(p); execErr != nil {
|
|
_ = database.Close()
|
|
return nil, fmt.Errorf("db: %s: %w", p, execErr)
|
|
}
|
|
}
|
|
|
|
// SQLite supports concurrent readers but only one writer. With WAL mode,
|
|
// reads don't block writes, but multiple Go connections competing for
|
|
// the write lock causes SQLITE_BUSY under concurrent load. Limit to one
|
|
// connection to serialize all access and eliminate busy errors.
|
|
database.SetMaxOpenConns(1)
|
|
|
|
// Ensure permissions are correct even if the file already existed.
|
|
if err := os.Chmod(path, 0600); err != nil {
|
|
_ = database.Close()
|
|
return nil, fmt.Errorf("db: chmod %s: %w", path, err)
|
|
}
|
|
|
|
return database, nil
|
|
}
|
|
|
|
// Migration is a numbered, named schema change. Services define their
|
|
// migrations as a []Migration slice — the slice is the schema history.
|
|
type Migration struct {
|
|
// Version is the migration number. Must be unique and should be
|
|
// sequential starting from 1.
|
|
Version int
|
|
|
|
// Name is a short human-readable description (e.g., "initial schema").
|
|
Name string
|
|
|
|
// SQL is the DDL/DML to execute. Multiple statements are allowed
|
|
// (separated by semicolons). Each migration runs in a transaction.
|
|
SQL string
|
|
}
|
|
|
|
// Migrate applies all pending migrations from the given slice. It creates
|
|
// the schema_migrations tracking table if it does not exist.
|
|
//
|
|
// Each migration runs in its own transaction. Already-applied migrations
|
|
// (identified by version number) are skipped. Timestamps are stored as
|
|
// RFC 3339 UTC.
|
|
func Migrate(database *sql.DB, migrations []Migration) error {
|
|
_, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
applied_at TEXT NOT NULL DEFAULT ''
|
|
)`)
|
|
if err != nil {
|
|
return fmt.Errorf("db: create schema_migrations: %w", err)
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
applied, checkErr := migrationApplied(database, m.Version)
|
|
if checkErr != nil {
|
|
return checkErr
|
|
}
|
|
if applied {
|
|
continue
|
|
}
|
|
|
|
tx, txErr := database.Begin()
|
|
if txErr != nil {
|
|
return fmt.Errorf("db: begin migration %d (%s): %w", m.Version, m.Name, txErr)
|
|
}
|
|
|
|
if _, execErr := tx.Exec(m.SQL); execErr != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("db: migration %d (%s): %w", m.Version, m.Name, execErr)
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, execErr := tx.Exec(
|
|
`INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)`,
|
|
m.Version, m.Name, now,
|
|
); execErr != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("db: record migration %d: %w", m.Version, execErr)
|
|
}
|
|
|
|
if commitErr := tx.Commit(); commitErr != nil {
|
|
return fmt.Errorf("db: commit migration %d: %w", m.Version, commitErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SchemaVersion returns the highest applied migration version, or 0 if
|
|
// no migrations have been applied.
|
|
func SchemaVersion(database *sql.DB) (int, error) {
|
|
var version sql.NullInt64
|
|
err := database.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("db: schema version: %w", err)
|
|
}
|
|
if !version.Valid {
|
|
return 0, nil
|
|
}
|
|
return int(version.Int64), nil
|
|
}
|
|
|
|
// Snapshot creates a consistent backup of the database at destPath using
|
|
// SQLite's VACUUM INTO. The destination file is created with 0600
|
|
// permissions.
|
|
func Snapshot(database *sql.DB, destPath string) error {
|
|
dir := filepath.Dir(destPath)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return fmt.Errorf("db: create snapshot directory %s: %w", dir, err)
|
|
}
|
|
|
|
if _, err := database.Exec("VACUUM INTO ?", destPath); err != nil {
|
|
return fmt.Errorf("db: snapshot: %w", err)
|
|
}
|
|
|
|
if err := os.Chmod(destPath, 0600); err != nil {
|
|
return fmt.Errorf("db: chmod snapshot %s: %w", destPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func migrationApplied(database *sql.DB, version int) (bool, error) {
|
|
var count int
|
|
err := database.QueryRow(
|
|
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
return false, fmt.Errorf("db: check migration %d: %w", version, err)
|
|
}
|
|
return count > 0, nil
|
|
}
|