package db import ( "context" "fmt" "encoding/json" "errors" "strings" "sync" "time" "github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/core/ports" "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/ontology" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // EntityRepo implements ports.EntityRepository over the postgres pool. // Command methods run everything in their input inside one transaction // (ADR 0016 §3.6): entity write, derived checks, idempotency record, audit, // event. type EntityRepo struct { pool *Pool } var _ ports.EntityRepository = (*EntityRepo)(nil) // NewEntityRepo builds the entity repository. func NewEntityRepo(pool *Pool) *EntityRepo { return &EntityRepo{pool: pool} } // mustUUID converts a domain.UUID (string alias) to uuid.UUID. The domain // layer guarantees UUID-shaped strings; parse failures are programming // errors and panic loudly rather than half-failing a transaction. func mustUUID(id domain.UUID) uuid.UUID { u, err := uuid.Parse(string(id)) if err != nil { panic(fmt.Sprintf("invalid entity UUID %q", string(id))) } return u } const entityFullCols = `id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at` type rowScanner interface{ Scan(dest ...any) error } func scanDomainEntity(row rowScanner) (domain.Entity, error) { var id uuid.UUID var slug, typ, name string var state *string var attrs []byte var maint *time.Time var version int32 var createdAt, updatedAt time.Time if err := row.Scan(&id, &slug, &typ, &name, &state, &attrs, &maint, &version, &createdAt, &updatedAt); err != nil { return domain.Entity{}, err } d := domain.Entity{ ID: domain.UUID(id.String()), Slug: slug, Type: typ, Name: name, Version: int(version), CreatedAt: createdAt, UpdatedAt: updatedAt, MaintenanceUntil: maint, } if state != nil { d.State = *state } if len(attrs) > 0 { var m map[string]any if json.Unmarshal(attrs, &m) == nil { d.Attributes = m } } return d, nil } func mapRowErr(err error) error { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrNotFound } return err } // Get returns the entity by ID. func (r *EntityRepo) Get(ctx context.Context, id domain.UUID) (domain.Entity, error) { uid, err := uuid.Parse(string(id)) if err != nil { return domain.Entity{}, err } e, err := scanDomainEntity(r.pool.QueryRow(ctx, `SELECT `+entityFullCols+` FROM entities WHERE id = $1`, uid)) return e, mapRowErr(err) } // BySlug returns the entity by slug. func (r *EntityRepo) BySlug(ctx context.Context, slug string) (domain.Entity, error) { e, err := scanDomainEntity(r.pool.QueryRow(ctx, `SELECT `+entityFullCols+` FROM entities WHERE slug = $1`, slug)) return e, mapRowErr(err) } // List returns entities filtered by type (including descendant types), // state, domain, layer, and a slug/name substring, keyset-paginated by // slug. The second return is the next cursor ("" when exhausted). func (r *EntityRepo) List(ctx context.Context, f ports.EntityFilters) ([]domain.Entity, string, error) { limit := f.Limit if limit <= 0 { limit = 50 } // NULL-able filter args: an absent filter must bind SQL NULL, not "". nullable := func(s string) any { if s == "" { return nil } return s } // Type filter includes descendants via the parent hierarchy (R3-1). rows, err := r.pool.Query(ctx, ` WITH RECURSIVE tt AS ( SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1 UNION SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name WHERE $1::text IS NOT NULL ) SELECT `+entityFullCols+` FROM entities e JOIN entity_types et ON et.name = e.type WHERE e.type IN (SELECT name FROM tt) AND ($2::text IS NULL OR e.state = $2) AND ($3::text IS NULL OR et.domain = $3) AND ($4::text IS NULL OR et.layer = $4) AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%') AND ($6::text IS NULL OR e.slug > $6) ORDER BY e.slug LIMIT $7`, nullable(f.Type), nullable(f.State), nullable(f.Domain), nullable(f.Layer), nullable(f.Q), nullable(f.Cursor), limit+1) if err != nil { return nil, "", err } defer rows.Close() items := []domain.Entity{} for rows.Next() { e, err := scanDomainEntity(rows) if err != nil { return nil, "", err } items = append(items, e) } if rows.Err() != nil { return nil, "", rows.Err() } next := "" if len(items) > limit { items = items[:limit] next = items[len(items)-1].Slug } return items, next, nil } // Search matches slug/name substrings. func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) { items, _, err := r.List(ctx, ports.EntityFilters{Q: q, Limit: limit}) return items, err } // writeSideEffects writes audit entries and the event inside the open // transaction. func writeSideEffects(ctx context.Context, tx pgx.Tx, entityID domain.UUID, audits []ports.AuditEntry, event *ports.Event) error { q := sqlcgen.New(tx) eid := mustUUID(entityID) for _, a := range audits { if err := observability.Audit(ctx, q, a.ActorType, a.ActorLabel, a.Action, &eid, a.Method, a.Path, a.CorrelationID, nil, a.Details); err != nil { return err } } if event != nil { if err := observability.Event(ctx, q, event.Type, &eid, event.Severity, event.Source, event.CorrelationID, event.Data); err != nil { return err } } return nil } func appTargetOf(e domain.Entity) app.CheckTarget { attrs, _ := json.Marshal(e.Attributes) return app.CheckTarget{ID: string(e.ID), Slug: e.Slug, Type: e.Type, Name: e.Name, Attrs: attrs} } func writeDerivedChecks(ctx context.Context, tx pgx.Tx, e domain.Entity, checks []ports.DerivedCheck) (int, error) { if _, err := tx.Exec(ctx, `INSERT INTO entity_status (entity_id, health, updated_at) VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, mustUUID(e.ID)); err != nil { return 0, err } created := 0 t := appTargetOf(e) for i, dc := range checks { ok, err := writeCheck(ctx, tx, t, i, app.CheckDef{Kind: dc.Kind, Config: dc.Config, IntervalS: dc.IntervalS}) if err != nil { return created, err } if ok { created++ } } return created, nil } // rederiveChecks re-runs derivation for an entity inside the open tx, with // the graph host fallback active (a service inherits its container's // address once the hosting edge exists). func rederiveChecks(ctx context.Context, tx pgx.Tx, e domain.Entity, tree *ontology.TypeTree) (int, error) { t := appTargetOf(e) defs, _ := app.Derive(tree, t, func() map[string]any { attrs, err := hostViaGraph(ctx, tx, t.ID) if err != nil { return nil } return attrs }) derived := make([]ports.DerivedCheck, len(defs)) for i, d := range defs { derived[i] = ports.DerivedCheck{Kind: d.Kind, Config: d.Config, IntervalS: d.IntervalS} } return writeDerivedChecks(ctx, tx, e, derived) } // Create inserts the entity with its derived checks, idempotency record, // audit, and event — one transaction. func (r *EntityRepo) Create(ctx context.Context, in ports.EntityCreateInput) (domain.Entity, error) { tx, err := r.pool.Begin(ctx) if err != nil { return domain.Entity{}, err } defer func() { _ = tx.Rollback(ctx) }() e := in.Entity attrsJSON, _ := json.Marshal(e.Attributes) if e.Attributes == nil { attrsJSON = []byte("{}") } var state *string if e.State != "" { state = &e.State } created, err := scanDomainEntity(tx.QueryRow(ctx, ` INSERT INTO entities (id, slug, type, name, state, attributes) VALUES ($1, $2, $3, $4, $5, $6) RETURNING `+entityFullCols, mustUUID(e.ID), e.Slug, e.Type, e.Name, state, attrsJSON)) if err != nil { if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { return domain.Entity{}, errors.Join(domain.ErrAlreadyExists, err) } return domain.Entity{}, err } if in.Idempotency != nil { var body []byte if in.Idempotency.RenderBody != nil { body = in.Idempotency.RenderBody(created) } code := int32(201) if err := sqlcgen.New(tx).PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{ Actor: in.Idempotency.Actor, Key: in.Idempotency.Key, RequestHash: in.Idempotency.RequestHash, ResponseCode: &code, ResponseBody: body, }); err != nil { return domain.Entity{}, err } } if _, err := writeDerivedChecks(ctx, tx, created, in.DerivedChecks); err != nil { return domain.Entity{}, err } if err := writeSideEffects(ctx, tx, created.ID, in.Audit, in.Event); err != nil { return domain.Entity{}, err } if err := tx.Commit(ctx); err != nil { return domain.Entity{}, err } return created, nil } // Update applies name/state/attributes/maintenance atomically with an // optimistic-version check, re-deriving default checks when asked. The // declared-transition + precondition validation runs inside the // transaction (check-then-act, plan §3.6). func (r *EntityRepo) Update(ctx context.Context, in ports.EntityUpdateInput) (domain.Entity, error) { tx, err := r.pool.Begin(ctx) if err != nil { return domain.Entity{}, err } defer func() { _ = tx.Rollback(ctx) }() current, err := scanDomainEntity(tx.QueryRow(ctx, `SELECT `+entityFullCols+` FROM entities WHERE id = $1`, mustUUID(in.Entity.ID))) if err != nil { return domain.Entity{}, mapRowErr(err) } if in.ExpectedVersion > 0 && current.Version != in.ExpectedVersion { return domain.Entity{}, domain.ErrConflict } e := in.Entity // Fields the caller left zero keep their current values. if e.Name == "" { e.Name = current.Name } if e.Slug == "" { e.Slug = current.Slug } if e.Type == "" { e.Type = current.Type } // Lifecycle validation when state changes (declared transition + // preconditions, in-tx). if e.State != "" && e.State != current.State { if err := ValidateTransition(ctx, tx, mustUUID(current.ID), current.Type, current.State, e.State); err != nil { if errors.Is(err, ErrTransitionInvalid) { return domain.Entity{}, errors.Join(domain.ErrInvalidTransition, err) } return domain.Entity{}, err } } else if e.State == "" { e.State = current.State } if e.Attributes == nil { e.Attributes = current.Attributes } setMaintenance := e.MaintenanceUntil != nil maint := e.MaintenanceUntil if !setMaintenance { maint = current.MaintenanceUntil } attrsJSON, _ := json.Marshal(e.Attributes) if e.Attributes == nil { attrsJSON = []byte("{}") } var state *string if e.State != "" { state = &e.State } updated, err := scanDomainEntity(tx.QueryRow(ctx, ` UPDATE entities SET name = $2, state = $3, attributes = $4, maintenance_until = $5, version = version + 1, updated_at = now() WHERE id = $1 AND version = $6 RETURNING `+entityFullCols, mustUUID(e.ID), e.Name, state, attrsJSON, maint, current.Version)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.Entity{}, domain.ErrConflict } return domain.Entity{}, err } if in.RederiveChecks { tree, err := LoadTypeTree(ctx, tx) if err != nil { return domain.Entity{}, err } if _, err := rederiveChecks(ctx, tx, updated, tree); err != nil { return domain.Entity{}, err } } if err := writeSideEffects(ctx, tx, updated.ID, in.Audit, in.Event); err != nil { return domain.Entity{}, err } if err := tx.Commit(ctx); err != nil { return domain.Entity{}, err } return updated, nil } // SetState transitions an entity's lifecycle state. Declared-transition // and precondition validation run in-tx; a stale From is refused. func (r *EntityRepo) SetState(ctx context.Context, in ports.EntityTransitionInput) (domain.Entity, error) { tx, err := r.pool.Begin(ctx) if err != nil { return domain.Entity{}, err } defer func() { _ = tx.Rollback(ctx) }() current, err := scanDomainEntity(tx.QueryRow(ctx, `SELECT `+entityFullCols+` FROM entities WHERE slug = $1`, in.Slug)) if err != nil { return domain.Entity{}, mapRowErr(err) } if current.State != in.From { return domain.Entity{}, domain.ErrConflict } if err := ValidateTransition(ctx, tx, mustUUID(current.ID), current.Type, in.From, in.To); err != nil { if errors.Is(err, ErrTransitionInvalid) { return domain.Entity{}, errors.Join(domain.ErrInvalidTransition, err) } return domain.Entity{}, err } if _, err := tx.Exec(ctx, `UPDATE entities SET state = $2, version = version + 1, updated_at = now() WHERE id = $1`, mustUUID(current.ID), in.To); err != nil { return domain.Entity{}, err } after := current after.State = in.To if err := writeSideEffects(ctx, tx, after.ID, in.Audit, in.Event); err != nil { return domain.Entity{}, err } if err := tx.Commit(ctx); err != nil { return domain.Entity{}, err } return after, nil } // GetIdempotent returns the cached response for (actor, key) or // domain.ErrNotFound. func (r *EntityRepo) GetIdempotent(ctx context.Context, actor, key string) (ports.IdempotentResponse, error) { cached, err := sqlcgen.New(r.pool).GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{Actor: actor, Key: key}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ports.IdempotentResponse{}, domain.ErrNotFound } return ports.IdempotentResponse{}, err } code := 0 if cached.ResponseCode != nil { code = int(*cached.ResponseCode) } return ports.IdempotentResponse{ RequestHash: cached.RequestHash, ResponseCode: code, ResponseBody: cached.ResponseBody, }, nil } // OntologyRepo implements ports.OntologyStore with a TTL cache — entity // types change at seed time, not per request, so a short cache trades a // little staleness for avoiding the meta-schema load on every mutation. type OntologyRepo struct { pool *Pool ttl time.Duration mu sync.Mutex loaded time.Time tree *ontology.TypeTree } var _ ports.OntologyStore = (*OntologyRepo)(nil) // NewOntologyRepo builds the ontology store with the given cache TTL // (values <= 0 disable caching). func NewOntologyRepo(pool *Pool, ttl time.Duration) *OntologyRepo { return &OntologyRepo{pool: pool, ttl: ttl} } // LoadTypeTree returns the (possibly cached) ontology tree. func (o *OntologyRepo) LoadTypeTree(ctx context.Context) (ports.TypeTree, error) { if o.ttl <= 0 { return o.load(ctx) } o.mu.Lock() defer o.mu.Unlock() if o.tree != nil && time.Since(o.loaded) < o.ttl { return o.tree, nil } tree, err := o.load(ctx) if err != nil { return nil, err } o.tree = tree o.loaded = time.Now() return tree, nil } func (o *OntologyRepo) load(ctx context.Context) (ports.TypeTree, error) { tx, err := o.pool.Begin(ctx) if err != nil { return nil, err } defer func() { _ = tx.Rollback(ctx) }() tree, err := LoadTypeTree(ctx, tx) if err != nil { return nil, err } return ports.TypeTree(tree), nil }