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

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

View File

@@ -0,0 +1,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. |