package service import ( "context" "errors" "fmt" "time" "github.com/itworx/pulse/internal/audit" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) type DependencyRepository struct { Pool *pgxpool.Pool Audit audit.Store Now func() time.Time } func NewDependencyRepository(pool *pgxpool.Pool, store audit.Store) (*DependencyRepository, error) { if pool == nil { return nil, errors.New("dependency repository requires a database pool") } return &DependencyRepository{Pool: pool, Audit: store}, nil } func (r *DependencyRepository) List(ctx context.Context, serviceID string, limit int) ([]Dependency, error) { if r == nil || r.Pool == nil { return nil, errors.New("dependency repository is unavailable") } if ctx == nil { return nil, errors.New("dependency context is nil") } if err := ctx.Err(); err != nil { return nil, err } if limit < 1 || limit > 500 { return nil, errors.New("dependency list limit must be between 1 and 500") } return readDependencies(ctx, r.Pool, serviceID, limit) } func (r *DependencyRepository) Upsert(ctx context.Context, dependency Dependency, actor, correlationID string) error { return r.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error { if err := ValidateDependencyGraphWithCandidate(txctx, tx, dependency); err != nil { return err } if err := upsertDependency(txctx, tx, dependency); err != nil { return err } return nil }, dependencyAuditEvent(dependency, actor, correlationID, "dependency.upsert")) } func (r *DependencyRepository) PersistDiscovery(ctx context.Context, dependencies []Dependency, sourceID, correlationID string) error { if len(dependencies) > 5000 { return errors.New("dependency discovery batch exceeds bounds") } return r.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error { existing, err := readDependencies(txctx, tx, "", 5000) if err != nil { return err } candidateByKey := make(map[string]Dependency, len(existing)+len(dependencies)) for _, item := range existing { candidateByKey[dependencyKey(item)] = item } for _, item := range dependencies { if item.SourceID == "" { item.SourceID = sourceID } if err := item.Validate(); err != nil { return err } candidateByKey[dependencyKey(item)] = item } all := make([]Dependency, 0, len(candidateByKey)) for _, item := range candidateByKey { all = append(all, item) } if err := ValidateDependencyGraph(all); err != nil { return err } for _, item := range dependencies { if item.SourceID == "" { item.SourceID = sourceID } if err := upsertDependency(txctx, tx, item); err != nil { return err } } return nil }, audit.Event{Action: "dependency.discovery", ResourceType: "service_dependency", Result: "success", CorrelationID: correlationID, After: map[string]any{"count": len(dependencies), "sourceId": sourceID}}) } func (r *DependencyRepository) Archive(ctx context.Context, dependency Dependency, actor, correlationID string) error { if err := dependency.Validate(); err != nil { return err } now := time.Now().UTC() if r.Now != nil { now = r.Now().UTC() } return r.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error { var command pgconn.CommandTag var err error if dependency.SourceID == "" { command, err = tx.Exec(txctx, `UPDATE service_dependencies SET archived_at=$1 WHERE service_id=$2 AND depends_on_service_id=$3 AND relation_type=$4 AND source_id IS NULL AND archived_at IS NULL`, now, dependency.ServiceID, dependency.DependsOnServiceID, dependency.RelationType) } else { command, err = tx.Exec(txctx, `UPDATE service_dependencies SET archived_at=$1 WHERE service_id=$2 AND depends_on_service_id=$3 AND relation_type=$4 AND source_id=$5 AND archived_at IS NULL`, now, dependency.ServiceID, dependency.DependsOnServiceID, dependency.RelationType, dependency.SourceID) } if err != nil { return fmt.Errorf("archive service dependency: %w", err) } if command.RowsAffected() == 0 { return pgx.ErrNoRows } return nil }, dependencyAuditEvent(dependency, actor, correlationID, "dependency.archive")) } func (r *DependencyRepository) InTx(ctx context.Context, fn func(context.Context, pgx.Tx) error, event audit.Event) error { if r == nil || r.Pool == nil { return errors.New("dependency repository is unavailable") } if ctx == nil { return errors.New("dependency context is nil") } if err := ctx.Err(); err != nil { return err } tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return fmt.Errorf("begin dependency transaction: %w", err) } defer func() { _ = tx.Rollback(ctx) }() if err := fn(ctx, tx); err != nil { return err } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit dependency transaction: %w", err) } if r.Audit != nil && event.Action != "" { if err := r.Audit.Append(ctx, event); err != nil { return fmt.Errorf("audit dependency change: %w", err) } } return nil } func ValidateDependencyGraphWithCandidate(ctx context.Context, tx pgx.Tx, candidate Dependency) error { if err := candidate.Validate(); err != nil { return err } items, err := readDependencies(ctx, tx, "", 5000) if err != nil { return err } byKey := make(map[string]Dependency, len(items)+1) for _, item := range items { byKey[dependencyKey(item)] = item } byKey[dependencyKey(candidate)] = candidate items = items[:0] for _, item := range byKey { items = append(items, item) } return ValidateDependencyGraph(items) } func readDependencies(ctx context.Context, q interface { Query(context.Context, string, ...any) (pgx.Rows, error) }, serviceID string, limit int) ([]Dependency, error) { rows, err := q.Query(ctx, `WITH projected_dependencies AS ( SELECT id::text AS id, service_id::text AS service_id, depends_on_service_id::text AS depends_on_service_id, COALESCE(source_id::text,'') AS source_id, relation_type, confidence::float8 AS confidence, confirmed, first_seen_at, last_seen_at, archived_at FROM service_dependencies WHERE archived_at IS NULL UNION ALL SELECT 'inventory:' || rel.id::text, source_service.id::text, target_service.id::text, rel.source_id::text, rel.relation_type, rel.confidence::float8, rel.confirmed, rel.first_seen_at, rel.last_seen_at, NULL::timestamptz FROM entity_relations rel JOIN services source_service ON source_service.entity_id=rel.source_entity_id AND source_service.archived_at IS NULL JOIN services target_service ON target_service.entity_id=rel.target_entity_id AND target_service.archived_at IS NULL WHERE rel.tombstoned_at IS NULL AND rel.relation_type IN ('depends_on','backs','exposes') ) SELECT id, service_id, depends_on_service_id, source_id, relation_type, confidence, confirmed, first_seen_at, last_seen_at, archived_at FROM projected_dependencies WHERE ($1='' OR service_id=$1 OR depends_on_service_id=$1) ORDER BY service_id ASC, depends_on_service_id ASC, relation_type ASC, source_id ASC, id ASC LIMIT $2`, serviceID, limit) if err != nil { return nil, fmt.Errorf("list service dependencies: %w", err) } defer rows.Close() items := make([]Dependency, 0) for rows.Next() { var item Dependency if err := rows.Scan(&item.ID, &item.ServiceID, &item.DependsOnServiceID, &item.SourceID, &item.RelationType, &item.Confidence, &item.Confirmed, &item.FirstSeenAt, &item.LastSeenAt, &item.ArchivedAt); err != nil { return nil, fmt.Errorf("scan service dependency: %w", err) } items = append(items, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("read service dependencies: %w", err) } SortDependencies(items) return items, nil } func upsertDependency(ctx context.Context, q interface { Exec(context.Context, string, ...any) (pgconn.CommandTag, error) }, item Dependency) error { if err := item.Validate(); err != nil { return err } if item.FirstSeenAt.IsZero() { item.FirstSeenAt = time.Now().UTC() } var err error if item.SourceID == "" { _, err = q.Exec(ctx, `INSERT INTO service_dependencies (id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at) VALUES ($1,$2,$3,NULL,$4,$5,$6,$7,$8,NULL) ON CONFLICT (service_id,depends_on_service_id,relation_type) WHERE source_id IS NULL DO UPDATE SET confidence=EXCLUDED.confidence,confirmed=EXCLUDED.confirmed,last_seen_at=EXCLUDED.last_seen_at,archived_at=NULL`, item.ID, item.ServiceID, item.DependsOnServiceID, item.RelationType, item.Confidence, item.Confirmed, item.FirstSeenAt, item.LastSeenAt) } else { _, err = q.Exec(ctx, `INSERT INTO service_dependencies (id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NULL) ON CONFLICT (service_id,depends_on_service_id,relation_type,source_id) DO UPDATE SET confidence=EXCLUDED.confidence,confirmed=EXCLUDED.confirmed,last_seen_at=EXCLUDED.last_seen_at,archived_at=NULL`, item.ID, item.ServiceID, item.DependsOnServiceID, item.SourceID, item.RelationType, item.Confidence, item.Confirmed, item.FirstSeenAt, item.LastSeenAt) } if err != nil { return fmt.Errorf("upsert service dependency: %w", err) } return nil } func dependencyAuditEvent(item Dependency, actor, correlationID, action string) audit.Event { return audit.Event{Actor: actor, Action: action, ResourceType: "service_dependency", ResourceID: item.ID, Result: "success", CorrelationID: correlationID, After: map[string]any{"serviceId": item.ServiceID, "dependsOnServiceId": item.DependsOnServiceID, "relationType": item.RelationType, "sourceId": item.SourceID, "confidence": item.Confidence, "confirmed": item.Confirmed}} }