Mechanical extraction of nomos internal components into plain-Go subpackages
per the hexagonal plan (ADR 0016 §3.1 rule 3):
turngate/ — per-session turn serialization (plan 2026-08-03 F1)
retrycap/ — per-turn run retry cap (maxRunRetries=3)
messagequeue/ — operator-message queue for busy-turn re-entry (F2)
assent/ — chat-assent detection (isAssent, isTypedConfirmation,
ExtractPendingApprovals), decoupled from agent via
[]string input instead of persistedCall
session/ — store (chat sessions, plan execution, DB persistence),
migration runner + local emitEvent to break adapter
dependency
internal/migrate/ — shared migration runner extracted from postgres pool,
used by both the oikos postgres adapter and session tests.
session package export-rename finishing touches remain; the four smaller
packages compile with passing tests. Depguard rules and ADR-0016 leaf-note
update deferred to a followup. VERSION 0.35.1.
120 lines
3.4 KiB
Go
120 lines
3.4 KiB
Go
// Package migrate applies the embedded forward-only SQL migrations
|
|
// (migrations/*.up.sql). It is shared infrastructure: the oikos postgres
|
|
// adapter runs it at pool startup, and nomos's session tests use it to
|
|
// build throwaway databases — nomos must not import the adapters, so the
|
|
// runner lives here, one level above both (ADR 0016 rule 3).
|
|
package migrate
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/dtoro/oikos/migrations"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// migrationLockKey is the advisory-lock key serializing migration runs —
|
|
// two concurrent migrators must not interleave DDL.
|
|
const migrationLockKey = 0x01c05e5
|
|
|
|
// PgxPool is the surface Apply needs: acquire a dedicated connection for
|
|
// the lock-held run.
|
|
type PgxPool interface {
|
|
Acquire(ctx context.Context) (*pgxpool.Conn, error)
|
|
}
|
|
|
|
// Apply runs all embedded forward migrations in order, on one connection
|
|
// holding a session advisory lock. Uses the schema_migrations table to
|
|
// track applied versions.
|
|
func Apply(ctx context.Context, pool PgxPool) error {
|
|
conn, err := pool.Acquire(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire migration conn: %w", err)
|
|
}
|
|
defer conn.Release()
|
|
|
|
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
|
return fmt.Errorf("acquire migration lock: %w", err)
|
|
}
|
|
defer func() {
|
|
if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
|
|
slog.Warn("migrate: release lock failed", "error", err)
|
|
}
|
|
}()
|
|
|
|
// Create tracking table if not exists
|
|
_, err = conn.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("create schema_migrations: %w", err)
|
|
}
|
|
|
|
// List migration files
|
|
entries, err := fs.ReadDir(migrations.FS, ".")
|
|
if err != nil {
|
|
return fmt.Errorf("read migration fs: %w", err)
|
|
}
|
|
|
|
var files []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
|
files = append(files, e.Name())
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
|
|
for _, fname := range files {
|
|
// Extract version number (001, 002, etc.)
|
|
var version int
|
|
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Check if already applied
|
|
var applied int
|
|
err := conn.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
|
|
if err != nil {
|
|
return fmt.Errorf("check migration %d: %w", version, err)
|
|
}
|
|
if applied > 0 {
|
|
continue
|
|
}
|
|
|
|
// Read and execute migration — split into individual statements
|
|
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
|
|
// and pgx's multi-statement Exec wraps them implicitly.
|
|
content, err := migrations.FS.ReadFile(fname)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", fname, err)
|
|
}
|
|
|
|
stmts := SplitSQL(string(content))
|
|
for i, stmt := range stmts {
|
|
stmt = strings.TrimSpace(stmt)
|
|
if stmt == "" {
|
|
continue
|
|
}
|
|
_, err := conn.Exec(ctx, stmt)
|
|
if err != nil {
|
|
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
|
|
}
|
|
}
|
|
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
|
|
if err != nil {
|
|
return fmt.Errorf("record migration %d: %w", version, err)
|
|
}
|
|
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
|
|
}
|
|
|
|
return nil
|
|
}
|