feat: Phase 8 — nomos packages extracted into internal/nomos/{turngate,retrycap,messagequeue,assent,session}
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.
This commit is contained in:
119
internal/migrate/migrate.go
Normal file
119
internal/migrate/migrate.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user