Initial commit: project setup and db package
- Project scaffolding: go.mod, Makefile, .golangci.yaml, doc.go - README, ARCHITECTURE, PROJECT_PLAN, PROGRESS documentation - db package: Open (WAL, FK, busy timeout, 0600 permissions), Migrate (sequential, transactional, idempotent), SchemaVersion, Snapshot (VACUUM INTO) - 11 tests covering open, migrate, and snapshot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
181
db/db.go
Normal file
181
db/db.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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
|
||||
}
|
||||
304
db/db_test.go
Normal file
304
db/db_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
database, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
// Verify WAL mode is enabled.
|
||||
var journalMode string
|
||||
if err := database.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
t.Fatalf("query journal_mode: %v", err)
|
||||
}
|
||||
if journalMode != "wal" {
|
||||
t.Fatalf("journal_mode = %q, want %q", journalMode, "wal")
|
||||
}
|
||||
|
||||
// Verify foreign keys are enabled.
|
||||
var fk int
|
||||
if err := database.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil {
|
||||
t.Fatalf("query foreign_keys: %v", err)
|
||||
}
|
||||
if fk != 1 {
|
||||
t.Fatalf("foreign_keys = %d, want 1", fk)
|
||||
}
|
||||
|
||||
// Verify busy timeout.
|
||||
var timeout int
|
||||
if err := database.QueryRow("PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
||||
t.Fatalf("query busy_timeout: %v", err)
|
||||
}
|
||||
if timeout != 5000 {
|
||||
t.Fatalf("busy_timeout = %d, want 5000", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFilePermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
database, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
_ = database.Close()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
t.Fatalf("permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCreatesParentDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sub", "dir", "test.db")
|
||||
|
||||
database, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
_ = database.Close()
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("database file does not exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenExistingDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create and populate.
|
||||
db1, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open (first): %v", err)
|
||||
}
|
||||
if _, err := db1.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := db1.Exec("INSERT INTO t (id) VALUES (42)"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
_ = db1.Close()
|
||||
|
||||
// Reopen and verify data persists.
|
||||
db2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open (second): %v", err)
|
||||
}
|
||||
defer func() { _ = db2.Close() }()
|
||||
|
||||
var id int
|
||||
if err := db2.QueryRow("SELECT id FROM t").Scan(&id); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if id != 42 {
|
||||
t.Fatalf("id = %d, want 42", id)
|
||||
}
|
||||
}
|
||||
|
||||
var testMigrations = []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
Name: "create users",
|
||||
SQL: `CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`,
|
||||
},
|
||||
{
|
||||
Version: 2,
|
||||
Name: "add email",
|
||||
SQL: `ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
}
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
if err := Migrate(database, testMigrations); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
// Verify both migrations applied.
|
||||
version, err := SchemaVersion(database)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion: %v", err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Fatalf("schema version = %d, want 2", version)
|
||||
}
|
||||
|
||||
// Verify schema is correct.
|
||||
if _, err := database.Exec("INSERT INTO users (name, email) VALUES ('a', 'a@b.c')"); err != nil {
|
||||
t.Fatalf("insert into migrated schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIdempotent(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
// Run twice.
|
||||
if err := Migrate(database, testMigrations); err != nil {
|
||||
t.Fatalf("Migrate (first): %v", err)
|
||||
}
|
||||
if err := Migrate(database, testMigrations); err != nil {
|
||||
t.Fatalf("Migrate (second): %v", err)
|
||||
}
|
||||
|
||||
version, err := SchemaVersion(database)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion: %v", err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Fatalf("schema version = %d, want 2", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIncremental(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
// Apply only the first migration.
|
||||
if err := Migrate(database, testMigrations[:1]); err != nil {
|
||||
t.Fatalf("Migrate (first only): %v", err)
|
||||
}
|
||||
|
||||
version, err := SchemaVersion(database)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Fatalf("schema version = %d, want 1", version)
|
||||
}
|
||||
|
||||
// Now apply all — should pick up only migration 2.
|
||||
if err := Migrate(database, testMigrations); err != nil {
|
||||
t.Fatalf("Migrate (all): %v", err)
|
||||
}
|
||||
|
||||
version, err = SchemaVersion(database)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion: %v", err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Fatalf("schema version = %d, want 2", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRecordsName(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
if err := Migrate(database, testMigrations); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
var name string
|
||||
err := database.QueryRow(
|
||||
`SELECT name FROM schema_migrations WHERE version = 1`,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Fatalf("query migration name: %v", err)
|
||||
}
|
||||
if name != "create users" {
|
||||
t.Fatalf("migration name = %q, want %q", name, "create users")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaVersionEmpty(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
// Create the table but apply no migrations.
|
||||
if err := Migrate(database, nil); err != nil {
|
||||
t.Fatalf("Migrate(nil): %v", err)
|
||||
}
|
||||
|
||||
version, err := SchemaVersion(database)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion: %v", err)
|
||||
}
|
||||
if version != 0 {
|
||||
t.Fatalf("schema version = %d, want 0", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
// Create some data.
|
||||
if _, err := database.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)"); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := database.Exec("INSERT INTO t (val) VALUES ('hello')"); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot.
|
||||
dir := t.TempDir()
|
||||
snapPath := filepath.Join(dir, "snap.db")
|
||||
if err := Snapshot(database, snapPath); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
// Verify snapshot file permissions.
|
||||
info, err := os.Stat(snapPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat snapshot: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Fatalf("snapshot permissions = %o, want 0600", perm)
|
||||
}
|
||||
|
||||
// Open snapshot and verify data.
|
||||
snapDB, err := sql.Open("sqlite", snapPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open snapshot: %v", err)
|
||||
}
|
||||
defer func() { _ = snapDB.Close() }()
|
||||
|
||||
var val string
|
||||
if err := snapDB.QueryRow("SELECT val FROM t").Scan(&val); err != nil {
|
||||
t.Fatalf("select from snapshot: %v", err)
|
||||
}
|
||||
if val != "hello" {
|
||||
t.Fatalf("val = %q, want %q", val, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotCreatesParentDir(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
snapPath := filepath.Join(dir, "sub", "snap.db")
|
||||
if err := Snapshot(database, snapPath); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(snapPath); err != nil {
|
||||
t.Fatalf("snapshot file does not exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
database, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return database
|
||||
}
|
||||
Reference in New Issue
Block a user