// 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) } } // 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 }