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:
@@ -5,12 +5,9 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/migrations"
|
||||
"github.com/dtoro/oikos/internal/migrate"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -39,99 +36,11 @@ func New(ctx context.Context, databaseURL string) (*Pool, error) {
|
||||
return &Pool{pool}, nil
|
||||
}
|
||||
|
||||
// migrationLockKey is the advisory-lock key serializing migration runs —
|
||||
// two concurrent `oikos migrate` invocations must not interleave DDL.
|
||||
const migrationLockKey = 0x01c05e5
|
||||
|
||||
// Migrate runs all embedded forward migrations in order.
|
||||
// Uses a schema_migrations table to track applied versions. The whole run
|
||||
// happens on one connection holding a session advisory lock.
|
||||
// Migrate applies all embedded forward migrations in order (delegates to
|
||||
// the shared runner in internal/migrate — the same one nomos's session
|
||||
// tests use; ADR 0016 rule 3 keeps nomos off the adapters).
|
||||
func (p *Pool) Migrate(ctx context.Context) error {
|
||||
conn, err := p.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("postgres: release migration 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() && 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
|
||||
return migrate.Apply(ctx, p.Pool)
|
||||
}
|
||||
|
||||
// SeedIngest ingests a YAML seed file into the database.
|
||||
@@ -191,98 +100,3 @@ func contentHash(content []byte) string {
|
||||
h := sha256.Sum256(content)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// hasSuffix reports whether the string ends with the given suffix.
|
||||
func hasSuffix(s, suffix string) bool {
|
||||
return strings.HasSuffix(s, suffix)
|
||||
}
|
||||
|
||||
// splitSQL splits a SQL string into individual statements.
|
||||
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
|
||||
// -- line comments, /* ... */ block comments, and '...' string literals
|
||||
// so that semicolons inside any of these constructs are not treated as
|
||||
// statement boundaries.
|
||||
func splitSQL(sql string) []string {
|
||||
var statements []string
|
||||
var current strings.Builder
|
||||
inDollarQuote := false
|
||||
dollarTag := ""
|
||||
|
||||
i := 0
|
||||
for i < len(sql) {
|
||||
// Handle line comments (-- to end of line)
|
||||
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
||||
for i < len(sql) && sql[i] != '\n' {
|
||||
current.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle block comments (/* ... */)
|
||||
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
|
||||
end := strings.Index(sql[i+2:], "*/")
|
||||
if end >= 0 {
|
||||
current.WriteString(sql[i : i+end+4])
|
||||
i += end + 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle single-quoted string literals ('...')
|
||||
if !inDollarQuote && sql[i] == '\'' {
|
||||
j := i + 1
|
||||
for j < len(sql) {
|
||||
if sql[j] == '\'' {
|
||||
if j+1 < len(sql) && sql[j+1] == '\'' {
|
||||
j += 2 // skip doubled quote ''
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
current.WriteString(sql[i : j+1])
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for dollar-quote start/end
|
||||
if !inDollarQuote && sql[i] == '$' {
|
||||
j := i + 1
|
||||
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
|
||||
j++
|
||||
}
|
||||
if j < len(sql) && sql[j] == '$' {
|
||||
dollarTag = sql[i : j+1]
|
||||
current.WriteString(dollarTag)
|
||||
inDollarQuote = true
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
|
||||
current.WriteString(dollarTag)
|
||||
i += len(dollarTag)
|
||||
inDollarQuote = false
|
||||
dollarTag = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if !inDollarQuote && sql[i] == ';' {
|
||||
statements = append(statements, current.String())
|
||||
current.Reset()
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
current.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
|
||||
if strings.TrimSpace(current.String()) != "" {
|
||||
statements = append(statements, current.String())
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func nonEmpty(stmts []string) []string {
|
||||
var out []string
|
||||
for _, s := range stmts {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSplitSQLBasic(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL("CREATE TABLE a (id int); CREATE TABLE b (id int);"))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDollarQuotedFunction(t *testing.T) {
|
||||
sql := `CREATE FUNCTION f() RETURNS int AS $$
|
||||
SELECT 1; SELECT 2;
|
||||
$$ LANGUAGE sql;
|
||||
CREATE TABLE t (id int);`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
if !strings.Contains(stmts[0], "SELECT 1; SELECT 2;") {
|
||||
t.Errorf("dollar-quoted body was split: %q", stmts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLTaggedDollarQuote(t *testing.T) {
|
||||
sql := `DO $body$ BEGIN PERFORM 1; END $body$;SELECT 1;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLSemicolonInComment(t *testing.T) {
|
||||
sql := "-- comment with ; semicolon\nCREATE TABLE t (id int); -- trailing; note\nSELECT 1;"
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLSemicolonInStringLiteral(t *testing.T) {
|
||||
sql := `SELECT 'hello; world'; INSERT INTO t VALUES (1);`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDollarSignInStringLiteral(t *testing.T) {
|
||||
sql := `SELECT '$100'; SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLBlockComment(t *testing.T) {
|
||||
sql := `SELECT 1; /* block; with; semicolons */ SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) {
|
||||
sql := `/* $$ not a dollar quote */ SELECT 1;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 1 {
|
||||
t.Fatalf("got %d statements, want 1: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDoubledQuoteInString(t *testing.T) {
|
||||
sql := `SELECT 'O''Brien'; SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLEmptyInput(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL(""))
|
||||
if len(stmts) != 0 {
|
||||
t.Fatalf("got %d statements, want 0", len(stmts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLNoSemicolon(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL("SELECT 1"))
|
||||
if len(stmts) != 1 {
|
||||
t.Fatalf("got %d statements, want 1", len(stmts))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user