From 65f415f9db3a622ae7945eb515ad1e5b96ee2aab Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 15 Aug 2026 23:58:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203d=20=E2=80=94=20ReadModels=20p?= =?UTF-8?q?ort,=20postgres=20impl,=20HTTP=20reads=20rewire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: entity/relationship/graph/blast-radius reads were embedded as inline SQL in the httpapi handlers, duplicating the recursive type tree CTE, the blast_radius function call, and the topology-picking query across the REST and MCP surfaces with no port abstraction. Change: - ports.ReadModels interface: ListEntities, GetEntity, GetEntityBySlug, GetEntityRelations, GetBlastRadius, GetGraph (with health), ListEntityTypes. Returns EntityWithHealth (domain.Entity + health from entity_status join) and domain.Relationship — no gen types in the port. - adapters/postgres/readmodels.go: EntityReader implements ReadModels with the existing SQL verbatim (recursive type filter, blast_radius, most-connected-first topology, graph edge listing). - httpapi/entities.go: ListEntities, GetEntity, GetEntityRelations, GetBlastRadius, GetGraph rewired to ReadModels. SQL moved to the adapter; handlers map domain/ports types to gen wire shapes. entityWithHealthToGen, sqlcEntityToGen helpers added. - Old sqlcEntityToGen (sqlcgen.Entity → gen.Entity) preserved for client_lifecycle.go; mutation handlers use domainToGen. Verification: go build/vet, full test suite (19 pkgs), DB integration (postgres + mcp — both green). httpapi DB tests have the pre-existing set of failures (TestAPIEndToEnd entity_types=60/501, TestPhase3*) verified at ec11956. --- VERSION | 2 +- internal/adapters/postgres/readmodels.go | 349 +++++++++++++++++++++++ internal/core/app/entities_test.go | 181 ++++++++++++ internal/core/ports/entities.go | 22 ++ internal/core/ports/portstest/fakes.go | 6 + internal/httpapi/entities.go | 320 +++++++-------------- internal/httpapi/server.go | 3 + 7 files changed, 664 insertions(+), 219 deletions(-) create mode 100644 internal/adapters/postgres/readmodels.go create mode 100644 internal/core/app/entities_test.go diff --git a/VERSION b/VERSION index be386c9e..8df3f459 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.0 +0.33.1 diff --git a/internal/adapters/postgres/readmodels.go b/internal/adapters/postgres/readmodels.go new file mode 100644 index 00000000..0fc2170f --- /dev/null +++ b/internal/adapters/postgres/readmodels.go @@ -0,0 +1,349 @@ +package db + +import ( + "context" + + "encoding/json" + "time" + + "github.com/dtoro/oikos/internal/core/domain" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// EntityReader implements ports.ReadModels over the postgres pool. +type EntityReader struct { + pool *Pool +} + +var _ ports.ReadModels = (*EntityReader)(nil) + +// NewEntityReader builds the read-models service. +func NewEntityReader(pool *Pool) *EntityReader { return &EntityReader{pool: pool} } + +const entityHealthCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, COALESCE(st.health, 'unknown'), st.last_check_at` + +type rowScanner2 interface{ Scan(dest ...any) error } + +func scanWithHealth(row rowScanner2) (ports.EntityWithHealth, 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 + var health string + var lastCheck *time.Time + if err := row.Scan(&id, &slug, &typ, &name, &state, &attrs, &maint, &version, &createdAt, &updatedAt, &health, &lastCheck); err != nil { + return ports.EntityWithHealth{}, err + } + e := 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 { + e.State = *state + } + if len(attrs) > 0 { + var m map[string]any + if json.Unmarshal(attrs, &m) == nil { + e.Attributes = m + } + } + return ports.EntityWithHealth{Entity: e, Health: health, LastCheckAt: lastCheck}, nil +} + +func (r *EntityReader) ListEntities(ctx context.Context, f ports.EntityFilters) ([]ports.EntityWithHealth, string, error) { + limit := f.Limit + if limit <= 0 { + limit = 50 + } + nullable := func(s string) any { + if s == "" { + return nil + } + return s + } + + 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 `+entityHealthCols+` + FROM entities e + JOIN entity_types et ON et.name = e.type + LEFT JOIN entity_status st ON st.entity_id = e.id + 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 := []ports.EntityWithHealth{} + for rows.Next() { + e, err := scanWithHealth(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].Entity.Slug + } + return items, next, nil +} + +func (r *EntityReader) GetEntity(ctx context.Context, id domain.UUID) (ports.EntityWithHealth, error) { + e, err := scanWithHealth(r.pool.QueryRow(ctx, + `SELECT `+entityHealthCols+` FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1`, mustUUID(id))) + if err != nil { + return ports.EntityWithHealth{}, mapRowErr(err) + } + return e, nil +} + +func (r *EntityReader) GetEntityBySlug(ctx context.Context, slug string) (ports.EntityWithHealth, error) { + e, err := scanWithHealth(r.pool.QueryRow(ctx, + `SELECT `+entityHealthCols+` FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.slug = $1`, slug)) + if err != nil { + return ports.EntityWithHealth{}, mapRowErr(err) + } + return e, nil +} + +func (r *EntityReader) GetEntityRelations(ctx context.Context, entityID domain.UUID, direction, relType string) ([]domain.Relationship, error) { + eid := mustUUID(entityID) + switch direction { + case "outbound": + return r.queryRels(ctx, `SELECT source_id, target_id, type, attributes, valid_from, valid_to + FROM relationships WHERE valid_to IS NULL AND source_id = $1`, eid, relType) + case "inbound": + return r.queryRels(ctx, `SELECT source_id, target_id, type, attributes, valid_from, valid_to + FROM relationships WHERE valid_to IS NULL AND target_id = $1`, eid, relType) + default: + return r.queryRels(ctx, `SELECT source_id, target_id, type, attributes, valid_from, valid_to + FROM relationships WHERE valid_to IS NULL AND (source_id = $1 OR target_id = $1)`, eid, relType) + } +} + +func (r *EntityReader) queryRels(ctx context.Context, query string, eid uuid.UUID, relType string) ([]domain.Relationship, error) { + var args []any + args = append(args, eid) + if relType != "" { + query += ` AND type = $2` + args = append(args, relType) + } else { + query += ` ORDER BY type` + } + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEdges(rows) +} + +func (r *EntityReader) GetBlastRadius(ctx context.Context, entityID domain.UUID, depth int) ([]ports.EntityWithHealth, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+entityHealthCols+`, b.depth + FROM blast_radius($1, $2) b + JOIN entities e ON e.id = b.entity_id + LEFT JOIN entity_status st ON st.entity_id = e.id + ORDER BY b.depth, e.slug`, mustUUID(entityID), depth) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []ports.EntityWithHealth + for rows.Next() { + var d int + e, err := scanWithHealth(rows) + if err != nil { + return nil, err + } + _ = d + items = append(items, e) + } + return items, rows.Err() +} + +func (r *EntityReader) GetGraph(ctx context.Context, depth int, root *domain.UUID, relTypes []string, cap int) ([]ports.EntityWithHealth, []domain.Relationship, bool, error) { + truncated := false + var nodes []ports.EntityWithHealth + var err error + + if root != nil { + nodes, err = r.blastRadiusNodes(ctx, mustUUID(*root), depth, relTypes) + } else { + nodes, err = r.topologyNodes(ctx, cap) + if err == nil && len(nodes) > cap { + nodes = nodes[:cap] + truncated = true + } + } + if err != nil { + return nil, nil, false, err + } + + ids := make([]uuid.UUID, len(nodes)) + for i, n := range nodes { + uid, _ := uuid.Parse(string(n.Entity.ID)) + ids[i] = uid + } + edges, err := r.listGraphEdges(ctx, ids, relTypes) + if err != nil { + return nil, nil, false, err + } + return nodes, edges, truncated, nil +} + +func (r *EntityReader) blastRadiusNodes(ctx context.Context, rootID uuid.UUID, depth int, relTypes []string) ([]ports.EntityWithHealth, error) { + query := ` + SELECT ` + entityHealthCols + ` + FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id + LEFT JOIN entity_status st ON st.entity_id = e.id + ORDER BY e.slug` + var relTypesArg any + if len(relTypes) > 0 { + relTypesArg = relTypes + } + rows, err := r.pool.Query(ctx, query, rootID, depth, relTypesArg) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ports.EntityWithHealth + for rows.Next() { + e, err := scanWithHealth(rows) + if err != nil { + return nil, err + } + items = append(items, e) + } + return items, rows.Err() +} + +func (r *EntityReader) topologyNodes(ctx context.Context, cap int) ([]ports.EntityWithHealth, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+entityHealthCols+` + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.type NOT IN ('execution','task') + AND e.id IN ( + SELECT e2.id FROM entities e2 + LEFT JOIN relationships r ON r.valid_to IS NULL + AND (r.source_id = e2.id OR r.target_id = e2.id) + WHERE e2.type NOT IN ('execution','task') + GROUP BY e2.id + ORDER BY count(r.type) DESC, e2.slug + LIMIT $1 + ) + ORDER BY e.slug`, cap+1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ports.EntityWithHealth + for rows.Next() { + e, err := scanWithHealth(rows) + if err != nil { + return nil, err + } + items = append(items, e) + } + return items, rows.Err() +} + +func (r *EntityReader) listGraphEdges(ctx context.Context, ids []uuid.UUID, relTypes []string) ([]domain.Relationship, error) { + query := `SELECT r.source_id, r.target_id, r.type, r.attributes, r.valid_from, r.valid_to + FROM relationships r + WHERE r.valid_to IS NULL AND (r.source_id = ANY($1) OR r.target_id = ANY($1))` + var args []any + args = append(args, ids) + if len(relTypes) > 0 { + query += ` AND r.type = ANY($2)` + args = append(args, relTypes) + } else { + query += ` ORDER BY r.type` + } + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEdges(rows) +} + +func (r *EntityReader) ListEntityTypes(ctx context.Context) ([]domain.EntityType, error) { + rows, err := r.pool.Query(ctx, + `SELECT name, COALESCE(parent_type,''), is_abstract, COALESCE(domain,''), COALESCE(layer,''), COALESCE(description,''), + COALESCE(lifecycle_id,''), '{}'::jsonb, COALESCE(schema_version,0), COALESCE(status,'') + FROM entity_types ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var items []domain.EntityType + for rows.Next() { + var t domain.EntityType + if err := rows.Scan(&t.Name, &t.ParentType, &t.IsAbstract, &t.Domain, &t.Layer, &t.Description, + &t.LifecycleID, &t.AttributeSchema, &t.SchemaVersion, &t.Status); err != nil { + return nil, err + } + items = append(items, t) + } + return items, rows.Err() +} + +func scanEdges(rows pgx.Rows) ([]domain.Relationship, error) { + var items []domain.Relationship + for rows.Next() { + var src, tgt uuid.UUID + var rType string + var attrs []byte + var vf time.Time + var vt *time.Time + if err := rows.Scan(&src, &tgt, &rType, &attrs, &vf, &vt); err != nil { + return nil, err + } + rel := domain.Relationship{ + SourceID: domain.UUID(src.String()), TargetID: domain.UUID(tgt.String()), + Type: rType, Attributes: map[string]any{}, ValidFrom: vf, ValidTo: vt, + } + if len(attrs) > 0 { + var m map[string]any + if json.Unmarshal(attrs, &m) == nil { + rel.Attributes = m + } + } + items = append(items, rel) + } + return items, rows.Err() +} \ No newline at end of file diff --git a/internal/core/app/entities_test.go b/internal/core/app/entities_test.go new file mode 100644 index 00000000..3de1c9d1 --- /dev/null +++ b/internal/core/app/entities_test.go @@ -0,0 +1,181 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/dtoro/oikos/internal/core/domain" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/dtoro/oikos/internal/core/ports/portstest" + "github.com/dtoro/oikos/internal/ontology" +) + +// fakeOntology is a static OntologyStore over a hand-built tree. +type fakeOntology struct{ tree *ontology.TypeTree } + +func (f *fakeOntology) LoadTypeTree(context.Context) (ports.TypeTree, error) { + return f.tree, nil +} + +func testTree() *ontology.TypeTree { + return &ontology.TypeTree{ + Types: map[string]ontology.TypeInfo{ + "service": {LifecycleID: "infra"}, + "host": {LifecycleID: "infra"}, + "widget": {IsAbstract: true}, + }, + Lifecycles: map[string]ontology.LifecycleInfo{ + "infra": {States: map[string]bool{"planned": true, "active": true, "deprecated": true}, DefaultState: "active"}, + }, + RelTypes: map[string]ontology.RelTypeInfo{}, + } +} + +func newSvc(t *testing.T) (*EntityService, *portstest.EntityRepo) { + t.Helper() + repo := portstest.NewEntityRepo() + svc := NewEntityService(repo, &fakeOntology{tree: testTree()}) + return svc, repo +} + +func TestEntityServiceCreate(t *testing.T) { + ctx := context.Background() + svc, repo := newSvc(t) + + e, res, err := svc.Create(ctx, CreateEntityCmd{ + Type: "service", Name: "jellyfin", Attributes: map[string]any{"url": "https://media.example"}, + ActorType: "operator", Actor: "tester", Method: "POST", Path: "/api/v1/entities", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if e.Slug != "service:jellyfin" { + t.Errorf("default slug = %q, want service:jellyfin", e.Slug) + } + if e.State != "active" { + t.Errorf("default state = %q, want active (lifecycle default)", e.State) + } + if res.Created != 0 { + // testTree's service declares no monitoring → nothing derived + t.Errorf("derived = %d, want 0 (no monitoring spec in fake tree)", res.Created) + } + if len(repo.Audits) != 1 || repo.Audits[0].Action != "create" { + t.Errorf("audit = %+v, want one create entry", repo.Audits) + } + if len(repo.Events) != 1 || repo.Events[0].Type != "entity.created" { + t.Errorf("event = %+v, want entity.created", repo.Events) + } +} + +func TestEntityServiceCreateValidation(t *testing.T) { + ctx := context.Background() + svc, _ := newSvc(t) + + if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "no-such-type", Name: "x"}); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("unknown type: got %v, want ErrNotFound", err) + } + if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "widget", Name: "x"}); !errors.Is(err, domain.ErrAbstractType) { + t.Errorf("abstract type: got %v, want ErrAbstractType", err) + } + // The stricter (MCP) rule now governs both surfaces: caller-supplied + // states must be declared in the lifecycle. + if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "x", State: "destroyed"}); !errors.Is(err, domain.ErrInvalidTransition) { + t.Errorf("undeclared state: got %v, want ErrInvalidTransition", err) + } +} + +func TestEntityServiceCreateIdempotency(t *testing.T) { + ctx := context.Background() + svc, repo := newSvc(t) + + idem := &ports.Idempotency{ + Actor: "tester", Key: "k1", RequestHash: "abc", + RenderBody: func(e domain.Entity) []byte { return []byte(`{"slug":"` + e.Slug + `"}`) }, + } + if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "h1", Idempotency: idem}); err != nil { + t.Fatalf("create: %v", err) + } + + cached, err := repo.GetIdempotent(ctx, "tester", "k1") + if err != nil { + t.Fatalf("get idempotent: %v", err) + } + if cached.RequestHash != "abc" || cached.ResponseCode != 201 { + t.Errorf("cached = %+v, want hash abc / code 201", cached) + } + if string(cached.ResponseBody) != `{"slug":"host:h1"}` { + t.Errorf("cached body = %s, want the rendered wire shape", cached.ResponseBody) + } + + if _, err := repo.GetIdempotent(ctx, "tester", "other"); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("missing key: got %v, want ErrNotFound", err) + } +} + +func TestEntityServiceUpdate(t *testing.T) { + ctx := context.Background() + svc, repo := newSvc(t) + + created, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "svc", Attributes: map[string]any{"a": 1}}) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Merge semantics: existing keys survive. + updated, _, err := svc.Update(ctx, UpdateEntityCmd{ + SlugOrID: "service:svc", ExpectedVer: created.Version, + Attributes: map[string]any{"b": 2}, RederiveChecks: true, + ActorType: "agent", Actor: "mcp", Method: "TOOL", Path: "update_entity_attributes", + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Attributes["a"] != 1 || updated.Attributes["b"] != 2 { + t.Errorf("merged attrs = %v, want a+b", updated.Attributes) + } + if len(repo.Rederived) != 1 || repo.Rederived[0] != created.ID { + t.Errorf("redrive = %v, want one pass for the entity", repo.Rederived) + } + + // Optimistic concurrency: stale version refuses. + if _, _, err := svc.Update(ctx, UpdateEntityCmd{SlugOrID: "service:svc", ExpectedVer: created.Version}); !errors.Is(err, domain.ErrConflict) { + t.Errorf("stale version: got %v, want ErrConflict", err) + } + + // Replace semantics: wholesale swap. + replaced, _, err := svc.Update(ctx, UpdateEntityCmd{ + SlugOrID: "service:svc", Attributes: map[string]any{"only": true}, AttrsReplace: true, + }) + if err != nil { + t.Fatalf("replace update: %v", err) + } + if len(replaced.Attributes) != 1 || replaced.Attributes["only"] != true { + t.Errorf("replaced attrs = %v, want only", replaced.Attributes) + } +} + +func TestEntityServiceSetState(t *testing.T) { + ctx := context.Background() + svc, repo := newSvc(t) + + if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "old"}); err != nil { + t.Fatalf("create: %v", err) + } + + after, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x") + if err != nil { + t.Fatalf("setState: %v", err) + } + if after.State != "deprecated" { + t.Errorf("state = %s, want deprecated", after.State) + } + if len(repo.Audits) != 2 || repo.Audits[1].Action != "state" { + t.Errorf("audits = %+v, want a state entry after create", repo.Audits) + } + + // Stale From refuses (check-then-act). + if _, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x"); !errors.Is(err, domain.ErrConflict) { + t.Errorf("stale from-state: got %v, want ErrConflict", err) + } +} diff --git a/internal/core/ports/entities.go b/internal/core/ports/entities.go index 3132e64a..26e888e8 100644 --- a/internal/core/ports/entities.go +++ b/internal/core/ports/entities.go @@ -1,6 +1,7 @@ package ports import ( + "time" "context" "github.com/dtoro/oikos/internal/core/domain" @@ -119,6 +120,27 @@ type RelationshipRepository interface { ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error) } +// EntityWithHealth pairs an entity with its probe health (from the +// entity_status join — the read shape graph/report endpoints need). +type EntityWithHealth struct { + Entity domain.Entity + Health string + LastCheckAt *time.Time +} + +// ReadModels surfaces query-shaped report reads consumed directly by the +// httpapi/mcpserver adapters (no service hop — invariant-free reads, plan +// §3.4). Methods accrete per phase as report handlers rewire. +type ReadModels interface { + ListEntities(ctx context.Context, filters EntityFilters) ([]EntityWithHealth, string, error) + GetEntity(ctx context.Context, id domain.UUID) (EntityWithHealth, error) + GetEntityBySlug(ctx context.Context, slug string) (EntityWithHealth, error) + GetEntityRelations(ctx context.Context, entityID domain.UUID, direction, relType string) ([]domain.Relationship, error) + GetBlastRadius(ctx context.Context, entityID domain.UUID, depth int) ([]EntityWithHealth, error) + GetGraph(ctx context.Context, depth int, root *domain.UUID, relTypes []string, cap int) (nodes []EntityWithHealth, edges []domain.Relationship, truncated bool, err error) + ListEntityTypes(ctx context.Context) ([]domain.EntityType, error) +} + // OntologyStore loads the type tree; implementations cache. Consumers // validate entity types, relationship endpoints, and lifecycle transitions // against it before issuing repository writes. diff --git a/internal/core/ports/portstest/fakes.go b/internal/core/ports/portstest/fakes.go index a15b493d..7ca60660 100644 --- a/internal/core/ports/portstest/fakes.go +++ b/internal/core/ports/portstest/fakes.go @@ -117,6 +117,9 @@ func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (doma if _, dup := r.bySlug[in.Entity.Slug]; dup { return domain.Entity{}, domain.ErrConflict } + if in.Entity.Version == 0 { + in.Entity.Version = 1 + } r.store(in.Entity) r.Audits = append(r.Audits, in.Audit...) if in.Event != nil { @@ -151,6 +154,9 @@ func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (doma } after := in.Entity after.Version = current.Version + 1 + if in.RederiveChecks { + r.Rederived = append(r.Rederived, after.ID) + } delete(r.bySlug, current.Slug) r.store(after) r.Audits = append(r.Audits, in.Audit...) diff --git a/internal/httpapi/entities.go b/internal/httpapi/entities.go index 1bc4492d..39ea9d2e 100644 --- a/internal/httpapi/entities.go +++ b/internal/httpapi/entities.go @@ -4,85 +4,59 @@ import ( "context" "encoding/json" "strconv" - "time" "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" + "github.com/dtoro/oikos/internal/core/domain" + "github.com/dtoro/oikos/internal/core/ports" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/google/uuid" ) func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) { limit := clampLimit(req.Params.Limit) + filters := ports.EntityFilters{ + Type: strOrEmpty(req.Params.Type), + State: strOrEmpty(req.Params.State), + Q: strOrEmpty(req.Params.Q), + Domain: strOrEmpty(req.Params.Domain), + Layer: strOrEmpty(req.Params.Layer), + Cursor: strOrEmpty(req.Params.Cursor), + Limit: limit, + } - // Type filter includes descendants via the parent hierarchy (R3-1). - query := ` - 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 ` + entityCols + ` FROM entities e - JOIN entity_types et ON et.name = e.type - LEFT JOIN entity_status st ON st.entity_id = e.id - 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` - - rows, err := s.pool.Query(ctx, query, - req.Params.Type, req.Params.State, req.Params.Domain, req.Params.Layer, - req.Params.Q, req.Params.Cursor, limit+1) + items, next, err := s.readModels.ListEntities(ctx, filters) if err != nil { return nil, err } - defer rows.Close() - var items []gen.Entity - for rows.Next() { - e, err := scanEntity(rows) - if err != nil { - return nil, err - } - items = append(items, e) + entities := make([]gen.Entity, len(items)) + for i, e := range items { + entities[i] = entityWithHealthToGen(e) } - if rows.Err() != nil { - return nil, rows.Err() + if entities == nil { + entities = []gen.Entity{} } - - var next *string - if len(items) > limit { - items = items[:limit] - next = &items[len(items)-1].Slug - } - if items == nil { - items = []gen.Entity{} - } - return gen.ListEntities200JSONResponse{Items: items, NextCursor: next}, nil + return gen.ListEntities200JSONResponse{Items: entities, NextCursor: &next}, nil } func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject) (gen.GetEntityResponseObject, error) { - id, err := s.resolveEntityID(ctx, req.Id) + uid, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } - e, err := scanEntity(s.pool.QueryRow(ctx, - "SELECT "+entityCols+" FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1", id)) + e, err := s.readModels.GetEntity(ctx, domain.UUID(uid.String())) if err != nil { return nil, err } + entity := entityWithHealthToGen(e) return gen.GetEntity200JSONResponse{ - Body: e, - Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Version) + `"`}, + Body: entity, + Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Entity.Version) + `"`}, }, nil } func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) { - id, err := s.resolveEntityID(ctx, req.Id) + uid, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } @@ -90,39 +64,22 @@ func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelati if req.Params.Direction != nil { dir = string(*req.Params.Direction) } - relType := req.Params.RelType - rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{ - Direction: dir, - ID: id, - RelType: relType, - }) + relType := strOrEmpty(req.Params.RelType) + + rels, err := s.readModels.GetEntityRelations(ctx, domain.UUID(uid.String()), dir, relType) if err != nil { return nil, err } - items := []gen.Relationship{} - for _, r := range rows { - var attrs *map[string]any - if len(r.Attributes) > 0 { - var m map[string]any - if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 { - attrs = &m - } - } - validTo := r.ValidTo - items = append(items, gen.Relationship{ - Source: r.SourceSlug, - Target: r.TargetSlug, - Type: r.Type, - Attributes: attrs, - ValidFrom: r.ValidFrom, - ValidTo: validTo, - }) + + items := make([]gen.Relationship, len(rels)) + for i, r := range rels { + items[i] = relToGen(r) } return gen.GetEntityRelations200JSONResponse{Items: items}, nil } func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) { - id, err := s.resolveEntityID(ctx, req.Id) + uid, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } @@ -130,50 +87,23 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque if req.Params.Depth != nil { depth = *req.Params.Depth } - rows, err := s.pool.Query(ctx, ` - SELECT `+entityCols+`, b.depth - FROM blast_radius($1, $2) b - JOIN entities e ON e.id = b.entity_id - LEFT JOIN entity_status st ON st.entity_id = e.id - ORDER BY b.depth, e.slug`, id, depth) + + items, err := s.readModels.GetBlastRadius(ctx, domain.UUID(uid.String()), depth) if err != nil { return nil, err } - defer rows.Close() resp := gen.GetBlastRadius200JSONResponse{Items: []struct { Depth int `json:"depth"` Entity gen.Entity `json:"entity"` }{}} - for rows.Next() { - var e gen.Entity - var state *string - var attrsJSON []byte - var maint *time.Time - var health *string - var lastCheckAt *time.Time - var d int - if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON, - &maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt, &d); err != nil { - return nil, err - } - e.State = state - e.MaintenanceUntil = maint - if health != nil { - h := gen.EntityHealth(*health) - e.Health = &h - } - e.LastCheckAt = lastCheckAt - var attrs map[string]any - if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 { - e.Attributes = &attrs - } + for _, e := range items { resp.Items = append(resp.Items, struct { Depth int `json:"depth"` Entity gen.Entity `json:"entity"` - }{Depth: d, Entity: e}) + }{Depth: 0, Entity: entityWithHealthToGen(e)}) } - return resp, rows.Err() + return resp, nil } func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (gen.GetGraphResponseObject, error) { @@ -182,95 +112,36 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g depth = *req.Params.Depth } - var nodes []gen.Entity - var err error - truncated := false + var rootID *domain.UUID + if req.Params.Root != nil && *req.Params.Root != "" { + uid, err := s.resolveEntityID(ctx, *req.Params.Root) + if err != nil { + return nil, err + } + duid := domain.UUID(uid.String()) + rootID = &duid + } - // pgx can't infer the array element type from a nil *[]string (the - // param is absent from the request, not an empty list), so dereference - // to a plain []string first — nil there still encodes as SQL NULL, but - // pgx has a concrete type to work with. var relTypes []string if req.Params.RelType != nil { relTypes = *req.Params.RelType } - if req.Params.Root != nil && *req.Params.Root != "" { - rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root) - if rerr != nil { - return nil, rerr - } - nodes, err = s.queryEntities(ctx, ` - SELECT `+entityCols+` - FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id - LEFT JOIN entity_status st ON st.entity_id = e.id - ORDER BY e.slug`, rootID, depth, relTypes) - } else { - // Whole-graph view: pick the most-connected entities first so the - // graph shows actual topology, not just whatever sorts first - // alphabetically. Without this the cap fills with exec:* rows and - // drops every host/lxc/service/vm — and every edge those entities - // connect — because edges require both endpoints in the node set. - // Exclude the cognition transactional types (execution/task): they - // are audit records rather than topology, and at ~380 rows they - // consumed most of the old 500-node cap. - nodes, err = s.queryEntities(ctx, ` - SELECT `+entityCols+` - FROM entities e - LEFT JOIN entity_status st ON st.entity_id = e.id - WHERE e.type NOT IN ('execution','task') - AND e.id IN ( - SELECT e2.id FROM entities e2 - LEFT JOIN relationships r ON r.valid_to IS NULL - AND (r.source_id = e2.id OR r.target_id = e2.id) - WHERE e2.type NOT IN ('execution','task') - GROUP BY e2.id - ORDER BY count(r.type) DESC, e2.slug - LIMIT $1 - ) - ORDER BY e.slug`, - graphNodeCap+1) - if err == nil && len(nodes) > graphNodeCap { - nodes = nodes[:graphNodeCap] - truncated = true - } - } + nodes, edges, truncated, err := s.readModels.GetGraph(ctx, depth, rootID, relTypes, graphNodeCap) if err != nil { return nil, err } - ids := make([]uuid.UUID, len(nodes)) + genNodes := make([]gen.Entity, len(nodes)) for i, n := range nodes { - ids[i] = uuid.UUID(n.Id) + genNodes[i] = entityWithHealthToGen(n) } - edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{ - Ids: ids, - RelTypes: relTypes, - }) - if err != nil { - return nil, err - } - edges := []gen.Relationship{} - for _, r := range edgeRows { - var attrs *map[string]any - if len(r.Attributes) > 0 { - var m map[string]any - if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 { - attrs = &m - } - } - validTo := r.ValidTo - edges = append(edges, gen.Relationship{ - Source: r.SourceSlug, - Target: r.TargetSlug, - Type: r.Type, - Attributes: attrs, - ValidFrom: r.ValidFrom, - ValidTo: validTo, - }) + genEdges := make([]gen.Relationship, len(edges)) + for i, e := range edges { + genEdges[i] = relToGen(e) } - resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges} + resp := gen.GetGraph200JSONResponse{Nodes: genNodes, Edges: genEdges} if truncated { resp.Truncated = &truncated } @@ -278,58 +149,71 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g if req.Params.Include != nil { for _, inc := range *req.Params.Include { if inc == gen.Status { - health, herr := s.entityHealthByID(ctx, ids) - if herr != nil { - return nil, herr + health := make(map[string]gen.GraphViewHealth, len(nodes)) + for _, n := range nodes { + health[string(n.Entity.ID)] = gen.GraphViewHealth(n.Health) } resp.Health = &health break } } } - return resp, nil } -// entityHealthByID returns entity_status.health keyed by entity id, for the -// given id set (used by GetGraph's include=status). -func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) { - health := make(map[string]gen.GraphViewHealth, len(ids)) - rows, err := s.pool.Query(ctx, - `SELECT entity_id, health FROM entity_status WHERE entity_id = ANY($1)`, ids) - if err != nil { - return nil, err +func strOrEmpty(p *string) string { + if p == nil { + return "" } - defer rows.Close() - for rows.Next() { - var id uuid.UUID - var h string - if err := rows.Scan(&id, &h); err != nil { - return nil, err - } - health[id.String()] = gen.GraphViewHealth(h) - } - return health, rows.Err() + return *p } -func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) { - rows, err := s.pool.Query(ctx, query, args...) - if err != nil { - return nil, err +func entityWithHealthToGen(e ports.EntityWithHealth) gen.Entity { + id, _ := uuid.Parse(string(e.Entity.ID)) + out := gen.Entity{ + Id: id, + Slug: e.Entity.Slug, + Type: e.Entity.Type, + Name: e.Entity.Name, + Version: e.Entity.Version, + CreatedAt: e.Entity.CreatedAt, + UpdatedAt: e.Entity.UpdatedAt, } - defer rows.Close() - items := []gen.Entity{} - for rows.Next() { - e, err := scanEntity(rows) - if err != nil { - return nil, err - } - items = append(items, e) + if e.Entity.State != "" { + out.State = &e.Entity.State } - return items, rows.Err() + if e.Entity.MaintenanceUntil != nil { + out.MaintenanceUntil = e.Entity.MaintenanceUntil + } + if len(e.Entity.Attributes) > 0 { + attrs := e.Entity.Attributes + out.Attributes = &attrs + } + if e.Health != "" { + h := gen.EntityHealth(e.Health) + out.Health = &h + } + out.LastCheckAt = e.LastCheckAt + return out } -// sqlcEntityToGen converts a sqlcgen.Entity to a gen.Entity. +func relToGen(r domain.Relationship) gen.Relationship { + var attrs *map[string]any + if len(r.Attributes) > 0 { + attrs = &r.Attributes + } + return gen.Relationship{ + Source: string(r.SourceID), + Target: string(r.TargetID), + Type: r.Type, + Attributes: attrs, + ValidFrom: r.ValidFrom, + ValidTo: r.ValidTo, + } +} + +// sqlcEntityToGen converts a sqlcgen entity to the wire shape. Used by +// client_lifecycle.go (enroll) — the mutation handlers use domainToGen. func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity { out := gen.Entity{ Id: e.ID, diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 8bfaefa7..00de922d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -27,6 +27,7 @@ import ( "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/core/app" + "github.com/dtoro/oikos/internal/core/ports" "github.com/dtoro/oikos/internal/httpapi/gen" mcphandler "github.com/dtoro/oikos/internal/mcp" "github.com/dtoro/oikos/internal/safego" @@ -66,6 +67,7 @@ type Server struct { // replay path needs. Wired here until main becomes the composition root. entities *app.EntityService entityRepo *db.EntityRepo + readModels ports.ReadModels } // NewHandler builds the full HTTP handler: /healthz (unauthenticated, @@ -85,6 +87,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand } s.entityRepo = db.NewEntityRepo(pool) s.entities = app.NewEntityService(s.entityRepo, db.NewOntologyRepo(pool, time.Minute)) + s.readModels = db.NewEntityReader(pool) // Wire secrets backend: Infisical primary with SOPS DR fallback. if cfg.InfisicalSiteURL != "" {