feat: Phase 4 — RelationshipService, postgres RelRepo, converged edges
Problem: relationship create/end existed as three drifted copies (HTTP CreateRelationship/EndRelationship, MCP create_relationship/ end_relationship) with inline SQL, no ontology edge validation on either path, and no audit on the MCP path. Change: - Internal/adapters/postgres/repositories.go: RelRepo implements ports.RelationshipRepository (Create/End/ListFor) over the pool, with in-tx upsert + audit/event side effects on Create. - Internal/core/app/relationships.go: RelationshipService validates edges against the cached ontology TypeTree (tree.ValidateEdge) and delegates the tx to the repository. The adapter resolves slug→entity and extracts types before calling the service. - HTTP CreateRelationship: resolves source/target via ReadModels, passes resolved types to RelationshipService for edge validation. EndRelationship calls the service directly (audit stays in the adapter for End — a simple toggle with no ontology check). - MCP create_relationship/end_relationship: rewired to the service (pool resolves entity IDs inline for the tool handlers; the service validates edges and writes audit). The MCP path now gets ontology validation and audit coverage for the first time. - Composition root: RelationshipService built with RelRepo + Ontology and wired through httpapi.NewHandler, ListenAndServe, and MCP constructors. Verification: go build/vet, full test suite (19 pkgs, DB integration postgres+mcp green).
This commit is contained in:
@@ -453,6 +453,113 @@ func (r *EntityRepo) GetIdempotent(ctx context.Context, actor, key string) (port
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RelRepo implements ports.RelationshipRepository over the postgres pool.
|
||||
type RelRepo struct {
|
||||
pool *Pool
|
||||
}
|
||||
|
||||
var _ ports.RelationshipRepository = (*RelRepo)(nil)
|
||||
|
||||
func NewRelRepo(pool *Pool) *RelRepo { return &RelRepo{pool: pool} }
|
||||
|
||||
func (r *RelRepo) Create(ctx context.Context, input ports.RelationshipCreateInput) (domain.Relationship, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Relationship{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
attrsJSON := []byte("{}")
|
||||
if len(input.Relationship.Attributes) > 0 {
|
||||
attrsJSON, _ = json.Marshal(input.Relationship.Attributes)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
VALUES ($1, $2, $3, $4, now())`,
|
||||
mustUUID(input.Relationship.SourceID), mustUUID(input.Relationship.TargetID),
|
||||
input.Relationship.Type, attrsJSON)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||
return domain.Relationship{}, errors.Join(domain.ErrAlreadyExists, err)
|
||||
}
|
||||
return domain.Relationship{}, err
|
||||
}
|
||||
|
||||
if err := writeSideEffects(ctx, tx, input.Relationship.SourceID, input.Audit, input.Event); err != nil {
|
||||
return domain.Relationship{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Relationship{}, err
|
||||
}
|
||||
input.Relationship.ValidFrom = time.Now()
|
||||
return input.Relationship, nil
|
||||
}
|
||||
|
||||
func (r *RelRepo) End(ctx context.Context, source, target domain.UUID, relType string) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
|
||||
SourceID: mustUUID(source), TargetID: mustUUID(target), Type: relType,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *RelRepo) ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error) {
|
||||
eid := mustUUID(entityID)
|
||||
switch direction {
|
||||
case "outbound":
|
||||
return queryRelsBySource(r.pool, eid)
|
||||
case "inbound":
|
||||
return queryRelsByTarget(r.pool, eid)
|
||||
default:
|
||||
return queryRelsBoth(r.pool, eid)
|
||||
}
|
||||
}
|
||||
|
||||
func queryRelsBySource(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
|
||||
rows, err := pool.Query(context.Background(),
|
||||
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
|
||||
FROM relationships WHERE valid_to IS NULL AND source_id = $1 ORDER BY type`, eid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEdges(rows)
|
||||
}
|
||||
|
||||
func queryRelsByTarget(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
|
||||
rows, err := pool.Query(context.Background(),
|
||||
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
|
||||
FROM relationships WHERE valid_to IS NULL AND target_id = $1 ORDER BY type`, eid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEdges(rows)
|
||||
}
|
||||
|
||||
func queryRelsBoth(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
|
||||
rows, err := pool.Query(context.Background(),
|
||||
`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) ORDER BY type`, eid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEdges(rows)
|
||||
}
|
||||
|
||||
// OntologyRepo implements ports.OntologyStore with a TTL cache — entity
|
||||
// types change at seed time, not per request, so a short cache trades a
|
||||
// little staleness for avoiding the meta-schema load on every mutation.
|
||||
|
||||
Reference in New Issue
Block a user