phase 1 review fixes: dedup edges, real export, validation, tests
Review of aa2ca0a found and fixed:
- re-ingest duplicated ALL edges (upsert conflicted on valid_from=now(),
never fired) — migration 007 dedupes + partial unique index on current
edges; upsert now targets it. Regression-tested.
- export was a stub that overwrote seeds/*.yaml with 11-byte "version: 1"
files — implemented real deterministic export (ontology/inventory/policy,
cognition-layer excluded); round-trip is byte-stable (tested)
- DB password leaked in startup logs (slog JSON bypasses String()) —
Config now implements slog.LogValuer; regression-tested
- docker-compose had literal '***' as DB password — env-interpolated
- uuid.New() (v4) → uuid.NewV7() per ADR-0005
- no ontology validation on ingest — internal/ontology TypeTree: abstract
instantiation rejected, relationship endpoints hierarchy-validated,
cardinality enforced in-transaction, lifecycle states checked, default
state applied (Phase 1 gate items, R3-1)
- getOrCreateEntityID swallowed non-ErrNoRows errors
- migration runner now holds a session advisory lock on one connection
- Makefile: hardcoded /opt/homebrew/bin/go → go; test-db target
Tests: 4 unit suites + 7 integration tests (env-guarded, throwaway DB per
run): migrate idempotent, seed idempotent + no dup edges, abstract/edge/
cardinality rejection, blast_radius cycle termination, export round-trip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,11 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SeedResult holds counts from a seed ingest operation.
|
||||
@@ -83,11 +81,20 @@ func IngestOntologySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*S
|
||||
}
|
||||
|
||||
// IngestInventorySeed ingests seeds/inventory.yaml into the DB.
|
||||
// Every entity and edge is validated against the ontology (abstract types
|
||||
// rejected, lifecycle states checked, relationship endpoints hierarchy-
|
||||
// validated, cardinality enforced) — a violating seed rolls back atomically.
|
||||
func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||
r := &SeedResult{}
|
||||
|
||||
tree, err := LoadTypeTree(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load type tree: %w", err)
|
||||
}
|
||||
|
||||
// Entities
|
||||
entities, _ := data["entities"].([]any)
|
||||
entityTypes := make(map[string]string) // slug -> type, for edge validation
|
||||
for _, raw := range entities {
|
||||
eMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
@@ -99,7 +106,14 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
||||
state, _ := eMap["state"].(string)
|
||||
attrs := eMap["attributes"]
|
||||
|
||||
// Generate UUIDv7 for new entities, or find existing by slug
|
||||
if err := tree.ValidateEntity(typeName, state); err != nil {
|
||||
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||
}
|
||||
if state == "" {
|
||||
state = tree.DefaultState(typeName)
|
||||
}
|
||||
entityTypes[slug] = typeName
|
||||
|
||||
entityID, err := getOrCreateEntityID(ctx, tx, slug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||
@@ -118,7 +132,8 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
||||
r.Entities++
|
||||
}
|
||||
|
||||
// Relationships
|
||||
// Relationships — upsert against the current-edge partial unique index
|
||||
// (migration 007) so re-ingest never duplicates edges.
|
||||
rels, _ := data["relationships"].([]any)
|
||||
for _, raw := range rels {
|
||||
relMap, ok := raw.(map[string]any)
|
||||
@@ -139,12 +154,32 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
||||
return nil, fmt.Errorf("rel to %s: %w", target, err)
|
||||
}
|
||||
|
||||
srcType := entityTypes[source]
|
||||
tgtType := entityTypes[target]
|
||||
if srcType == "" || tgtType == "" { // entity pre-existing in DB, not in this seed
|
||||
if srcType == "" {
|
||||
srcType, err = getEntityTypeBySlug(ctx, tx, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if tgtType == "" {
|
||||
tgtType, err = getEntityTypeBySlug(ctx, tx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tree.ValidateEdge(relType, srcType, tgtType); err != nil {
|
||||
return nil, fmt.Errorf("rel %s→%s: %w", source, target, err)
|
||||
}
|
||||
|
||||
attrsBytes, _ := json.Marshal(attrs)
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
|
||||
VALUES ($1, $2, $3, $4, now(), NULL)
|
||||
ON CONFLICT (source_id, target_id, type, valid_from)
|
||||
DO UPDATE SET attributes = $4`,
|
||||
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
|
||||
DO UPDATE SET attributes = EXCLUDED.attributes`,
|
||||
sourceID, targetID, relType, string(attrsBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rel %s→%s %s: %w", source, target, relType, err)
|
||||
@@ -152,6 +187,10 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
||||
r.Relationships++
|
||||
}
|
||||
|
||||
if err := ValidateCardinality(ctx, tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -231,15 +270,29 @@ func IngestPolicySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*See
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// getOrCreateEntityID returns the UUID for a slug, generating a new UUIDv7 if not found.
|
||||
// getOrCreateEntityID returns the UUID for a slug, generating a new
|
||||
// time-ordered UUIDv7 if the slug doesn't exist yet (ADR-0005).
|
||||
func getOrCreateEntityID(ctx context.Context, tx pgx.Tx, slug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
|
||||
if err == nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
return id, nil
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
return uuid.NewV7()
|
||||
default:
|
||||
return uuid.Nil, fmt.Errorf("lookup slug %s: %w", slug, err)
|
||||
}
|
||||
// Generate a time-ordered UUID (using uuid.New for now; UUIDv7 in production)
|
||||
return uuid.New(), nil
|
||||
}
|
||||
|
||||
// getEntityTypeBySlug resolves a slug to its entity type name.
|
||||
func getEntityTypeBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error) {
|
||||
var t string
|
||||
err := tx.QueryRow(ctx, "SELECT type FROM entities WHERE slug = $1", slug).Scan(&t)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve type of %s: %w", slug, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// getEntityIDBySlug resolves a slug to its UUID.
|
||||
@@ -338,49 +391,3 @@ func keysOf(m map[string]map[string]any) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// ExportToYAML regenerates the three seed YAMLs from the DB (for DR / version control, D6).
|
||||
func ExportToYAML(ctx context.Context, pool *Pool) (map[string][]byte, error) {
|
||||
result := make(map[string][]byte)
|
||||
|
||||
// Export ontology
|
||||
onto, err := exportOntology(ctx, pool)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export ontology: %w", err)
|
||||
}
|
||||
result["ontology.yaml"], _ = yaml.Marshal(onto)
|
||||
|
||||
// Export inventory
|
||||
inv, err := exportInventory(ctx, pool)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export inventory: %w", err)
|
||||
}
|
||||
result["inventory.yaml"], _ = yaml.Marshal(inv)
|
||||
|
||||
// Export policy
|
||||
pol, err := exportPolicy(ctx, pool)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export policy: %w", err)
|
||||
}
|
||||
result["policy.yaml"], _ = yaml.Marshal(pol)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func exportOntology(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||
// TODO: implement full export from DB
|
||||
return map[string]any{"version": 1}, nil
|
||||
}
|
||||
|
||||
func exportInventory(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||
// TODO: implement full export from DB
|
||||
return map[string]any{"version": 1}, nil
|
||||
}
|
||||
|
||||
func exportPolicy(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||
// TODO: implement full export from DB
|
||||
return map[string]any{"version": 1}, nil
|
||||
}
|
||||
|
||||
// Unused import suppression for domain (will be needed when we add more logic)
|
||||
var _ = domain.Entity{}
|
||||
var _ = time.Now
|
||||
|
||||
Reference in New Issue
Block a user