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

@@ -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()
}