feat: Phase 3d — ReadModels port, postgres impl, HTTP reads rewire
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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.
This commit is contained in:
2026-08-15 23:58:31 +02:00
parent 9e3783734e
commit 65f415f9db
7 changed files with 664 additions and 219 deletions

View File

@@ -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,

View File

@@ -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 != "" {