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:
@@ -8,13 +8,36 @@
|
||||
- `docs/operations/artifact-migration-runbook.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/technical/testing/TEST_SUITE_OVERVIEW.md`
|
||||
- `docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md`
|
||||
- `docs/db/EF_CORE_RUNTIME_CUTOVER_STRATEGY.md`
|
||||
|
||||
## Working Agreements
|
||||
- Deterministic outputs (ordering, timestamps, hashing).
|
||||
- Offline-friendly; avoid runtime network calls.
|
||||
- Note cross-module impacts in the active sprint tracker.
|
||||
|
||||
## DAL Technology
|
||||
- **EF Core v10** for PostgreSQL artifact index repository (converted from raw Npgsql in Sprint 077).
|
||||
- Schema: `evidence` (shared with Evidence.Persistence module).
|
||||
- SQL migrations remain authoritative; no EF auto-migrations at runtime.
|
||||
- Compiled model used for default schema path; reflection-based model building for non-default schemas.
|
||||
- UPSERT operations use `ExecuteSqlRawAsync` for the multi-column ON CONFLICT pattern.
|
||||
|
||||
## EF Core Directory Structure
|
||||
```
|
||||
EfCore/
|
||||
Context/
|
||||
ArtifactDbContext.cs # Main DbContext
|
||||
ArtifactDesignTimeDbContextFactory.cs # For dotnet ef CLI
|
||||
Models/
|
||||
ArtifactIndexEntity.cs # Entity POCO
|
||||
CompiledModels/
|
||||
ArtifactDbContextModel.cs # Compiled model stub
|
||||
Postgres/
|
||||
ArtifactDbContextFactory.cs # Runtime factory with UseModel()
|
||||
```
|
||||
|
||||
## Testing Expectations
|
||||
- Add or update unit tests under `src/__Libraries/__Tests`.
|
||||
- Run `dotnet test` for affected test projects when changes are made.
|
||||
|
||||
- Build sequentially (`-p:BuildInParallel=false` or `--no-dependencies`).
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for ArtifactDbContext.
|
||||
/// 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.ArtifactDbContext))]
|
||||
public partial class ArtifactDbContextModel : RuntimeModel
|
||||
{
|
||||
private static ArtifactDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new ArtifactDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Artifact.Infrastructure.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Artifact Infrastructure module.
|
||||
/// Maps to the evidence PostgreSQL schema: artifact_index table.
|
||||
/// Scaffolded from SQL migration 001_artifact_index_schema.sql.
|
||||
/// </summary>
|
||||
public partial class ArtifactDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public ArtifactDbContext(DbContextOptions<ArtifactDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "evidence"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<ArtifactIndexEntity> ArtifactIndexes { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- artifact_index --------------------------------------------------
|
||||
modelBuilder.Entity<ArtifactIndexEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("artifact_index_pkey");
|
||||
entity.ToTable("artifact_index", schemaName);
|
||||
|
||||
// Unique constraint for UPSERT conflict target
|
||||
entity.HasAlternateKey(e => new { e.TenantId, e.BomRef, e.SerialNumber, e.ArtifactId })
|
||||
.HasName("uq_artifact_index_key");
|
||||
|
||||
// Indexes matching SQL migration
|
||||
entity.HasIndex(e => new { e.TenantId, e.BomRef }, "idx_artifact_index_bom_ref")
|
||||
.HasFilter("(NOT is_deleted)");
|
||||
|
||||
entity.HasIndex(e => e.Sha256, "idx_artifact_index_sha256")
|
||||
.HasFilter("(NOT is_deleted)");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.ArtifactType }, "idx_artifact_index_type")
|
||||
.HasFilter("(NOT is_deleted)");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.BomRef, e.SerialNumber }, "idx_artifact_index_serial")
|
||||
.HasFilter("(NOT is_deleted)");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_artifact_index_created")
|
||||
.IsDescending(false, true)
|
||||
.HasFilter("(NOT is_deleted)");
|
||||
|
||||
// Column mappings
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
|
||||
entity.Property(e => e.TenantId)
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
entity.Property(e => e.BomRef)
|
||||
.HasColumnName("bom_ref");
|
||||
|
||||
entity.Property(e => e.SerialNumber)
|
||||
.HasColumnName("serial_number");
|
||||
|
||||
entity.Property(e => e.ArtifactId)
|
||||
.HasColumnName("artifact_id");
|
||||
|
||||
entity.Property(e => e.StorageKey)
|
||||
.HasColumnName("storage_key");
|
||||
|
||||
entity.Property(e => e.ArtifactType)
|
||||
.HasColumnName("artifact_type");
|
||||
|
||||
entity.Property(e => e.ContentType)
|
||||
.HasColumnName("content_type");
|
||||
|
||||
entity.Property(e => e.Sha256)
|
||||
.HasColumnName("sha256");
|
||||
|
||||
entity.Property(e => e.SizeBytes)
|
||||
.HasColumnName("size_bytes");
|
||||
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
entity.Property(e => e.IsDeleted)
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
entity.Property(e => e.DeletedAt)
|
||||
.HasColumnName("deleted_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="ArtifactDbContext"/>.
|
||||
/// Used by dotnet ef CLI tooling (scaffold, optimize, migrations).
|
||||
/// </summary>
|
||||
public sealed class ArtifactDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ArtifactDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=evidence,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_ARTIFACT_EF_CONNECTION";
|
||||
|
||||
public ArtifactDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<ArtifactDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new ArtifactDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Artifact.Infrastructure.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the evidence.artifact_index table.
|
||||
/// Scaffolded from SQL migration 001_artifact_index_schema.sql.
|
||||
/// </summary>
|
||||
public partial class ArtifactIndexEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public string BomRef { get; set; } = null!;
|
||||
public string SerialNumber { get; set; } = null!;
|
||||
public string ArtifactId { get; set; } = null!;
|
||||
public string StorageKey { get; set; } = null!;
|
||||
public string ArtifactType { get; set; } = null!;
|
||||
public string ContentType { get; set; } = null!;
|
||||
public string Sha256 { get; set; } = null!;
|
||||
public long SizeBytes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Artifact.Infrastructure.EfCore.CompiledModels;
|
||||
using StellaOps.Artifact.Infrastructure.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="ArtifactDbContext"/> 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 ArtifactDbContextFactory
|
||||
{
|
||||
public static ArtifactDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? ArtifactDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ArtifactDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, ArtifactDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(ArtifactDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new ArtifactDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresArtifactIndexRepository.Find.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-003 - Create ArtifactStore PostgreSQL index
|
||||
// Description: Query operations for the artifact repository
|
||||
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
|
||||
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
|
||||
// Description: Query operations for the artifact repository (EF Core)
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Artifact.Core;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure;
|
||||
@@ -13,11 +14,16 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ArtifactIndexEntry>> FindByBomRefAsync(string bomRef, CancellationToken ct = default)
|
||||
{
|
||||
return await QueryAsync(_tenantKey, ArtifactIndexSql.SelectByBomRef, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", _tenantId);
|
||||
AddParameter(cmd, "bom_ref", bomRef);
|
||||
}, MapEntry, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateReadContextAsync(ct);
|
||||
|
||||
var entities = await dbContext.ArtifactIndexes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == _tenantId && e.BomRef == bomRef && !e.IsDeleted)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToEntry).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -26,21 +32,32 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
string serialNumber,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await QueryAsync(_tenantKey, ArtifactIndexSql.SelectByBomRefAndSerial, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", _tenantId);
|
||||
AddParameter(cmd, "bom_ref", bomRef);
|
||||
AddParameter(cmd, "serial_number", serialNumber);
|
||||
}, MapEntry, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateReadContextAsync(ct);
|
||||
|
||||
var entities = await dbContext.ArtifactIndexes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == _tenantId && e.BomRef == bomRef && e.SerialNumber == serialNumber && !e.IsDeleted)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToEntry).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ArtifactIndexEntry>> FindBySha256Async(string sha256, CancellationToken ct = default)
|
||||
{
|
||||
return await QueryAsync(_tenantKey, ArtifactIndexSql.SelectBySha256, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sha256", sha256);
|
||||
}, MapEntry, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateReadContextAsync(ct);
|
||||
|
||||
var entities = await dbContext.ArtifactIndexes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Sha256 == sha256 && !e.IsDeleted)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Take(100)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToEntry).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -51,12 +68,19 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantKey = tenantId.ToString("D");
|
||||
return await QueryAsync(tenantKey, ArtifactIndexSql.SelectByType, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "artifact_type", type.ToString());
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntry, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateReadContextAsync(tenantKey, ct);
|
||||
|
||||
var typeString = type.ToString();
|
||||
|
||||
var entities = await dbContext.ArtifactIndexes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.ArtifactType == typeString && !e.IsDeleted)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToEntry).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -70,12 +94,19 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantKey = tenantId.ToString("D");
|
||||
return await QueryAsync(tenantKey, ArtifactIndexSql.SelectByTimeRange, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntry, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateReadContextAsync(tenantKey, ct);
|
||||
|
||||
var fromUtc = from.UtcDateTime;
|
||||
var toUtc = to.UtcDateTime;
|
||||
|
||||
var entities = await dbContext.ArtifactIndexes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.CreatedAt >= fromUtc && e.CreatedAt < toUtc && !e.IsDeleted)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToEntry).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresArtifactIndexRepository.Index.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-003 - Create ArtifactStore PostgreSQL index
|
||||
// Description: Index write operations for the artifact repository
|
||||
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
|
||||
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
|
||||
// Description: Index write operations for the artifact repository (EF Core)
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure;
|
||||
|
||||
public sealed partial class PostgresArtifactIndexRepository
|
||||
@@ -12,22 +14,42 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
public async Task IndexAsync(ArtifactIndexEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
await using var connection = await DataSource.OpenConnectionAsync(_tenantKey, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(ArtifactIndexSql.Insert, connection);
|
||||
|
||||
AddParameter(command, "id", entry.Id);
|
||||
AddParameter(command, "tenant_id", entry.TenantId);
|
||||
AddParameter(command, "bom_ref", entry.BomRef);
|
||||
AddParameter(command, "serial_number", entry.SerialNumber);
|
||||
AddParameter(command, "artifact_id", entry.ArtifactId);
|
||||
AddParameter(command, "storage_key", entry.StorageKey);
|
||||
AddParameter(command, "artifact_type", entry.Type.ToString());
|
||||
AddParameter(command, "content_type", entry.ContentType);
|
||||
AddParameter(command, "sha256", entry.Sha256);
|
||||
AddParameter(command, "size_bytes", entry.SizeBytes);
|
||||
AddParameter(command, "created_at", entry.CreatedAt);
|
||||
// The original SQL used INSERT ... ON CONFLICT DO UPDATE (multi-column conflict clause).
|
||||
// Using ExecuteSqlRawAsync to preserve the exact upsert semantics per cutover strategy guidance.
|
||||
await using var dbContext = await CreateWriteContextAsync(ct);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO evidence.artifact_index (
|
||||
id, tenant_id, bom_ref, serial_number, artifact_id, storage_key,
|
||||
artifact_type, content_type, sha256, size_bytes, created_at
|
||||
) VALUES (
|
||||
{0}, {1}, {2}, {3}, {4}, {5},
|
||||
{6}, {7}, {8}, {9}, {10}
|
||||
)
|
||||
ON CONFLICT (tenant_id, bom_ref, serial_number, artifact_id)
|
||||
DO UPDATE SET
|
||||
storage_key = EXCLUDED.storage_key,
|
||||
artifact_type = EXCLUDED.artifact_type,
|
||||
content_type = EXCLUDED.content_type,
|
||||
sha256 = EXCLUDED.sha256,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
updated_at = NOW(),
|
||||
is_deleted = FALSE,
|
||||
deleted_at = NULL
|
||||
""",
|
||||
entry.Id,
|
||||
entry.TenantId,
|
||||
entry.BomRef,
|
||||
entry.SerialNumber,
|
||||
entry.ArtifactId,
|
||||
entry.StorageKey,
|
||||
entry.Type.ToString(),
|
||||
entry.ContentType,
|
||||
entry.Sha256,
|
||||
entry.SizeBytes,
|
||||
entry.CreatedAt.UtcDateTime,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,73 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresArtifactIndexRepository.Mapping.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-003 - Create ArtifactStore PostgreSQL index
|
||||
// Description: Row mapping helpers for artifact index repository
|
||||
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
|
||||
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
|
||||
// Description: Mapping helpers between EF Core entities and domain models
|
||||
// -----------------------------------------------------------------------------
|
||||
using Npgsql;
|
||||
using StellaOps.Artifact.Core;
|
||||
using StellaOps.Artifact.Infrastructure.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure;
|
||||
|
||||
public sealed partial class PostgresArtifactIndexRepository
|
||||
{
|
||||
private static ArtifactIndexEntry MapEntry(NpgsqlDataReader reader)
|
||||
private static ArtifactIndexEntry MapToEntry(ArtifactIndexEntity entity)
|
||||
{
|
||||
var artifactTypeString = reader.GetString(6);
|
||||
var artifactType = Enum.TryParse<ArtifactType>(artifactTypeString, out var parsedType)
|
||||
var artifactType = Enum.TryParse<ArtifactType>(entity.ArtifactType, out var parsedType)
|
||||
? parsedType
|
||||
: ArtifactType.Unknown;
|
||||
|
||||
return new ArtifactIndexEntry
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetGuid(1),
|
||||
BomRef = reader.GetString(2),
|
||||
SerialNumber = reader.GetString(3),
|
||||
ArtifactId = reader.GetString(4),
|
||||
StorageKey = reader.GetString(5),
|
||||
Id = entity.Id,
|
||||
TenantId = entity.TenantId,
|
||||
BomRef = entity.BomRef,
|
||||
SerialNumber = entity.SerialNumber,
|
||||
ArtifactId = entity.ArtifactId,
|
||||
StorageKey = entity.StorageKey,
|
||||
Type = artifactType,
|
||||
ContentType = reader.GetString(7),
|
||||
Sha256 = reader.GetString(8),
|
||||
SizeBytes = reader.GetInt64(9),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
|
||||
UpdatedAt = reader.IsDBNull(11) ? null : reader.GetFieldValue<DateTimeOffset>(11),
|
||||
IsDeleted = reader.GetBoolean(12),
|
||||
DeletedAt = reader.IsDBNull(13) ? null : reader.GetFieldValue<DateTimeOffset>(13)
|
||||
ContentType = entity.ContentType,
|
||||
Sha256 = entity.Sha256,
|
||||
SizeBytes = entity.SizeBytes,
|
||||
CreatedAt = entity.CreatedAt.Kind == DateTimeKind.Utc
|
||||
? new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero)
|
||||
: new DateTimeOffset(DateTime.SpecifyKind(entity.CreatedAt, DateTimeKind.Utc), TimeSpan.Zero),
|
||||
UpdatedAt = entity.UpdatedAt.HasValue
|
||||
? new DateTimeOffset(
|
||||
entity.UpdatedAt.Value.Kind == DateTimeKind.Utc
|
||||
? entity.UpdatedAt.Value
|
||||
: DateTime.SpecifyKind(entity.UpdatedAt.Value, DateTimeKind.Utc),
|
||||
TimeSpan.Zero)
|
||||
: null,
|
||||
IsDeleted = entity.IsDeleted,
|
||||
DeletedAt = entity.DeletedAt.HasValue
|
||||
? new DateTimeOffset(
|
||||
entity.DeletedAt.Value.Kind == DateTimeKind.Utc
|
||||
? entity.DeletedAt.Value
|
||||
: DateTime.SpecifyKind(entity.DeletedAt.Value, DateTimeKind.Utc),
|
||||
TimeSpan.Zero)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static ArtifactIndexEntity MapToEntity(ArtifactIndexEntry entry)
|
||||
{
|
||||
return new ArtifactIndexEntity
|
||||
{
|
||||
Id = entry.Id,
|
||||
TenantId = entry.TenantId,
|
||||
BomRef = entry.BomRef,
|
||||
SerialNumber = entry.SerialNumber,
|
||||
ArtifactId = entry.ArtifactId,
|
||||
StorageKey = entry.StorageKey,
|
||||
ArtifactType = entry.Type.ToString(),
|
||||
ContentType = entry.ContentType,
|
||||
Sha256 = entry.Sha256,
|
||||
SizeBytes = entry.SizeBytes,
|
||||
CreatedAt = entry.CreatedAt.UtcDateTime,
|
||||
UpdatedAt = entry.UpdatedAt?.UtcDateTime,
|
||||
IsDeleted = entry.IsDeleted,
|
||||
DeletedAt = entry.DeletedAt?.UtcDateTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresArtifactIndexRepository.Mutate.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-003 - Create ArtifactStore PostgreSQL index
|
||||
// Description: Mutation operations for the artifact repository
|
||||
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
|
||||
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
|
||||
// Description: Mutation operations for the artifact repository (EF Core)
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure;
|
||||
|
||||
public sealed partial class PostgresArtifactIndexRepository
|
||||
@@ -15,15 +17,19 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
string artifactId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = await QueryAsync(_tenantKey, ArtifactIndexSql.SelectByKey, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", _tenantId);
|
||||
AddParameter(cmd, "bom_ref", bomRef);
|
||||
AddParameter(cmd, "serial_number", serialNumber);
|
||||
AddParameter(cmd, "artifact_id", artifactId);
|
||||
}, MapEntry, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateReadContextAsync(ct);
|
||||
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
var entity = await dbContext.ArtifactIndexes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == _tenantId
|
||||
&& e.BomRef == bomRef
|
||||
&& e.SerialNumber == serialNumber
|
||||
&& e.ArtifactId == artifactId
|
||||
&& !e.IsDeleted)
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : MapToEntry(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -33,13 +39,20 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
string artifactId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var rowsAffected = await ExecuteAsync(_tenantKey, ArtifactIndexSql.UpdateSoftDelete, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", _tenantId);
|
||||
AddParameter(cmd, "bom_ref", bomRef);
|
||||
AddParameter(cmd, "serial_number", serialNumber);
|
||||
AddParameter(cmd, "artifact_id", artifactId);
|
||||
}, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateWriteContextAsync(ct);
|
||||
|
||||
var rowsAffected = await dbContext.ArtifactIndexes
|
||||
.Where(e => e.TenantId == _tenantId
|
||||
&& e.BomRef == bomRef
|
||||
&& e.SerialNumber == serialNumber
|
||||
&& e.ArtifactId == artifactId
|
||||
&& !e.IsDeleted)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(e => e.IsDeleted, true)
|
||||
.SetProperty(e => e.DeletedAt, DateTime.UtcNow)
|
||||
.SetProperty(e => e.UpdatedAt, DateTime.UtcNow),
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
@@ -50,11 +63,14 @@ public sealed partial class PostgresArtifactIndexRepository
|
||||
public async Task<int> CountAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
var tenantKey = tenantId.ToString("D");
|
||||
var result = await ExecuteScalarAsync<long>(tenantKey, ArtifactIndexSql.CountByTenant, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
}, ct).ConfigureAwait(false);
|
||||
await using var dbContext = await CreateReadContextAsync(tenantKey, ct);
|
||||
|
||||
return (int)result;
|
||||
var count = await dbContext.ArtifactIndexes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && !e.IsDeleted)
|
||||
.LongCountAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return (int)count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresArtifactIndexRepository.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-003 - Create ArtifactStore PostgreSQL index
|
||||
// Description: PostgreSQL implementation of artifact index repository
|
||||
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
|
||||
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
|
||||
// Description: PostgreSQL (EF Core) implementation of artifact index repository
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Artifact.Infrastructure.EfCore.Context;
|
||||
using StellaOps.Artifact.Infrastructure.Postgres;
|
||||
|
||||
namespace StellaOps.Artifact.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IArtifactIndexRepository"/>.
|
||||
/// PostgreSQL (EF Core) implementation of <see cref="IArtifactIndexRepository"/>.
|
||||
/// </summary>
|
||||
public sealed partial class PostgresArtifactIndexRepository : RepositoryBase<ArtifactDataSource>, IArtifactIndexRepository
|
||||
public sealed partial class PostgresArtifactIndexRepository : IArtifactIndexRepository
|
||||
{
|
||||
private readonly ArtifactDataSource _dataSource;
|
||||
private readonly ILogger<PostgresArtifactIndexRepository> _logger;
|
||||
private readonly Guid _tenantId;
|
||||
private readonly string _tenantKey;
|
||||
|
||||
@@ -21,10 +24,38 @@ public sealed partial class PostgresArtifactIndexRepository : RepositoryBase<Art
|
||||
ArtifactDataSource dataSource,
|
||||
ILogger<PostgresArtifactIndexRepository> logger,
|
||||
IArtifactTenantContext tenantContext)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataSource);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_tenantId = tenantContext.TenantId;
|
||||
_tenantKey = tenantContext.TenantIdValue;
|
||||
}
|
||||
|
||||
private int CommandTimeoutSeconds => _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
private string GetSchemaName() => ArtifactDataSource.DefaultSchemaName;
|
||||
|
||||
private async Task<ArtifactDbContext> CreateReadContextAsync(CancellationToken ct)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(_tenantKey, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
return ArtifactDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
}
|
||||
|
||||
private async Task<ArtifactDbContext> CreateWriteContextAsync(CancellationToken ct)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(_tenantKey, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
return ArtifactDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
}
|
||||
|
||||
private async Task<ArtifactDbContext> CreateReadContextAsync(string tenantKey, CancellationToken ct)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(tenantKey, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
return ArtifactDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" />
|
||||
<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>
|
||||
@@ -22,10 +25,16 @@
|
||||
<ProjectReference Include="..\StellaOps.Artifact.Core\StellaOps.Artifact.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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\ArtifactDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# StellaOps.Artifact.Infrastructure Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | DONE | Remediation complete; split store/migration/index, tenant context + deterministic time/ID, S3 integration tests added; dotnet test src/__Libraries/StellaOps.Artifact.Core.Tests/StellaOps.Artifact.Core.Tests.csproj passed 2026-02-03 (25 tests, MTP0001 warning). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| ARTIF-EF-01 | DONE | AGENTS.md verified; migration plugin added to Evidence module (multi-source) in Platform MigrationModulePlugins.cs. |
|
||||
| ARTIF-EF-02 | DONE | EF Core model scaffolded: ArtifactDbContext, ArtifactIndexEntity, design-time factory, compiled model stub. |
|
||||
| ARTIF-EF-03 | DONE | PostgresArtifactIndexRepository converted from Npgsql/RepositoryBase to EF Core. Interface preserved. UPSERT via ExecuteSqlRawAsync per cutover strategy. |
|
||||
| ARTIF-EF-04 | DONE | Compiled model stub, design-time factory, runtime factory with UseModel() for default schema. Assembly attribute exclusion in csproj. |
|
||||
| ARTIF-EF-05 | DONE | Sequential build (0 errors, 0 warnings); tests pass (25/25); docs updated. |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for EventingDbContext.
|
||||
/// 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.EventingDbContext))]
|
||||
public partial class EventingDbContextModel : RuntimeModel
|
||||
{
|
||||
private static EventingDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new EventingDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for EventingDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class EventingDbContextModel
|
||||
{
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Eventing.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Eventing module.
|
||||
/// Maps to the timeline PostgreSQL schema: events and outbox tables.
|
||||
/// </summary>
|
||||
public partial class EventingDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public EventingDbContext(DbContextOptions<EventingDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "timeline"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<TimelineEventEntity> Events { get; set; }
|
||||
public virtual DbSet<OutboxEntry> OutboxEntries { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- events ---------------------------------------------------------------
|
||||
modelBuilder.Entity<TimelineEventEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EventId).HasName("events_pkey");
|
||||
entity.ToTable("events", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.CorrelationId, e.THlc }, "idx_events_corr_hlc");
|
||||
entity.HasIndex(e => new { e.Service, e.THlc }, "idx_events_svc_hlc");
|
||||
entity.HasIndex(e => e.Kind, "idx_events_kind");
|
||||
entity.HasIndex(e => e.CreatedAt, "idx_events_created_at");
|
||||
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.THlc).HasColumnName("t_hlc");
|
||||
entity.Property(e => e.TsWall).HasColumnName("ts_wall");
|
||||
entity.Property(e => e.Service).HasColumnName("service");
|
||||
entity.Property(e => e.TraceParent).HasColumnName("trace_parent");
|
||||
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
|
||||
entity.Property(e => e.Kind).HasColumnName("kind");
|
||||
entity.Property(e => e.Payload)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
entity.Property(e => e.PayloadDigest).HasColumnName("payload_digest");
|
||||
entity.Property(e => e.EngineName).HasColumnName("engine_name");
|
||||
entity.Property(e => e.EngineVersion).HasColumnName("engine_version");
|
||||
entity.Property(e => e.EngineDigest).HasColumnName("engine_digest");
|
||||
entity.Property(e => e.DsseSig).HasColumnName("dsse_sig");
|
||||
entity.Property(e => e.SchemaVersion)
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("schema_version");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// -- outbox ---------------------------------------------------------------
|
||||
modelBuilder.Entity<OutboxEntry>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("outbox_pkey");
|
||||
entity.ToTable("outbox", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.Status, e.NextRetryAt }, "idx_outbox_status_retry")
|
||||
.HasFilter("(status IN ('PENDING', 'FAILED'))");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd()
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.Status)
|
||||
.HasDefaultValueSql("'PENDING'")
|
||||
.HasColumnName("status");
|
||||
entity.Property(e => e.RetryCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("retry_count");
|
||||
entity.Property(e => e.NextRetryAt).HasColumnName("next_retry_at");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="EventingDbContext"/>.
|
||||
/// Used by <c>dotnet ef</c> CLI tooling for scaffold and optimize commands.
|
||||
/// </summary>
|
||||
public sealed class EventingDesignTimeDbContextFactory : IDesignTimeDbContextFactory<EventingDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=timeline,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_EVENTING_EF_CONNECTION";
|
||||
|
||||
public EventingDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<EventingDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new EventingDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for timeline.outbox table.
|
||||
/// </summary>
|
||||
public partial class OutboxEntry
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string EventId { get; set; } = null!;
|
||||
public string Status { get; set; } = null!;
|
||||
public int RetryCount { get; set; }
|
||||
public DateTimeOffset? NextRetryAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for timeline.events table.
|
||||
/// </summary>
|
||||
public partial class TimelineEventEntity
|
||||
{
|
||||
public string EventId { get; set; } = null!;
|
||||
public string THlc { get; set; } = null!;
|
||||
public DateTimeOffset TsWall { get; set; }
|
||||
public string Service { get; set; } = null!;
|
||||
public string? TraceParent { get; set; }
|
||||
public string CorrelationId { get; set; } = null!;
|
||||
public string Kind { get; set; } = null!;
|
||||
public string Payload { get; set; } = null!;
|
||||
public byte[] PayloadDigest { get; set; } = null!;
|
||||
public string EngineName { get; set; } = null!;
|
||||
public string EngineVersion { get; set; } = null!;
|
||||
public string EngineDigest { get; set; } = null!;
|
||||
public string? DsseSig { get; set; }
|
||||
public int SchemaVersion { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using System.Data;
|
||||
using StellaOps.Eventing.Postgres;
|
||||
|
||||
namespace StellaOps.Eventing.Outbox;
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IOptions<EventingOptions> _options;
|
||||
private readonly ILogger<TimelineOutboxProcessor> _logger;
|
||||
private readonly EventingDataSource? _eventingDataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineOutboxProcessor"/> class.
|
||||
@@ -24,11 +25,13 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
public TimelineOutboxProcessor(
|
||||
NpgsqlDataSource dataSource,
|
||||
IOptions<EventingOptions> options,
|
||||
ILogger<TimelineOutboxProcessor> logger)
|
||||
ILogger<TimelineOutboxProcessor> logger,
|
||||
EventingDataSource? eventingDataSource = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventingDataSource = eventingDataSource;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -74,36 +77,28 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
private async Task<int> ProcessBatchAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Select and lock pending entries
|
||||
const string selectSql = """
|
||||
SELECT id, event_id, retry_count
|
||||
FROM timeline.outbox
|
||||
WHERE status = 'PENDING'
|
||||
OR (status = 'FAILED' AND next_retry_at <= NOW())
|
||||
ORDER BY id
|
||||
LIMIT @batch_size
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""";
|
||||
|
||||
await using var selectCmd = new NpgsqlCommand(selectSql, connection, transaction);
|
||||
selectCmd.Parameters.AddWithValue("@batch_size", _options.Value.OutboxBatchSize);
|
||||
|
||||
var entries = new List<(long Id, string EventId, int RetryCount)>();
|
||||
|
||||
await using (var reader = await selectCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
entries.Add((
|
||||
reader.GetInt64(0),
|
||||
reader.GetString(1),
|
||||
reader.GetInt32(2)));
|
||||
}
|
||||
}
|
||||
// Use raw SQL for the SELECT ... FOR UPDATE SKIP LOCKED pattern
|
||||
// which is not directly expressible in LINQ.
|
||||
var batchSize = _options.Value.OutboxBatchSize;
|
||||
var entries = await dbContext.OutboxEntries
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT id, event_id, status, retry_count, next_retry_at, error_message, created_at, updated_at
|
||||
FROM timeline.outbox
|
||||
WHERE status = 'PENDING'
|
||||
OR (status = 'FAILED' AND next_retry_at <= NOW())
|
||||
ORDER BY id
|
||||
LIMIT {0}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
batchSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
@@ -124,22 +119,20 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process outbox entry {Id}", entry.Id);
|
||||
await MarkAsFailedAsync(connection, transaction, entry.Id, entry.RetryCount, ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
MarkAsFailed(entry, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed entries
|
||||
if (completedIds.Count > 0)
|
||||
{
|
||||
const string completeSql = """
|
||||
UPDATE timeline.outbox
|
||||
SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE id = ANY(@ids)
|
||||
""";
|
||||
foreach (var entry in entries.Where(e => completedIds.Contains(e.Id)))
|
||||
{
|
||||
entry.Status = "COMPLETED";
|
||||
entry.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await using var completeCmd = new NpgsqlCommand(completeSql, connection, transaction);
|
||||
completeCmd.Parameters.AddWithValue("@ids", completedIds.ToArray());
|
||||
await completeCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -152,37 +145,23 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MarkAsFailedAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
long id,
|
||||
int retryCount,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
private static void MarkAsFailed(EfCore.Models.OutboxEntry entry, string errorMessage)
|
||||
{
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 5 retries
|
||||
var nextRetryDelay = TimeSpan.FromSeconds(Math.Pow(2, retryCount));
|
||||
var nextRetryDelay = TimeSpan.FromSeconds(Math.Pow(2, entry.RetryCount));
|
||||
var maxRetries = 5;
|
||||
|
||||
var newStatus = retryCount >= maxRetries ? "FAILED" : "PENDING";
|
||||
entry.Status = entry.RetryCount >= maxRetries ? "FAILED" : "PENDING";
|
||||
entry.RetryCount += 1;
|
||||
entry.NextRetryAt = DateTimeOffset.UtcNow.Add(nextRetryDelay);
|
||||
entry.ErrorMessage = errorMessage;
|
||||
entry.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
UPDATE timeline.outbox
|
||||
SET status = @status,
|
||||
retry_count = @retry_count,
|
||||
next_retry_at = @next_retry_at,
|
||||
error_message = @error_message,
|
||||
updated_at = NOW()
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@status", newStatus);
|
||||
cmd.Parameters.AddWithValue("@retry_count", retryCount + 1);
|
||||
cmd.Parameters.AddWithValue("@next_retry_at", DateTimeOffset.UtcNow.Add(nextRetryDelay));
|
||||
cmd.Parameters.AddWithValue("@error_message", errorMessage);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
private EfCore.Context.EventingDbContext CreateDbContext(NpgsqlConnection connection)
|
||||
{
|
||||
var commandTimeout = _eventingDataSource?.CommandTimeoutSeconds ?? 30;
|
||||
var schemaName = _eventingDataSource?.SchemaName ?? EventingDataSource.DefaultSchemaName;
|
||||
return EventingDbContextFactory.Create(connection, commandTimeout, schemaName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Eventing.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Eventing module.
|
||||
/// Manages connections for timeline event storage and outbox processing.
|
||||
/// </summary>
|
||||
public sealed class EventingDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for Eventing tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "timeline";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Eventing data source.
|
||||
/// </summary>
|
||||
public EventingDataSource(IOptions<PostgresOptions> options, ILogger<EventingDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "Eventing";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
// No custom enum mappings required for the Eventing module.
|
||||
}
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Eventing.EfCore.CompiledModels;
|
||||
using StellaOps.Eventing.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Eventing.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="EventingDbContext"/> 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 EventingDbContextFactory
|
||||
{
|
||||
public static EventingDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? EventingDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<EventingDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, EventingDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(EventingDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new EventingDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,27 @@
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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\EventingDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Eventing.EfCore.Models;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Postgres;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Eventing.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ITimelineEventStore"/>.
|
||||
/// PostgreSQL implementation of <see cref="ITimelineEventStore"/> backed by EF Core.
|
||||
/// </summary>
|
||||
public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresTimelineEventStore> _logger;
|
||||
private readonly EventingDataSource? _eventingDataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresTimelineEventStore"/> class.
|
||||
/// Uses the raw NpgsqlDataSource (legacy DI path) or EventingDataSource (EF Core DI path).
|
||||
/// </summary>
|
||||
public PostgresTimelineEventStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresTimelineEventStore> logger)
|
||||
ILogger<PostgresTimelineEventStore> logger,
|
||||
EventingDataSource? eventingDataSource = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventingDataSource = eventingDataSource;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -34,27 +38,17 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timelineEvent);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO timeline.events (
|
||||
event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
) VALUES (
|
||||
@event_id, @t_hlc, @ts_wall, @service, @trace_parent,
|
||||
@correlation_id, @kind, @payload::jsonb, @payload_digest,
|
||||
@engine_name, @engine_version, @engine_digest, @dsse_sig, @schema_version
|
||||
)
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
AddEventParameters(command, timelineEvent);
|
||||
var entity = MapToEntity(timelineEvent);
|
||||
dbContext.Events.Add(entity);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (rowsAffected == 0)
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
_logger.LogDebug("Event {EventId} already exists (idempotent insert)", timelineEvent.EventId);
|
||||
}
|
||||
@@ -72,28 +66,25 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO timeline.events (
|
||||
event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
) VALUES (
|
||||
@event_id, @t_hlc, @ts_wall, @service, @trace_parent,
|
||||
@correlation_id, @kind, @payload::jsonb, @payload_digest,
|
||||
@engine_name, @engine_version, @engine_digest, @dsse_sig, @schema_version
|
||||
)
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
""";
|
||||
|
||||
foreach (var timelineEvent in eventList)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
AddEventParameters(command, timelineEvent);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var entity = MapToEntity(timelineEvent);
|
||||
dbContext.Events.Add(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Idempotent: event already exists, detach and continue
|
||||
dbContext.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -116,24 +107,19 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
const string sql = """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE correlation_id = @correlation_id
|
||||
ORDER BY t_hlc ASC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
command.Parameters.AddWithValue("@limit", limit);
|
||||
command.Parameters.AddWithValue("@offset", offset);
|
||||
var entities = await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CorrelationId == correlationId)
|
||||
.OrderBy(e => e.THlc)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -145,25 +131,22 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
const string sql = """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE correlation_id = @correlation_id
|
||||
AND t_hlc >= @from_hlc
|
||||
AND t_hlc <= @to_hlc
|
||||
ORDER BY t_hlc ASC
|
||||
""";
|
||||
var fromStr = fromHlc.ToSortableString();
|
||||
var toStr = toHlc.ToSortableString();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
command.Parameters.AddWithValue("@from_hlc", fromHlc.ToSortableString());
|
||||
command.Parameters.AddWithValue("@to_hlc", toHlc.ToSortableString());
|
||||
var entities = await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CorrelationId == correlationId
|
||||
&& string.Compare(e.THlc, fromStr) >= 0
|
||||
&& string.Compare(e.THlc, toStr) <= 0)
|
||||
.OrderBy(e => e.THlc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -175,38 +158,26 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(service);
|
||||
|
||||
var sql = fromHlc.HasValue
|
||||
? """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE service = @service AND t_hlc >= @from_hlc
|
||||
ORDER BY t_hlc ASC
|
||||
LIMIT @limit
|
||||
"""
|
||||
: """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE service = @service
|
||||
ORDER BY t_hlc ASC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@service", service);
|
||||
command.Parameters.AddWithValue("@limit", limit);
|
||||
IQueryable<TimelineEventEntity> query = dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Service == service);
|
||||
|
||||
if (fromHlc.HasValue)
|
||||
{
|
||||
command.Parameters.AddWithValue("@from_hlc", fromHlc.Value.ToSortableString());
|
||||
var fromStr = fromHlc.Value.ToSortableString();
|
||||
query = query.Where(e => string.Compare(e.THlc, fromStr) >= 0);
|
||||
}
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
var entities = await query
|
||||
.OrderBy(e => e.THlc)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -214,21 +185,15 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
|
||||
|
||||
const string sql = """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE event_id = @event_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@event_id", eventId);
|
||||
var entity = await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.EventId == eventId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var events = await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return events.Count > 0 ? events[0] : null;
|
||||
return entity is null ? null : MapToDomain(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -236,78 +201,75 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
const string sql = """
|
||||
SELECT COUNT(*) FROM timeline.events WHERE correlation_id = @correlation_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
|
||||
return await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CorrelationId == correlationId)
|
||||
.LongCountAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AddEventParameters(NpgsqlCommand command, TimelineEvent e)
|
||||
private EfCore.Context.EventingDbContext CreateDbContext(NpgsqlConnection connection)
|
||||
{
|
||||
command.Parameters.AddWithValue("@event_id", e.EventId);
|
||||
command.Parameters.AddWithValue("@t_hlc", e.THlc.ToSortableString());
|
||||
command.Parameters.AddWithValue("@ts_wall", e.TsWall);
|
||||
command.Parameters.AddWithValue("@service", e.Service);
|
||||
command.Parameters.AddWithValue("@trace_parent", (object?)e.TraceParent ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@correlation_id", e.CorrelationId);
|
||||
command.Parameters.AddWithValue("@kind", e.Kind);
|
||||
command.Parameters.AddWithValue("@payload", e.Payload);
|
||||
command.Parameters.AddWithValue("@payload_digest", e.PayloadDigest);
|
||||
command.Parameters.AddWithValue("@engine_name", e.EngineVersion.EngineName);
|
||||
command.Parameters.AddWithValue("@engine_version", e.EngineVersion.Version);
|
||||
command.Parameters.AddWithValue("@engine_digest", e.EngineVersion.SourceDigest);
|
||||
command.Parameters.AddWithValue("@dsse_sig", (object?)e.DsseSig ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@schema_version", e.SchemaVersion);
|
||||
var commandTimeout = _eventingDataSource?.CommandTimeoutSeconds ?? 30;
|
||||
var schemaName = _eventingDataSource?.SchemaName ?? EventingDataSource.DefaultSchemaName;
|
||||
return EventingDbContextFactory.Create(connection, commandTimeout, schemaName);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<TimelineEvent>> ExecuteQueryAsync(
|
||||
NpgsqlCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
private static TimelineEventEntity MapToEntity(TimelineEvent e)
|
||||
{
|
||||
var events = new List<TimelineEvent>();
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
return new TimelineEventEntity
|
||||
{
|
||||
events.Add(MapFromReader(reader));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static TimelineEvent MapFromReader(NpgsqlDataReader reader)
|
||||
{
|
||||
var hlcString = reader.GetString(reader.GetOrdinal("t_hlc"));
|
||||
|
||||
return new TimelineEvent
|
||||
{
|
||||
EventId = reader.GetString(reader.GetOrdinal("event_id")),
|
||||
THlc = HlcTimestamp.Parse(hlcString),
|
||||
TsWall = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("ts_wall")),
|
||||
Service = reader.GetString(reader.GetOrdinal("service")),
|
||||
TraceParent = reader.IsDBNull(reader.GetOrdinal("trace_parent"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("trace_parent")),
|
||||
CorrelationId = reader.GetString(reader.GetOrdinal("correlation_id")),
|
||||
Kind = reader.GetString(reader.GetOrdinal("kind")),
|
||||
Payload = reader.GetString(reader.GetOrdinal("payload")),
|
||||
PayloadDigest = (byte[])reader.GetValue(reader.GetOrdinal("payload_digest")),
|
||||
EngineVersion = new EngineVersionRef(
|
||||
reader.GetString(reader.GetOrdinal("engine_name")),
|
||||
reader.GetString(reader.GetOrdinal("engine_version")),
|
||||
reader.GetString(reader.GetOrdinal("engine_digest"))),
|
||||
DsseSig = reader.IsDBNull(reader.GetOrdinal("dsse_sig"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("dsse_sig")),
|
||||
SchemaVersion = reader.GetInt32(reader.GetOrdinal("schema_version"))
|
||||
EventId = e.EventId,
|
||||
THlc = e.THlc.ToSortableString(),
|
||||
TsWall = e.TsWall,
|
||||
Service = e.Service,
|
||||
TraceParent = e.TraceParent,
|
||||
CorrelationId = e.CorrelationId,
|
||||
Kind = e.Kind,
|
||||
Payload = e.Payload,
|
||||
PayloadDigest = e.PayloadDigest,
|
||||
EngineName = e.EngineVersion.EngineName,
|
||||
EngineVersion = e.EngineVersion.Version,
|
||||
EngineDigest = e.EngineVersion.SourceDigest,
|
||||
DsseSig = e.DsseSig,
|
||||
SchemaVersion = e.SchemaVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineEvent MapToDomain(TimelineEventEntity entity)
|
||||
{
|
||||
return new TimelineEvent
|
||||
{
|
||||
EventId = entity.EventId,
|
||||
THlc = HlcTimestamp.Parse(entity.THlc),
|
||||
TsWall = entity.TsWall,
|
||||
Service = entity.Service,
|
||||
TraceParent = entity.TraceParent,
|
||||
CorrelationId = entity.CorrelationId,
|
||||
Kind = entity.Kind,
|
||||
Payload = entity.Payload,
|
||||
PayloadDigest = entity.PayloadDigest,
|
||||
EngineVersion = new EngineVersionRef(
|
||||
entity.EngineName,
|
||||
entity.EngineVersion,
|
||||
entity.EngineDigest),
|
||||
DsseSig = entity.DsseSig,
|
||||
SchemaVersion = entity.SchemaVersion
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Eventing Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_079_Eventing_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -9,3 +9,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0077-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0077-A | TODO | Revalidated 2026-01-08 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| EVENT-EF-01 | DONE | AGENTS verified, migration plugin registered in Platform.Database. |
|
||||
| EVENT-EF-02 | DONE | EF Core models and DbContext scaffolded under EfCore/Context and EfCore/Models. |
|
||||
| EVENT-EF-03 | DONE | PostgresTimelineEventStore and TimelineOutboxProcessor converted to EF Core. |
|
||||
| EVENT-EF-04 | DONE | Compiled model stubs, design-time factory, and runtime factory added. |
|
||||
| EVENT-EF-05 | DONE | Sequential build/test pass (28/28 tests). Sprint and docs updated. |
|
||||
|
||||
@@ -8,11 +8,22 @@ Provide PostgreSQL persistence for evidence records with tenant isolation.
|
||||
- Ensure RLS/tenant scoping is enforced on every operation.
|
||||
- Track task status in `TASKS.md`.
|
||||
|
||||
## DAL Technology
|
||||
- **Primary**: EF Core v10 (converted from Npgsql/Dapper in Sprint 078).
|
||||
- **Runtime factory**: `EvidenceDbContextFactory` applies compiled model for default schema.
|
||||
- **Design-time factory**: `EvidenceDesignTimeDbContextFactory` for `dotnet ef` CLI.
|
||||
- **Schema**: `evidence` (single migration: `001_initial_schema.sql`).
|
||||
- **Migration registry**: Registered as `EvidenceMigrationModulePlugin` in Platform.Database.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/evidence/unified-model.md`
|
||||
- `docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md`
|
||||
- `docs/db/EF_CORE_RUNTIME_CUTOVER_STRATEGY.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status in the sprint file and local `TASKS.md`.
|
||||
- 2. Prefer deterministic ordering and stable pagination.
|
||||
- 3. Add tests for tenant isolation and migration behavior.
|
||||
- 4. EF Core models are scaffolded FROM SQL migrations, never the reverse.
|
||||
- 5. No EF Core auto-migrations at runtime.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for EvidenceDbContext.
|
||||
/// 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.EvidenceDbContext))]
|
||||
public partial class EvidenceDbContextModel : RuntimeModel
|
||||
{
|
||||
private static EvidenceDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new EvidenceDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -1,21 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Evidence module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// EF Core DbContext for the Evidence module.
|
||||
/// Maps to the evidence PostgreSQL schema: records table.
|
||||
/// </summary>
|
||||
public class EvidenceDbContext : DbContext
|
||||
public partial class EvidenceDbContext : DbContext
|
||||
{
|
||||
public EvidenceDbContext(DbContextOptions<EvidenceDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public EvidenceDbContext(DbContextOptions<EvidenceDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "evidence"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<EvidenceRecordEntity> Records { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("evidence");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- records -------------------------------------------------------
|
||||
modelBuilder.Entity<EvidenceRecordEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EvidenceId).HasName("records_pkey");
|
||||
entity.ToTable("records", schemaName);
|
||||
|
||||
// Index for subject-based queries (most common access pattern)
|
||||
entity.HasIndex(e => new { e.SubjectNodeId, e.EvidenceType }, "idx_evidence_subject");
|
||||
|
||||
// Index for type-based queries with recency ordering
|
||||
entity.HasIndex(e => new { e.EvidenceType, e.CreatedAt }, "idx_evidence_type")
|
||||
.IsDescending(false, true);
|
||||
|
||||
// Index for tenant-based queries with recency ordering
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_evidence_tenant")
|
||||
.IsDescending(false, true);
|
||||
|
||||
// Index for external CID lookups (partial index)
|
||||
entity.HasIndex(e => e.ExternalCid, "idx_evidence_external_cid")
|
||||
.HasFilter("(external_cid IS NOT NULL)");
|
||||
|
||||
entity.Property(e => e.EvidenceId).HasColumnName("evidence_id");
|
||||
entity.Property(e => e.SubjectNodeId).HasColumnName("subject_node_id");
|
||||
entity.Property(e => e.EvidenceType).HasColumnName("evidence_type");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload");
|
||||
entity.Property(e => e.PayloadSchemaVer).HasColumnName("payload_schema_ver");
|
||||
entity.Property(e => e.ExternalCid).HasColumnName("external_cid");
|
||||
entity.Property(e => e.Provenance)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("provenance");
|
||||
entity.Property(e => e.Signatures)
|
||||
.HasColumnType("jsonb")
|
||||
.HasDefaultValueSql("'[]'::jsonb")
|
||||
.HasColumnName("signatures");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for EF Core CLI tooling (scaffold, optimize, etc.).
|
||||
/// </summary>
|
||||
public sealed class EvidenceDesignTimeDbContextFactory : IDesignTimeDbContextFactory<EvidenceDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=evidence,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_EVIDENCE_EF_CONNECTION";
|
||||
|
||||
public EvidenceDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<EvidenceDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new EvidenceDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for evidence.records table.
|
||||
/// Scaffolded from 001_initial_schema.sql.
|
||||
/// </summary>
|
||||
public partial class EvidenceRecordEntity
|
||||
{
|
||||
public string EvidenceId { get; set; } = null!;
|
||||
public string SubjectNodeId { get; set; } = null!;
|
||||
public short EvidenceType { get; set; }
|
||||
public byte[] Payload { get; set; } = null!;
|
||||
public string PayloadSchemaVer { get; set; } = null!;
|
||||
public string? ExternalCid { get; set; }
|
||||
public string Provenance { get; set; } = null!;
|
||||
public string Signatures { get; set; } = null!;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Evidence.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Evidence.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="EvidenceDbContext"/> 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 EvidenceDbContextFactory
|
||||
{
|
||||
public static EvidenceDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? EvidenceDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<EvidenceDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, EvidenceDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(EvidenceDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new EvidenceDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
public sealed partial class PostgresEvidenceStore
|
||||
@@ -7,23 +9,15 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
||||
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM evidence.records
|
||||
WHERE subject_node_id = @subjectNodeId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await ExecuteScalarAsync<long>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
|
||||
return (int)result;
|
||||
return await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.CountAsync(r => r.SubjectNodeId == subjectNodeId && r.TenantId == tenantGuid, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
public sealed partial class PostgresEvidenceStore
|
||||
@@ -7,21 +9,16 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM evidence.records
|
||||
WHERE evidence_id = @evidenceId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var affected = await ExecuteAsync(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@evidenceId", evidenceId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
|
||||
var affected = await dbContext.Records
|
||||
.Where(r => r.EvidenceId == evidenceId && r.TenantId == tenantGuid)
|
||||
.ExecuteDeleteAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -9,26 +10,20 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
||||
|
||||
const string sql = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM evidence.records
|
||||
WHERE subject_node_id = @subjectNodeId
|
||||
AND evidence_type = @evidenceType
|
||||
AND tenant_id = @tenantId
|
||||
)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await ExecuteScalarAsync<bool>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
||||
AddParameter(cmd, "@evidenceType", (short)type);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
var typeValue = (short)type;
|
||||
|
||||
return result;
|
||||
return await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.AnyAsync(r =>
|
||||
r.SubjectNodeId == subjectNodeId &&
|
||||
r.EvidenceType == typeValue &&
|
||||
r.TenantId == tenantGuid,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -9,23 +10,15 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
|
||||
|
||||
const string sql = """
|
||||
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures
|
||||
FROM evidence.records
|
||||
WHERE evidence_id = @evidenceId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync<IEvidence>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@evidenceId", evidenceId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
MapEvidence,
|
||||
ct).ConfigureAwait(false);
|
||||
var entity = await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.EvidenceId == evidenceId && r.TenantId == Guid.Parse(_tenantId), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : MapFromEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -12,34 +13,25 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
||||
|
||||
var sql = """
|
||||
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures
|
||||
FROM evidence.records
|
||||
WHERE subject_node_id = @subjectNodeId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
|
||||
var query = dbContext.Records
|
||||
.AsNoTracking()
|
||||
.Where(r => r.SubjectNodeId == subjectNodeId && r.TenantId == tenantGuid);
|
||||
|
||||
if (typeFilter.HasValue)
|
||||
{
|
||||
sql += " AND evidence_type = @evidenceType";
|
||||
var typeValue = (short)typeFilter.Value;
|
||||
query = query.Where(r => r.EvidenceType == typeValue);
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC";
|
||||
query = query.OrderByDescending(r => r.CreatedAt);
|
||||
|
||||
return await QueryAsync<IEvidence>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
if (typeFilter.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "@evidenceType", (short)typeFilter.Value);
|
||||
}
|
||||
},
|
||||
MapEvidence,
|
||||
ct).ConfigureAwait(false);
|
||||
var entities = await query.ToListAsync(ct).ConfigureAwait(false);
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -10,26 +11,21 @@ public sealed partial class PostgresEvidenceStore
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures
|
||||
FROM evidence.records
|
||||
WHERE evidence_type = @evidenceType
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync<IEvidence>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@evidenceType", (short)evidenceType);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
AddParameter(cmd, "@limit", limit);
|
||||
},
|
||||
MapEvidence,
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
var typeValue = (short)evidenceType;
|
||||
|
||||
var entities = await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EvidenceType == typeValue && r.TenantId == tenantGuid)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
using Npgsql;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.Evidence.Persistence.EfCore.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
private static IEvidence MapEvidence(NpgsqlDataReader reader)
|
||||
private static IEvidence MapFromEntity(EvidenceRecordEntity entity)
|
||||
{
|
||||
var evidenceId = reader.GetString(0);
|
||||
var subjectNodeId = reader.GetString(1);
|
||||
var evidenceType = (EvidenceType)reader.GetInt16(2);
|
||||
var payload = reader.GetFieldValue<byte[]>(3);
|
||||
var payloadSchemaVer = reader.GetString(4);
|
||||
var externalCid = GetNullableString(reader, 5);
|
||||
var provenanceJson = reader.GetString(6);
|
||||
var signaturesJson = reader.GetString(7);
|
||||
var provenance = JsonSerializer.Deserialize<EvidenceProvenance>(entity.Provenance, _jsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize provenance for evidence {entity.EvidenceId}");
|
||||
|
||||
var provenance = JsonSerializer.Deserialize<EvidenceProvenance>(provenanceJson, _jsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize provenance for evidence {evidenceId}");
|
||||
|
||||
var signatures = JsonSerializer.Deserialize<List<EvidenceSignature>>(signaturesJson, _jsonOptions)
|
||||
var signatures = JsonSerializer.Deserialize<List<EvidenceSignature>>(entity.Signatures, _jsonOptions)
|
||||
?? [];
|
||||
|
||||
return new EvidenceRecord
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
SubjectNodeId = subjectNodeId,
|
||||
EvidenceType = evidenceType,
|
||||
Payload = payload,
|
||||
PayloadSchemaVersion = payloadSchemaVer,
|
||||
ExternalPayloadCid = externalCid,
|
||||
EvidenceId = entity.EvidenceId,
|
||||
SubjectNodeId = entity.SubjectNodeId,
|
||||
EvidenceType = (EvidenceType)entity.EvidenceType,
|
||||
Payload = entity.Payload,
|
||||
PayloadSchemaVersion = entity.PayloadSchemaVer,
|
||||
ExternalPayloadCid = entity.ExternalCid,
|
||||
Provenance = provenance,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private EvidenceRecordEntity MapToEntity(IEvidence evidence)
|
||||
{
|
||||
return new EvidenceRecordEntity
|
||||
{
|
||||
EvidenceId = evidence.EvidenceId,
|
||||
SubjectNodeId = evidence.SubjectNodeId,
|
||||
EvidenceType = (short)evidence.EvidenceType,
|
||||
Payload = evidence.Payload.ToArray(),
|
||||
PayloadSchemaVer = evidence.PayloadSchemaVersion,
|
||||
ExternalCid = evidence.ExternalPayloadCid,
|
||||
Provenance = JsonSerializer.Serialize(evidence.Provenance, _jsonOptions),
|
||||
Signatures = JsonSerializer.Serialize(evidence.Signatures, _jsonOptions),
|
||||
TenantId = Guid.Parse(_tenantId)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Evidence.Core;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
@@ -12,43 +11,34 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO evidence.records (
|
||||
evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures, tenant_id
|
||||
) VALUES (
|
||||
@evidenceId, @subjectNodeId, @evidenceType, @payload,
|
||||
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
|
||||
)
|
||||
ON CONFLICT (evidence_id) DO NOTHING
|
||||
RETURNING evidence_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
AddEvidenceParameters(command, evidence);
|
||||
var entity = MapToEntity(evidence);
|
||||
dbContext.Records.Add(entity);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Row already existed (idempotent ON CONFLICT DO NOTHING equivalent)
|
||||
}
|
||||
|
||||
// If result is null, row already existed (idempotent)
|
||||
return evidence.EvidenceId;
|
||||
}
|
||||
|
||||
private void AddEvidenceParameters(NpgsqlCommand command, IEvidence evidence)
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
AddParameter(command, "@evidenceId", evidence.EvidenceId);
|
||||
AddParameter(command, "@subjectNodeId", evidence.SubjectNodeId);
|
||||
AddParameter(command, "@evidenceType", (short)evidence.EvidenceType);
|
||||
command.Parameters.Add(new NpgsqlParameter<byte[]>("@payload", NpgsqlDbType.Bytea)
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
TypedValue = evidence.Payload.ToArray()
|
||||
});
|
||||
AddParameter(command, "@payloadSchemaVer", evidence.PayloadSchemaVersion);
|
||||
AddParameter(command, "@externalCid", evidence.ExternalPayloadCid);
|
||||
AddJsonbParameter(command, "@provenance", JsonSerializer.Serialize(evidence.Provenance, _jsonOptions));
|
||||
AddJsonbParameter(command, "@signatures", JsonSerializer.Serialize(evidence.Signatures, _jsonOptions));
|
||||
AddParameter(command, "@tenantId", Guid.Parse(_tenantId));
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
@@ -16,37 +17,28 @@ public sealed partial class PostgresEvidenceStore
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var storedCount = 0;
|
||||
|
||||
foreach (var evidence in records)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO evidence.records (
|
||||
evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures, tenant_id
|
||||
) VALUES (
|
||||
@evidenceId, @subjectNodeId, @evidenceType, @payload,
|
||||
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
|
||||
)
|
||||
ON CONFLICT (evidence_id) DO NOTHING
|
||||
""";
|
||||
var entity = MapToEntity(evidence);
|
||||
dbContext.Records.Add(entity);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction)
|
||||
{
|
||||
CommandTimeout = CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
AddEvidenceParameters(command, evidence);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
if (affected > 0)
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
storedCount++;
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Row already existed (idempotent); detach the entity to reset change tracker
|
||||
dbContext.Entry(entity).State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEvidenceStore"/>.
|
||||
/// PostgreSQL (EF Core) implementation of <see cref="IEvidenceStore"/>.
|
||||
/// Stores evidence records with content-addressed IDs and tenant isolation via RLS.
|
||||
/// </summary>
|
||||
public sealed partial class PostgresEvidenceStore : RepositoryBase<EvidenceDataSource>, IEvidenceStore
|
||||
public sealed partial class PostgresEvidenceStore : IEvidenceStore
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly EvidenceDataSource _dataSource;
|
||||
private readonly string _tenantId;
|
||||
private readonly ILogger<PostgresEvidenceStore> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -27,9 +32,15 @@ public sealed partial class PostgresEvidenceStore : RepositoryBase<EvidenceDataS
|
||||
EvidenceDataSource dataSource,
|
||||
string tenantId,
|
||||
ILogger<PostgresEvidenceStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataSource);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_dataSource = dataSource;
|
||||
_tenantId = tenantId;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => EvidenceDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
<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\EvidenceDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Evidence Persistence Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_078_Evidence_persistence_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -10,3 +10,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0081-A | TODO | Revalidated 2026-01-08 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-07 | DONE | Split PostgresEvidenceStore into partials; dotnet test 2026-02-04 (35 tests). |
|
||||
| EVID-EF-01 | DONE | AGENTS.md verified, migration plugin registered in Platform.Database. |
|
||||
| EVID-EF-02 | DONE | EF Core DbContext and model scaffolded from 001_initial_schema.sql. |
|
||||
| EVID-EF-03 | DONE | All repository partials converted from Npgsql to EF Core LINQ. |
|
||||
| EVID-EF-04 | DONE | Compiled model stub, design-time factory, runtime factory with UseModel(). |
|
||||
| EVID-EF-05 | DONE | Sequential builds pass (0 errors, 0 warnings). Docs/TASKS updated. |
|
||||
|
||||
@@ -104,7 +104,8 @@ public static class MigrationServiceExtensions
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
Func<TOptions, string> connectionStringSelector)
|
||||
Func<TOptions, string> connectionStringSelector,
|
||||
string? resourcePrefix = null)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddSingleton<IMigrationStatusService>(sp =>
|
||||
@@ -118,7 +119,8 @@ public static class MigrationServiceExtensions
|
||||
schemaName,
|
||||
moduleName,
|
||||
migrationsAssembly,
|
||||
logger);
|
||||
logger,
|
||||
resourcePrefix);
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -217,6 +219,13 @@ public sealed record MigrationStatus
|
||||
/// </summary>
|
||||
public sealed record PendingMigrationInfo(string Name, MigrationCategory Category);
|
||||
|
||||
/// <summary>
|
||||
/// Migration source descriptor (assembly + optional resource prefix).
|
||||
/// </summary>
|
||||
public sealed record MigrationAssemblySource(
|
||||
Assembly MigrationsAssembly,
|
||||
string? ResourcePrefix = null);
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of migration status service.
|
||||
/// </summary>
|
||||
@@ -225,7 +234,7 @@ public sealed class MigrationStatusService : IMigrationStatusService
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _moduleName;
|
||||
private readonly Assembly _migrationsAssembly;
|
||||
private readonly IReadOnlyList<MigrationAssemblySource> _migrationSources;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public MigrationStatusService(
|
||||
@@ -233,12 +242,30 @@ public sealed class MigrationStatusService : IMigrationStatusService
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger,
|
||||
string? resourcePrefix = null)
|
||||
: this(
|
||||
connectionString,
|
||||
schemaName,
|
||||
moduleName,
|
||||
[new MigrationAssemblySource(migrationsAssembly, resourcePrefix)],
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
public MigrationStatusService(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
IReadOnlyList<MigrationAssemblySource> migrationSources,
|
||||
ILogger logger)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_schemaName = schemaName;
|
||||
_moduleName = moduleName;
|
||||
_migrationsAssembly = migrationsAssembly;
|
||||
_migrationSources = migrationSources is null || migrationSources.Count == 0
|
||||
? throw new ArgumentException("At least one migration source is required.", nameof(migrationSources))
|
||||
: migrationSources;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -338,27 +365,50 @@ public sealed class MigrationStatusService : IMigrationStatusService
|
||||
|
||||
private List<(string Name, MigrationCategory Category, string Checksum)> LoadMigrationsFromAssembly()
|
||||
{
|
||||
var migrations = new List<(string, MigrationCategory, string)>();
|
||||
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name);
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
var migrations = new Dictionary<string, (MigrationCategory Category, string Checksum)>(StringComparer.Ordinal);
|
||||
foreach (var source in _migrationSources)
|
||||
{
|
||||
using var stream = _migrationsAssembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) continue;
|
||||
var resourceNames = source.MigrationsAssembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(name =>
|
||||
string.IsNullOrWhiteSpace(source.ResourcePrefix) ||
|
||||
name.Contains(source.ResourcePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name, StringComparer.Ordinal);
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = reader.ReadToEnd();
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
using var stream = source.MigrationsAssembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileName = ExtractFileName(resourceName);
|
||||
var category = MigrationCategoryExtensions.GetCategory(fileName);
|
||||
var checksum = ComputeChecksum(content);
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = reader.ReadToEnd();
|
||||
|
||||
migrations.Add((fileName, category, checksum));
|
||||
var fileName = ExtractFileName(resourceName);
|
||||
var category = MigrationCategoryExtensions.GetCategory(fileName);
|
||||
var checksum = ComputeChecksum(content);
|
||||
|
||||
if (migrations.TryGetValue(fileName, out var existing))
|
||||
{
|
||||
if (!string.Equals(existing.Checksum, checksum, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate migration name '{fileName}' discovered across migration sources for module '{_moduleName}'.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
migrations[fileName] = (category, checksum);
|
||||
}
|
||||
}
|
||||
|
||||
return migrations;
|
||||
return migrations
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(static pair => (pair.Key, pair.Value.Category, pair.Value.Checksum))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string ExtractFileName(string resourceName)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for ReachGraphDbContext.
|
||||
/// 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.ReachGraphDbContext))]
|
||||
public partial class ReachGraphDbContextModel : RuntimeModel
|
||||
{
|
||||
private static ReachGraphDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new ReachGraphDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for ReachGraphDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class ReachGraphDbContextModel
|
||||
{
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
public partial class ReachGraphDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
// -- FK: slice_cache.subgraph_digest -> subgraphs.digest (ON DELETE CASCADE) --
|
||||
modelBuilder.Entity<SliceCache>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Subgraph)
|
||||
.WithMany(s => s.SliceCaches)
|
||||
.HasForeignKey(e => e.SubgraphDigest)
|
||||
.HasPrincipalKey(s => s.Digest)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the ReachGraph module.
|
||||
/// Maps to the reachgraph PostgreSQL schema: subgraphs, slice_cache, and replay_log tables.
|
||||
/// </summary>
|
||||
public partial class ReachGraphDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public ReachGraphDbContext(DbContextOptions<ReachGraphDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "reachgraph"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<Subgraph> Subgraphs { get; set; }
|
||||
public virtual DbSet<SliceCache> SliceCaches { get; set; }
|
||||
public virtual DbSet<ReplayLog> ReplayLogs { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- subgraphs --------------------------------------------------------
|
||||
modelBuilder.Entity<Subgraph>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Digest).HasName("subgraphs_pkey");
|
||||
entity.ToTable("subgraphs", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.ArtifactDigest, e.CreatedAt }, "idx_subgraphs_tenant_artifact")
|
||||
.IsDescending(false, false, true);
|
||||
entity.HasIndex(e => new { e.ArtifactDigest, e.CreatedAt }, "idx_subgraphs_artifact")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.Digest).HasColumnName("digest");
|
||||
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Scope)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("scope");
|
||||
entity.Property(e => e.NodeCount).HasColumnName("node_count");
|
||||
entity.Property(e => e.EdgeCount).HasColumnName("edge_count");
|
||||
entity.Property(e => e.Blob).HasColumnName("blob");
|
||||
entity.Property(e => e.BlobSizeBytes).HasColumnName("blob_size_bytes");
|
||||
entity.Property(e => e.Provenance)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("provenance");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// -- slice_cache ------------------------------------------------------
|
||||
modelBuilder.Entity<SliceCache>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.CacheKey).HasName("slice_cache_pkey");
|
||||
entity.ToTable("slice_cache", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.ExpiresAt, "idx_slice_cache_expiry");
|
||||
entity.HasIndex(e => new { e.SubgraphDigest, e.CreatedAt }, "idx_slice_cache_subgraph")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.CacheKey).HasColumnName("cache_key");
|
||||
entity.Property(e => e.SubgraphDigest).HasColumnName("subgraph_digest");
|
||||
entity.Property(e => e.SliceBlob).HasColumnName("slice_blob");
|
||||
entity.Property(e => e.QueryType).HasColumnName("query_type");
|
||||
entity.Property(e => e.QueryParams)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("query_params");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.HitCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("hit_count");
|
||||
});
|
||||
|
||||
// -- replay_log -------------------------------------------------------
|
||||
modelBuilder.Entity<ReplayLog>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("replay_log_pkey");
|
||||
entity.ToTable("replay_log", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.SubgraphDigest, e.ComputedAt }, "idx_replay_log_digest")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TenantId, e.ComputedAt }, "idx_replay_log_tenant")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.Matches, e.ComputedAt }, "idx_replay_log_failures")
|
||||
.IsDescending(false, true)
|
||||
.HasFilter("(matches = false)");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.SubgraphDigest).HasColumnName("subgraph_digest");
|
||||
entity.Property(e => e.InputDigests)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("input_digests");
|
||||
entity.Property(e => e.ComputedDigest).HasColumnName("computed_digest");
|
||||
entity.Property(e => e.Matches).HasColumnName("matches");
|
||||
entity.Property(e => e.Divergence)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("divergence");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ComputedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("computed_at");
|
||||
entity.Property(e => e.DurationMs).HasColumnName("duration_ms");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="ReachGraphDbContext"/>.
|
||||
/// Used by <c>dotnet ef</c> CLI tooling for scaffold and optimize commands.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ReachGraphDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=reachgraph,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_REACHGRAPH_EF_CONNECTION";
|
||||
|
||||
public ReachGraphDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<ReachGraphDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new ReachGraphDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for reachgraph.replay_log table.
|
||||
/// Audit log for deterministic replay verification.
|
||||
/// </summary>
|
||||
public partial class ReplayLog
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string SubgraphDigest { get; set; } = null!;
|
||||
public string InputDigests { get; set; } = null!;
|
||||
public string ComputedDigest { get; set; } = null!;
|
||||
public bool Matches { get; set; }
|
||||
public string? Divergence { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public DateTime ComputedAt { get; set; }
|
||||
public int DurationMs { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
public partial class SliceCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: parent subgraph (FK: subgraph_digest -> subgraphs.digest).
|
||||
/// </summary>
|
||||
public virtual Subgraph? Subgraph { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for reachgraph.slice_cache table.
|
||||
/// Precomputed slices for hot queries.
|
||||
/// </summary>
|
||||
public partial class SliceCache
|
||||
{
|
||||
public string CacheKey { get; set; } = null!;
|
||||
public string SubgraphDigest { get; set; } = null!;
|
||||
public byte[] SliceBlob { get; set; } = null!;
|
||||
public string QueryType { get; set; } = null!;
|
||||
public string QueryParams { get; set; } = null!;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public int HitCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
public partial class Subgraph
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: cached slices derived from this subgraph.
|
||||
/// </summary>
|
||||
public virtual ICollection<SliceCache> SliceCaches { get; set; } = new List<SliceCache>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for reachgraph.subgraphs table.
|
||||
/// Content-addressed storage for reachability subgraphs.
|
||||
/// </summary>
|
||||
public partial class Subgraph
|
||||
{
|
||||
public string Digest { get; set; } = null!;
|
||||
public string ArtifactDigest { get; set; } = null!;
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string Scope { get; set; } = null!;
|
||||
public int NodeCount { get; set; }
|
||||
public int EdgeCount { get; set; }
|
||||
public byte[] Blob { get; set; } = null!;
|
||||
public int BlobSizeBytes { get; set; }
|
||||
public string Provenance { get; set; } = null!;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the ReachGraph module.
|
||||
/// Manages connections for reachability graph persistence.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for ReachGraph tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "reachgraph";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ReachGraph data source.
|
||||
/// </summary>
|
||||
public ReachGraphDataSource(IOptions<PostgresOptions> options, ILogger<ReachGraphDataSource> logger)
|
||||
: base(EnsureSchema(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "ReachGraph";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
// No custom enum types for ReachGraph; JSONB columns use string storage.
|
||||
}
|
||||
|
||||
private static PostgresOptions EnsureSchema(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="ReachGraphDbContext"/> 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 ReachGraphDbContextFactory
|
||||
{
|
||||
public static ReachGraphDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? ReachGraphDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ReachGraphDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, ReachGraphDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(ReachGraphDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new ReachGraphDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
@@ -16,24 +17,16 @@ public sealed partial class PostgresReachGraphRepository
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
AND tenant_id = @TenantId
|
||||
RETURNING digest
|
||||
""";
|
||||
var affected = await dbContext.Subgraphs
|
||||
.Where(s => s.Digest == digest && s.TenantId == tenantId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { Digest = digest, TenantId = tenantId },
|
||||
cancellationToken: cancellationToken);
|
||||
var deleted = await connection.QuerySingleOrDefaultAsync<string>(command).ConfigureAwait(false);
|
||||
|
||||
if (deleted is not null)
|
||||
if (affected > 0)
|
||||
{
|
||||
_logger.LogInformation("Deleted reachability graph {Digest}", digest);
|
||||
return true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
@@ -16,29 +17,23 @@ public sealed partial class PostgresReachGraphRepository
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
SELECT blob
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
AND tenant_id = @TenantId
|
||||
""";
|
||||
var entity = await dbContext.Subgraphs
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Digest == digest && s.TenantId == tenantId)
|
||||
.Select(s => s.Blob)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { Digest = digest, TenantId = tenantId },
|
||||
cancellationToken: cancellationToken);
|
||||
var blob = await connection.QuerySingleOrDefaultAsync<byte[]>(command).ConfigureAwait(false);
|
||||
|
||||
if (blob is null)
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var decompressed = ReachGraphPersistenceCodec.DecompressGzip(blob);
|
||||
var decompressed = ReachGraphPersistenceCodec.DecompressGzip(entity);
|
||||
return _serializer.Deserialize(decompressed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -19,34 +20,37 @@ public sealed partial class PostgresReachGraphRepository
|
||||
var effectiveLimit = ClampLimit(limit);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE artifact_digest = @ArtifactDigest
|
||||
AND tenant_id = @TenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
var entities = await dbContext.Subgraphs
|
||||
.AsNoTracking()
|
||||
.Where(s => s.ArtifactDigest == artifactDigest && s.TenantId == tenantId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Take(effectiveLimit)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Digest,
|
||||
s.ArtifactDigest,
|
||||
s.NodeCount,
|
||||
s.EdgeCount,
|
||||
s.BlobSizeBytes,
|
||||
s.CreatedAt,
|
||||
s.Scope
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { ArtifactDigest = artifactDigest, TenantId = tenantId, Limit = effectiveLimit },
|
||||
cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
return entities.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
|
||||
Digest = row.Digest,
|
||||
ArtifactDigest = row.ArtifactDigest,
|
||||
NodeCount = row.NodeCount,
|
||||
EdgeCount = row.EdgeCount,
|
||||
BlobSizeBytes = row.BlobSizeBytes,
|
||||
CreatedAt = new DateTimeOffset(DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc)),
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope(row.Scope)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -62,35 +66,39 @@ public sealed partial class PostgresReachGraphRepository
|
||||
var effectiveLimit = ClampLimit(limit);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE scope->'cves' @> @CveJson::jsonb
|
||||
AND tenant_id = @TenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// The GIN index on scope->'cves' requires raw SQL for jsonb containment (@>).
|
||||
// EF Core LINQ does not translate jsonb containment operators.
|
||||
var cveJson = JsonSerializer.Serialize(new[] { cveId });
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { CveJson = cveJson, TenantId = tenantId, Limit = effectiveLimit },
|
||||
cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
var entities = await dbContext.Subgraphs
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT digest, artifact_digest, tenant_id, scope, node_count, edge_count,
|
||||
blob, blob_size_bytes, provenance, created_at
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE scope->'cves' @> {0}::jsonb
|
||||
AND tenant_id = {1}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT {2}
|
||||
""",
|
||||
cveJson, tenantId, effectiveLimit)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
|
||||
Digest = row.Digest,
|
||||
ArtifactDigest = row.ArtifactDigest,
|
||||
NodeCount = row.NodeCount,
|
||||
EdgeCount = row.EdgeCount,
|
||||
BlobSizeBytes = row.BlobSizeBytes,
|
||||
CreatedAt = new DateTimeOffset(DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc)),
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope(row.Scope)
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
@@ -14,36 +15,36 @@ public sealed partial class PostgresReachGraphRepository
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, entry.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var inputsJson = ReachGraphPersistenceCodec.SerializeInputs(entry.InputDigests);
|
||||
var divergenceJson = ReachGraphPersistenceCodec.SerializeDivergence(entry.Divergence);
|
||||
|
||||
const string sql = """
|
||||
// Use raw SQL for the INSERT with jsonb casts since EF Core
|
||||
// does not natively handle the ::jsonb cast in parameterized inserts.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO reachgraph.replay_log (
|
||||
subgraph_digest, input_digests, computed_digest, matches,
|
||||
divergence, tenant_id, duration_ms
|
||||
)
|
||||
VALUES (
|
||||
@SubgraphDigest, @InputDigests::jsonb, @ComputedDigest, @Matches,
|
||||
@Divergence::jsonb, @TenantId, @DurationMs
|
||||
{0}, {1}::jsonb, {2}, {3},
|
||||
{4}::jsonb, {5}, {6}
|
||||
)
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(sql, new
|
||||
{
|
||||
entry.SubgraphDigest,
|
||||
InputDigests = inputsJson,
|
||||
entry.ComputedDigest,
|
||||
entry.Matches,
|
||||
Divergence = divergenceJson,
|
||||
entry.TenantId,
|
||||
entry.DurationMs
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
await connection.ExecuteAsync(command).ConfigureAwait(false);
|
||||
""",
|
||||
[
|
||||
entry.SubgraphDigest,
|
||||
inputsJson,
|
||||
entry.ComputedDigest,
|
||||
entry.Matches,
|
||||
(object?)divergenceJson ?? DBNull.Value,
|
||||
entry.TenantId,
|
||||
entry.DurationMs
|
||||
],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded replay {Result} for {Digest} (computed: {Computed}, {Duration}ms)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
@@ -24,39 +25,35 @@ public sealed partial class PostgresReachGraphRepository
|
||||
var provenanceJson = ReachGraphPersistenceCodec.SerializeProvenance(graph.Provenance);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
// Use raw SQL for INSERT ON CONFLICT DO NOTHING + RETURNING.
|
||||
// EF Core does not support INSERT ... ON CONFLICT natively.
|
||||
var result = await dbContext.Database.SqlQueryRaw<DateTime?>(
|
||||
"""
|
||||
INSERT INTO reachgraph.subgraphs (
|
||||
digest, artifact_digest, tenant_id, scope, node_count, edge_count,
|
||||
blob, blob_size_bytes, provenance
|
||||
)
|
||||
VALUES (
|
||||
@Digest, @ArtifactDigest, @TenantId, @Scope::jsonb, @NodeCount, @EdgeCount,
|
||||
@Blob, @BlobSizeBytes, @Provenance::jsonb
|
||||
{0}, {1}, {2}, {3}::jsonb, {4}, {5},
|
||||
{6}, {7}, {8}::jsonb
|
||||
)
|
||||
ON CONFLICT (digest) DO NOTHING
|
||||
RETURNING created_at
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(sql, new
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactDigest = graph.Artifact.Digest,
|
||||
TenantId = tenantId,
|
||||
Scope = scopeJson,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
Blob = compressedBlob,
|
||||
BlobSizeBytes = compressedBlob.Length,
|
||||
Provenance = provenanceJson
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
var result = await connection
|
||||
.QuerySingleOrDefaultAsync<DateTime?>(command)
|
||||
.ConfigureAwait(false);
|
||||
""",
|
||||
digest,
|
||||
graph.Artifact.Digest,
|
||||
tenantId,
|
||||
scopeJson,
|
||||
graph.Nodes.Length,
|
||||
graph.Edges.Length,
|
||||
compressedBlob,
|
||||
compressedBlob.Length,
|
||||
provenanceJson
|
||||
).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var created = result.HasValue;
|
||||
var storedAt = result.HasValue
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
private static async Task SetTenantContextAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT set_config('app.tenant_id', @TenantId, false);";
|
||||
command.Parameters.AddWithValue("TenantId", tenantId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
// Tenant context is now managed by DataSourceBase.OpenConnectionAsync(tenantId, role).
|
||||
// The base class sets app.tenant_id and app.current_tenant via ConfigureSessionAsync.
|
||||
// No per-repository tenant context setup is needed.
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the ReachGraph repository.
|
||||
/// PostgreSQL (EF Core) implementation of the ReachGraph repository.
|
||||
/// </summary>
|
||||
public sealed partial class PostgresReachGraphRepository : IReachGraphRepository
|
||||
{
|
||||
private const int MaxLimit = 100;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private readonly ReachGraphDataSource _dataSource;
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
private readonly ReachGraphDigestComputer _digestComputer;
|
||||
private readonly ILogger<PostgresReachGraphRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresReachGraphRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ReachGraphDataSource dataSource,
|
||||
CanonicalReachGraphSerializer serializer,
|
||||
ReachGraphDigestComputer digestComputer,
|
||||
ILogger<PostgresReachGraphRepository> logger,
|
||||
@@ -41,4 +42,6 @@ public sealed partial class PostgresReachGraphRepository : IReachGraphRepository
|
||||
|
||||
return Math.Min(limit, MaxLimit);
|
||||
}
|
||||
|
||||
private string GetSchemaName() => ReachGraphDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -13,17 +13,26 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.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\ReachGraphDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ReachGraph Persistence Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_076_ReachGraph_persistence_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -10,3 +10,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0104-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Enforced tenant filters in list/get/delete queries, added Intent traits for tests; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Persistence.Tests/StellaOps.ReachGraph.Persistence.Tests.csproj` passed 2026-02-03. |
|
||||
| RGRAPH-EF-01 | DONE | AGENTS.md verified; migration plugin registered in MigrationModulePlugins.cs; Platform.Database.csproj updated with project reference. |
|
||||
| RGRAPH-EF-02 | DONE | EF Core models scaffolded: Subgraph, SliceCache, ReplayLog under EfCore/Models/; ReachGraphDbContext with full OnModelCreating under EfCore/Context/; partial file for FK relationships. |
|
||||
| RGRAPH-EF-03 | DONE | All repository partials converted from Dapper to EF Core: Get, List, Store (INSERT ON CONFLICT via raw SQL), Delete (ExecuteDeleteAsync), Replay (ExecuteSqlRawAsync). Tenant partial simplified (DataSourceBase handles tenant context). Interface unchanged. |
|
||||
| RGRAPH-EF-04 | DONE | Design-time factory created (STELLAOPS_REACHGRAPH_EF_CONNECTION env var). Compiled model stubs created under EfCore/CompiledModels/. Runtime factory with UseModel for default schema. .csproj updated with EF Core packages and assembly attribute exclusion. |
|
||||
| RGRAPH-EF-05 | DONE | Sequential build passed (0 warnings, 0 errors) for both persistence and test projects. TASKS.md and sprint tracker updated. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AGENTS.md — StellaOps.Verdict Module
|
||||
# AGENTS.md -- StellaOps.Verdict Module
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -8,30 +8,67 @@ The StellaOps.Verdict module provides a **unified StellaVerdict artifact** that
|
||||
|
||||
```
|
||||
src/__Libraries/StellaOps.Verdict/
|
||||
├── Schema/
|
||||
│ └── StellaVerdict.cs # Core verdict schema and supporting types
|
||||
├── Contexts/
|
||||
│ └── verdict-1.0.jsonld # JSON-LD context for standards interop
|
||||
├── Services/
|
||||
│ ├── VerdictAssemblyService.cs # Assembles verdicts from components
|
||||
│ ├── VerdictSigningService.cs # DSSE signing integration
|
||||
│ └── IVerdictAssemblyService.cs
|
||||
├── Persistence/
|
||||
│ ├── PostgresVerdictStore.cs # PostgreSQL storage implementation
|
||||
│ ├── IVerdictStore.cs # Storage interface
|
||||
│ ├── VerdictRow.cs # EF Core entity
|
||||
│ └── Migrations/
|
||||
│ └── 001_create_verdicts.sql
|
||||
├── Api/
|
||||
│ ├── VerdictEndpoints.cs # REST API endpoints
|
||||
│ └── VerdictContracts.cs # Request/response DTOs
|
||||
├── Oci/
|
||||
│ └── OciAttestationPublisher.cs # OCI registry attestation
|
||||
├── Export/
|
||||
│ └── VerdictBundleExporter.cs # Replay bundle export
|
||||
└── StellaOps.Verdict.csproj
|
||||
+-- Schema/
|
||||
| +-- StellaVerdict.cs # Core verdict schema and supporting types
|
||||
+-- Contexts/
|
||||
| +-- verdict-1.0.jsonld # JSON-LD context for standards interop
|
||||
+-- Services/
|
||||
| +-- VerdictAssemblyService.cs # Assembles verdicts from components
|
||||
| +-- VerdictSigningService.cs # DSSE signing integration
|
||||
| +-- IVerdictAssemblyService.cs
|
||||
+-- Persistence/
|
||||
| +-- PostgresVerdictStore.cs # PostgreSQL (EF Core) storage implementation
|
||||
| +-- IVerdictStore.cs # Storage interface
|
||||
| +-- VerdictRow.cs # EF Core entity (Fluent API mappings)
|
||||
| +-- EfCore/
|
||||
| | +-- Context/
|
||||
| | | +-- VerdictDbContext.cs # Partial DbContext with Fluent API
|
||||
| | | +-- VerdictDesignTimeDbContextFactory.cs # For dotnet ef CLI
|
||||
| | +-- CompiledModels/
|
||||
| | +-- VerdictDbContextModel.cs # Compiled model singleton
|
||||
| | +-- VerdictDbContextModelBuilder.cs # Compiled model builder
|
||||
| | +-- VerdictDbContextAssemblyAttributes.cs # Excluded from compilation
|
||||
| +-- Postgres/
|
||||
| | +-- VerdictDataSource.cs # DataSourceBase derivation, connection pool
|
||||
| | +-- VerdictDbContextFactory.cs # Runtime factory with compiled model hookup
|
||||
| +-- Migrations/
|
||||
| +-- 001_create_verdicts.sql
|
||||
+-- Api/
|
||||
| +-- VerdictEndpoints.cs # REST API endpoints
|
||||
| +-- VerdictContracts.cs # Request/response DTOs
|
||||
| +-- VerdictPolicies.cs # Authorization policies
|
||||
+-- Oci/
|
||||
| +-- OciAttestationPublisher.cs # OCI registry attestation
|
||||
+-- Export/
|
||||
| +-- VerdictBundleExporter.cs # Replay bundle export
|
||||
+-- StellaOps.Verdict.csproj
|
||||
```
|
||||
|
||||
## DAL Architecture (EF Core v10)
|
||||
|
||||
The Verdict persistence layer follows the EF Core v10 standards documented in `docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md`:
|
||||
|
||||
- **DbContext**: `VerdictDbContext` (partial class, schema-injectable, Fluent API mappings)
|
||||
- **Schema**: `stellaops` (shared platform schema)
|
||||
- **Design-time factory**: `VerdictDesignTimeDbContextFactory` (for `dotnet ef` CLI)
|
||||
- **Runtime factory**: `VerdictDbContextFactory` (compiled model for default schema, reflection for non-default)
|
||||
- **DataSource**: `VerdictDataSource` extends `DataSourceBase` for connection pooling and tenant context
|
||||
- **Compiled model**: Stub in `EfCore/CompiledModels/`; assembly attributes excluded from compilation
|
||||
- **Migration registry**: Registered as `VerdictMigrationModulePlugin` in Platform.Database
|
||||
|
||||
### Connection Pattern
|
||||
```csharp
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString(), "reader", ct);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
// Use context.Verdicts with AsNoTracking() for reads...
|
||||
```
|
||||
|
||||
### Schema Governance
|
||||
- SQL migrations in `Persistence/Migrations/` are the authoritative schema definition
|
||||
- EF Core models are derived from schema, not the reverse
|
||||
- No EF Core auto-migrations at runtime
|
||||
- Schema changes require new SQL migration files
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### StellaVerdict Schema
|
||||
@@ -115,6 +152,7 @@ var result = await publisher.PublishAsync(verdict, "registry.io/app:latest@sha25
|
||||
- `StellaOps.Attestor.Envelope`: DSSE signing
|
||||
- `StellaOps.Cryptography`: BLAKE3/SHA256 hashing
|
||||
- `StellaOps.Replay.Core`: Bundle structures
|
||||
- `StellaOps.Infrastructure.Postgres`: DataSourceBase, PostgresOptions, connection pooling
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -126,7 +164,7 @@ Unit tests should cover:
|
||||
- Query filtering and pagination
|
||||
|
||||
Integration tests should cover:
|
||||
- Full assembly → sign → store → query → verify flow
|
||||
- Full assembly -> sign -> store -> query -> verify flow
|
||||
- OCI publish/fetch cycle
|
||||
- Replay bundle export and verification
|
||||
|
||||
@@ -135,10 +173,14 @@ Integration tests should cover:
|
||||
1. **Determinism**: All JSON output must be deterministic (sorted keys, stable ordering)
|
||||
2. **Content Addressing**: VerdictId must match `ComputeVerdictId()` output
|
||||
3. **Immutability**: Use records with `init` properties
|
||||
4. **Tenant Isolation**: All store operations must include tenantId
|
||||
4. **Tenant Isolation**: All store operations must include tenantId; RLS enforced at DB level
|
||||
5. **Offline Support**: OCI publisher and CLI must handle offline mode
|
||||
6. **EF Core Standards**: Follow `docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md`
|
||||
7. **AsNoTracking**: Always use for read-only queries
|
||||
8. **DbContext per operation**: Create via VerdictDbContextFactory, not cached
|
||||
|
||||
## Related Sprints
|
||||
|
||||
- SPRINT_1227_0014_0001: StellaVerdict Unified Artifact Consolidation
|
||||
- SPRINT_1227_0014_0002: Verdict UI Components (pending)
|
||||
- SPRINT_20260222_080: Verdict Persistence DAL to EF Core (queue order 16)
|
||||
|
||||
@@ -42,68 +42,68 @@ public static class VerdictEndpoints
|
||||
.WithName("verdict.create")
|
||||
.Produces<VerdictResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Create);
|
||||
|
||||
// GET /v1/verdicts/{id} - Get verdict by ID
|
||||
group.MapGet("/{id}", HandleGet)
|
||||
.WithName("verdict.get")
|
||||
.Produces<StellaVerdict>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Read);
|
||||
|
||||
// GET /v1/verdicts - Query verdicts
|
||||
group.MapGet("/", HandleQuery)
|
||||
.WithName("verdict.query")
|
||||
.Produces<VerdictQueryResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Read);
|
||||
|
||||
// POST /v1/verdicts/build - Build deterministic verdict with CGS (CGS-003)
|
||||
group.MapPost("/build", HandleBuild)
|
||||
.WithName("verdict.build")
|
||||
.Produces<CgsVerdictResult>(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Create);
|
||||
|
||||
// GET /v1/verdicts/cgs/{cgsHash} - Replay verdict by CGS hash (CGS-004)
|
||||
group.MapGet("/cgs/{cgsHash}", HandleReplay)
|
||||
.WithName("verdict.replay")
|
||||
.Produces<CgsVerdictResult>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Read);
|
||||
|
||||
// POST /v1/verdicts/diff - Compute verdict delta (CGS-005)
|
||||
group.MapPost("/diff", HandleDiff)
|
||||
.WithName("verdict.diff")
|
||||
.Produces<VerdictDelta>(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Read);
|
||||
|
||||
// POST /v1/verdicts/{id}/verify - Verify verdict signature
|
||||
group.MapPost("/{id}/verify", HandleVerify)
|
||||
.WithName("verdict.verify")
|
||||
.Produces<VerdictVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Read);
|
||||
|
||||
// GET /v1/verdicts/{id}/download - Download signed JSON-LD
|
||||
group.MapGet("/{id}/download", HandleDownload)
|
||||
.WithName("verdict.download")
|
||||
.Produces<StellaVerdict>(StatusCodes.Status200OK, "application/ld+json")
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Read);
|
||||
|
||||
// GET /v1/verdicts/latest - Get latest verdict for PURL+CVE
|
||||
group.MapGet("/latest", HandleGetLatest)
|
||||
.WithName("verdict.latest")
|
||||
.Produces<StellaVerdict>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization(VerdictPolicies.Read);
|
||||
|
||||
// DELETE /v1/verdicts/expired - Clean up expired verdicts
|
||||
group.MapDelete("/expired", HandleDeleteExpired)
|
||||
.WithName("verdict.deleteExpired")
|
||||
.Produces<ExpiredDeleteResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization("verdict:admin");
|
||||
.RequireAuthorization(VerdictPolicies.Admin);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreate(
|
||||
|
||||
20
src/__Libraries/StellaOps.Verdict/Api/VerdictPolicies.cs
Normal file
20
src/__Libraries/StellaOps.Verdict/Api/VerdictPolicies.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Verdict.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for Verdict endpoints.
|
||||
/// Consuming services must register these policies (e.g., via AddStellaOpsScopePolicy)
|
||||
/// mapping them to the appropriate scopes (evidence:read, evidence:create).
|
||||
/// </summary>
|
||||
public static class VerdictPolicies
|
||||
{
|
||||
/// <summary>Policy for reading verdicts, querying, replaying, verifying, and downloading. Maps to evidence:read scope.</summary>
|
||||
public const string Read = "Verdict.Read";
|
||||
|
||||
/// <summary>Policy for creating verdicts and building deterministic verdicts via CGS. Maps to evidence:create scope.</summary>
|
||||
public const string Create = "Verdict.Create";
|
||||
|
||||
/// <summary>Policy for administrative verdict operations such as deleting expired verdicts. Maps to verdict:admin scope.</summary>
|
||||
public const string Admin = "Verdict.Admin";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Auto-generated by EF Core compiled model tooling.
|
||||
// This file is excluded from compilation via .csproj to allow non-default schema
|
||||
// integration tests to use reflection-based model building.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Verdict.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Verdict.Persistence.EfCore.Context;
|
||||
|
||||
[assembly: DbContext(typeof(VerdictDbContext), typeof(VerdictDbContextModel))]
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Verdict.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for VerdictDbContext.
|
||||
/// 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.VerdictDbContext))]
|
||||
public partial class VerdictDbContextModel : RuntimeModel
|
||||
{
|
||||
private static VerdictDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new VerdictDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Verdict.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for VerdictDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class VerdictDbContextModel
|
||||
{
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Verdict.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Verdict module.
|
||||
/// Maps to the stellaops PostgreSQL schema: verdicts table.
|
||||
/// Scaffolded from 001_create_verdicts.sql migration.
|
||||
/// </summary>
|
||||
public partial class VerdictDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public VerdictDbContext(DbContextOptions<VerdictDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "stellaops"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<VerdictRow> Verdicts { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- verdicts -------------------------------------------------------
|
||||
modelBuilder.Entity<VerdictRow>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.VerdictId }).HasName("verdicts_pkey");
|
||||
entity.ToTable("verdicts", schemaName);
|
||||
|
||||
// -- Indexes matching 001_create_verdicts.sql -------------------
|
||||
entity.HasIndex(e => new { e.TenantId, e.SubjectPurl }, "idx_verdicts_purl");
|
||||
entity.HasIndex(e => new { e.TenantId, e.SubjectCveId }, "idx_verdicts_cve");
|
||||
entity.HasIndex(e => new { e.TenantId, e.SubjectPurl, e.SubjectCveId }, "idx_verdicts_purl_cve");
|
||||
entity.HasIndex(e => new { e.TenantId, e.SubjectImageDigest }, "idx_verdicts_image_digest")
|
||||
.HasFilter("(subject_image_digest IS NOT NULL)");
|
||||
entity.HasIndex(e => new { e.TenantId, e.ClaimStatus }, "idx_verdicts_status");
|
||||
entity.HasIndex(e => new { e.TenantId, e.InputsHash }, "idx_verdicts_inputs_hash");
|
||||
entity.HasIndex(e => new { e.TenantId, e.ExpiresAt }, "idx_verdicts_expires")
|
||||
.HasFilter("(expires_at IS NOT NULL)");
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_verdicts_created")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TenantId, e.ProvenancePolicyBundleId }, "idx_verdicts_policy_bundle")
|
||||
.HasFilter("(provenance_policy_bundle_id IS NOT NULL)");
|
||||
|
||||
// -- Column mappings --------------------------------------------
|
||||
entity.Property(e => e.VerdictId).HasColumnName("verdict_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
|
||||
// Subject fields
|
||||
entity.Property(e => e.SubjectPurl).HasColumnName("subject_purl");
|
||||
entity.Property(e => e.SubjectCveId).HasColumnName("subject_cve_id");
|
||||
entity.Property(e => e.SubjectComponentName).HasColumnName("subject_component_name");
|
||||
entity.Property(e => e.SubjectComponentVersion).HasColumnName("subject_component_version");
|
||||
entity.Property(e => e.SubjectImageDigest).HasColumnName("subject_image_digest");
|
||||
entity.Property(e => e.SubjectDigest).HasColumnName("subject_digest");
|
||||
|
||||
// Claim fields
|
||||
entity.Property(e => e.ClaimStatus).HasColumnName("claim_status");
|
||||
entity.Property(e => e.ClaimConfidence).HasColumnName("claim_confidence");
|
||||
entity.Property(e => e.ClaimVexStatus).HasColumnName("claim_vex_status");
|
||||
|
||||
// Result fields
|
||||
entity.Property(e => e.ResultDisposition).HasColumnName("result_disposition");
|
||||
entity.Property(e => e.ResultScore).HasColumnName("result_score");
|
||||
entity.Property(e => e.ResultMatchedRule).HasColumnName("result_matched_rule");
|
||||
entity.Property(e => e.ResultQuiet)
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("result_quiet");
|
||||
|
||||
// Provenance fields
|
||||
entity.Property(e => e.ProvenanceGenerator).HasColumnName("provenance_generator");
|
||||
entity.Property(e => e.ProvenanceRunId).HasColumnName("provenance_run_id");
|
||||
entity.Property(e => e.ProvenancePolicyBundleId).HasColumnName("provenance_policy_bundle_id");
|
||||
|
||||
// Inputs hash
|
||||
entity.Property(e => e.InputsHash).HasColumnName("inputs_hash");
|
||||
|
||||
// Full verdict JSON
|
||||
entity.Property(e => e.VerdictJson)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verdict_json");
|
||||
|
||||
// Timestamps
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Verdict.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <c>dotnet ef</c> CLI tooling.
|
||||
/// Does NOT use compiled models (uses reflection-based discovery).
|
||||
/// </summary>
|
||||
public sealed class VerdictDesignTimeDbContextFactory : IDesignTimeDbContextFactory<VerdictDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=stellaops,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_VERDICT_EF_CONNECTION";
|
||||
|
||||
public VerdictDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<VerdictDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new VerdictDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Verdict.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Verdict module.
|
||||
/// Manages connections for verdict storage and querying with tenant isolation via RLS.
|
||||
/// </summary>
|
||||
public sealed class VerdictDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for Verdict tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "stellaops";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Verdict data source.
|
||||
/// </summary>
|
||||
public VerdictDataSource(IOptions<PostgresOptions> options, ILogger<VerdictDataSource> logger)
|
||||
: base(EnsureSchema(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "Verdict";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
// Enable JSON support for JSONB verdict_json column
|
||||
}
|
||||
|
||||
private static PostgresOptions EnsureSchema(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Verdict.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Verdict.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Verdict.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="VerdictDbContext"/> 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 VerdictDbContextFactory
|
||||
{
|
||||
public static VerdictDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? VerdictDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<VerdictDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, VerdictDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(VerdictDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new VerdictDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Verdict.Persistence.EfCore.Context;
|
||||
using StellaOps.Verdict.Persistence.Postgres;
|
||||
using StellaOps.Verdict.Schema;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
@@ -10,21 +12,25 @@ using System.Text.Json;
|
||||
namespace StellaOps.Verdict.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of verdict store.
|
||||
/// PostgreSQL (EF Core) implementation of verdict store.
|
||||
/// Uses VerdictDataSource for tenant-scoped connections and VerdictDbContextFactory
|
||||
/// for compiled model support on the default schema path.
|
||||
/// </summary>
|
||||
public sealed class PostgresVerdictStore : IVerdictStore
|
||||
{
|
||||
private readonly IDbContextFactory<VerdictDbContext> _contextFactory;
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly VerdictDataSource _dataSource;
|
||||
private readonly ILogger<PostgresVerdictStore> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresVerdictStore(
|
||||
IDbContextFactory<VerdictDbContext> contextFactory,
|
||||
VerdictDataSource dataSource,
|
||||
ILogger<PostgresVerdictStore> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
@@ -38,7 +44,8 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString(), "writer", cancellationToken);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var row = ToRow(verdict, tenantId);
|
||||
var existing = await context.Verdicts
|
||||
@@ -70,7 +77,8 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
|
||||
public async Task<StellaVerdict?> GetAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString(), "reader", cancellationToken);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var row = await context.Verdicts
|
||||
.AsNoTracking()
|
||||
@@ -81,7 +89,8 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
|
||||
public async Task<VerdictQueryResult> QueryAsync(VerdictQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(query.TenantId.ToString(), "reader", cancellationToken);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var queryable = context.Verdicts
|
||||
.AsNoTracking()
|
||||
@@ -172,7 +181,8 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
|
||||
public async Task<bool> ExistsAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString(), "reader", cancellationToken);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await context.Verdicts
|
||||
.AsNoTracking()
|
||||
@@ -181,7 +191,8 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
|
||||
public async Task<ImmutableArray<StellaVerdict>> GetBySubjectAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString(), "reader", cancellationToken);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await context.Verdicts
|
||||
.AsNoTracking()
|
||||
@@ -194,7 +205,8 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
|
||||
public async Task<StellaVerdict?> GetLatestAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString(), "reader", cancellationToken);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var row = await context.Verdicts
|
||||
@@ -209,7 +221,8 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(Guid tenantId, DateTimeOffset asOf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString(), "writer", cancellationToken);
|
||||
await using var context = VerdictDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var deleted = await context.Verdicts
|
||||
.Where(v => v.TenantId == tenantId && v.ExpiresAt.HasValue && v.ExpiresAt <= asOf)
|
||||
@@ -280,26 +293,6 @@ public sealed class PostgresVerdictStore : IVerdictStore
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(inputsJson));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DbContext for verdict persistence.
|
||||
/// </summary>
|
||||
public sealed class VerdictDbContext : DbContext
|
||||
{
|
||||
public VerdictDbContext(DbContextOptions<VerdictDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<VerdictRow> Verdicts { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<VerdictRow>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.VerdictId });
|
||||
entity.ToTable("verdicts", "stellaops");
|
||||
});
|
||||
}
|
||||
|
||||
private string GetSchemaName() => VerdictDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,84 +1,59 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Verdict.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Database entity for verdict storage.
|
||||
/// Column and table mappings configured via Fluent API in VerdictDbContext.OnModelCreating.
|
||||
/// </summary>
|
||||
[Table("verdicts", Schema = "stellaops")]
|
||||
public sealed class VerdictRow
|
||||
{
|
||||
[Column("verdict_id")]
|
||||
public required string VerdictId { get; set; }
|
||||
|
||||
[Column("tenant_id")]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
// Subject fields
|
||||
[Column("subject_purl")]
|
||||
public required string SubjectPurl { get; set; }
|
||||
|
||||
[Column("subject_cve_id")]
|
||||
public required string SubjectCveId { get; set; }
|
||||
|
||||
[Column("subject_component_name")]
|
||||
public string? SubjectComponentName { get; set; }
|
||||
|
||||
[Column("subject_component_version")]
|
||||
public string? SubjectComponentVersion { get; set; }
|
||||
|
||||
[Column("subject_image_digest")]
|
||||
public string? SubjectImageDigest { get; set; }
|
||||
|
||||
[Column("subject_digest")]
|
||||
public string? SubjectDigest { get; set; }
|
||||
|
||||
// Claim fields
|
||||
[Column("claim_status")]
|
||||
public required string ClaimStatus { get; set; }
|
||||
|
||||
[Column("claim_confidence")]
|
||||
public decimal? ClaimConfidence { get; set; }
|
||||
|
||||
[Column("claim_vex_status")]
|
||||
public string? ClaimVexStatus { get; set; }
|
||||
|
||||
// Result fields
|
||||
[Column("result_disposition")]
|
||||
public required string ResultDisposition { get; set; }
|
||||
|
||||
[Column("result_score")]
|
||||
public decimal? ResultScore { get; set; }
|
||||
|
||||
[Column("result_matched_rule")]
|
||||
public string? ResultMatchedRule { get; set; }
|
||||
|
||||
[Column("result_quiet")]
|
||||
public bool ResultQuiet { get; set; }
|
||||
|
||||
// Provenance fields
|
||||
[Column("provenance_generator")]
|
||||
public required string ProvenanceGenerator { get; set; }
|
||||
|
||||
[Column("provenance_run_id")]
|
||||
public string? ProvenanceRunId { get; set; }
|
||||
|
||||
[Column("provenance_policy_bundle_id")]
|
||||
public string? ProvenancePolicyBundleId { get; set; }
|
||||
|
||||
// Inputs hash
|
||||
[Column("inputs_hash")]
|
||||
public required string InputsHash { get; set; }
|
||||
|
||||
// Full JSON
|
||||
[Column("verdict_json", TypeName = "jsonb")]
|
||||
public required string VerdictJson { get; set; }
|
||||
|
||||
// Timestamps
|
||||
[Column("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
[Column("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
@@ -27,4 +30,14 @@
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Contexts\verdict-1.0.jsonld" LogicalName="StellaOps.Verdict.Contexts.verdict-1.0.jsonld" Condition="Exists('Contexts\verdict-1.0.jsonld')" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embed SQL migrations as resources -->
|
||||
<EmbeddedResource Include="Persistence\Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exclude assembly attribute for non-default schema support -->
|
||||
<Compile Remove="Persistence\EfCore\CompiledModels\VerdictDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# StellaOps.Verdict Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_080_Verdict_persistence_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| VERDICT-EF-01 | DONE | AGENTS.md verified and updated; migration registry plugin wired in Platform.Database. |
|
||||
| VERDICT-EF-02 | DONE | EF Core model scaffolded: VerdictDbContext with Fluent API, VerdictRow entity, EfCore/Context and EfCore/CompiledModels directories. |
|
||||
| VERDICT-EF-03 | DONE | PostgresVerdictStore converted to use VerdictDataSource + VerdictDbContextFactory pattern; inline VerdictDbContext removed. |
|
||||
| VERDICT-EF-04 | DONE | Compiled model stubs generated; assembly attributes excluded from compilation; VerdictDbContextFactory uses compiled model for default schema. |
|
||||
| VERDICT-EF-05 | DONE | Sequential builds pass (0 warnings, 0 errors); module docs and AGENTS.md updated; sprint tracker updated. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Verdict/StellaOps.Verdict.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.Tests;
|
||||
|
||||
internal sealed class ReachGraphPostgresTestHarness : IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ReachGraphDataSource _dataSource;
|
||||
|
||||
public ReachGraphPostgresTestHarness(string connectionString, DateTimeOffset utcNow)
|
||||
{
|
||||
ConnectionString = connectionString;
|
||||
TimeProvider = new FixedTimeProvider(utcNow);
|
||||
_dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = connectionString,
|
||||
SchemaName = ReachGraphDataSource.DefaultSchemaName,
|
||||
CommandTimeoutSeconds = 30
|
||||
});
|
||||
_dataSource = new ReachGraphDataSource(options, NullLogger<ReachGraphDataSource>.Instance);
|
||||
|
||||
var serializer = new CanonicalReachGraphSerializer();
|
||||
var digestComputer = new ReachGraphDigestComputer(serializer);
|
||||
@@ -31,5 +40,5 @@ internal sealed class ReachGraphPostgresTestHarness : IAsyncDisposable
|
||||
|
||||
public PostgresReachGraphRepository Repository { get; }
|
||||
|
||||
public ValueTask DisposeAsync() => _dataSource.DisposeAsync();
|
||||
public async ValueTask DisposeAsync() => await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user