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,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)
}
}

View File

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

View File

@@ -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...)