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.
349 lines
10 KiB
Go
349 lines
10 KiB
Go
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()
|
|
} |