Core deliverables: - Go module github.com/dtoro/oikos (Go 1.26.3) - cmd/oikos: single binary with role subcommands (migrate, seed, export) - 6 SQL migrations: ontology meta-schema, entity instances (UUID+slug, blast_radius recursive function), operations (signals/checks/approvals), cognition (classifications/executions/feedback/patterns/skills), policy, observability (TimescaleDB hypertables + CAGGs + retention) - Domain layer: entity, signal, execution, classification, pattern, skill, approval, check types + 11 sentinel errors + lifecycle state machines - DB layer: pgx pool, SQL splitter (handles 94436 and -- comments), migration runner, seed ingest (ontology+inventory+policy) with content-hash dedup - Config: env-based with defaults, secrets redaction - Observability: slog JSON logger with debug mode - Infrastructure: Makefile, docker-compose.yml, multi-stage Dockerfile (distroless, CGO_ENABLED=0) Verified end-to-end against timescale/timescaledb:2.17.2-pg16: - 6 migrations applied (65 SQL statements) - Seeds ingested: 6 lifecycles, 59 entity types, 46 relationship types, 111 entities, 144 relationships, 4 risk classes, 27 approval rules, 9 autonomy settings - Idempotent: second seed run is a no-op (content hash matches) Bugs fixed during implementation: - TimescaleDB CAGGs can't run in a transaction -> splitSQL() executes statements individually - Semicolons in -- comments treated as separators -> comment handling - YAML keys source/target didn't match code's source_type/target_type - yaml.Marshal produced YAML for JSONB columns -> json.Marshal
235 lines
5.9 KiB
Go
235 lines
5.9 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/dtoro/oikos/migrations"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Pool wraps a pgx connection pool.
|
|
type Pool struct {
|
|
*pgxpool.Pool
|
|
}
|
|
|
|
// New creates a new connection pool.
|
|
func New(ctx context.Context, databaseURL string) (*Pool, error) {
|
|
cfg, err := pgxpool.ParseConfig(databaseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse database url: %w", err)
|
|
}
|
|
cfg.MaxConns = 15
|
|
|
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create pool: %w", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
return nil, fmt.Errorf("ping db: %w", err)
|
|
}
|
|
return &Pool{pool}, nil
|
|
}
|
|
|
|
// Migrate runs all embedded forward migrations in order.
|
|
// Uses a schema_migrations table to track applied versions.
|
|
func (p *Pool) Migrate(ctx context.Context) error {
|
|
// Create tracking table if not exists
|
|
_, err := p.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 := p.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 := p.Exec(ctx, stmt)
|
|
if err != nil {
|
|
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
|
|
}
|
|
}
|
|
_, err = p.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
|
|
}
|
|
|
|
// SeedIngest ingests a YAML seed file into the database.
|
|
// Idempotent: if the file's content hash matches seed_versions, it's a no-op (A4).
|
|
func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
|
|
ingestFn func(ctx context.Context, tx pgx.Tx, data map[string]any) error) error {
|
|
|
|
hash := contentHash(content)
|
|
|
|
// Check if already applied with same hash
|
|
var existing string
|
|
err := p.QueryRow(ctx,
|
|
"SELECT content_hash FROM seed_versions WHERE file = $1", filename).Scan(&existing)
|
|
if err == nil && existing == hash {
|
|
return nil // no-op, same content
|
|
}
|
|
|
|
// Parse YAML
|
|
var data map[string]any
|
|
if err := yaml.Unmarshal(content, &data); err != nil {
|
|
return fmt.Errorf("parse %s: %w", filename, err)
|
|
}
|
|
|
|
// Apply in a single transaction
|
|
tx, err := p.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if err := ingestFn(ctx, tx, data); err != nil {
|
|
return fmt.Errorf("ingest %s: %w", filename, err)
|
|
}
|
|
|
|
// Record the seed version
|
|
_, err = tx.Exec(ctx,
|
|
`INSERT INTO seed_versions (file, content_hash) VALUES ($1, $2)
|
|
ON CONFLICT (file) DO UPDATE SET content_hash = $2, applied_at = now()`,
|
|
filename, hash)
|
|
if err != nil {
|
|
return fmt.Errorf("record seed version: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit seed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// contentHash returns a SHA-256 hex digest of the content.
|
|
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 and -- line comments.
|
|
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] == '-' {
|
|
// Skip to end of line
|
|
for i < len(sql) && sql[i] != '\n' {
|
|
current.WriteByte(sql[i])
|
|
i++
|
|
}
|
|
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
|
|
}
|