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

@@ -0,0 +1,15 @@
# VexLens Compiled Models
This directory contains compiled model stubs for the VexLens EF Core DbContext.
To regenerate after schema or model changes, run:
```bash
dotnet ef dbcontext optimize \
--project src/VexLens/StellaOps.VexLens.Persistence/ \
--output-dir EfCore/CompiledModels \
--namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels
```
After regeneration, ensure that `VexLensDbContextAssemblyAttributes.cs` remains
excluded from compilation via the `.csproj` `<Compile Remove>` directive.

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for VexLensDbContext.
/// 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.VexLensDbContext))]
public partial class VexLensDbContextModel : RuntimeModel
{
private static VexLensDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new VexLensDbContextModel();
_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.VexLens.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model builder stub for VexLensDbContext.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
public partial class VexLensDbContextModel
{
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

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.VexLens.Persistence.EfCore.Models;
namespace StellaOps.VexLens.Persistence.EfCore.Context;
public partial class VexLensDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
// -- FK: consensus_projections.previous_projection_id -> consensus_projections.id --
modelBuilder.Entity<ConsensusProjectionEntity>(entity =>
{
entity.HasOne(e => e.PreviousProjection)
.WithMany()
.HasForeignKey(e => e.PreviousProjectionId)
.OnDelete(DeleteBehavior.SetNull);
});
// -- FK: consensus_inputs.projection_id -> consensus_projections.id (ON DELETE CASCADE) --
modelBuilder.Entity<ConsensusInputEntity>(entity =>
{
entity.HasOne(e => e.Projection)
.WithMany(p => p.Inputs)
.HasForeignKey(e => e.ProjectionId)
.OnDelete(DeleteBehavior.Cascade);
});
// -- FK: consensus_conflicts.projection_id -> consensus_projections.id (ON DELETE CASCADE) --
modelBuilder.Entity<ConsensusConflictEntity>(entity =>
{
entity.HasOne(e => e.Projection)
.WithMany(p => p.Conflicts)
.HasForeignKey(e => e.ProjectionId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.VexLens.Persistence.EfCore.Models;
namespace StellaOps.VexLens.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for the VexLens module.
/// Maps to the vexlens PostgreSQL schema: consensus_projections,
/// consensus_inputs, and consensus_conflicts tables.
/// </summary>
public partial class VexLensDbContext : DbContext
{
private readonly string _schemaName;
public VexLensDbContext(DbContextOptions<VexLensDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "vexlens"
: schemaName.Trim();
}
public virtual DbSet<ConsensusProjectionEntity> ConsensusProjections { get; set; }
public virtual DbSet<ConsensusInputEntity> ConsensusInputs { get; set; }
public virtual DbSet<ConsensusConflictEntity> ConsensusConflicts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
// -- consensus_projections ------------------------------------------------
modelBuilder.Entity<ConsensusProjectionEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("consensus_projections_pkey");
entity.ToTable("consensus_projections", schemaName);
// Indexes matching SQL migration
entity.HasIndex(e => e.VulnerabilityId, "idx_consensus_projections_vuln_id");
entity.HasIndex(e => e.ProductKey, "idx_consensus_projections_product_key");
entity.HasIndex(e => e.TenantId, "idx_consensus_projections_tenant_id")
.HasFilter("(tenant_id IS NOT NULL)");
entity.HasIndex(e => e.Status, "idx_consensus_projections_status");
entity.HasIndex(e => e.Outcome, "idx_consensus_projections_outcome");
entity.HasIndex(e => e.ComputedAt, "idx_consensus_projections_computed_at")
.IsDescending(true);
entity.HasIndex(e => e.StoredAt, "idx_consensus_projections_stored_at")
.IsDescending(true);
entity.HasIndex(e => e.ConfidenceScore, "idx_consensus_projections_confidence")
.IsDescending(true);
entity.HasIndex(e => e.StatusChanged, "idx_consensus_projections_status_changed")
.HasFilter("(status_changed = true)");
entity.HasIndex(e => new { e.VulnerabilityId, e.ProductKey, e.TenantId, e.ComputedAt },
"idx_consensus_projections_history")
.IsDescending(false, false, false, true);
// Column mappings
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
entity.Property(e => e.ProductKey).HasColumnName("product_key");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Justification).HasColumnName("justification");
entity.Property(e => e.ConfidenceScore).HasColumnName("confidence_score");
entity.Property(e => e.Outcome).HasColumnName("outcome");
entity.Property(e => e.StatementCount)
.HasDefaultValue(0)
.HasColumnName("statement_count");
entity.Property(e => e.ConflictCount)
.HasDefaultValue(0)
.HasColumnName("conflict_count");
entity.Property(e => e.RationaleSummary).HasColumnName("rationale_summary");
entity.Property(e => e.MergeTrace)
.HasColumnType("jsonb")
.HasColumnName("merge_trace");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
entity.Property(e => e.StoredAt)
.HasDefaultValueSql("now()")
.HasColumnName("stored_at");
entity.Property(e => e.PreviousProjectionId).HasColumnName("previous_projection_id");
entity.Property(e => e.StatusChanged)
.HasDefaultValue(false)
.HasColumnName("status_changed");
entity.Property(e => e.AttestationDigest).HasColumnName("attestation_digest");
entity.Property(e => e.InputHash).HasColumnName("input_hash");
});
// -- consensus_inputs ----------------------------------------------------
modelBuilder.Entity<ConsensusInputEntity>(entity =>
{
entity.HasKey(e => new { e.ProjectionId, e.StatementId })
.HasName("consensus_inputs_pkey");
entity.ToTable("consensus_inputs", schemaName);
entity.HasIndex(e => e.ProjectionId, "idx_consensus_inputs_projection");
entity.Property(e => e.ProjectionId).HasColumnName("projection_id");
entity.Property(e => e.StatementId).HasColumnName("statement_id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Confidence).HasColumnName("confidence");
entity.Property(e => e.Weight).HasColumnName("weight");
});
// -- consensus_conflicts -------------------------------------------------
modelBuilder.Entity<ConsensusConflictEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("consensus_conflicts_pkey");
entity.ToTable("consensus_conflicts", schemaName);
entity.HasIndex(e => e.ProjectionId, "idx_consensus_conflicts_projection");
entity.HasIndex(e => e.Severity, "idx_consensus_conflicts_severity");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.ProjectionId).HasColumnName("projection_id");
entity.Property(e => e.Issuer1).HasColumnName("issuer1");
entity.Property(e => e.Issuer2).HasColumnName("issuer2");
entity.Property(e => e.Status1).HasColumnName("status1");
entity.Property(e => e.Status2).HasColumnName("status2");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.DetectedAt)
.HasDefaultValueSql("now()")
.HasColumnName("detected_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.VexLens.Persistence.EfCore.Context;
public sealed class VexLensDesignTimeDbContextFactory : IDesignTimeDbContextFactory<VexLensDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vexlens,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_VEXLENS_EF_CONNECTION";
public VexLensDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<VexLensDbContext>()
.UseNpgsql(connectionString)
.Options;
return new VexLensDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
public partial class ConsensusConflictEntity
{
/// <summary>
/// Navigation property: the projection this conflict belongs to.
/// </summary>
public virtual ConsensusProjectionEntity Projection { get; set; } = null!;
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for vexlens.consensus_conflicts table.
/// Detailed conflict records from consensus computation.
/// </summary>
public partial class ConsensusConflictEntity
{
public Guid Id { get; set; }
public Guid ProjectionId { get; set; }
public string Issuer1 { get; set; } = null!;
public string Issuer2 { get; set; } = null!;
public string Status1 { get; set; } = null!;
public string Status2 { get; set; } = null!;
public string Severity { get; set; } = null!;
public DateTime DetectedAt { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
public partial class ConsensusInputEntity
{
/// <summary>
/// Navigation property: the projection this input belongs to.
/// </summary>
public virtual ConsensusProjectionEntity Projection { get; set; } = null!;
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for vexlens.consensus_inputs table.
/// Tracks which VEX statements contributed to a consensus projection.
/// </summary>
public partial class ConsensusInputEntity
{
public Guid ProjectionId { get; set; }
public string StatementId { get; set; } = null!;
public string SourceId { get; set; } = null!;
public string Status { get; set; } = null!;
public double? Confidence { get; set; }
public double? Weight { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
public partial class ConsensusProjectionEntity
{
/// <summary>
/// Navigation property: self-referential link to previous projection.
/// </summary>
public virtual ConsensusProjectionEntity? PreviousProjection { get; set; }
/// <summary>
/// Navigation property: input statements that contributed to this projection.
/// </summary>
public virtual ICollection<ConsensusInputEntity> Inputs { get; set; } = new List<ConsensusInputEntity>();
/// <summary>
/// Navigation property: conflicts detected during this projection.
/// </summary>
public virtual ICollection<ConsensusConflictEntity> Conflicts { get; set; } = new List<ConsensusConflictEntity>();
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for vexlens.consensus_projections table.
/// </summary>
public partial class ConsensusProjectionEntity
{
public Guid Id { get; set; }
public string VulnerabilityId { get; set; } = null!;
public string ProductKey { get; set; } = null!;
public string? TenantId { get; set; }
public string Status { get; set; } = null!;
public string? Justification { get; set; }
public double ConfidenceScore { get; set; }
public string Outcome { get; set; } = null!;
public int StatementCount { get; set; }
public int ConflictCount { get; set; }
public string? RationaleSummary { get; set; }
public string? MergeTrace { get; set; }
public DateTime ComputedAt { get; set; }
public DateTime StoredAt { get; set; }
public Guid? PreviousProjectionId { get; set; }
public bool StatusChanged { get; set; }
public string? AttestationDigest { get; set; }
public string? InputHash { get; set; }
}

View File

@@ -1,29 +1,32 @@
// SPDX-License-Identifier: BUSL-1.1
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
// (c) StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Persistence.EfCore.Models;
using StellaOps.VexLens.Storage;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.VexLens.Persistence.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-021)
/// PostgreSQL (EF Core) implementation of <see cref="IConsensusProjectionStore"/>.
/// Sprint: SPRINT_20260222_071_VexLens_dal_to_efcore (VEXLENS-EF-03)
/// </summary>
public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
{
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Persistence.PostgresConsensusProjectionStore");
private const int CommandTimeoutSeconds = 30;
private readonly NpgsqlDataSource _dataSource;
private readonly IConsensusEventEmitter? _eventEmitter;
@@ -76,65 +79,58 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
!string.Equals(previous.Status.ToString(), result.ConsensusStatus.ToString(), StringComparison.OrdinalIgnoreCase);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await using var dbContext = CreateDbContext(connection);
try
var entity = new ConsensusProjectionEntity
{
// Insert the projection
await using var cmd = new NpgsqlCommand(InsertProjectionSql, connection, transaction);
cmd.Parameters.AddWithValue("id", projectionId);
cmd.Parameters.AddWithValue("vulnerability_id", result.VulnerabilityId);
cmd.Parameters.AddWithValue("product_key", result.ProductKey);
cmd.Parameters.AddWithValue("tenant_id", options.TenantId ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("status", MapStatus(result.ConsensusStatus));
cmd.Parameters.AddWithValue("justification", result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : DBNull.Value);
cmd.Parameters.AddWithValue("confidence_score", result.ConfidenceScore);
cmd.Parameters.AddWithValue("outcome", MapOutcome(result.Outcome));
cmd.Parameters.AddWithValue("statement_count", result.Contributions.Count);
cmd.Parameters.AddWithValue("conflict_count", result.Conflicts?.Count ?? 0);
cmd.Parameters.AddWithValue("rationale_summary", result.Rationale.Summary ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("computed_at", result.ComputedAt);
cmd.Parameters.AddWithValue("stored_at", now);
cmd.Parameters.AddWithValue("previous_projection_id", previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : DBNull.Value);
cmd.Parameters.AddWithValue("status_changed", statusChanged);
Id = projectionId,
VulnerabilityId = result.VulnerabilityId,
ProductKey = result.ProductKey,
TenantId = options.TenantId,
Status = MapStatus(result.ConsensusStatus),
Justification = result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : null,
ConfidenceScore = result.ConfidenceScore,
Outcome = MapOutcome(result.Outcome),
StatementCount = result.Contributions.Count,
ConflictCount = result.Conflicts?.Count ?? 0,
RationaleSummary = result.Rationale.Summary,
ComputedAt = result.ComputedAt.UtcDateTime,
StoredAt = now.UtcDateTime,
PreviousProjectionId = previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : null,
StatusChanged = statusChanged
};
await cmd.ExecuteNonQueryAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
dbContext.ConsensusProjections.Add(entity);
await dbContext.SaveChangesAsync(cancellationToken);
var projection = new ConsensusProjection(
ProjectionId: projectionId.ToString(),
VulnerabilityId: result.VulnerabilityId,
ProductKey: result.ProductKey,
TenantId: options.TenantId,
Status: result.ConsensusStatus,
Justification: result.ConsensusJustification,
ConfidenceScore: result.ConfidenceScore,
Outcome: result.Outcome,
StatementCount: result.Contributions.Count,
ConflictCount: result.Conflicts?.Count ?? 0,
RationaleSummary: result.Rationale.Summary ?? string.Empty,
ComputedAt: result.ComputedAt,
StoredAt: now,
PreviousProjectionId: previous?.ProjectionId,
StatusChanged: statusChanged);
var projection = new ConsensusProjection(
ProjectionId: projectionId.ToString(),
VulnerabilityId: result.VulnerabilityId,
ProductKey: result.ProductKey,
TenantId: options.TenantId,
Status: result.ConsensusStatus,
Justification: result.ConsensusJustification,
ConfidenceScore: result.ConfidenceScore,
Outcome: result.Outcome,
StatementCount: result.Contributions.Count,
ConflictCount: result.Conflicts?.Count ?? 0,
RationaleSummary: result.Rationale.Summary ?? string.Empty,
ComputedAt: result.ComputedAt,
StoredAt: now,
PreviousProjectionId: previous?.ProjectionId,
StatusChanged: statusChanged);
_logger.LogDebug(
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
projectionId, result.VulnerabilityId, result.ProductKey);
_logger.LogDebug(
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
projectionId, result.VulnerabilityId, result.ProductKey);
// Emit events if configured
if (options.EmitEvent && _eventEmitter is not null)
{
await EmitEventsAsync(projection, previous, cancellationToken);
}
return projection;
}
catch
// Emit events if configured
if (options.EmitEvent && _eventEmitter is not null)
{
await transaction.RollbackAsync(cancellationToken);
throw;
await EmitEventsAsync(projection, previous, cancellationToken);
}
return projection;
}
/// <inheritdoc />
@@ -153,16 +149,13 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
activity?.SetTag("projectionId", projectionId);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(SelectByIdSql, connection);
cmd.Parameters.AddWithValue("id", id);
await using var dbContext = CreateDbContext(connection);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
return MapProjection(reader);
}
var entity = await dbContext.ConsensusProjections
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
return null;
return entity is not null ? MapToProjection(entity) : null;
}
/// <inheritdoc />
@@ -180,18 +173,17 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
activity?.SetTag("productKey", productKey);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(SelectLatestSql, connection);
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
cmd.Parameters.AddWithValue("product_key", productKey);
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
await using var dbContext = CreateDbContext(connection);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
return MapProjection(reader);
}
var entity = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& (tenantId == null ? e.TenantId == null : e.TenantId == tenantId))
.OrderByDescending(e => e.ComputedAt)
.FirstOrDefaultAsync(cancellationToken);
return null;
return entity is not null ? MapToProjection(entity) : null;
}
/// <inheritdoc />
@@ -203,32 +195,81 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
using var activity = ActivitySource.StartActivity("ListAsync");
var (sql, countSql, parameters) = BuildListQuery(query);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var dbContext = CreateDbContext(connection);
IQueryable<ConsensusProjectionEntity> dbQuery = dbContext.ConsensusProjections.AsNoTracking();
// Apply filters
if (query.TenantId is not null)
dbQuery = dbQuery.Where(e => e.TenantId == query.TenantId);
if (query.VulnerabilityId is not null)
dbQuery = dbQuery.Where(e => e.VulnerabilityId == query.VulnerabilityId);
if (query.ProductKey is not null)
dbQuery = dbQuery.Where(e => e.ProductKey == query.ProductKey);
if (query.Status.HasValue)
{
var statusStr = MapStatus(query.Status.Value);
dbQuery = dbQuery.Where(e => e.Status == statusStr);
}
if (query.Outcome.HasValue)
{
var outcomeStr = MapOutcome(query.Outcome.Value);
dbQuery = dbQuery.Where(e => e.Outcome == outcomeStr);
}
if (query.MinimumConfidence.HasValue)
dbQuery = dbQuery.Where(e => e.ConfidenceScore >= query.MinimumConfidence.Value);
if (query.ComputedAfter.HasValue)
{
var afterUtc = query.ComputedAfter.Value.UtcDateTime;
dbQuery = dbQuery.Where(e => e.ComputedAt >= afterUtc);
}
if (query.ComputedBefore.HasValue)
{
var beforeUtc = query.ComputedBefore.Value.UtcDateTime;
dbQuery = dbQuery.Where(e => e.ComputedAt <= beforeUtc);
}
if (query.StatusChanged.HasValue)
dbQuery = dbQuery.Where(e => e.StatusChanged == query.StatusChanged.Value);
// Get total count
await using var countCmd = new NpgsqlCommand(countSql, connection);
foreach (var (name, value) in parameters)
{
countCmd.Parameters.AddWithValue(name, value);
}
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
var totalCount = await dbQuery.CountAsync(cancellationToken);
// Get projections
var projections = new List<ConsensusProjection>();
await using var cmd = new NpgsqlCommand(sql, connection);
foreach (var (name, value) in parameters)
// Apply sorting
dbQuery = query.SortBy switch
{
cmd.Parameters.AddWithValue(name, value);
}
ProjectionSortField.StoredAt => query.SortDescending
? dbQuery.OrderByDescending(e => e.StoredAt)
: dbQuery.OrderBy(e => e.StoredAt),
ProjectionSortField.VulnerabilityId => query.SortDescending
? dbQuery.OrderByDescending(e => e.VulnerabilityId)
: dbQuery.OrderBy(e => e.VulnerabilityId),
ProjectionSortField.ProductKey => query.SortDescending
? dbQuery.OrderByDescending(e => e.ProductKey)
: dbQuery.OrderBy(e => e.ProductKey),
ProjectionSortField.ConfidenceScore => query.SortDescending
? dbQuery.OrderByDescending(e => e.ConfidenceScore)
: dbQuery.OrderBy(e => e.ConfidenceScore),
_ => query.SortDescending
? dbQuery.OrderByDescending(e => e.ComputedAt)
: dbQuery.OrderBy(e => e.ComputedAt)
};
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
projections.Add(MapProjection(reader));
}
// Apply pagination
var entities = await dbQuery
.Skip(query.Offset)
.Take(query.Limit)
.ToListAsync(cancellationToken);
var projections = entities.Select(MapToProjection).ToList();
return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit);
}
@@ -248,20 +289,18 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
activity?.SetTag("productKey", productKey);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(SelectHistorySql, connection);
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
cmd.Parameters.AddWithValue("product_key", productKey);
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("limit", limit ?? 100);
await using var dbContext = CreateDbContext(connection);
var projections = new List<ConsensusProjection>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
projections.Add(MapProjection(reader));
}
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& (tenantId == null ? e.TenantId == null : e.TenantId == tenantId))
.OrderByDescending(e => e.ComputedAt)
.Take(limit ?? 100)
.ToListAsync(cancellationToken);
return projections;
return entities.Select(MapToProjection).ToList();
}
/// <inheritdoc />
@@ -274,98 +313,46 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture));
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(
tenantId is null ? PurgeSql : PurgeByTenantSql,
connection);
cmd.Parameters.AddWithValue("older_than", olderThan);
if (tenantId is not null)
{
cmd.Parameters.AddWithValue("tenant_id", tenantId);
}
await using var dbContext = CreateDbContext(connection);
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
var olderThanUtc = olderThan.UtcDateTime;
IQueryable<ConsensusProjectionEntity> query = dbContext.ConsensusProjections
.Where(e => e.ComputedAt < olderThanUtc);
if (tenantId is not null)
query = query.Where(e => e.TenantId == tenantId);
var deleted = await query.ExecuteDeleteAsync(cancellationToken);
_logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan);
return deleted;
}
#region SQL Queries
private const string InsertProjectionSql = """
INSERT INTO vexlens.consensus_projections (
id, vulnerability_id, product_key, tenant_id, status, justification,
confidence_score, outcome, statement_count, conflict_count,
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
) VALUES (
@id, @vulnerability_id, @product_key, @tenant_id, @status, @justification,
@confidence_score, @outcome, @statement_count, @conflict_count,
@rationale_summary, @computed_at, @stored_at, @previous_projection_id, @status_changed
)
""";
private const string SelectByIdSql = """
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
confidence_score, outcome, statement_count, conflict_count,
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
FROM vexlens.consensus_projections
WHERE id = @id
""";
private const string SelectLatestSql = """
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
confidence_score, outcome, statement_count, conflict_count,
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
FROM vexlens.consensus_projections
WHERE vulnerability_id = @vulnerability_id
AND product_key = @product_key
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
ORDER BY computed_at DESC
LIMIT 1
""";
private const string SelectHistorySql = """
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
confidence_score, outcome, statement_count, conflict_count,
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
FROM vexlens.consensus_projections
WHERE vulnerability_id = @vulnerability_id
AND product_key = @product_key
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
ORDER BY computed_at DESC
LIMIT @limit
""";
private const string PurgeSql = """
DELETE FROM vexlens.consensus_projections
WHERE computed_at < @older_than
""";
private const string PurgeByTenantSql = """
DELETE FROM vexlens.consensus_projections
WHERE computed_at < @older_than AND tenant_id = @tenant_id
""";
#endregion
#region Helpers
private static ConsensusProjection MapProjection(NpgsqlDataReader reader)
private EfCore.Context.VexLensDbContext CreateDbContext(NpgsqlConnection connection)
{
return VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, VexLensDataSource.DefaultSchemaName);
}
private static ConsensusProjection MapToProjection(ConsensusProjectionEntity entity)
{
return new ConsensusProjection(
ProjectionId: reader.GetGuid(0).ToString(),
VulnerabilityId: reader.GetString(1),
ProductKey: reader.GetString(2),
TenantId: reader.IsDBNull(3) ? null : reader.GetString(3),
Status: ParseStatus(reader.GetString(4)),
Justification: reader.IsDBNull(5) ? null : ParseJustification(reader.GetString(5)),
ConfidenceScore: reader.GetDouble(6),
Outcome: ParseOutcome(reader.GetString(7)),
StatementCount: reader.GetInt32(8),
ConflictCount: reader.GetInt32(9),
RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
ComputedAt: reader.GetFieldValue<DateTimeOffset>(11),
StoredAt: reader.GetFieldValue<DateTimeOffset>(12),
PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(),
StatusChanged: reader.GetBoolean(14));
ProjectionId: entity.Id.ToString(),
VulnerabilityId: entity.VulnerabilityId,
ProductKey: entity.ProductKey,
TenantId: entity.TenantId,
Status: ParseStatus(entity.Status),
Justification: entity.Justification is not null ? ParseJustification(entity.Justification) : null,
ConfidenceScore: entity.ConfidenceScore,
Outcome: ParseOutcome(entity.Outcome),
StatementCount: entity.StatementCount,
ConflictCount: entity.ConflictCount,
RationaleSummary: entity.RationaleSummary ?? string.Empty,
ComputedAt: new DateTimeOffset(entity.ComputedAt, TimeSpan.Zero),
StoredAt: new DateTimeOffset(entity.StoredAt, TimeSpan.Zero),
PreviousProjectionId: entity.PreviousProjectionId?.ToString(),
StatusChanged: entity.StatusChanged);
}
private static string MapStatus(VexStatus status) => status switch
@@ -428,96 +415,6 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
_ => throw new ArgumentOutOfRangeException(nameof(outcome))
};
private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query)
{
var conditions = new List<string>();
var parameters = new List<(string name, object value)>();
if (query.TenantId is not null)
{
conditions.Add("tenant_id = @tenant_id");
parameters.Add(("tenant_id", query.TenantId));
}
if (query.VulnerabilityId is not null)
{
conditions.Add("vulnerability_id = @vulnerability_id");
parameters.Add(("vulnerability_id", query.VulnerabilityId));
}
if (query.ProductKey is not null)
{
conditions.Add("product_key = @product_key");
parameters.Add(("product_key", query.ProductKey));
}
if (query.Status.HasValue)
{
conditions.Add("status = @status");
parameters.Add(("status", MapStatus(query.Status.Value)));
}
if (query.Outcome.HasValue)
{
conditions.Add("outcome = @outcome");
parameters.Add(("outcome", MapOutcome(query.Outcome.Value)));
}
if (query.MinimumConfidence.HasValue)
{
conditions.Add("confidence_score >= @min_confidence");
parameters.Add(("min_confidence", query.MinimumConfidence.Value));
}
if (query.ComputedAfter.HasValue)
{
conditions.Add("computed_at >= @computed_after");
parameters.Add(("computed_after", query.ComputedAfter.Value));
}
if (query.ComputedBefore.HasValue)
{
conditions.Add("computed_at <= @computed_before");
parameters.Add(("computed_before", query.ComputedBefore.Value));
}
if (query.StatusChanged.HasValue)
{
conditions.Add("status_changed = @status_changed");
parameters.Add(("status_changed", query.StatusChanged.Value));
}
var whereClause = conditions.Count > 0
? $"WHERE {string.Join(" AND ", conditions)}"
: string.Empty;
var sortColumn = query.SortBy switch
{
ProjectionSortField.ComputedAt => "computed_at",
ProjectionSortField.StoredAt => "stored_at",
ProjectionSortField.VulnerabilityId => "vulnerability_id",
ProjectionSortField.ProductKey => "product_key",
ProjectionSortField.ConfidenceScore => "confidence_score",
_ => "computed_at"
};
var sortDirection = query.SortDescending ? "DESC" : "ASC";
var sql = $"""
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
confidence_score, outcome, statement_count, conflict_count,
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
FROM vexlens.consensus_projections
{whereClause}
ORDER BY {sortColumn} {sortDirection}
LIMIT {query.Limit} OFFSET {query.Offset}
""";
var countSql = $"SELECT COUNT(*) FROM vexlens.consensus_projections {whereClause}";
return (sql, countSql, parameters);
}
private async Task EmitEventsAsync(
ConsensusProjection projection,
ConsensusProjection? previous,

View File

@@ -19,7 +19,7 @@ public sealed class VexLensDataSource : DataSourceBase
/// <summary>
/// Default schema name for VexLens tables.
/// </summary>
public const string DefaultSchemaName = "vex";
public const string DefaultSchemaName = "vexlens";
public VexLensDataSource(
IOptions<PostgresOptions> options,

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.VexLens.Persistence.EfCore.CompiledModels;
using StellaOps.VexLens.Persistence.EfCore.Context;
namespace StellaOps.VexLens.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="VexLensDbContext"/> 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 VexLensDbContextFactory
{
public static VexLensDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? VexLensDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<VexLensDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, VexLensDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(VexLensDbContextModel.Instance);
}
return new VexLensDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,78 +1,67 @@
// -----------------------------------------------------------------------------
// ConsensusProjectionRepository.cs
// Sprint: SPRINT_20251229_001_002_BE_vex_delta (VEX-006)
// Task: Implement IConsensusProjectionRepository
// Sprint: SPRINT_20260222_071_VexLens_dal_to_efcore (VEXLENS-EF-03)
// Task: Convert DAL repository from Npgsql to EF Core
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using Npgsql;
using StellaOps.VexLens.Persistence.EfCore.Models;
using StellaOps.VexLens.Persistence.Postgres;
using System.Text.Json;
namespace StellaOps.VexLens.Persistence.Repositories;
/// <summary>
/// PostgreSQL implementation of consensus projection repository.
/// EF Core implementation of consensus projection repository.
/// </summary>
public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSource>, IConsensusProjectionRepository
public sealed class ConsensusProjectionRepository : IConsensusProjectionRepository
{
private const string Schema = "vex";
private const string Table = "consensus_projections";
private const string FullTable = $"{Schema}.{Table}";
private const int CommandTimeoutSeconds = 30;
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly VexLensDataSource _dataSource;
private readonly ILogger<ConsensusProjectionRepository> _logger;
public ConsensusProjectionRepository(
VexLensDataSource dataSource,
ILogger<ConsensusProjectionRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<ConsensusProjection> AddAsync(
ConsensusProjection projection,
CancellationToken ct = default)
{
const string sql = $"""
INSERT INTO {FullTable} (
id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, previous_projection_id, status_changed
)
VALUES (
@id, @tenantId, @vulnId, @productKey, @status,
@confidence, @outcome, @stmtCount, @conflictCount,
@mergeTrace::jsonb, @computedAt, @previousId, @statusChanged
)
RETURNING id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
""";
await using var connection = await OpenConnectionAsync(projection.TenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var result = await QuerySingleOrDefaultAsync(
projection.TenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "id", projection.Id);
AddParameter(cmd, "tenantId", projection.TenantId);
AddParameter(cmd, "vulnId", projection.VulnerabilityId);
AddParameter(cmd, "productKey", projection.ProductKey);
AddParameter(cmd, "status", projection.Status.ToString().ToLowerInvariant());
AddParameter(cmd, "confidence", projection.ConfidenceScore);
AddParameter(cmd, "outcome", projection.Outcome);
AddParameter(cmd, "stmtCount", projection.StatementCount);
AddParameter(cmd, "conflictCount", projection.ConflictCount);
AddParameter(cmd, "mergeTrace", SerializeTrace(projection.Trace));
AddParameter(cmd, "computedAt", projection.ComputedAt);
AddParameter(cmd, "previousId", (object?)projection.PreviousProjectionId ?? DBNull.Value);
AddParameter(cmd, "statusChanged", projection.StatusChanged);
},
MapProjection,
ct);
var entity = ToEntity(projection);
dbContext.ConsensusProjections.Add(entity);
return result ?? throw new InvalidOperationException("Failed to add consensus projection");
try
{
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
_logger.LogWarning(
"Duplicate consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}; idempotent skip.",
projection.Id, projection.VulnerabilityId, projection.ProductKey);
}
// Re-read to get DB-generated stored_at
await using var readConnection = await OpenConnectionAsync(projection.TenantId.ToString(), ct);
await using var readContext = VexLensDbContextFactory.Create(readConnection, CommandTimeoutSeconds, GetSchemaName());
var stored = await readContext.ConsensusProjections
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == projection.Id, ct);
return stored is not null ? ToModel(stored) : projection;
}
public async ValueTask<ConsensusProjection?> GetLatestAsync(
@@ -81,29 +70,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
Guid tenantId,
CancellationToken ct = default)
{
const string sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE vulnerability_id = @vulnId
AND product_key = @productKey
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT 1
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QuerySingleOrDefaultAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "vulnId", vulnerabilityId);
AddParameter(cmd, "productKey", productKey);
AddParameter(cmd, "tenantId", tenantId);
},
MapProjection,
ct);
var entity = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.FirstOrDefaultAsync(ct);
return entity is not null ? ToModel(entity) : null;
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByVulnerabilityAsync(
@@ -112,28 +90,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 100,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE vulnerability_id = @vulnId
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "vulnId", vulnerabilityId);
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByProductAsync(
@@ -142,28 +110,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 100,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE product_key = @productKey
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "productKey", productKey);
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.ProductKey == productKey
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetStatusChangesAsync(
@@ -172,29 +130,21 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 100,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE tenant_id = @tenantId
AND status_changed = TRUE
AND computed_at >= @since
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "since", since);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var sinceUtc = since.UtcDateTime;
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.TenantId == tenantId.ToString()
&& e.StatusChanged == true
&& e.ComputedAt >= sinceUtc)
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
@@ -204,36 +154,52 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 50,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE vulnerability_id = @vulnId
AND product_key = @productKey
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "vulnId", vulnerabilityId);
AddParameter(cmd, "productKey", productKey);
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
private static ConsensusProjection MapProjection(System.Data.Common.DbDataReader reader)
#region Mapping
private static ConsensusProjectionEntity ToEntity(ConsensusProjection model) => new()
{
var statusStr = reader.GetString(reader.GetOrdinal("status"));
var status = statusStr.ToLowerInvariant() switch
Id = model.Id,
TenantId = model.TenantId.ToString(),
VulnerabilityId = model.VulnerabilityId,
ProductKey = model.ProductKey,
Status = model.Status.ToString().ToLowerInvariant() switch
{
"unknown" => "unknown",
"underinvestigation" => "under_investigation",
"notaffected" => "not_affected",
"affected" => "affected",
"fixed" => "fixed",
_ => model.Status.ToString().ToLowerInvariant()
},
ConfidenceScore = (double)model.ConfidenceScore,
Outcome = model.Outcome,
StatementCount = model.StatementCount,
ConflictCount = model.ConflictCount,
MergeTrace = SerializeTrace(model.Trace),
ComputedAt = model.ComputedAt.UtcDateTime,
PreviousProjectionId = model.PreviousProjectionId,
StatusChanged = model.StatusChanged
};
private static ConsensusProjection ToModel(ConsensusProjectionEntity entity)
{
var statusStr = entity.Status.ToLowerInvariant();
var status = statusStr switch
{
"unknown" => VexConsensusStatus.Unknown,
"under_investigation" => VexConsensusStatus.UnderInvestigation,
@@ -243,28 +209,21 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
_ => throw new InvalidOperationException($"Unknown status: {statusStr}")
};
var traceJson = reader.IsDBNull(reader.GetOrdinal("merge_trace"))
? null
: reader.GetString(reader.GetOrdinal("merge_trace"));
return new ConsensusProjection(
Id: reader.GetGuid(reader.GetOrdinal("id")),
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
VulnerabilityId: reader.GetString(reader.GetOrdinal("vulnerability_id")),
ProductKey: reader.GetString(reader.GetOrdinal("product_key")),
Id: entity.Id,
TenantId: Guid.TryParse(entity.TenantId, out var tenantId) ? tenantId : Guid.Empty,
VulnerabilityId: entity.VulnerabilityId,
ProductKey: entity.ProductKey,
Status: status,
ConfidenceScore: reader.GetDecimal(reader.GetOrdinal("confidence_score")),
Outcome: reader.GetString(reader.GetOrdinal("outcome")),
StatementCount: reader.GetInt32(reader.GetOrdinal("statement_count")),
ConflictCount: reader.GetInt32(reader.GetOrdinal("conflict_count")),
Trace: DeserializeTrace(traceJson),
ComputedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("computed_at")),
StoredAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("stored_at")),
PreviousProjectionId: reader.IsDBNull(reader.GetOrdinal("previous_projection_id"))
? null
: reader.GetGuid(reader.GetOrdinal("previous_projection_id")),
StatusChanged: reader.GetBoolean(reader.GetOrdinal("status_changed"))
);
ConfidenceScore: (decimal)entity.ConfidenceScore,
Outcome: entity.Outcome,
StatementCount: entity.StatementCount,
ConflictCount: entity.ConflictCount,
Trace: DeserializeTrace(entity.MergeTrace),
ComputedAt: new DateTimeOffset(entity.ComputedAt, TimeSpan.Zero),
StoredAt: new DateTimeOffset(entity.StoredAt, TimeSpan.Zero),
PreviousProjectionId: entity.PreviousProjectionId,
StatusChanged: entity.StatusChanged);
}
private static string SerializeTrace(MergeTrace? trace)
@@ -282,4 +241,29 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
return JsonSerializer.Deserialize<MergeTrace>(json, SerializerOptions);
}
#endregion
#region Helpers
private async Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, CancellationToken ct)
{
return await _dataSource.OpenConnectionAsync(tenantId, "reader", ct);
}
private static string GetSchemaName() => VexLensDataSource.DefaultSchemaName;
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
#endregion
}

View File

@@ -10,7 +10,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
@@ -18,11 +21,17 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\VexLensDbContextAssemblyAttributes.cs" />
</ItemGroup>
</Project>