wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -23,6 +23,7 @@ public static class ScmWebhookEndpoints
webhooks.MapPost("/github", HandleGitHubWebhookAsync)
.WithName("ScmWebhookGitHub")
.WithDescription("Inbound webhook endpoint for GitHub events. Validates the X-Hub-Signature-256 HMAC signature, extracts the event type and delivery ID, and dispatches the payload to the SCM webhook service for scan and SBOM trigger evaluation. Returns 202 Accepted on success.")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
@@ -31,6 +32,7 @@ public static class ScmWebhookEndpoints
webhooks.MapPost("/gitlab", HandleGitLabWebhookAsync)
.WithName("ScmWebhookGitLab")
.WithDescription("Inbound webhook endpoint for GitLab events. Validates the X-Gitlab-Token header, extracts the event UUID and type, and dispatches the payload for scan and SBOM trigger evaluation. Returns 202 Accepted on success.")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
@@ -39,6 +41,7 @@ public static class ScmWebhookEndpoints
webhooks.MapPost("/gitea", HandleGiteaWebhookAsync)
.WithName("ScmWebhookGitea")
.WithDescription("Inbound webhook endpoint for Gitea events. Validates the X-Hub-Signature-256 HMAC signature (falls back to X-Hub-Signature), extracts the event type and delivery ID, and dispatches the payload for scan and SBOM trigger evaluation. Returns 202 Accepted on success.")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Signals.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for SignalsDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(Context.SignalsDbContext))]
public partial class SignalsDbContextModel : RuntimeModel
{
private static SignalsDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new SignalsDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Signals.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model builder stub for SignalsDbContext.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
public partial class SignalsDbContextModel
{
partial void Initialize()
{
// Stub: when a real compiled model is generated, entity types will be registered here.
// The runtime factory will fall back to reflection-based model building for all schemas
// until this stub is replaced with a full compiled model.
}
}

View File

@@ -1,21 +1,532 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Signals.Persistence.EfCore.Models;
namespace StellaOps.Signals.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for Signals module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// EF Core DbContext for the Signals module.
/// Maps to the signals PostgreSQL schema: callgraphs, reachability_facts, unknowns,
/// func_nodes, call_edges, cve_func_hits, deploy_refs, graph_metrics,
/// scans, cg_nodes, cg_edges, entrypoints, artifacts, symbol_component_map,
/// reachability_components, reachability_findings, runtime_samples,
/// runtime_agents, runtime_facts, agent_heartbeats, agent_commands.
/// </summary>
public class SignalsDbContext : DbContext
public partial class SignalsDbContext : DbContext
{
public SignalsDbContext(DbContextOptions<SignalsDbContext> options)
private readonly string _schemaName;
public SignalsDbContext(DbContextOptions<SignalsDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "signals"
: schemaName.Trim();
}
// Document-store tables (repository-provisioned)
public virtual DbSet<Callgraph> Callgraphs { get; set; }
public virtual DbSet<ReachabilityFact> ReachabilityFacts { get; set; }
public virtual DbSet<Unknown> Unknowns { get; set; }
public virtual DbSet<FuncNode> FuncNodes { get; set; }
public virtual DbSet<CallEdge> CallEdges { get; set; }
public virtual DbSet<CveFuncHit> CveFuncHits { get; set; }
public virtual DbSet<DeployRef> DeployRefs { get; set; }
public virtual DbSet<GraphMetric> GraphMetrics { get; set; }
// Migration-defined relational tables
public virtual DbSet<Scan> Scans { get; set; }
public virtual DbSet<CgNode> CgNodes { get; set; }
public virtual DbSet<CgEdge> CgEdges { get; set; }
public virtual DbSet<Entrypoint> Entrypoints { get; set; }
public virtual DbSet<SymbolComponentMap> SymbolComponentMaps { get; set; }
public virtual DbSet<ReachabilityComponent> ReachabilityComponents { get; set; }
public virtual DbSet<ReachabilityFinding> ReachabilityFindings { get; set; }
// Runtime agent tables
public virtual DbSet<SignalsRuntimeAgent> RuntimeAgents { get; set; }
public virtual DbSet<RuntimeFact> RuntimeFacts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("signals");
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
// ── callgraphs ──────────────────────────────────────────────────
modelBuilder.Entity<Callgraph>(entity =>
{
entity.HasKey(e => e.Id).HasName("callgraphs_pkey");
entity.ToTable("callgraphs", schemaName);
entity.HasIndex(e => e.Component, "idx_callgraphs_component");
entity.HasIndex(e => e.GraphHash, "idx_callgraphs_graph_hash");
entity.HasIndex(e => e.IngestedAt, "idx_callgraphs_ingested_at")
.IsDescending(true);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.Language).HasColumnName("language");
entity.Property(e => e.Component).HasColumnName("component");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
entity.Property(e => e.IngestedAt).HasColumnName("ingested_at");
entity.Property(e => e.DocumentJson)
.HasColumnType("jsonb")
.HasColumnName("document_json");
});
// ── reachability_facts ──────────────────────────────────────────
modelBuilder.Entity<ReachabilityFact>(entity =>
{
entity.HasKey(e => e.SubjectKey).HasName("reachability_facts_pkey");
entity.ToTable("reachability_facts", schemaName);
entity.HasIndex(e => e.CallgraphId, "idx_reachability_facts_callgraph_id");
entity.HasIndex(e => e.ComputedAt, "idx_reachability_facts_computed_at");
entity.HasIndex(e => e.Score, "idx_reachability_facts_score")
.IsDescending(true);
entity.Property(e => e.SubjectKey).HasColumnName("subject_key");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.CallgraphId).HasColumnName("callgraph_id");
entity.Property(e => e.Score).HasColumnName("score");
entity.Property(e => e.RiskScore).HasColumnName("risk_score");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
entity.Property(e => e.DocumentJson)
.HasColumnType("jsonb")
.HasColumnName("document_json");
});
// ── unknowns ────────────────────────────────────────────────────
modelBuilder.Entity<Unknown>(entity =>
{
entity.HasKey(e => new { e.SubjectKey, e.Id }).HasName("unknowns_pkey");
entity.ToTable("unknowns", schemaName);
entity.HasIndex(e => e.Band, "idx_unknowns_band");
entity.HasIndex(e => e.Score, "idx_unknowns_score_desc")
.IsDescending(true);
entity.HasIndex(e => new { e.Band, e.Score }, "idx_unknowns_band_score")
.IsDescending(false, true);
entity.HasIndex(e => e.NextScheduledRescan, "idx_unknowns_next_rescan")
.HasFilter("(next_scheduled_rescan IS NOT NULL)");
entity.HasIndex(e => e.Score, "idx_unknowns_hot_band")
.IsDescending(true)
.HasFilter("(band = 'hot')");
entity.HasIndex(e => e.Purl, "idx_unknowns_purl");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.SubjectKey).HasColumnName("subject_key");
entity.Property(e => e.CallgraphId).HasColumnName("callgraph_id");
entity.Property(e => e.SymbolId).HasColumnName("symbol_id");
entity.Property(e => e.CodeId).HasColumnName("code_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.PurlVersion).HasColumnName("purl_version");
entity.Property(e => e.EdgeFrom).HasColumnName("edge_from");
entity.Property(e => e.EdgeTo).HasColumnName("edge_to");
entity.Property(e => e.Reason).HasColumnName("reason");
entity.Property(e => e.PopularityP).HasDefaultValue(0.0).HasColumnName("popularity_p");
entity.Property(e => e.DeploymentCount).HasDefaultValue(0).HasColumnName("deployment_count");
entity.Property(e => e.ExploitPotentialE).HasDefaultValue(0.0).HasColumnName("exploit_potential_e");
entity.Property(e => e.UncertaintyU).HasDefaultValue(0.0).HasColumnName("uncertainty_u");
entity.Property(e => e.CentralityC).HasDefaultValue(0.0).HasColumnName("centrality_c");
entity.Property(e => e.DegreeCentrality).HasDefaultValue(0).HasColumnName("degree_centrality");
entity.Property(e => e.BetweennessCentrality).HasDefaultValue(0.0).HasColumnName("betweenness_centrality");
entity.Property(e => e.StalenessS).HasDefaultValue(0.0).HasColumnName("staleness_s");
entity.Property(e => e.DaysSinceAnalysis).HasDefaultValue(0).HasColumnName("days_since_analysis");
entity.Property(e => e.Score).HasDefaultValue(0.0).HasColumnName("score");
entity.Property(e => e.Band).HasDefaultValueSql("'cold'").HasColumnName("band");
entity.Property(e => e.UnknownFlags)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("unknown_flags");
entity.Property(e => e.NormalizationTrace)
.HasColumnType("jsonb")
.HasColumnName("normalization_trace");
entity.Property(e => e.RescanAttempts).HasDefaultValue(0).HasColumnName("rescan_attempts");
entity.Property(e => e.LastRescanResult).HasColumnName("last_rescan_result");
entity.Property(e => e.NextScheduledRescan).HasColumnName("next_scheduled_rescan");
entity.Property(e => e.LastAnalyzedAt).HasColumnName("last_analyzed_at");
entity.Property(e => e.GraphSliceHash).HasColumnName("graph_slice_hash");
entity.Property(e => e.EvidenceSetHash).HasColumnName("evidence_set_hash");
entity.Property(e => e.CallgraphAttemptHash).HasColumnName("callgraph_attempt_hash");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
});
// ── func_nodes ──────────────────────────────────────────────────
modelBuilder.Entity<FuncNode>(entity =>
{
entity.HasKey(e => e.Id).HasName("func_nodes_pkey");
entity.ToTable("func_nodes", schemaName);
entity.HasIndex(e => e.GraphHash, "idx_func_nodes_graph_hash");
entity.HasIndex(e => e.SymbolDigest, "idx_func_nodes_symbol_digest")
.HasFilter("(symbol_digest IS NOT NULL)");
entity.HasIndex(e => e.Purl, "idx_func_nodes_purl")
.HasFilter("(purl IS NOT NULL)");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
entity.Property(e => e.SymbolId).HasColumnName("symbol_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.Namespace).HasColumnName("namespace");
entity.Property(e => e.File).HasColumnName("file");
entity.Property(e => e.Line).HasColumnName("line");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
entity.Property(e => e.BuildId).HasColumnName("build_id");
entity.Property(e => e.CodeId).HasColumnName("code_id");
entity.Property(e => e.Language).HasColumnName("language");
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
entity.Property(e => e.Analyzer).HasColumnType("jsonb").HasColumnName("analyzer");
entity.Property(e => e.IngestedAt).HasColumnName("ingested_at");
});
// ── call_edges ──────────────────────────────────────────────────
modelBuilder.Entity<CallEdge>(entity =>
{
entity.HasKey(e => e.Id).HasName("call_edges_pkey");
entity.ToTable("call_edges", schemaName);
entity.HasIndex(e => e.GraphHash, "idx_call_edges_graph_hash");
entity.HasIndex(e => e.SourceId, "idx_call_edges_source_id");
entity.HasIndex(e => e.TargetId, "idx_call_edges_target_id");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.TargetId).HasColumnName("target_id");
entity.Property(e => e.Type).HasColumnName("type");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
entity.Property(e => e.Candidates).HasColumnType("jsonb").HasColumnName("candidates");
entity.Property(e => e.Confidence).HasColumnName("confidence");
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
entity.Property(e => e.IngestedAt).HasColumnName("ingested_at");
});
// ── cve_func_hits ───────────────────────────────────────────────
modelBuilder.Entity<CveFuncHit>(entity =>
{
entity.HasKey(e => e.Id).HasName("cve_func_hits_pkey");
entity.ToTable("cve_func_hits", schemaName);
entity.HasIndex(e => e.SubjectKey, "idx_cve_func_hits_subject_key");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.SubjectKey).HasColumnName("subject_key");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
entity.Property(e => e.Reachable).HasDefaultValue(false).HasColumnName("reachable");
entity.Property(e => e.Confidence).HasColumnName("confidence");
entity.Property(e => e.LatticeState).HasColumnName("lattice_state");
entity.Property(e => e.EvidenceUris).HasColumnType("jsonb").HasColumnName("evidence_uris");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
});
// ── deploy_refs ─────────────────────────────────────────────────
modelBuilder.Entity<DeployRef>(entity =>
{
entity.HasKey(e => e.Id).HasName("deploy_refs_pkey");
entity.ToTable("deploy_refs", schemaName);
entity.HasAlternateKey(e => new { e.Purl, e.ImageId, e.Environment })
.HasName("uq_deploy_refs_purl_image_env");
entity.HasIndex(e => e.Purl, "idx_deploy_refs_purl");
entity.HasIndex(e => e.LastSeenAt, "idx_deploy_refs_last_seen");
entity.HasIndex(e => e.Environment, "idx_deploy_refs_environment");
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.PurlVersion).HasColumnName("purl_version");
entity.Property(e => e.ImageId).HasColumnName("image_id");
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
entity.Property(e => e.Environment).HasDefaultValueSql("'unknown'").HasColumnName("environment");
entity.Property(e => e.Namespace).HasColumnName("namespace");
entity.Property(e => e.Cluster).HasColumnName("cluster");
entity.Property(e => e.Region).HasColumnName("region");
entity.Property(e => e.FirstSeenAt).HasDefaultValueSql("now()").HasColumnName("first_seen_at");
entity.Property(e => e.LastSeenAt).HasDefaultValueSql("now()").HasColumnName("last_seen_at");
});
// ── graph_metrics ───────────────────────────────────────────────
modelBuilder.Entity<GraphMetric>(entity =>
{
entity.HasKey(e => e.Id).HasName("graph_metrics_pkey");
entity.ToTable("graph_metrics", schemaName);
entity.HasAlternateKey(e => new { e.NodeId, e.CallgraphId })
.HasName("uq_graph_metrics_node_graph");
entity.HasIndex(e => e.NodeId, "idx_graph_metrics_node");
entity.HasIndex(e => e.CallgraphId, "idx_graph_metrics_callgraph");
entity.HasIndex(e => e.BetweennessCentrality, "idx_graph_metrics_betweenness")
.IsDescending(true);
entity.HasIndex(e => e.ComputedAt, "idx_graph_metrics_computed");
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.NodeId).HasColumnName("node_id");
entity.Property(e => e.CallgraphId).HasColumnName("callgraph_id");
entity.Property(e => e.NodeType).HasDefaultValueSql("'symbol'").HasColumnName("node_type");
entity.Property(e => e.DegreeCentrality).HasDefaultValue(0).HasColumnName("degree_centrality");
entity.Property(e => e.InDegree).HasDefaultValue(0).HasColumnName("in_degree");
entity.Property(e => e.OutDegree).HasDefaultValue(0).HasColumnName("out_degree");
entity.Property(e => e.BetweennessCentrality).HasDefaultValue(0.0).HasColumnName("betweenness_centrality");
entity.Property(e => e.ClosenessCentrality).HasColumnName("closeness_centrality");
entity.Property(e => e.EigenvectorCentrality).HasColumnName("eigenvector_centrality");
entity.Property(e => e.NormalizedBetweenness).HasColumnName("normalized_betweenness");
entity.Property(e => e.NormalizedDegree).HasColumnName("normalized_degree");
entity.Property(e => e.ComputedAt).HasDefaultValueSql("now()").HasColumnName("computed_at");
entity.Property(e => e.ComputationDurationMs).HasColumnName("computation_duration_ms");
entity.Property(e => e.AlgorithmVersion).HasDefaultValueSql("'1.0'").HasColumnName("algorithm_version");
entity.Property(e => e.TotalNodes).HasColumnName("total_nodes");
entity.Property(e => e.TotalEdges).HasColumnName("total_edges");
});
// ── scans ───────────────────────────────────────────────────────
modelBuilder.Entity<Scan>(entity =>
{
entity.HasKey(e => e.ScanId).HasName("scans_pkey");
entity.ToTable("scans", schemaName);
entity.HasIndex(e => e.Status, "idx_scans_status");
entity.HasIndex(e => e.ArtifactDigest, "idx_scans_artifact");
entity.HasIndex(e => e.CreatedAt, "idx_scans_created")
.IsDescending(true);
entity.Property(e => e.ScanId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("scan_id");
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
entity.Property(e => e.RepoUri).HasColumnName("repo_uri");
entity.Property(e => e.CommitSha).HasColumnName("commit_sha");
entity.Property(e => e.SbomDigest).HasColumnName("sbom_digest");
entity.Property(e => e.PolicyDigest).HasColumnName("policy_digest");
entity.Property(e => e.Status).HasDefaultValueSql("'pending'").HasColumnName("status");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
});
// ── cg_nodes ────────────────────────────────────────────────────
modelBuilder.Entity<CgNode>(entity =>
{
entity.HasKey(e => e.Id).HasName("cg_nodes_pkey");
entity.ToTable("cg_nodes", schemaName);
entity.HasAlternateKey(e => new { e.ScanId, e.NodeId })
.HasName("cg_nodes_scan_node_unique");
entity.HasIndex(e => e.ScanId, "idx_cg_nodes_scan");
entity.HasIndex(e => e.SymbolKey, "idx_cg_nodes_symbol_key");
entity.Property(e => e.Id)
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.NodeId).HasColumnName("node_id");
entity.Property(e => e.ArtifactKey).HasColumnName("artifact_key");
entity.Property(e => e.SymbolKey).HasColumnName("symbol_key");
entity.Property(e => e.Visibility).HasDefaultValueSql("'unknown'").HasColumnName("visibility");
entity.Property(e => e.IsEntrypointCandidate).HasDefaultValue(false).HasColumnName("is_entrypoint_candidate");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
entity.Property(e => e.Flags).HasDefaultValue(0).HasColumnName("flags");
entity.Property(e => e.Attributes).HasColumnType("jsonb").HasColumnName("attributes");
});
// ── cg_edges ────────────────────────────────────────────────────
modelBuilder.Entity<CgEdge>(entity =>
{
entity.HasKey(e => e.Id).HasName("cg_edges_pkey");
entity.ToTable("cg_edges", schemaName);
entity.HasAlternateKey(e => new { e.ScanId, e.FromNodeId, e.ToNodeId, e.Kind, e.Reason })
.HasName("cg_edges_unique");
entity.HasIndex(e => e.ScanId, "idx_cg_edges_scan");
entity.Property(e => e.Id)
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.FromNodeId).HasColumnName("from_node_id");
entity.Property(e => e.ToNodeId).HasColumnName("to_node_id");
entity.Property(e => e.Kind).HasDefaultValue((short)0).HasColumnName("kind");
entity.Property(e => e.Reason).HasDefaultValue((short)0).HasColumnName("reason");
entity.Property(e => e.Weight).HasDefaultValue(1.0f).HasColumnName("weight");
entity.Property(e => e.OffsetBytes).HasColumnName("offset_bytes");
entity.Property(e => e.IsResolved).HasDefaultValue(true).HasColumnName("is_resolved");
entity.Property(e => e.Provenance).HasColumnName("provenance");
});
// ── entrypoints ─────────────────────────────────────────────────
modelBuilder.Entity<Entrypoint>(entity =>
{
entity.HasKey(e => e.Id).HasName("entrypoints_pkey");
entity.ToTable("entrypoints", schemaName);
entity.HasAlternateKey(e => new { e.ScanId, e.NodeId, e.Kind })
.HasName("entrypoints_scan_node_unique");
entity.HasIndex(e => e.ScanId, "idx_entrypoints_scan");
entity.HasIndex(e => e.Kind, "idx_entrypoints_kind");
entity.Property(e => e.Id)
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.NodeId).HasColumnName("node_id");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.Framework).HasColumnName("framework");
entity.Property(e => e.Route).HasColumnName("route");
entity.Property(e => e.HttpMethod).HasColumnName("http_method");
entity.Property(e => e.Phase).HasDefaultValueSql("'runtime'").HasColumnName("phase");
entity.Property(e => e.OrderIdx).HasDefaultValue(0).HasColumnName("order_idx");
});
// ── symbol_component_map ────────────────────────────────────────
modelBuilder.Entity<SymbolComponentMap>(entity =>
{
entity.HasKey(e => e.Id).HasName("symbol_component_map_pkey");
entity.ToTable("symbol_component_map", schemaName);
entity.HasAlternateKey(e => new { e.ScanId, e.NodeId, e.Purl })
.HasName("symbol_component_map_unique");
entity.HasIndex(e => e.ScanId, "idx_symbol_component_scan");
entity.HasIndex(e => e.Purl, "idx_symbol_component_purl");
entity.Property(e => e.Id)
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.NodeId).HasColumnName("node_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.MappingKind).HasColumnName("mapping_kind");
entity.Property(e => e.Confidence).HasDefaultValue(1.0f).HasColumnName("confidence");
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
});
// ── reachability_components ─────────────────────────────────────
modelBuilder.Entity<ReachabilityComponent>(entity =>
{
entity.HasKey(e => e.Id).HasName("reachability_components_pkey");
entity.ToTable("reachability_components", schemaName);
entity.HasAlternateKey(e => new { e.ScanId, e.Purl })
.HasName("reachability_components_unique");
entity.HasIndex(e => e.ScanId, "idx_reachability_components_scan");
entity.HasIndex(e => e.Purl, "idx_reachability_components_purl");
entity.HasIndex(e => e.Status, "idx_reachability_components_status");
entity.Property(e => e.Id)
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.Status).HasDefaultValue((short)0).HasColumnName("status");
entity.Property(e => e.LatticeState).HasColumnName("lattice_state");
entity.Property(e => e.Confidence).HasDefaultValue(0f).HasColumnName("confidence");
entity.Property(e => e.Why).HasColumnType("jsonb").HasColumnName("why");
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
entity.Property(e => e.ComputedAt).HasDefaultValueSql("now()").HasColumnName("computed_at");
});
// ── reachability_findings ───────────────────────────────────────
modelBuilder.Entity<ReachabilityFinding>(entity =>
{
entity.HasKey(e => e.Id).HasName("reachability_findings_pkey");
entity.ToTable("reachability_findings", schemaName);
entity.HasAlternateKey(e => new { e.ScanId, e.CveId, e.Purl })
.HasName("reachability_findings_unique");
entity.HasIndex(e => e.ScanId, "idx_reachability_findings_scan");
entity.HasIndex(e => e.CveId, "idx_reachability_findings_cve");
entity.HasIndex(e => e.Purl, "idx_reachability_findings_purl");
entity.HasIndex(e => e.Status, "idx_reachability_findings_status");
entity.Property(e => e.Id)
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.Status).HasDefaultValue((short)0).HasColumnName("status");
entity.Property(e => e.LatticeState).HasColumnName("lattice_state");
entity.Property(e => e.Confidence).HasDefaultValue(0f).HasColumnName("confidence");
entity.Property(e => e.PathWitness).HasColumnName("path_witness");
entity.Property(e => e.Why).HasColumnType("jsonb").HasColumnName("why");
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
entity.Property(e => e.SpineId).HasColumnName("spine_id");
entity.Property(e => e.ComputedAt).HasDefaultValueSql("now()").HasColumnName("computed_at");
});
// ── runtime_agents ──────────────────────────────────────────────
modelBuilder.Entity<SignalsRuntimeAgent>(entity =>
{
entity.HasKey(e => e.AgentId).HasName("runtime_agents_pkey");
entity.ToTable("runtime_agents", schemaName);
entity.HasIndex(e => e.TenantId, "idx_runtime_agents_tenant");
entity.HasIndex(e => e.ArtifactDigest, "idx_runtime_agents_artifact");
entity.HasIndex(e => e.LastHeartbeatAt, "idx_runtime_agents_heartbeat");
entity.HasIndex(e => e.State, "idx_runtime_agents_state");
entity.Property(e => e.AgentId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("agent_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
entity.Property(e => e.Platform).HasColumnName("platform");
entity.Property(e => e.Posture).HasDefaultValueSql("'sampled'").HasColumnName("posture");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.RegisteredAt).HasDefaultValueSql("now()").HasColumnName("registered_at");
entity.Property(e => e.LastHeartbeatAt).HasColumnName("last_heartbeat_at");
entity.Property(e => e.State).HasDefaultValueSql("'registered'").HasColumnName("state");
entity.Property(e => e.Statistics).HasColumnType("jsonb").HasColumnName("statistics");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.Hostname).HasColumnName("hostname");
entity.Property(e => e.ContainerId).HasColumnName("container_id");
entity.Property(e => e.PodName).HasColumnName("pod_name");
entity.Property(e => e.Namespace).HasColumnName("namespace");
});
// ── runtime_facts ───────────────────────────────────────────────
modelBuilder.Entity<RuntimeFact>(entity =>
{
entity.HasKey(e => e.Id).HasName("runtime_facts_pkey");
entity.ToTable("runtime_facts", schemaName);
entity.HasAlternateKey(e => new { e.TenantId, e.ArtifactDigest, e.CanonicalSymbolId })
.HasName("runtime_facts_unique");
entity.HasIndex(e => e.TenantId, "idx_runtime_facts_tenant");
entity.HasIndex(e => e.CanonicalSymbolId, "idx_runtime_facts_symbol");
entity.HasIndex(e => e.LastSeen, "idx_runtime_facts_last_seen")
.IsDescending(true);
entity.HasIndex(e => e.HitCount, "idx_runtime_facts_hit_count")
.IsDescending(true);
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
entity.Property(e => e.CanonicalSymbolId).HasColumnName("canonical_symbol_id");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.HitCount).HasDefaultValue(0L).HasColumnName("hit_count");
entity.Property(e => e.FirstSeen).HasColumnName("first_seen");
entity.Property(e => e.LastSeen).HasColumnName("last_seen");
entity.Property(e => e.Contexts)
.HasDefaultValueSql("'[]'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("contexts");
entity.Property(e => e.AgentIds).HasColumnName("agent_ids");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Signals.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for <see cref="SignalsDbContext"/>.
/// Used by <c>dotnet ef</c> CLI tooling (scaffold, optimize).
/// </summary>
public sealed class SignalsDesignTimeDbContextFactory : IDesignTimeDbContextFactory<SignalsDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=signals,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_SIGNALS_EF_CONNECTION";
public SignalsDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<SignalsDbContext>()
.UseNpgsql(connectionString)
.Options;
return new SignalsDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.call_edges table.
/// </summary>
public partial class CallEdge
{
public string Id { get; set; } = null!;
public string GraphHash { get; set; } = null!;
public string SourceId { get; set; } = null!;
public string TargetId { get; set; } = null!;
public string Type { get; set; } = null!;
public string? Purl { get; set; }
public string? SymbolDigest { get; set; }
public string? Candidates { get; set; }
public double? Confidence { get; set; }
public string? Evidence { get; set; }
public DateTimeOffset IngestedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.callgraphs table.
/// </summary>
public partial class Callgraph
{
public string Id { get; set; } = null!;
public string Language { get; set; } = null!;
public string Component { get; set; } = null!;
public string Version { get; set; } = null!;
public string GraphHash { get; set; } = null!;
public DateTimeOffset IngestedAt { get; set; }
public string DocumentJson { get; set; } = null!;
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.cg_edges table.
/// </summary>
public partial class CgEdge
{
public long Id { get; set; }
public Guid ScanId { get; set; }
public string FromNodeId { get; set; } = null!;
public string ToNodeId { get; set; } = null!;
public short Kind { get; set; }
public short Reason { get; set; }
public float Weight { get; set; }
public int? OffsetBytes { get; set; }
public bool IsResolved { get; set; }
public string? Provenance { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.cg_nodes table.
/// </summary>
public partial class CgNode
{
public long Id { get; set; }
public Guid ScanId { get; set; }
public string NodeId { get; set; } = null!;
public string? ArtifactKey { get; set; }
public string SymbolKey { get; set; } = null!;
public string Visibility { get; set; } = null!;
public bool IsEntrypointCandidate { get; set; }
public string? Purl { get; set; }
public string? SymbolDigest { get; set; }
public int Flags { get; set; }
public string? Attributes { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.cve_func_hits table.
/// </summary>
public partial class CveFuncHit
{
public string Id { get; set; } = null!;
public string SubjectKey { get; set; } = null!;
public string CveId { get; set; } = null!;
public string GraphHash { get; set; } = null!;
public string? Purl { get; set; }
public string? SymbolDigest { get; set; }
public bool Reachable { get; set; }
public double? Confidence { get; set; }
public string? LatticeState { get; set; }
public string? EvidenceUris { get; set; }
public DateTimeOffset ComputedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.deploy_refs table.
/// </summary>
public partial class DeployRef
{
public Guid Id { get; set; }
public string Purl { get; set; } = null!;
public string? PurlVersion { get; set; }
public string ImageId { get; set; } = null!;
public string? ImageDigest { get; set; }
public string Environment { get; set; } = null!;
public string? Namespace { get; set; }
public string? Cluster { get; set; }
public string? Region { get; set; }
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.entrypoints table.
/// </summary>
public partial class Entrypoint
{
public long Id { get; set; }
public Guid ScanId { get; set; }
public string NodeId { get; set; } = null!;
public string Kind { get; set; } = null!;
public string? Framework { get; set; }
public string? Route { get; set; }
public string? HttpMethod { get; set; }
public string Phase { get; set; } = null!;
public int OrderIdx { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.func_nodes table.
/// </summary>
public partial class FuncNode
{
public string Id { get; set; } = null!;
public string GraphHash { get; set; } = null!;
public string SymbolId { get; set; } = null!;
public string Name { get; set; } = null!;
public string Kind { get; set; } = null!;
public string? Namespace { get; set; }
public string? File { get; set; }
public int? Line { get; set; }
public string? Purl { get; set; }
public string? SymbolDigest { get; set; }
public string? BuildId { get; set; }
public string? CodeId { get; set; }
public string? Language { get; set; }
public string? Evidence { get; set; }
public string? Analyzer { get; set; }
public DateTimeOffset IngestedAt { get; set; }
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.graph_metrics table.
/// </summary>
public partial class GraphMetric
{
public Guid Id { get; set; }
public string NodeId { get; set; } = null!;
public string CallgraphId { get; set; } = null!;
public string NodeType { get; set; } = null!;
public int DegreeCentrality { get; set; }
public int InDegree { get; set; }
public int OutDegree { get; set; }
public double BetweennessCentrality { get; set; }
public double? ClosenessCentrality { get; set; }
public double? EigenvectorCentrality { get; set; }
public double? NormalizedBetweenness { get; set; }
public double? NormalizedDegree { get; set; }
public DateTimeOffset ComputedAt { get; set; }
public int? ComputationDurationMs { get; set; }
public string AlgorithmVersion { get; set; } = null!;
public int? TotalNodes { get; set; }
public int? TotalEdges { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.reachability_components table.
/// </summary>
public partial class ReachabilityComponent
{
public long Id { get; set; }
public Guid ScanId { get; set; }
public string Purl { get; set; } = null!;
public short Status { get; set; }
public string? LatticeState { get; set; }
public float Confidence { get; set; }
public string? Why { get; set; }
public string? Evidence { get; set; }
public DateTimeOffset ComputedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.reachability_facts table.
/// </summary>
public partial class ReachabilityFact
{
public string SubjectKey { get; set; } = null!;
public string Id { get; set; } = null!;
public string CallgraphId { get; set; } = null!;
public double Score { get; set; }
public double RiskScore { get; set; }
public DateTimeOffset ComputedAt { get; set; }
public string DocumentJson { get; set; } = null!;
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.reachability_findings table.
/// </summary>
public partial class ReachabilityFinding
{
public long Id { get; set; }
public Guid ScanId { get; set; }
public string CveId { get; set; } = null!;
public string Purl { get; set; } = null!;
public short Status { get; set; }
public string? LatticeState { get; set; }
public float Confidence { get; set; }
public string[]? PathWitness { get; set; }
public string? Why { get; set; }
public string? Evidence { get; set; }
public Guid? SpineId { get; set; }
public DateTimeOffset ComputedAt { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.runtime_agents table.
/// </summary>
public partial class SignalsRuntimeAgent
{
public Guid AgentId { get; set; }
public Guid TenantId { get; set; }
public string ArtifactDigest { get; set; } = null!;
public string Platform { get; set; } = null!;
public string Posture { get; set; } = null!;
public string? Metadata { get; set; }
public DateTimeOffset RegisteredAt { get; set; }
public DateTimeOffset? LastHeartbeatAt { get; set; }
public string State { get; set; } = null!;
public string? Statistics { get; set; }
public string? Version { get; set; }
public string? Hostname { get; set; }
public string? ContainerId { get; set; }
public string? PodName { get; set; }
public string? Namespace { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.runtime_facts table.
/// </summary>
public partial class RuntimeFact
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string ArtifactDigest { get; set; } = null!;
public string CanonicalSymbolId { get; set; } = null!;
public string DisplayName { get; set; } = null!;
public long HitCount { get; set; }
public DateTimeOffset FirstSeen { get; set; }
public DateTimeOffset LastSeen { get; set; }
public string Contexts { get; set; } = null!;
public Guid[] AgentIds { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.scans table.
/// </summary>
public partial class Scan
{
public Guid ScanId { get; set; }
public string ArtifactDigest { get; set; } = null!;
public string? RepoUri { get; set; }
public string? CommitSha { get; set; }
public string? SbomDigest { get; set; }
public string? PolicyDigest { get; set; }
public string Status { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.symbol_component_map table.
/// </summary>
public partial class SymbolComponentMap
{
public long Id { get; set; }
public Guid ScanId { get; set; }
public string NodeId { get; set; } = null!;
public string Purl { get; set; } = null!;
public string MappingKind { get; set; } = null!;
public float Confidence { get; set; }
public string? Evidence { get; set; }
}

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Signals.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for signals.unknowns table.
/// </summary>
public partial class Unknown
{
public string Id { get; set; } = null!;
public string SubjectKey { get; set; } = null!;
public string? CallgraphId { get; set; }
public string? SymbolId { get; set; }
public string? CodeId { get; set; }
public string? Purl { get; set; }
public string? PurlVersion { get; set; }
public string? EdgeFrom { get; set; }
public string? EdgeTo { get; set; }
public string? Reason { get; set; }
// Scoring factors (range: 0.0 - 1.0)
public double PopularityP { get; set; }
public int DeploymentCount { get; set; }
public double ExploitPotentialE { get; set; }
public double UncertaintyU { get; set; }
public double CentralityC { get; set; }
public int DegreeCentrality { get; set; }
public double BetweennessCentrality { get; set; }
public double StalenessS { get; set; }
public int DaysSinceAnalysis { get; set; }
// Composite score and band
public double Score { get; set; }
public string Band { get; set; } = null!;
// JSONB columns
public string UnknownFlags { get; set; } = null!;
public string? NormalizationTrace { get; set; }
// Rescan scheduling
public int RescanAttempts { get; set; }
public string? LastRescanResult { get; set; }
public DateTimeOffset? NextScheduledRescan { get; set; }
public DateTimeOffset? LastAnalyzedAt { get; set; }
// Graph slice reference
public byte[]? GraphSliceHash { get; set; }
public byte[]? EvidenceSetHash { get; set; }
public byte[]? CallgraphAttemptHash { get; set; }
// Timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}

View File

@@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.EfCore.Context;
using StellaOps.Signals.Persistence.Postgres;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -18,6 +21,8 @@ namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="ICallGraphProjectionRepository"/>.
/// Projects callgraph documents into relational tables for efficient querying.
/// Uses EF Core for simple operations; retains raw SQL for batch upserts and
/// parameterized multi-row inserts.
/// </summary>
public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<SignalsDataSource>, ICallGraphProjectionRepository
{
@@ -45,6 +50,7 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
string? commitSha = null,
CancellationToken cancellationToken = default)
{
// Keep raw SQL for RETURNING (xmax = 0) which EF cannot express
const string sql = """
INSERT INTO signals.scans (scan_id, artifact_digest, sbom_digest, repo_uri, commit_sha, status, created_at)
VALUES (@scan_id, @artifact_digest, @sbom_digest, @repo_uri, @commit_sha, 'processing', NOW())
@@ -74,34 +80,34 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
/// <inheritdoc />
public async Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
const string sql = """
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE signals.scans
SET status = 'completed', completed_at = NOW()
WHERE scan_id = @scan_id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@scan_id", scanId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
WHERE scan_id = {0}
""",
scanId,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE signals.scans
SET status = 'failed', error_message = @error_message, completed_at = NOW()
WHERE scan_id = @scan_id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@scan_id", scanId);
AddParameter(command, "@error_message", errorMessage);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE signals.scans
SET status = 'failed', error_message = {1}, completed_at = NOW()
WHERE scan_id = {0}
""",
scanId,
errorMessage,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -125,7 +131,7 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
try
{
// Process in batches
// Process in batches - keep raw SQL for parameterized multi-row VALUES inserts
for (var i = 0; i < sortedNodes.Count; i += BatchSize)
{
var batch = sortedNodes.Skip(i).Take(BatchSize).ToList();
@@ -222,7 +228,7 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
try
{
// Process in batches
// Process in batches - keep raw SQL for parameterized multi-row VALUES inserts
for (var i = 0; i < sortedEdges.Count; i += BatchSize)
{
var batch = sortedEdges.Skip(i).Take(BatchSize).ToList();
@@ -353,14 +359,14 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
/// <inheritdoc />
public async Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
// Delete from scans cascades to all related tables via FK
const string sql = "DELETE FROM signals.scans WHERE scan_id = @scan_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@scan_id", scanId);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Delete from scans cascades to all related tables via FK
await dbContext.Scans
.Where(e => e.ScanId == scanId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
// ===== HELPER METHODS =====
@@ -464,4 +470,6 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
_ => "runtime"
};
}
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
}

View File

@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Persistence.EfCore.Context;
using StellaOps.Signals.Persistence.Postgres;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -12,6 +14,8 @@ namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// Repository for querying call graph data across scans.
/// Optimized for analytics and cross-artifact queries.
/// Uses EF Core DbContext for connection management; retains raw SQL for
/// complex CTEs and recursive queries that cannot be expressed in LINQ.
/// </summary>
public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDataSource>, ICallGraphQueryRepository
{
@@ -24,6 +28,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
/// <summary>
/// Finds all paths to a CVE across all scans.
/// Retains raw SQL: multi-CTE with JOINs across four tables cannot be expressed in LINQ.
/// </summary>
public async Task<IReadOnlyList<CvePath>> FindPathsToCveAsync(
string cveId,
@@ -83,6 +88,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
/// <summary>
/// Gets symbols reachable from an entrypoint.
/// Retains raw SQL: recursive CTE with cycle detection cannot be expressed in LINQ.
/// </summary>
public async Task<IReadOnlyList<string>> GetReachableSymbolsAsync(
Guid scanId,
@@ -137,6 +143,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
/// <summary>
/// Gets graph statistics for a scan.
/// Retains raw SQL: multiple sub-selects across different tables with conditional counts.
/// </summary>
public async Task<CallGraphStats> GetStatsAsync(
Guid scanId,
@@ -175,6 +182,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
/// <summary>
/// Finds common paths between two symbols.
/// Retains raw SQL: recursive CTE with cycle detection and path accumulation.
/// </summary>
public async Task<IReadOnlyList<string[]>> FindPathsBetweenAsync(
Guid scanId,
@@ -238,6 +246,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
/// <summary>
/// Searches nodes by symbol key pattern.
/// Retains raw SQL: ILIKE pattern matching with correlated sub-queries for edge counts.
/// </summary>
public async Task<IReadOnlyList<CallGraphNodeSummary>> SearchNodesAsync(
Guid scanId,

View File

@@ -1,15 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.EfCore.Context;
using System.Text.Json;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="ICallgraphRepository"/>.
/// Uses EF Core for persistence operations.
/// </summary>
public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSource>, ICallgraphRepository
{
@@ -37,9 +40,16 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for UPSERT with ON CONFLICT since the callgraphs table uses a single-column PK upsert
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO signals.callgraphs (id, language, component, version, graph_hash, ingested_at, document_json)
VALUES (@id, @language, @component, @version, @graph_hash, @ingested_at, @document_json)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}::jsonb)
ON CONFLICT (id)
DO UPDATE SET
language = EXCLUDED.language,
@@ -48,22 +58,15 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
graph_hash = EXCLUDED.graph_hash,
ingested_at = EXCLUDED.ingested_at,
document_json = EXCLUDED.document_json
RETURNING id";
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", document.Id);
AddParameter(command, "@language", document.Language ?? string.Empty);
AddParameter(command, "@component", document.Component ?? string.Empty);
AddParameter(command, "@version", document.Version ?? string.Empty);
AddParameter(command, "@graph_hash", document.GraphHash ?? string.Empty);
AddParameter(command, "@ingested_at", document.IngestedAt);
AddJsonbParameter(command, "@document_json", documentJson);
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
""",
document.Id,
document.Language ?? string.Empty,
document.Component ?? string.Empty,
document.Version ?? string.Empty,
document.GraphHash ?? string.Empty,
document.IngestedAt,
documentJson,
cancellationToken).ConfigureAwait(false);
return document;
}
@@ -77,23 +80,21 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT document_json
FROM signals.callgraphs
WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", id.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
var entity = await dbContext.Callgraphs
.AsNoTracking()
.Where(e => e.Id == id.Trim())
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (entity is null)
{
return null;
}
var documentJson = reader.GetString(0);
return JsonSerializer.Deserialize<CallgraphDocument>(documentJson, JsonOptions);
return JsonSerializer.Deserialize<CallgraphDocument>(entity.DocumentJson, JsonOptions);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
@@ -126,4 +127,6 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
_tableInitialized = true;
}
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
}

View File

@@ -1,12 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.EfCore.Context;
using StellaOps.Signals.Persistence.Postgres;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IDeploymentRefsRepository"/>.
/// Tracks package deployments for popularity scoring (P factor).
/// Uses EF Core for read operations; preserves raw SQL for UPSERT patterns.
/// </summary>
public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDataSource>, IDeploymentRefsRepository
{
@@ -24,6 +29,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
// Keep raw SQL for COUNT(DISTINCT ...) with date-interval filter that EF doesn't translate cleanly
const string sql = @"
SELECT COUNT(DISTINCT image_id)
FROM signals.deploy_refs
@@ -45,6 +51,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
// Keep raw SQL for DISTINCT with date-interval filter
const string sql = @"
SELECT DISTINCT image_id
FROM signals.deploy_refs
@@ -75,14 +82,19 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Keep raw SQL for UPSERT with ON CONFLICT and COALESCE/NOW() expressions
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO signals.deploy_refs (
purl, purl_version, image_id, image_digest,
environment, namespace, cluster, region,
first_seen_at, last_seen_at
) VALUES (
@purl, @purl_version, @image_id, @image_digest,
@environment, @namespace, @cluster, @region,
{0}, {1}, {2}, {3},
{4}, {5}, {6}, {7},
NOW(), NOW()
)
ON CONFLICT (purl, image_id, environment)
@@ -92,21 +104,17 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
namespace = COALESCE(EXCLUDED.namespace, signals.deploy_refs.namespace),
cluster = COALESCE(EXCLUDED.cluster, signals.deploy_refs.cluster),
region = COALESCE(EXCLUDED.region, signals.deploy_refs.region),
last_seen_at = NOW()";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@purl", deployment.Purl.Trim());
AddParameter(command, "@purl_version", (object?)deployment.PurlVersion ?? DBNull.Value);
AddParameter(command, "@image_id", deployment.ImageId.Trim());
AddParameter(command, "@image_digest", (object?)deployment.ImageDigest ?? DBNull.Value);
AddParameter(command, "@environment", deployment.Environment.Trim());
AddParameter(command, "@namespace", (object?)deployment.Namespace ?? DBNull.Value);
AddParameter(command, "@cluster", (object?)deployment.Cluster ?? DBNull.Value);
AddParameter(command, "@region", (object?)deployment.Region ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
last_seen_at = NOW()
""",
deployment.Purl.Trim(),
(object?)deployment.PurlVersion ?? DBNull.Value,
deployment.ImageId.Trim(),
(object?)deployment.ImageDigest ?? DBNull.Value,
deployment.Environment.Trim(),
(object?)deployment.Namespace ?? DBNull.Value,
(object?)deployment.Cluster ?? DBNull.Value,
(object?)deployment.Region ?? DBNull.Value,
cancellationToken).ConfigureAwait(false);
}
public async Task BulkUpsertAsync(IEnumerable<DeploymentRef> deployments, CancellationToken cancellationToken = default)
@@ -144,7 +152,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
if (deployment is null)
continue;
await using var command = CreateCommand(sql, connection, transaction);
await using var command = new NpgsqlCommand(sql, connection, transaction);
AddParameter(command, "@purl", deployment.Purl.Trim());
AddParameter(command, "@purl_version", (object?)deployment.PurlVersion ?? DBNull.Value);
@@ -174,6 +182,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
// Keep raw SQL for aggregate functions with date-interval filter and GROUP BY
const string sql = @"
SELECT
purl,
@@ -207,11 +216,6 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
};
}
private static Npgsql.NpgsqlCommand CreateCommand(string sql, Npgsql.NpgsqlConnection connection, Npgsql.NpgsqlTransaction transaction)
{
return new Npgsql.NpgsqlCommand(sql, connection, transaction);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
@@ -246,4 +250,6 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
_tableInitialized = true;
}
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
}

View File

@@ -1,12 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.EfCore.Context;
using StellaOps.Signals.Persistence.Postgres;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IGraphMetricsRepository"/>.
/// Stores computed centrality metrics for call graph nodes (C factor).
/// Uses EF Core for read operations; preserves raw SQL for UPSERT patterns.
/// </summary>
public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataSource>, IGraphMetricsRepository
{
@@ -27,28 +32,22 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT
node_id, callgraph_id, node_type,
degree_centrality, in_degree, out_degree,
betweenness_centrality, closeness_centrality,
normalized_betweenness, normalized_degree,
computed_at, computation_duration_ms, algorithm_version,
total_nodes, total_edges
FROM signals.graph_metrics
WHERE node_id = @node_id AND callgraph_id = @callgraph_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@node_id", symbolId.Trim());
AddParameter(command, "@callgraph_id", callgraphId.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var normalizedNodeId = symbolId.Trim();
var normalizedCallgraphId = callgraphId.Trim();
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
var entity = await dbContext.GraphMetrics
.AsNoTracking()
.Where(e => e.NodeId == normalizedNodeId && e.CallgraphId == normalizedCallgraphId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (entity is null)
return null;
return MapMetrics(reader);
return MapEntityToModel(entity);
}
public async Task UpsertAsync(GraphMetrics metrics, CancellationToken cancellationToken = default)
@@ -57,7 +56,12 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Keep raw SQL for UPSERT with ON CONFLICT on composite unique constraint
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO signals.graph_metrics (
node_id, callgraph_id, node_type,
degree_centrality, in_degree, out_degree,
@@ -66,12 +70,12 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
computed_at, computation_duration_ms, algorithm_version,
total_nodes, total_edges
) VALUES (
@node_id, @callgraph_id, @node_type,
@degree_centrality, @in_degree, @out_degree,
@betweenness_centrality, @closeness_centrality,
@normalized_betweenness, @normalized_degree,
@computed_at, @computation_duration_ms, @algorithm_version,
@total_nodes, @total_edges
{0}, {1}, {2},
{3}, {4}, {5},
{6}, {7},
{8}, {9},
{10}, {11}, {12},
{13}, {14}
)
ON CONFLICT (node_id, callgraph_id)
DO UPDATE SET
@@ -87,14 +91,24 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
computation_duration_ms = EXCLUDED.computation_duration_ms,
algorithm_version = EXCLUDED.algorithm_version,
total_nodes = EXCLUDED.total_nodes,
total_edges = EXCLUDED.total_edges";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddMetricsParameters(command, metrics);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
total_edges = EXCLUDED.total_edges
""",
metrics.NodeId.Trim(),
metrics.CallgraphId.Trim(),
metrics.NodeType,
metrics.Degree,
metrics.InDegree,
metrics.OutDegree,
metrics.Betweenness,
(object?)metrics.Closeness ?? DBNull.Value,
(object?)metrics.NormalizedBetweenness ?? DBNull.Value,
(object?)metrics.NormalizedDegree ?? DBNull.Value,
metrics.ComputedAt == default ? DateTimeOffset.UtcNow : metrics.ComputedAt,
(object?)metrics.ComputationDurationMs ?? DBNull.Value,
metrics.AlgorithmVersion,
(object?)metrics.TotalNodes ?? DBNull.Value,
(object?)metrics.TotalEdges ?? DBNull.Value,
cancellationToken).ConfigureAwait(false);
}
public async Task BulkUpsertAsync(IEnumerable<GraphMetrics> metrics, CancellationToken cancellationToken = default)
@@ -145,7 +159,7 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
if (m is null)
continue;
await using var command = CreateCommand(sql, connection, transaction);
await using var command = new NpgsqlCommand(sql, connection, transaction);
AddMetricsParameters(command, m);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
@@ -166,29 +180,20 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT DISTINCT callgraph_id
FROM signals.graph_metrics
WHERE computed_at < @cutoff
ORDER BY callgraph_id
LIMIT @limit";
var cutoff = DateTimeOffset.UtcNow - maxAge;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@cutoff", cutoff);
AddParameter(command, "@limit", limit);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<string>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(reader.GetString(0));
}
return results;
return await dbContext.GraphMetrics
.AsNoTracking()
.Where(e => e.ComputedAt < cutoff)
.Select(e => e.CallgraphId)
.Distinct()
.OrderBy(id => id)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task DeleteByCallgraphAsync(string callgraphId, CancellationToken cancellationToken = default)
@@ -198,16 +203,18 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM signals.graph_metrics WHERE callgraph_id = @callgraph_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@callgraph_id", callgraphId.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
var normalizedId = callgraphId.Trim();
await dbContext.GraphMetrics
.Where(e => e.CallgraphId == normalizedId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
private void AddMetricsParameters(Npgsql.NpgsqlCommand command, GraphMetrics metrics)
private void AddMetricsParameters(NpgsqlCommand command, GraphMetrics metrics)
{
AddParameter(command, "@node_id", metrics.NodeId.Trim());
AddParameter(command, "@callgraph_id", metrics.CallgraphId.Trim());
@@ -226,30 +233,25 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
AddParameter(command, "@total_edges", metrics.TotalEdges.HasValue ? metrics.TotalEdges.Value : DBNull.Value);
}
private static Npgsql.NpgsqlCommand CreateCommand(string sql, Npgsql.NpgsqlConnection connection, Npgsql.NpgsqlTransaction transaction)
{
return new Npgsql.NpgsqlCommand(sql, connection, transaction);
}
private static GraphMetrics MapMetrics(Npgsql.NpgsqlDataReader reader)
private static GraphMetrics MapEntityToModel(EfCore.Models.GraphMetric entity)
{
return new GraphMetrics
{
NodeId = reader.GetString(0),
CallgraphId = reader.GetString(1),
NodeType = reader.IsDBNull(2) ? "symbol" : reader.GetString(2),
Degree = reader.IsDBNull(3) ? 0 : reader.GetInt32(3),
InDegree = reader.IsDBNull(4) ? 0 : reader.GetInt32(4),
OutDegree = reader.IsDBNull(5) ? 0 : reader.GetInt32(5),
Betweenness = reader.IsDBNull(6) ? 0.0 : reader.GetDouble(6),
Closeness = reader.IsDBNull(7) ? null : reader.GetDouble(7),
NormalizedBetweenness = reader.IsDBNull(8) ? null : reader.GetDouble(8),
NormalizedDegree = reader.IsDBNull(9) ? null : reader.GetDouble(9),
ComputedAt = reader.IsDBNull(10) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(10),
ComputationDurationMs = reader.IsDBNull(11) ? null : reader.GetInt32(11),
AlgorithmVersion = reader.IsDBNull(12) ? "1.0" : reader.GetString(12),
TotalNodes = reader.IsDBNull(13) ? null : reader.GetInt32(13),
TotalEdges = reader.IsDBNull(14) ? null : reader.GetInt32(14)
NodeId = entity.NodeId,
CallgraphId = entity.CallgraphId,
NodeType = entity.NodeType ?? "symbol",
Degree = entity.DegreeCentrality,
InDegree = entity.InDegree,
OutDegree = entity.OutDegree,
Betweenness = entity.BetweennessCentrality,
Closeness = entity.ClosenessCentrality,
NormalizedBetweenness = entity.NormalizedBetweenness,
NormalizedDegree = entity.NormalizedDegree,
ComputedAt = entity.ComputedAt,
ComputationDurationMs = entity.ComputationDurationMs,
AlgorithmVersion = entity.AlgorithmVersion ?? "1.0",
TotalNodes = entity.TotalNodes,
TotalEdges = entity.TotalEdges
};
}
@@ -293,4 +295,6 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
_tableInitialized = true;
}
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
}

View File

@@ -1,15 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.EfCore.Context;
using System.Text.Json;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IReachabilityFactRepository"/>.
/// Uses EF Core for persistence operations.
/// </summary>
public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsDataSource>, IReachabilityFactRepository
{
@@ -37,9 +40,15 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO signals.reachability_facts (subject_key, id, callgraph_id, score, risk_score, computed_at, document_json)
VALUES (@subject_key, @id, @callgraph_id, @score, @risk_score, @computed_at, @document_json)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}::jsonb)
ON CONFLICT (subject_key)
DO UPDATE SET
id = EXCLUDED.id,
@@ -48,22 +57,15 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
risk_score = EXCLUDED.risk_score,
computed_at = EXCLUDED.computed_at,
document_json = EXCLUDED.document_json
RETURNING subject_key";
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@subject_key", document.SubjectKey);
AddParameter(command, "@id", document.Id);
AddParameter(command, "@callgraph_id", document.CallgraphId ?? string.Empty);
AddParameter(command, "@score", document.Score);
AddParameter(command, "@risk_score", document.RiskScore);
AddParameter(command, "@computed_at", document.ComputedAt);
AddJsonbParameter(command, "@document_json", documentJson);
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
""",
document.SubjectKey,
document.Id,
document.CallgraphId ?? string.Empty,
document.Score,
document.RiskScore,
document.ComputedAt,
documentJson,
cancellationToken).ConfigureAwait(false);
return document;
}
@@ -77,48 +79,42 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT document_json
FROM signals.reachability_facts
WHERE subject_key = @subject_key";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@subject_key", subjectKey.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
var entity = await dbContext.ReachabilityFacts
.AsNoTracking()
.Where(e => e.SubjectKey == subjectKey.Trim())
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (entity is null)
{
return null;
}
var documentJson = reader.GetString(0);
return JsonSerializer.Deserialize<ReachabilityFactDocument>(documentJson, JsonOptions);
return JsonSerializer.Deserialize<ReachabilityFactDocument>(entity.DocumentJson, JsonOptions);
}
public async Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT document_json
FROM signals.reachability_facts
WHERE computed_at < @cutoff
ORDER BY computed_at ASC
LIMIT @limit";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@cutoff", cutoff);
AddParameter(command, "@limit", limit);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var entities = await dbContext.ReachabilityFacts
.AsNoTracking()
.Where(e => e.ComputedAt < cutoff)
.OrderBy(e => e.ComputedAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var results = new List<ReachabilityFactDocument>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
foreach (var entity in entities)
{
var documentJson = reader.GetString(0);
var document = JsonSerializer.Deserialize<ReachabilityFactDocument>(documentJson, JsonOptions);
var document = JsonSerializer.Deserialize<ReachabilityFactDocument>(entity.DocumentJson, JsonOptions);
if (document is not null)
{
results.Add(document);
@@ -137,15 +133,14 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
DELETE FROM signals.reachability_facts
WHERE subject_key = @subject_key";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@subject_key", subjectKey.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.ReachabilityFacts
.Where(e => e.SubjectKey == subjectKey.Trim())
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -158,6 +153,7 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
// Keep raw SQL for JSONB path extraction which EF doesn't translate well
const string sql = @"
SELECT COALESCE(jsonb_array_length(document_json->'runtimeFacts'), 0)
FROM signals.reachability_facts
@@ -232,4 +228,6 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
_tableInitialized = true;
}
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
}

View File

@@ -1,16 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Models;
using StellaOps.Signals.Models.ReachabilityStore;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.EfCore.Context;
using StellaOps.Signals.Persistence.Postgres;
using System.Text.Json;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IReachabilityStoreRepository"/>.
/// Uses EF Core for read operations; preserves raw SQL for complex UPSERT patterns.
/// </summary>
public sealed class PostgresReachabilityStoreRepository : RepositoryBase<SignalsDataSource>, IReachabilityStoreRepository
{
@@ -52,7 +56,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
try
{
// Upsert func nodes
// Upsert func nodes - keep raw SQL for complex ON CONFLICT with many columns
foreach (var node in nodes)
{
var symbolId = node.Id?.Trim() ?? string.Empty;
@@ -76,7 +80,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
analyzer = EXCLUDED.analyzer,
ingested_at = EXCLUDED.ingested_at";
await using var nodeCommand = CreateCommand(nodeSql, connection, transaction);
await using var nodeCommand = new NpgsqlCommand(nodeSql, connection, transaction);
AddParameter(nodeCommand, "@id", id);
AddParameter(nodeCommand, "@graph_hash", normalizedGraphHash);
AddParameter(nodeCommand, "@symbol_id", symbolId);
@@ -97,7 +101,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
await nodeCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Upsert call edges
// Upsert call edges - keep raw SQL for complex ON CONFLICT
foreach (var edge in edges)
{
var sourceId = edge.SourceId?.Trim() ?? string.Empty;
@@ -116,7 +120,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
evidence = EXCLUDED.evidence,
ingested_at = EXCLUDED.ingested_at";
await using var edgeCommand = CreateCommand(edgeSql, connection, transaction);
await using var edgeCommand = new NpgsqlCommand(edgeSql, connection, transaction);
AddParameter(edgeCommand, "@id", id);
AddParameter(edgeCommand, "@graph_hash", normalizedGraphHash);
AddParameter(edgeCommand, "@source_id", sourceId);
@@ -150,25 +154,39 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT id, graph_hash, symbol_id, name, kind, namespace, file, line, purl, symbol_digest, build_id, code_id, language, evidence, analyzer, ingested_at
FROM signals.func_nodes
WHERE graph_hash = @graph_hash
ORDER BY symbol_id, purl, symbol_digest";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@graph_hash", graphHash.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var normalizedHash = graphHash.Trim();
var results = new List<FuncNodeDocument>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
var entities = await dbContext.FuncNodes
.AsNoTracking()
.Where(e => e.GraphHash == normalizedHash)
.OrderBy(e => e.SymbolId)
.ThenBy(e => e.Purl)
.ThenBy(e => e.SymbolDigest)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.Select(e => new FuncNodeDocument
{
results.Add(MapFuncNode(reader));
}
return results;
Id = e.Id,
GraphHash = e.GraphHash,
SymbolId = e.SymbolId,
Name = e.Name,
Kind = e.Kind,
Namespace = e.Namespace,
File = e.File,
Line = e.Line,
Purl = e.Purl,
SymbolDigest = e.SymbolDigest,
BuildId = e.BuildId,
CodeId = e.CodeId,
Language = e.Language,
Evidence = string.IsNullOrEmpty(e.Evidence) ? null : JsonSerializer.Deserialize<List<string>>(e.Evidence, JsonOptions),
Analyzer = string.IsNullOrEmpty(e.Analyzer) ? null : JsonSerializer.Deserialize<Dictionary<string, string?>>(e.Analyzer, JsonOptions),
IngestedAt = e.IngestedAt
}).ToList();
}
public async Task<IReadOnlyList<CallEdgeDocument>> GetCallEdgesByGraphAsync(string graphHash, CancellationToken cancellationToken)
@@ -180,25 +198,34 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT id, graph_hash, source_id, target_id, type, purl, symbol_digest, candidates, confidence, evidence, ingested_at
FROM signals.call_edges
WHERE graph_hash = @graph_hash
ORDER BY source_id, target_id, type";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@graph_hash", graphHash.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var normalizedHash = graphHash.Trim();
var results = new List<CallEdgeDocument>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
var entities = await dbContext.CallEdges
.AsNoTracking()
.Where(e => e.GraphHash == normalizedHash)
.OrderBy(e => e.SourceId)
.ThenBy(e => e.TargetId)
.ThenBy(e => e.Type)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.Select(e => new CallEdgeDocument
{
results.Add(MapCallEdge(reader));
}
return results;
Id = e.Id,
GraphHash = e.GraphHash,
SourceId = e.SourceId,
TargetId = e.TargetId,
Type = e.Type,
Purl = e.Purl,
SymbolDigest = e.SymbolDigest,
Candidates = string.IsNullOrEmpty(e.Candidates) ? null : JsonSerializer.Deserialize<List<string>>(e.Candidates, JsonOptions),
Confidence = e.Confidence,
Evidence = string.IsNullOrEmpty(e.Evidence) ? null : JsonSerializer.Deserialize<List<string>>(e.Evidence, JsonOptions),
IngestedAt = e.IngestedAt
}).ToList();
}
public async Task UpsertCveFuncHitsAsync(IReadOnlyCollection<CveFuncHitDocument> hits, CancellationToken cancellationToken)
@@ -216,32 +243,34 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
var id = $"{hit.SubjectKey.Trim()}|{hit.CveId.Trim().ToUpperInvariant()}|{hit.Purl?.Trim() ?? string.Empty}|{hit.SymbolDigest?.Trim()?.ToLowerInvariant() ?? string.Empty}";
const string sql = @"
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Keep raw SQL for UPSERT with ON CONFLICT
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO signals.cve_func_hits (id, subject_key, cve_id, graph_hash, purl, symbol_digest, reachable, confidence, lattice_state, evidence_uris, computed_at)
VALUES (@id, @subject_key, @cve_id, @graph_hash, @purl, @symbol_digest, @reachable, @confidence, @lattice_state, @evidence_uris, @computed_at)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}::jsonb, {10})
ON CONFLICT (id) DO UPDATE SET
graph_hash = EXCLUDED.graph_hash,
reachable = EXCLUDED.reachable,
confidence = EXCLUDED.confidence,
lattice_state = EXCLUDED.lattice_state,
evidence_uris = EXCLUDED.evidence_uris,
computed_at = EXCLUDED.computed_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", id);
AddParameter(command, "@subject_key", hit.SubjectKey.Trim());
AddParameter(command, "@cve_id", hit.CveId.Trim().ToUpperInvariant());
AddParameter(command, "@graph_hash", hit.GraphHash ?? string.Empty);
AddParameter(command, "@purl", (object?)hit.Purl?.Trim() ?? DBNull.Value);
AddParameter(command, "@symbol_digest", (object?)hit.SymbolDigest?.Trim()?.ToLowerInvariant() ?? DBNull.Value);
AddParameter(command, "@reachable", hit.Reachable);
AddParameter(command, "@confidence", (object?)hit.Confidence ?? DBNull.Value);
AddParameter(command, "@lattice_state", (object?)hit.LatticeState ?? DBNull.Value);
AddJsonbParameter(command, "@evidence_uris", hit.EvidenceUris is null ? null : JsonSerializer.Serialize(hit.EvidenceUris, JsonOptions));
AddParameter(command, "@computed_at", hit.ComputedAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
computed_at = EXCLUDED.computed_at
""",
id,
hit.SubjectKey.Trim(),
hit.CveId.Trim().ToUpperInvariant(),
hit.GraphHash ?? string.Empty,
(object?)hit.Purl?.Trim() ?? DBNull.Value,
(object?)hit.SymbolDigest?.Trim()?.ToLowerInvariant() ?? DBNull.Value,
hit.Reachable,
(object?)hit.Confidence ?? DBNull.Value,
(object?)hit.LatticeState ?? DBNull.Value,
hit.EvidenceUris is null ? DBNull.Value : JsonSerializer.Serialize(hit.EvidenceUris, JsonOptions),
hit.ComputedAt,
cancellationToken).ConfigureAwait(false);
}
}
@@ -257,82 +286,35 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT id, subject_key, cve_id, graph_hash, purl, symbol_digest, reachable, confidence, lattice_state, evidence_uris, computed_at
FROM signals.cve_func_hits
WHERE subject_key = @subject_key AND UPPER(cve_id) = UPPER(@cve_id)
ORDER BY cve_id, purl, symbol_digest";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@subject_key", subjectKey.Trim());
AddParameter(command, "@cve_id", cveId.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var normalizedSubjectKey = subjectKey.Trim();
var normalizedCveId = cveId.Trim().ToUpperInvariant();
var results = new List<CveFuncHitDocument>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
var entities = await dbContext.CveFuncHits
.AsNoTracking()
.Where(e => e.SubjectKey == normalizedSubjectKey && e.CveId.ToUpper() == normalizedCveId)
.OrderBy(e => e.CveId)
.ThenBy(e => e.Purl)
.ThenBy(e => e.SymbolDigest)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.Select(e => new CveFuncHitDocument
{
results.Add(MapCveFuncHit(reader));
}
return results;
}
private static FuncNodeDocument MapFuncNode(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
GraphHash = reader.GetString(1),
SymbolId = reader.GetString(2),
Name = reader.GetString(3),
Kind = reader.GetString(4),
Namespace = reader.IsDBNull(5) ? null : reader.GetString(5),
File = reader.IsDBNull(6) ? null : reader.GetString(6),
Line = reader.IsDBNull(7) ? null : reader.GetInt32(7),
Purl = reader.IsDBNull(8) ? null : reader.GetString(8),
SymbolDigest = reader.IsDBNull(9) ? null : reader.GetString(9),
BuildId = reader.IsDBNull(10) ? null : reader.GetString(10),
CodeId = reader.IsDBNull(11) ? null : reader.GetString(11),
Language = reader.IsDBNull(12) ? null : reader.GetString(12),
Evidence = reader.IsDBNull(13) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(13), JsonOptions),
Analyzer = reader.IsDBNull(14) ? null : JsonSerializer.Deserialize<Dictionary<string, string?>>(reader.GetString(14), JsonOptions),
IngestedAt = reader.GetFieldValue<DateTimeOffset>(15)
};
private static CallEdgeDocument MapCallEdge(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
GraphHash = reader.GetString(1),
SourceId = reader.GetString(2),
TargetId = reader.GetString(3),
Type = reader.GetString(4),
Purl = reader.IsDBNull(5) ? null : reader.GetString(5),
SymbolDigest = reader.IsDBNull(6) ? null : reader.GetString(6),
Candidates = reader.IsDBNull(7) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(7), JsonOptions),
Confidence = reader.IsDBNull(8) ? null : reader.GetDouble(8),
Evidence = reader.IsDBNull(9) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(9), JsonOptions),
IngestedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
private static CveFuncHitDocument MapCveFuncHit(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(0),
SubjectKey = reader.GetString(1),
CveId = reader.GetString(2),
GraphHash = reader.GetString(3),
Purl = reader.IsDBNull(4) ? null : reader.GetString(4),
SymbolDigest = reader.IsDBNull(5) ? null : reader.GetString(5),
Reachable = reader.GetBoolean(6),
Confidence = reader.IsDBNull(7) ? null : reader.GetDouble(7),
LatticeState = reader.IsDBNull(8) ? null : reader.GetString(8),
EvidenceUris = reader.IsDBNull(9) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(9), JsonOptions),
ComputedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
{
var command = new NpgsqlCommand(sql, connection, transaction);
return command;
Id = e.Id,
SubjectKey = e.SubjectKey,
CveId = e.CveId,
GraphHash = e.GraphHash,
Purl = e.Purl,
SymbolDigest = e.SymbolDigest,
Reachable = e.Reachable,
Confidence = e.Confidence,
LatticeState = e.LatticeState,
EvidenceUris = string.IsNullOrEmpty(e.EvidenceUris) ? null : JsonSerializer.Deserialize<List<string>>(e.EvidenceUris, JsonOptions),
ComputedAt = e.ComputedAt
}).ToList();
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
@@ -410,4 +392,6 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
_tableInitialized = true;
}
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
}

View File

@@ -1,10 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Persistence.EfCore.Context;
using StellaOps.Signals.Persistence.EfCore.Models;
using StellaOps.Signals.Persistence.Postgres;
using System.Text.Json;
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
@@ -12,6 +16,7 @@ namespace StellaOps.Signals.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IUnknownsRepository"/>.
/// Supports full scoring schema per Sprint 1102.
/// Uses EF Core for persistence operations.
/// </summary>
public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSource>, IUnknownsRepository
{
@@ -37,20 +42,22 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
var normalizedSubjectKey = subjectKey.Trim();
// Use raw connection + transaction for delete-then-insert pattern within a single transaction
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await dbContext.Database.UseTransactionAsync(transaction, cancellationToken).ConfigureAwait(false);
try
{
// Delete existing items for this subject
const string deleteSql = "DELETE FROM signals.unknowns WHERE subject_key = @subject_key";
await using (var deleteCommand = CreateCommand(deleteSql, connection, transaction))
{
AddParameter(deleteCommand, "@subject_key", normalizedSubjectKey);
await deleteCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Delete existing items for this subject via EF Core
await dbContext.Unknowns
.Where(e => e.SubjectKey == normalizedSubjectKey)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
// Insert new items with all scoring columns
// Insert new items with all scoring columns via raw SQL (complex parameter mapping)
const string insertSql = @"
INSERT INTO signals.unknowns (
id, subject_key, callgraph_id, symbol_id, code_id, purl, purl_version,
@@ -89,7 +96,7 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
var itemId = string.IsNullOrWhiteSpace(item.Id) ? Guid.NewGuid().ToString("N") : item.Id.Trim();
await using var insertCommand = CreateCommand(insertSql, connection, transaction);
await using var insertCommand = new NpgsqlCommand(insertSql, connection, transaction);
AddInsertParameters(insertCommand, itemId, normalizedSubjectKey, item);
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -110,24 +117,20 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = SelectAllColumns + @"
FROM signals.unknowns
WHERE subject_key = @subject_key
ORDER BY score DESC, created_at DESC";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@subject_key", subjectKey.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var normalizedKey = subjectKey.Trim();
var results = new List<UnknownSymbolDocument>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapUnknownSymbol(reader));
}
var entities = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.SubjectKey == normalizedKey)
.OrderByDescending(e => e.Score)
.ThenByDescending(e => e.CreatedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results;
return entities.Select(MapEntityToDocument).ToList();
}
public async Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
@@ -136,17 +139,15 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT COUNT(*)
FROM signals.unknowns
WHERE subject_key = @subject_key";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@subject_key", subjectKey.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is long count ? (int)count : 0;
var normalizedKey = subjectKey.Trim();
return await dbContext.Unknowns
.Where(e => e.SubjectKey == normalizedKey)
.CountAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
@@ -192,7 +193,7 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
continue;
}
await using var updateCommand = CreateCommand(updateSql, connection, transaction);
await using var updateCommand = new NpgsqlCommand(updateSql, connection, transaction);
AddUpdateParameters(updateCommand, item);
await updateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -211,23 +212,16 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT DISTINCT subject_key
FROM signals.unknowns
ORDER BY subject_key";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<string>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(reader.GetString(0));
}
return results;
return await dbContext.Unknowns
.AsNoTracking()
.Select(e => e.SubjectKey)
.Distinct()
.OrderBy(k => k)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
@@ -238,28 +232,21 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
var bandValue = band.ToString().ToLowerInvariant();
const string sql = SelectAllColumns + @"
FROM signals.unknowns
WHERE band = @band
AND (next_scheduled_rescan IS NULL OR next_scheduled_rescan <= NOW())
ORDER BY score DESC
LIMIT @limit";
var now = DateTimeOffset.UtcNow;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@band", bandValue);
AddParameter(command, "@limit", limit);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var entities = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.Band == bandValue &&
(e.NextScheduledRescan == null || e.NextScheduledRescan <= now))
.OrderByDescending(e => e.Score)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var results = new List<UnknownSymbolDocument>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapUnknownSymbol(reader));
}
return results;
return entities.Select(MapEntityToDocument).ToList();
}
public async Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(
@@ -270,39 +257,26 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
var sql = SelectAllColumns + @"
FROM signals.unknowns
WHERE 1=1";
if (band.HasValue)
{
sql += " AND band = @band";
}
sql += @"
ORDER BY score DESC, created_at DESC
LIMIT @limit OFFSET @offset";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
IQueryable<Unknown> query = dbContext.Unknowns.AsNoTracking();
if (band.HasValue)
{
AddParameter(command, "@band", band.Value.ToString().ToLowerInvariant());
var bandValue = band.Value.ToString().ToLowerInvariant();
query = query.Where(e => e.Band == bandValue);
}
AddParameter(command, "@limit", limit);
AddParameter(command, "@offset", offset);
var entities = await query
.OrderByDescending(e => e.Score)
.ThenByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<UnknownSymbolDocument>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapUnknownSymbol(reader));
}
return results;
return entities.Select(MapEntityToDocument).ToList();
}
public async Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
@@ -312,35 +286,77 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = SelectAllColumns + @"
FROM signals.unknowns
WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@id", id.Trim());
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var normalizedId = id.Trim();
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
return null;
var entity = await dbContext.Unknowns
.AsNoTracking()
.Where(e => e.Id == normalizedId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return MapUnknownSymbol(reader);
return entity is null ? null : MapEntityToDocument(entity);
}
private const string SelectAllColumns = @"
SELECT id, subject_key, callgraph_id, symbol_id, code_id, purl, purl_version,
edge_from, edge_to, reason,
popularity_p, deployment_count,
exploit_potential_e,
uncertainty_u,
centrality_c, degree_centrality, betweenness_centrality,
staleness_s, days_since_analysis,
score, band,
unknown_flags, normalization_trace,
rescan_attempts, last_rescan_result, next_scheduled_rescan, last_analyzed_at,
graph_slice_hash, evidence_set_hash, callgraph_attempt_hash,
created_at, updated_at";
private static UnknownSymbolDocument MapEntityToDocument(Unknown entity)
{
return new UnknownSymbolDocument
{
Id = entity.Id,
SubjectKey = entity.SubjectKey,
CallgraphId = entity.CallgraphId,
SymbolId = entity.SymbolId,
CodeId = entity.CodeId,
Purl = entity.Purl,
PurlVersion = entity.PurlVersion,
EdgeFrom = entity.EdgeFrom,
EdgeTo = entity.EdgeTo,
Reason = entity.Reason,
// Scoring factors
PopularityScore = entity.PopularityP,
DeploymentCount = entity.DeploymentCount,
ExploitPotentialScore = entity.ExploitPotentialE,
UncertaintyScore = entity.UncertaintyU,
CentralityScore = entity.CentralityC,
DegreeCentrality = entity.DegreeCentrality,
BetweennessCentrality = entity.BetweennessCentrality,
StalenessScore = entity.StalenessS,
DaysSinceLastAnalysis = entity.DaysSinceAnalysis,
// Composite
Score = entity.Score,
Band = ParseBand(entity.Band ?? "cold"),
// JSONB columns
Flags = string.IsNullOrEmpty(entity.UnknownFlags) ? new UnknownFlags() : JsonSerializer.Deserialize<UnknownFlags>(entity.UnknownFlags, JsonOptions) ?? new UnknownFlags(),
NormalizationTrace = string.IsNullOrEmpty(entity.NormalizationTrace) ? null : JsonSerializer.Deserialize<UnknownsNormalizationTrace>(entity.NormalizationTrace, JsonOptions),
// Rescan scheduling
RescanAttempts = entity.RescanAttempts,
LastRescanResult = entity.LastRescanResult,
NextScheduledRescan = entity.NextScheduledRescan,
LastAnalyzedAt = entity.LastAnalyzedAt,
// Hashes
GraphSliceHash = entity.GraphSliceHash is not null ? Convert.ToHexString(entity.GraphSliceHash).ToLowerInvariant() : null,
EvidenceSetHash = entity.EvidenceSetHash is not null ? Convert.ToHexString(entity.EvidenceSetHash).ToLowerInvariant() : null,
CallgraphAttemptHash = entity.CallgraphAttemptHash is not null ? Convert.ToHexString(entity.CallgraphAttemptHash).ToLowerInvariant() : null,
// Timestamps
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt ?? DateTimeOffset.UtcNow
};
}
private static UnknownsBand ParseBand(string value) => value.ToLowerInvariant() switch
{
"hot" => UnknownsBand.Hot,
"warm" => UnknownsBand.Warm,
_ => UnknownsBand.Cold
};
private void AddInsertParameters(NpgsqlCommand command, string itemId, string subjectKey, UnknownSymbolDocument item)
{
@@ -435,83 +451,6 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
param.Value = value != null ? JsonSerializer.Serialize(value, JsonOptions) : DBNull.Value;
}
private static UnknownSymbolDocument MapUnknownSymbol(NpgsqlDataReader reader)
{
var doc = new UnknownSymbolDocument
{
Id = reader.GetString(0),
SubjectKey = reader.GetString(1),
CallgraphId = reader.IsDBNull(2) ? null : reader.GetString(2),
SymbolId = reader.IsDBNull(3) ? null : reader.GetString(3),
CodeId = reader.IsDBNull(4) ? null : reader.GetString(4),
Purl = reader.IsDBNull(5) ? null : reader.GetString(5),
PurlVersion = reader.IsDBNull(6) ? null : reader.GetString(6),
EdgeFrom = reader.IsDBNull(7) ? null : reader.GetString(7),
EdgeTo = reader.IsDBNull(8) ? null : reader.GetString(8),
Reason = reader.IsDBNull(9) ? null : reader.GetString(9),
// Scoring factors
PopularityScore = reader.IsDBNull(10) ? 0.0 : reader.GetDouble(10),
DeploymentCount = reader.IsDBNull(11) ? 0 : reader.GetInt32(11),
ExploitPotentialScore = reader.IsDBNull(12) ? 0.0 : reader.GetDouble(12),
UncertaintyScore = reader.IsDBNull(13) ? 0.0 : reader.GetDouble(13),
CentralityScore = reader.IsDBNull(14) ? 0.0 : reader.GetDouble(14),
DegreeCentrality = reader.IsDBNull(15) ? 0 : reader.GetInt32(15),
BetweennessCentrality = reader.IsDBNull(16) ? 0.0 : reader.GetDouble(16),
StalenessScore = reader.IsDBNull(17) ? 0.0 : reader.GetDouble(17),
DaysSinceLastAnalysis = reader.IsDBNull(18) ? 0 : reader.GetInt32(18),
// Composite
Score = reader.IsDBNull(19) ? 0.0 : reader.GetDouble(19),
Band = ParseBand(reader.IsDBNull(20) ? "cold" : reader.GetString(20)),
// JSONB columns
Flags = ParseJson<UnknownFlags>(reader, 21) ?? new UnknownFlags(),
NormalizationTrace = ParseJson<UnknownsNormalizationTrace>(reader, 22),
// Rescan scheduling
RescanAttempts = reader.IsDBNull(23) ? 0 : reader.GetInt32(23),
LastRescanResult = reader.IsDBNull(24) ? null : reader.GetString(24),
NextScheduledRescan = reader.IsDBNull(25) ? null : reader.GetFieldValue<DateTimeOffset>(25),
LastAnalyzedAt = reader.IsDBNull(26) ? null : reader.GetFieldValue<DateTimeOffset>(26),
// Hashes
GraphSliceHash = reader.IsDBNull(27) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(27)).ToLowerInvariant(),
EvidenceSetHash = reader.IsDBNull(28) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(28)).ToLowerInvariant(),
CallgraphAttemptHash = reader.IsDBNull(29) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(29)).ToLowerInvariant(),
// Timestamps
CreatedAt = reader.IsDBNull(30) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(30),
UpdatedAt = reader.IsDBNull(31) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(31)
};
return doc;
}
private static UnknownsBand ParseBand(string value) => value.ToLowerInvariant() switch
{
"hot" => UnknownsBand.Hot,
"warm" => UnknownsBand.Warm,
_ => UnknownsBand.Cold
};
private static T? ParseJson<T>(NpgsqlDataReader reader, int ordinal) where T : class
{
if (reader.IsDBNull(ordinal))
{
return null;
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
{
var command = new NpgsqlCommand(sql, connection, transaction);
return command;
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
@@ -589,4 +528,6 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
_tableInitialized = true;
}
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
}

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Signals.Persistence.EfCore.CompiledModels;
using StellaOps.Signals.Persistence.EfCore.Context;
namespace StellaOps.Signals.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="SignalsDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class SignalsDbContextFactory
{
public static SignalsDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? SignalsDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<SignalsDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, SignalsDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(SignalsDbContextModel.Instance);
}
return new SignalsDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -18,6 +18,11 @@
LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\SignalsDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />