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