Files
mcias/internal/db/db.go
Kyle Isom d7b69ed983 db: integrate golang-migrate for schema migrations
- internal/db/migrations/: five embedded SQL files containing
  the migration SQL previously held as Go string literals.
  Files follow the NNN_description.up.sql naming convention
  required by golang-migrate's iofs source.
- internal/db/migrate.go: rewritten to use
  github.com/golang-migrate/migrate/v4 with the
  database/sqlite driver (modernc.org/sqlite, pure Go) and
  source/iofs for compile-time embedded SQL.
  - newMigrate() opens a dedicated *sql.DB so m.Close() does
    not affect the caller's shared connection.
  - Migrate() includes a compatibility shim: reads the legacy
    schema_version table and calls m.Force(v) before m.Up()
    so existing databases are not re-migrated.
  - LatestSchemaVersion promoted from var to const.
- internal/db/db.go: added path field to DB struct; Open()
  translates ':memory:' to a named shared-cache URI
  (file:mcias_N?mode=memory&cache=shared) so the migration
  runner can open a second connection to the same in-memory
  database without sharing the handle that golang-migrate
  will close on teardown.
- go.mod: added golang-migrate/migrate/v4 v4.19.1 (direct).
All callers unchanged. All tests pass; golangci-lint clean.
2026-03-12 11:52:39 -07:00

127 lines
3.6 KiB
Go

// Package db provides the SQLite database access layer for MCIAS.
//
// Security design:
// - All queries use parameterized statements; no string concatenation.
// - Foreign keys are enforced (PRAGMA foreign_keys = ON).
// - WAL mode is enabled for safe concurrent reads during writes.
// - The audit log is append-only: no update or delete operations are provided.
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"sync/atomic"
"time"
_ "modernc.org/sqlite" // register the sqlite3 driver
)
// memCounter generates unique names for in-memory shared-cache databases.
var memCounter atomic.Int64
// DB wraps a *sql.DB with MCIAS-specific helpers.
type DB struct {
sql *sql.DB
// path is the DSN used to open this database. For in-memory databases
// (originally ":memory:") it is a unique shared-cache URI of the form
// file:mcias_N?mode=memory&cache=shared so that a second connection can be
// opened to the same in-memory database (needed by the migration runner).
path string
}
// Open opens (or creates) the SQLite database at path and configures it for
// MCIAS use (WAL mode, foreign keys, busy timeout).
func Open(path string) (*DB, error) {
// Translate bare ":memory:" to a named shared-cache in-memory URI.
// This allows the migration runner to open a second connection to the
// same in-memory database without sharing the *sql.DB handle (which
// would be closed by golang-migrate when the migrator is done).
if path == ":memory:" {
path = fmt.Sprintf("file:mcias_%d?mode=memory&cache=shared", memCounter.Add(1))
}
// The modernc.org/sqlite driver is registered as "sqlite".
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("db: open sqlite: %w", err)
}
// Use a single connection for writes; reads can use the pool.
sqlDB.SetMaxOpenConns(1)
db := &DB{sql: sqlDB, path: path}
if err := db.configure(); err != nil {
_ = sqlDB.Close()
return nil, err
}
return db, nil
}
// configure applies PRAGMAs that must be set on every connection.
func (db *DB) configure() error {
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
"PRAGMA synchronous=NORMAL",
}
for _, p := range pragmas {
if _, err := db.sql.Exec(p); err != nil {
return fmt.Errorf("db: configure pragma %q: %w", p, err)
}
}
return nil
}
// Close closes the underlying database connection.
func (db *DB) Close() error {
return db.sql.Close()
}
// Ping verifies the database connection is alive.
func (db *DB) Ping(ctx context.Context) error {
return db.sql.PingContext(ctx)
}
// SQL returns the underlying *sql.DB for use in tests or advanced queries.
// Prefer the typed methods on DB for all production code.
func (db *DB) SQL() *sql.DB {
return db.sql
}
// now returns the current UTC time formatted as ISO-8601.
func now() string {
return time.Now().UTC().Format(time.RFC3339)
}
// parseTime parses an ISO-8601 UTC time string returned by SQLite.
func parseTime(s string) (time.Time, error) {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
// Try without timezone suffix (some SQLite defaults).
t, err = time.Parse("2006-01-02T15:04:05", s)
if err != nil {
return time.Time{}, fmt.Errorf("db: parse time %q: %w", s, err)
}
return t.UTC(), nil
}
return t.UTC(), nil
}
// ErrNotFound is returned when a requested record does not exist.
var ErrNotFound = errors.New("db: record not found")
// nullableTime converts a *string from SQLite into a *time.Time.
func nullableTime(s *string) (*time.Time, error) {
if s == nil {
return nil, nil
}
t, err := parseTime(*s)
if err != nil {
return nil, err
}
return &t, nil
}