Files
mcias/internal/db/migrate.go
Kyle Isom 22158824bd Checkpoint: password reset, rule expiry, migrations
- Self-service and admin password-change endpoints
  (PUT /v1/auth/password, PUT /v1/accounts/{id}/password)
- Policy rule time-scoped expiry (not_before / expires_at)
  with migration 000006 and engine filtering
- golang-migrate integration; embedded SQL migrations
- PolicyRecord fieldalignment lint fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 14:38:38 -07:00

144 lines
5.1 KiB
Go

package db
import (
"database/sql"
"embed"
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite" // driver registration
)
// migrationsFS embeds all migration SQL files from the migrations/ directory.
// Each file is named NNN_description.up.sql (and optionally .down.sql).
//
//go:embed migrations/*.sql
var migrationsFS embed.FS
// LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file
// is added.
const LatestSchemaVersion = 6
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
// files. It opens a dedicated *sql.DB using the same DSN as the main
// database so that calling m.Close() (which closes the underlying connection)
// does not affect the caller's main database connection.
//
// Security: migration SQL is embedded at compile time from the migrations/
// directory and is never loaded from the filesystem at runtime, preventing
// injection of arbitrary SQL via a compromised working directory.
func newMigrate(database *DB) (*migrate.Migrate, error) {
src, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return nil, fmt.Errorf("db: create migration source: %w", err)
}
// Open a dedicated connection for the migrator. golang-migrate's sqlite
// driver calls db.Close() when the migrator is closed; using a dedicated
// connection (same DSN, different *sql.DB) prevents it from closing the
// shared connection. For in-memory databases, Open() translates
// ":memory:" to a named shared-cache URI so both connections see the same
// data.
migrateDB, err := sql.Open("sqlite", database.path)
if err != nil {
return nil, fmt.Errorf("db: open migration connection: %w", err)
}
migrateDB.SetMaxOpenConns(1)
if _, err := migrateDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
_ = migrateDB.Close()
return nil, fmt.Errorf("db: migration connection pragma: %w", err)
}
driver, err := sqlitedriver.WithInstance(migrateDB, &sqlitedriver.Config{
MigrationsTable: "schema_migrations",
})
if err != nil {
_ = migrateDB.Close()
return nil, fmt.Errorf("db: create migration driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
if err != nil {
return nil, fmt.Errorf("db: initialise migrator: %w", err)
}
return m, nil
}
// Migrate applies any unapplied schema migrations to the database in order.
// It is idempotent: running it on an already-current database is safe and
// returns nil.
//
// Existing databases that were migrated by the previous hand-rolled runner
// (schema_version table) are handled by the compatibility shim below: the
// legacy version is read and used to fast-forward the golang-migrate state
// before calling Up, so no migration is applied twice.
func Migrate(database *DB) error {
// Compatibility shim: if the database was previously migrated by the
// hand-rolled runner it has a schema_version table with the current
// version. Inform golang-migrate of the existing version so it does
// not try to re-apply already-applied migrations.
legacyVersion, err := legacySchemaVersion(database)
if err != nil {
return fmt.Errorf("db: read legacy schema version: %w", err)
}
m, err := newMigrate(database)
if err != nil {
return err
}
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
if legacyVersion > 0 {
// Force the migrator to treat the database as already at
// legacyVersion so Up only applies newer migrations.
if err := m.Force(legacyVersion); err != nil {
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
}
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("db: apply migrations: %w", err)
}
return nil
}
// SchemaVersion returns the current applied schema version of the database.
// Returns 0 if no migrations have been applied yet.
func SchemaVersion(database *DB) (int, error) {
m, err := newMigrate(database)
if err != nil {
return 0, err
}
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
v, _, err := m.Version()
if errors.Is(err, migrate.ErrNilVersion) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("db: read schema version: %w", err)
}
// Security: v is a migration version number (small positive integer);
// the uint→int conversion is safe for any realistic schema version count.
return int(v), nil //nolint:gosec // G115: migration version is always a small positive integer
}
// legacySchemaVersion reads the version from the old schema_version table
// created by the hand-rolled migration runner. Returns 0 if the table does
// not exist (fresh database or already migrated to golang-migrate only).
func legacySchemaVersion(database *DB) (int, error) {
var version int
err := database.sql.QueryRow(
`SELECT version FROM schema_version LIMIT 1`,
).Scan(&version)
if err != nil {
// Table does not exist or is empty — treat as version 0.
return 0, nil //nolint:nilerr
}
return version, nil
}