package db import ( "context" "encoding/json" "fmt" "github.com/dtoro/oikos/internal/core/ports" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // SeedRepo implements ports.SeedRepository over the postgres pool. Each // Ingest method wraps Pool.SeedIngest (hash no-op + one transaction + // seed_versions recording) around the file's ingest functions; the // knowledge ingest moved here from internal/knowledge when SeedService // absorbed seeding (Phase 7) — raw SQL over the open transaction belongs // in the adapter. type SeedRepo struct { pool *Pool } var _ ports.SeedRepository = (*SeedRepo)(nil) // NewSeedRepo builds the seed repository. func NewSeedRepo(pool *Pool) *SeedRepo { return &SeedRepo{pool: pool} } // IngestOntology ingests seeds/ontology.yaml (lifecycles, entity types, // relationship types) — one transaction, no-op on unchanged hash. func (r *SeedRepo) IngestOntology(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) { var counts ports.SeedCounts applied := false err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error { res, err := IngestOntologySeed(ctx, tx, data) if err != nil { return err } counts.Lifecycles = res.Lifecycles counts.EntityTypes = res.EntityTypes counts.RelationshipTypes = res.RelationshipTypes applied = true return nil }) return counts, applied, err } // IngestInventory ingests seeds/inventory.yaml (entities, relationships, // derived default checks) with ontology validation — a violating seed // rolls back atomically. func (r *SeedRepo) IngestInventory(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) { var counts ports.SeedCounts applied := false err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error { res, err := IngestInventorySeed(ctx, tx, data) if err != nil { return err } counts.Entities = res.Entities counts.Relationships = res.Relationships counts.Checks = res.Checks applied = true return nil }) return counts, applied, err } // IngestPolicy ingests seeds/policy.yaml (risk classes, approval rules, // autonomy settings). func (r *SeedRepo) IngestPolicy(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) { var counts ports.SeedCounts applied := false err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error { res, err := IngestPolicySeed(ctx, tx, data) if err != nil { return err } counts.RiskClasses = res.RiskClasses counts.ApprovalRules = res.ApprovalRules counts.AutonomySettings = res.AutonomySettings applied = true return nil }) return counts, applied, err } // IngestKnowledge ingests seeds/knowledge.yaml (documents, // investigations, runbooks) as knowledge entities linked into the graph. func (r *SeedRepo) IngestKnowledge(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) { var counts ports.SeedCounts applied := false err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error { res, err := IngestKnowledgeSeed(ctx, tx, data) if err != nil { return err } counts.Documents = res.Documents counts.Investigations = res.Investigations counts.Runbooks = res.Runbooks applied = true return nil }) return counts, applied, err } // Export regenerates the three structural seed YAMLs from the DB. func (r *SeedRepo) Export(ctx context.Context) (map[string][]byte, error) { return ExportToYAML(ctx, r.pool) } // KnowledgeSeedResult holds counts from the knowledge seed ingest. type KnowledgeSeedResult struct { Documents int Investigations int Runbooks int } // IngestKnowledgeSeed ingests the knowledge seed (documents, // investigations, runbooks) into knowledge entities plus their graph // edges, inside the caller's ingest transaction. Absorbed from // internal/knowledge (Phase 7): the logic is seed ingest, its home is the // seed repository. func IngestKnowledgeSeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*KnowledgeSeedResult, error) { r := &KnowledgeSeedResult{} docs, _ := data["documents"].([]any) for _, raw := range docs { d, _ := raw.(map[string]any) if err := ingestDocument(ctx, tx, d); err != nil { return nil, fmt.Errorf("document %v: %w", seedStr(d, "slug"), err) } r.Documents++ } invs, _ := data["investigations"].([]any) for _, raw := range invs { m, _ := raw.(map[string]any) if err := ingestInvestigation(ctx, tx, m); err != nil { return nil, fmt.Errorf("investigation %v: %w", seedStr(m, "slug"), err) } r.Investigations++ } rbs, _ := data["runbooks"].([]any) for _, raw := range rbs { m, _ := raw.(map[string]any) if err := ingestRunbook(ctx, tx, m); err != nil { return nil, fmt.Errorf("runbook %v: %w", seedStr(m, "slug"), err) } r.Runbooks++ } return r, nil } func seedStr(m map[string]any, key string) string { s, _ := m[key].(string) return s } func seedStrSlice(m map[string]any, key string) []string { raw, _ := m[key].([]any) var out []string for _, v := range raw { if s, ok := v.(string); ok { out = append(out, s) } } return out } func ingestDocument(ctx context.Context, tx pgx.Tx, m map[string]any) error { slug := seedStr(m, "slug") title := seedStr(m, "title") content := seedStr(m, "content") entitySlug := seedStr(m, "entity_slug") tags := seedStrSlice(m, "tags") atGlance, _ := m["at_glance"].(map[string]any) clRaw, _ := m["changelog"].([]any) entityDocSlug := "document:" + slug if err := upsertKnowledgeEntity(ctx, tx, entityDocSlug, "document", title, content, slug, tags); err != nil { return err } attrs := map[string]any{} if len(atGlance) > 0 { attrs["at_glance"] = atGlance } if len(clRaw) > 0 { attrs["changelog"] = clRaw } if len(attrs) > 0 { attrsBytes, _ := json.Marshal(attrs) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = attributes || $1, updated_at = now() WHERE slug = $2`, string(attrsBytes), entityDocSlug) if err != nil { return fmt.Errorf("update document attrs: %w", err) } } if entitySlug != "" { if err := createSeedEdge(ctx, tx, entityDocSlug, entitySlug, "documents", nil); err != nil { return fmt.Errorf("link document: %w", err) } } if len(atGlance) > 0 && entitySlug != "" { backfillAttrs, _ := json.Marshal(atGlance) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = $1 || attributes, updated_at = now() WHERE slug = $2`, string(backfillAttrs), entitySlug) if err != nil { return fmt.Errorf("backfill entity attrs: %w", err) } } return nil } func ingestInvestigation(ctx context.Context, tx pgx.Tx, m map[string]any) error { slug := seedStr(m, "slug") title := seedStr(m, "title") content := seedStr(m, "content") date := seedStr(m, "date") status := seedStr(m, "status") duration := seedStr(m, "duration") aboutSlugs := seedStrSlice(m, "about_slugs") tags := seedStrSlice(m, "tags") entitySlug := "investigation:" + slug if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "investigation", title, content, slug, tags); err != nil { return err } attrs := map[string]any{} if date != "" { attrs["date"] = date } if status != "" { attrs["status"] = status } if duration != "" { attrs["duration"] = duration } if len(attrs) > 0 { attrsBytes, _ := json.Marshal(attrs) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = attributes || $1, updated_at = now() WHERE slug = $2`, string(attrsBytes), entitySlug) if err != nil { return fmt.Errorf("update investigation attrs: %w", err) } } for _, aboutSlug := range aboutSlugs { if err := createSeedEdge(ctx, tx, entitySlug, aboutSlug, "about", nil); err != nil { return fmt.Errorf("link investigation about %s: %w", aboutSlug, err) } } return nil } func ingestRunbook(ctx context.Context, tx pgx.Tx, m map[string]any) error { slug := seedStr(m, "slug") name := seedStr(m, "name") riskClass := seedStr(m, "risk_class") entityType := seedStr(m, "entity_type") content := seedStr(m, "content") tags := seedStrSlice(m, "tags") procedure, _ := m["procedure"].(map[string]any) entitySlug := "runbook:" + slug if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "runbook", name, content, slug, tags); err != nil { return err } attrs := map[string]any{} if riskClass != "" { attrs["risk_class"] = riskClass } if entityType != "" { attrs["applies_to_type"] = entityType } if len(procedure) > 0 { attrs["procedure"] = procedure } if len(attrs) > 0 { attrsBytes, _ := json.Marshal(attrs) _, err := tx.Exec(ctx, `UPDATE entities SET attributes = attributes || $1, updated_at = now() WHERE slug = $2`, string(attrsBytes), entitySlug) if err != nil { return fmt.Errorf("update runbook attrs: %w", err) } } return nil } func upsertKnowledgeEntity(ctx context.Context, tx pgx.Tx, slug, entityType, title, content, source string, tags []string) error { id, err := getOrCreateKnowledgeEntity(ctx, tx, slug, entityType, title) if err != nil { return err } hash := contentHash([]byte(content)) tagArray := toPGArray(tags) _, err = tx.Exec(ctx, `INSERT INTO knowledge_entities (entity_id, title, content, source, tags, content_hash, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, now(), now()) ON CONFLICT (entity_id) DO UPDATE SET title = $2, content = $3, source = $4, tags = $5, content_hash = $6, updated_at = now()`, id, title, content, source, tagArray, hash) return err } func getOrCreateKnowledgeEntity(ctx context.Context, tx pgx.Tx, slug, entityType, name 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 { return id, nil } if err != pgx.ErrNoRows { return uuid.Nil, fmt.Errorf("lookup entity %s: %w", slug, err) } id, err = uuid.NewV7() if err != nil { return uuid.Nil, fmt.Errorf("generate uuid: %w", err) } _, err = tx.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at) VALUES ($1, $2, $3, $4, NULL, '{}', 1, now(), now())`, id, slug, entityType, name) if err != nil { return uuid.Nil, fmt.Errorf("create entity %s: %w", slug, err) } return id, nil } func createSeedEdge(ctx context.Context, tx pgx.Tx, sourceSlug, targetSlug, relType string, attrs map[string]any) error { var sourceID, targetID uuid.UUID if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", sourceSlug).Scan(&sourceID); err != nil { return fmt.Errorf("source %s: %w", sourceSlug, err) } if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil { return fmt.Errorf("target %s: %w", targetSlug, 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) WHERE valid_to IS NULL DO UPDATE SET attributes = EXCLUDED.attributes`, sourceID, targetID, relType, string(attrsBytes)) return err } func toPGArray(tags []string) string { if len(tags) == 0 { return "{}" } out := "{" for i, t := range tags { if i > 0 { out += "," } out += `"` + t + `"` } out += "}" return out }