wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -8,11 +8,22 @@ Provide PostgreSQL persistence for evidence records with tenant isolation.
|
||||
- Ensure RLS/tenant scoping is enforced on every operation.
|
||||
- Track task status in `TASKS.md`.
|
||||
|
||||
## DAL Technology
|
||||
- **Primary**: EF Core v10 (converted from Npgsql/Dapper in Sprint 078).
|
||||
- **Runtime factory**: `EvidenceDbContextFactory` applies compiled model for default schema.
|
||||
- **Design-time factory**: `EvidenceDesignTimeDbContextFactory` for `dotnet ef` CLI.
|
||||
- **Schema**: `evidence` (single migration: `001_initial_schema.sql`).
|
||||
- **Migration registry**: Registered as `EvidenceMigrationModulePlugin` in Platform.Database.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/evidence/unified-model.md`
|
||||
- `docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md`
|
||||
- `docs/db/EF_CORE_RUNTIME_CUTOVER_STRATEGY.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status in the sprint file and local `TASKS.md`.
|
||||
- 2. Prefer deterministic ordering and stable pagination.
|
||||
- 3. Add tests for tenant isolation and migration behavior.
|
||||
- 4. EF Core models are scaffolded FROM SQL migrations, never the reverse.
|
||||
- 5. No EF Core auto-migrations at runtime.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for EvidenceDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.EvidenceDbContext))]
|
||||
public partial class EvidenceDbContextModel : RuntimeModel
|
||||
{
|
||||
private static EvidenceDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new EvidenceDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -1,21 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Evidence module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// EF Core DbContext for the Evidence module.
|
||||
/// Maps to the evidence PostgreSQL schema: records table.
|
||||
/// </summary>
|
||||
public class EvidenceDbContext : DbContext
|
||||
public partial class EvidenceDbContext : DbContext
|
||||
{
|
||||
public EvidenceDbContext(DbContextOptions<EvidenceDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public EvidenceDbContext(DbContextOptions<EvidenceDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "evidence"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<EvidenceRecordEntity> Records { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("evidence");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- records -------------------------------------------------------
|
||||
modelBuilder.Entity<EvidenceRecordEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EvidenceId).HasName("records_pkey");
|
||||
entity.ToTable("records", schemaName);
|
||||
|
||||
// Index for subject-based queries (most common access pattern)
|
||||
entity.HasIndex(e => new { e.SubjectNodeId, e.EvidenceType }, "idx_evidence_subject");
|
||||
|
||||
// Index for type-based queries with recency ordering
|
||||
entity.HasIndex(e => new { e.EvidenceType, e.CreatedAt }, "idx_evidence_type")
|
||||
.IsDescending(false, true);
|
||||
|
||||
// Index for tenant-based queries with recency ordering
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_evidence_tenant")
|
||||
.IsDescending(false, true);
|
||||
|
||||
// Index for external CID lookups (partial index)
|
||||
entity.HasIndex(e => e.ExternalCid, "idx_evidence_external_cid")
|
||||
.HasFilter("(external_cid IS NOT NULL)");
|
||||
|
||||
entity.Property(e => e.EvidenceId).HasColumnName("evidence_id");
|
||||
entity.Property(e => e.SubjectNodeId).HasColumnName("subject_node_id");
|
||||
entity.Property(e => e.EvidenceType).HasColumnName("evidence_type");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload");
|
||||
entity.Property(e => e.PayloadSchemaVer).HasColumnName("payload_schema_ver");
|
||||
entity.Property(e => e.ExternalCid).HasColumnName("external_cid");
|
||||
entity.Property(e => e.Provenance)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("provenance");
|
||||
entity.Property(e => e.Signatures)
|
||||
.HasColumnType("jsonb")
|
||||
.HasDefaultValueSql("'[]'::jsonb")
|
||||
.HasColumnName("signatures");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for EF Core CLI tooling (scaffold, optimize, etc.).
|
||||
/// </summary>
|
||||
public sealed class EvidenceDesignTimeDbContextFactory : IDesignTimeDbContextFactory<EvidenceDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=evidence,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_EVIDENCE_EF_CONNECTION";
|
||||
|
||||
public EvidenceDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<EvidenceDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new EvidenceDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Evidence.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for evidence.records table.
|
||||
/// Scaffolded from 001_initial_schema.sql.
|
||||
/// </summary>
|
||||
public partial class EvidenceRecordEntity
|
||||
{
|
||||
public string EvidenceId { get; set; } = null!;
|
||||
public string SubjectNodeId { get; set; } = null!;
|
||||
public short EvidenceType { get; set; }
|
||||
public byte[] Payload { get; set; } = null!;
|
||||
public string PayloadSchemaVer { get; set; } = null!;
|
||||
public string? ExternalCid { get; set; }
|
||||
public string Provenance { get; set; } = null!;
|
||||
public string Signatures { get; set; } = null!;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Evidence.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Evidence.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="EvidenceDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class EvidenceDbContextFactory
|
||||
{
|
||||
public static EvidenceDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? EvidenceDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<EvidenceDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, EvidenceDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(EvidenceDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new EvidenceDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
public sealed partial class PostgresEvidenceStore
|
||||
@@ -7,23 +9,15 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
||||
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM evidence.records
|
||||
WHERE subject_node_id = @subjectNodeId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await ExecuteScalarAsync<long>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
|
||||
return (int)result;
|
||||
return await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.CountAsync(r => r.SubjectNodeId == subjectNodeId && r.TenantId == tenantGuid, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
public sealed partial class PostgresEvidenceStore
|
||||
@@ -7,21 +9,16 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM evidence.records
|
||||
WHERE evidence_id = @evidenceId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var affected = await ExecuteAsync(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@evidenceId", evidenceId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
|
||||
var affected = await dbContext.Records
|
||||
.Where(r => r.EvidenceId == evidenceId && r.TenantId == tenantGuid)
|
||||
.ExecuteDeleteAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -9,26 +10,20 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
||||
|
||||
const string sql = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM evidence.records
|
||||
WHERE subject_node_id = @subjectNodeId
|
||||
AND evidence_type = @evidenceType
|
||||
AND tenant_id = @tenantId
|
||||
)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await ExecuteScalarAsync<bool>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
||||
AddParameter(cmd, "@evidenceType", (short)type);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
var typeValue = (short)type;
|
||||
|
||||
return result;
|
||||
return await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.AnyAsync(r =>
|
||||
r.SubjectNodeId == subjectNodeId &&
|
||||
r.EvidenceType == typeValue &&
|
||||
r.TenantId == tenantGuid,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -9,23 +10,15 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
|
||||
|
||||
const string sql = """
|
||||
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures
|
||||
FROM evidence.records
|
||||
WHERE evidence_id = @evidenceId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync<IEvidence>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@evidenceId", evidenceId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
},
|
||||
MapEvidence,
|
||||
ct).ConfigureAwait(false);
|
||||
var entity = await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.EvidenceId == evidenceId && r.TenantId == Guid.Parse(_tenantId), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : MapFromEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -12,34 +13,25 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
||||
|
||||
var sql = """
|
||||
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures
|
||||
FROM evidence.records
|
||||
WHERE subject_node_id = @subjectNodeId
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
|
||||
var query = dbContext.Records
|
||||
.AsNoTracking()
|
||||
.Where(r => r.SubjectNodeId == subjectNodeId && r.TenantId == tenantGuid);
|
||||
|
||||
if (typeFilter.HasValue)
|
||||
{
|
||||
sql += " AND evidence_type = @evidenceType";
|
||||
var typeValue = (short)typeFilter.Value;
|
||||
query = query.Where(r => r.EvidenceType == typeValue);
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC";
|
||||
query = query.OrderByDescending(r => r.CreatedAt);
|
||||
|
||||
return await QueryAsync<IEvidence>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
if (typeFilter.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "@evidenceType", (short)typeFilter.Value);
|
||||
}
|
||||
},
|
||||
MapEvidence,
|
||||
ct).ConfigureAwait(false);
|
||||
var entities = await query.ToListAsync(ct).ConfigureAwait(false);
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
@@ -10,26 +11,21 @@ public sealed partial class PostgresEvidenceStore
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures
|
||||
FROM evidence.records
|
||||
WHERE evidence_type = @evidenceType
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync<IEvidence>(
|
||||
_tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "@evidenceType", (short)evidenceType);
|
||||
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
||||
AddParameter(cmd, "@limit", limit);
|
||||
},
|
||||
MapEvidence,
|
||||
ct).ConfigureAwait(false);
|
||||
var tenantGuid = Guid.Parse(_tenantId);
|
||||
var typeValue = (short)evidenceType;
|
||||
|
||||
var entities = await dbContext.Records
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EvidenceType == typeValue && r.TenantId == tenantGuid)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
using Npgsql;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.Evidence.Persistence.EfCore.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
private static IEvidence MapEvidence(NpgsqlDataReader reader)
|
||||
private static IEvidence MapFromEntity(EvidenceRecordEntity entity)
|
||||
{
|
||||
var evidenceId = reader.GetString(0);
|
||||
var subjectNodeId = reader.GetString(1);
|
||||
var evidenceType = (EvidenceType)reader.GetInt16(2);
|
||||
var payload = reader.GetFieldValue<byte[]>(3);
|
||||
var payloadSchemaVer = reader.GetString(4);
|
||||
var externalCid = GetNullableString(reader, 5);
|
||||
var provenanceJson = reader.GetString(6);
|
||||
var signaturesJson = reader.GetString(7);
|
||||
var provenance = JsonSerializer.Deserialize<EvidenceProvenance>(entity.Provenance, _jsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize provenance for evidence {entity.EvidenceId}");
|
||||
|
||||
var provenance = JsonSerializer.Deserialize<EvidenceProvenance>(provenanceJson, _jsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize provenance for evidence {evidenceId}");
|
||||
|
||||
var signatures = JsonSerializer.Deserialize<List<EvidenceSignature>>(signaturesJson, _jsonOptions)
|
||||
var signatures = JsonSerializer.Deserialize<List<EvidenceSignature>>(entity.Signatures, _jsonOptions)
|
||||
?? [];
|
||||
|
||||
return new EvidenceRecord
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
SubjectNodeId = subjectNodeId,
|
||||
EvidenceType = evidenceType,
|
||||
Payload = payload,
|
||||
PayloadSchemaVersion = payloadSchemaVer,
|
||||
ExternalPayloadCid = externalCid,
|
||||
EvidenceId = entity.EvidenceId,
|
||||
SubjectNodeId = entity.SubjectNodeId,
|
||||
EvidenceType = (EvidenceType)entity.EvidenceType,
|
||||
Payload = entity.Payload,
|
||||
PayloadSchemaVersion = entity.PayloadSchemaVer,
|
||||
ExternalPayloadCid = entity.ExternalCid,
|
||||
Provenance = provenance,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private EvidenceRecordEntity MapToEntity(IEvidence evidence)
|
||||
{
|
||||
return new EvidenceRecordEntity
|
||||
{
|
||||
EvidenceId = evidence.EvidenceId,
|
||||
SubjectNodeId = evidence.SubjectNodeId,
|
||||
EvidenceType = (short)evidence.EvidenceType,
|
||||
Payload = evidence.Payload.ToArray(),
|
||||
PayloadSchemaVer = evidence.PayloadSchemaVersion,
|
||||
ExternalCid = evidence.ExternalPayloadCid,
|
||||
Provenance = JsonSerializer.Serialize(evidence.Provenance, _jsonOptions),
|
||||
Signatures = JsonSerializer.Serialize(evidence.Signatures, _jsonOptions),
|
||||
TenantId = Guid.Parse(_tenantId)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Evidence.Core;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
@@ -12,43 +11,34 @@ public sealed partial class PostgresEvidenceStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO evidence.records (
|
||||
evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures, tenant_id
|
||||
) VALUES (
|
||||
@evidenceId, @subjectNodeId, @evidenceType, @payload,
|
||||
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
|
||||
)
|
||||
ON CONFLICT (evidence_id) DO NOTHING
|
||||
RETURNING evidence_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
AddEvidenceParameters(command, evidence);
|
||||
var entity = MapToEntity(evidence);
|
||||
dbContext.Records.Add(entity);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Row already existed (idempotent ON CONFLICT DO NOTHING equivalent)
|
||||
}
|
||||
|
||||
// If result is null, row already existed (idempotent)
|
||||
return evidence.EvidenceId;
|
||||
}
|
||||
|
||||
private void AddEvidenceParameters(NpgsqlCommand command, IEvidence evidence)
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
AddParameter(command, "@evidenceId", evidence.EvidenceId);
|
||||
AddParameter(command, "@subjectNodeId", evidence.SubjectNodeId);
|
||||
AddParameter(command, "@evidenceType", (short)evidence.EvidenceType);
|
||||
command.Parameters.Add(new NpgsqlParameter<byte[]>("@payload", NpgsqlDbType.Bytea)
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
TypedValue = evidence.Payload.ToArray()
|
||||
});
|
||||
AddParameter(command, "@payloadSchemaVer", evidence.PayloadSchemaVersion);
|
||||
AddParameter(command, "@externalCid", evidence.ExternalPayloadCid);
|
||||
AddJsonbParameter(command, "@provenance", JsonSerializer.Serialize(evidence.Provenance, _jsonOptions));
|
||||
AddJsonbParameter(command, "@signatures", JsonSerializer.Serialize(evidence.Signatures, _jsonOptions));
|
||||
AddParameter(command, "@tenantId", Guid.Parse(_tenantId));
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Evidence.Core;
|
||||
|
||||
@@ -16,37 +17,28 @@ public sealed partial class PostgresEvidenceStore
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var storedCount = 0;
|
||||
|
||||
foreach (var evidence in records)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO evidence.records (
|
||||
evidence_id, subject_node_id, evidence_type, payload,
|
||||
payload_schema_ver, external_cid, provenance, signatures, tenant_id
|
||||
) VALUES (
|
||||
@evidenceId, @subjectNodeId, @evidenceType, @payload,
|
||||
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
|
||||
)
|
||||
ON CONFLICT (evidence_id) DO NOTHING
|
||||
""";
|
||||
var entity = MapToEntity(evidence);
|
||||
dbContext.Records.Add(entity);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction)
|
||||
{
|
||||
CommandTimeout = CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
AddEvidenceParameters(command, evidence);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
if (affected > 0)
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
storedCount++;
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Row already existed (idempotent); detach the entity to reset change tracker
|
||||
dbContext.Entry(entity).State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEvidenceStore"/>.
|
||||
/// PostgreSQL (EF Core) implementation of <see cref="IEvidenceStore"/>.
|
||||
/// Stores evidence records with content-addressed IDs and tenant isolation via RLS.
|
||||
/// </summary>
|
||||
public sealed partial class PostgresEvidenceStore : RepositoryBase<EvidenceDataSource>, IEvidenceStore
|
||||
public sealed partial class PostgresEvidenceStore : IEvidenceStore
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly EvidenceDataSource _dataSource;
|
||||
private readonly string _tenantId;
|
||||
private readonly ILogger<PostgresEvidenceStore> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -27,9 +32,15 @@ public sealed partial class PostgresEvidenceStore : RepositoryBase<EvidenceDataS
|
||||
EvidenceDataSource dataSource,
|
||||
string tenantId,
|
||||
ILogger<PostgresEvidenceStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataSource);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_dataSource = dataSource;
|
||||
_tenantId = tenantId;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => EvidenceDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\EvidenceDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Evidence Persistence Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_078_Evidence_persistence_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -10,3 +10,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0081-A | TODO | Revalidated 2026-01-08 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-07 | DONE | Split PostgresEvidenceStore into partials; dotnet test 2026-02-04 (35 tests). |
|
||||
| EVID-EF-01 | DONE | AGENTS.md verified, migration plugin registered in Platform.Database. |
|
||||
| EVID-EF-02 | DONE | EF Core DbContext and model scaffolded from 001_initial_schema.sql. |
|
||||
| EVID-EF-03 | DONE | All repository partials converted from Npgsql to EF Core LINQ. |
|
||||
| EVID-EF-04 | DONE | Compiled model stub, design-time factory, runtime factory with UseModel(). |
|
||||
| EVID-EF-05 | DONE | Sequential builds pass (0 errors, 0 warnings). Docs/TASKS updated. |
|
||||
|
||||
Reference in New Issue
Block a user