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

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

View File

@@ -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`).

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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();
}

View File

@@ -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.
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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. |

View File

@@ -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.

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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)
};
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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" />

View File

@@ -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. |

View File

@@ -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)

View File

@@ -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();
}

View File

@@ -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.
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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>();
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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)",

View File

@@ -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

View File

@@ -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.
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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)

View File

@@ -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(

View 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";
}

View File

@@ -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))]

View File

@@ -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();
}

View File

@@ -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.
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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();
}