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:
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for ReachGraphDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.ReachGraphDbContext))]
|
||||
public partial class ReachGraphDbContextModel : RuntimeModel
|
||||
{
|
||||
private static ReachGraphDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new ReachGraphDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for ReachGraphDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class ReachGraphDbContextModel
|
||||
{
|
||||
partial void Initialize()
|
||||
{
|
||||
// Stub: when a real compiled model is generated, entity types will be registered here.
|
||||
// The runtime factory will fall back to reflection-based model building for all schemas
|
||||
// until this stub is replaced with a full compiled model.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
public partial class ReachGraphDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
// -- FK: slice_cache.subgraph_digest -> subgraphs.digest (ON DELETE CASCADE) --
|
||||
modelBuilder.Entity<SliceCache>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Subgraph)
|
||||
.WithMany(s => s.SliceCaches)
|
||||
.HasForeignKey(e => e.SubgraphDigest)
|
||||
.HasPrincipalKey(s => s.Digest)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the ReachGraph module.
|
||||
/// Maps to the reachgraph PostgreSQL schema: subgraphs, slice_cache, and replay_log tables.
|
||||
/// </summary>
|
||||
public partial class ReachGraphDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public ReachGraphDbContext(DbContextOptions<ReachGraphDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "reachgraph"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<Subgraph> Subgraphs { get; set; }
|
||||
public virtual DbSet<SliceCache> SliceCaches { get; set; }
|
||||
public virtual DbSet<ReplayLog> ReplayLogs { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- subgraphs --------------------------------------------------------
|
||||
modelBuilder.Entity<Subgraph>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Digest).HasName("subgraphs_pkey");
|
||||
entity.ToTable("subgraphs", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.ArtifactDigest, e.CreatedAt }, "idx_subgraphs_tenant_artifact")
|
||||
.IsDescending(false, false, true);
|
||||
entity.HasIndex(e => new { e.ArtifactDigest, e.CreatedAt }, "idx_subgraphs_artifact")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.Digest).HasColumnName("digest");
|
||||
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Scope)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("scope");
|
||||
entity.Property(e => e.NodeCount).HasColumnName("node_count");
|
||||
entity.Property(e => e.EdgeCount).HasColumnName("edge_count");
|
||||
entity.Property(e => e.Blob).HasColumnName("blob");
|
||||
entity.Property(e => e.BlobSizeBytes).HasColumnName("blob_size_bytes");
|
||||
entity.Property(e => e.Provenance)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("provenance");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// -- slice_cache ------------------------------------------------------
|
||||
modelBuilder.Entity<SliceCache>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.CacheKey).HasName("slice_cache_pkey");
|
||||
entity.ToTable("slice_cache", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.ExpiresAt, "idx_slice_cache_expiry");
|
||||
entity.HasIndex(e => new { e.SubgraphDigest, e.CreatedAt }, "idx_slice_cache_subgraph")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.CacheKey).HasColumnName("cache_key");
|
||||
entity.Property(e => e.SubgraphDigest).HasColumnName("subgraph_digest");
|
||||
entity.Property(e => e.SliceBlob).HasColumnName("slice_blob");
|
||||
entity.Property(e => e.QueryType).HasColumnName("query_type");
|
||||
entity.Property(e => e.QueryParams)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("query_params");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.HitCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("hit_count");
|
||||
});
|
||||
|
||||
// -- replay_log -------------------------------------------------------
|
||||
modelBuilder.Entity<ReplayLog>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("replay_log_pkey");
|
||||
entity.ToTable("replay_log", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.SubgraphDigest, e.ComputedAt }, "idx_replay_log_digest")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TenantId, e.ComputedAt }, "idx_replay_log_tenant")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.Matches, e.ComputedAt }, "idx_replay_log_failures")
|
||||
.IsDescending(false, true)
|
||||
.HasFilter("(matches = false)");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.SubgraphDigest).HasColumnName("subgraph_digest");
|
||||
entity.Property(e => e.InputDigests)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("input_digests");
|
||||
entity.Property(e => e.ComputedDigest).HasColumnName("computed_digest");
|
||||
entity.Property(e => e.Matches).HasColumnName("matches");
|
||||
entity.Property(e => e.Divergence)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("divergence");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ComputedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("computed_at");
|
||||
entity.Property(e => e.DurationMs).HasColumnName("duration_ms");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="ReachGraphDbContext"/>.
|
||||
/// Used by <c>dotnet ef</c> CLI tooling for scaffold and optimize commands.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ReachGraphDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=reachgraph,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_REACHGRAPH_EF_CONNECTION";
|
||||
|
||||
public ReachGraphDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<ReachGraphDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new ReachGraphDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for reachgraph.replay_log table.
|
||||
/// Audit log for deterministic replay verification.
|
||||
/// </summary>
|
||||
public partial class ReplayLog
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string SubgraphDigest { get; set; } = null!;
|
||||
public string InputDigests { get; set; } = null!;
|
||||
public string ComputedDigest { get; set; } = null!;
|
||||
public bool Matches { get; set; }
|
||||
public string? Divergence { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public DateTime ComputedAt { get; set; }
|
||||
public int DurationMs { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
public partial class SliceCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: parent subgraph (FK: subgraph_digest -> subgraphs.digest).
|
||||
/// </summary>
|
||||
public virtual Subgraph? Subgraph { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for reachgraph.slice_cache table.
|
||||
/// Precomputed slices for hot queries.
|
||||
/// </summary>
|
||||
public partial class SliceCache
|
||||
{
|
||||
public string CacheKey { get; set; } = null!;
|
||||
public string SubgraphDigest { get; set; } = null!;
|
||||
public byte[] SliceBlob { get; set; } = null!;
|
||||
public string QueryType { get; set; } = null!;
|
||||
public string QueryParams { get; set; } = null!;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public int HitCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
public partial class Subgraph
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: cached slices derived from this subgraph.
|
||||
/// </summary>
|
||||
public virtual ICollection<SliceCache> SliceCaches { get; set; } = new List<SliceCache>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for reachgraph.subgraphs table.
|
||||
/// Content-addressed storage for reachability subgraphs.
|
||||
/// </summary>
|
||||
public partial class Subgraph
|
||||
{
|
||||
public string Digest { get; set; } = null!;
|
||||
public string ArtifactDigest { get; set; } = null!;
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string Scope { get; set; } = null!;
|
||||
public int NodeCount { get; set; }
|
||||
public int EdgeCount { get; set; }
|
||||
public byte[] Blob { get; set; } = null!;
|
||||
public int BlobSizeBytes { get; set; }
|
||||
public string Provenance { get; set; } = null!;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the ReachGraph module.
|
||||
/// Manages connections for reachability graph persistence.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for ReachGraph tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "reachgraph";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ReachGraph data source.
|
||||
/// </summary>
|
||||
public ReachGraphDataSource(IOptions<PostgresOptions> options, ILogger<ReachGraphDataSource> logger)
|
||||
: base(EnsureSchema(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "ReachGraph";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
// No custom enum types for ReachGraph; JSONB columns use string storage.
|
||||
}
|
||||
|
||||
private static PostgresOptions EnsureSchema(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.ReachGraph.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="ReachGraphDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class ReachGraphDbContextFactory
|
||||
{
|
||||
public static ReachGraphDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? ReachGraphDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ReachGraphDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, ReachGraphDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(ReachGraphDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new ReachGraphDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
@@ -16,24 +17,16 @@ public sealed partial class PostgresReachGraphRepository
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
AND tenant_id = @TenantId
|
||||
RETURNING digest
|
||||
""";
|
||||
var affected = await dbContext.Subgraphs
|
||||
.Where(s => s.Digest == digest && s.TenantId == tenantId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { Digest = digest, TenantId = tenantId },
|
||||
cancellationToken: cancellationToken);
|
||||
var deleted = await connection.QuerySingleOrDefaultAsync<string>(command).ConfigureAwait(false);
|
||||
|
||||
if (deleted is not null)
|
||||
if (affected > 0)
|
||||
{
|
||||
_logger.LogInformation("Deleted reachability graph {Digest}", digest);
|
||||
return true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
@@ -16,29 +17,23 @@ public sealed partial class PostgresReachGraphRepository
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
SELECT blob
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
AND tenant_id = @TenantId
|
||||
""";
|
||||
var entity = await dbContext.Subgraphs
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Digest == digest && s.TenantId == tenantId)
|
||||
.Select(s => s.Blob)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { Digest = digest, TenantId = tenantId },
|
||||
cancellationToken: cancellationToken);
|
||||
var blob = await connection.QuerySingleOrDefaultAsync<byte[]>(command).ConfigureAwait(false);
|
||||
|
||||
if (blob is null)
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var decompressed = ReachGraphPersistenceCodec.DecompressGzip(blob);
|
||||
var decompressed = ReachGraphPersistenceCodec.DecompressGzip(entity);
|
||||
return _serializer.Deserialize(decompressed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -19,34 +20,37 @@ public sealed partial class PostgresReachGraphRepository
|
||||
var effectiveLimit = ClampLimit(limit);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE artifact_digest = @ArtifactDigest
|
||||
AND tenant_id = @TenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
var entities = await dbContext.Subgraphs
|
||||
.AsNoTracking()
|
||||
.Where(s => s.ArtifactDigest == artifactDigest && s.TenantId == tenantId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Take(effectiveLimit)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Digest,
|
||||
s.ArtifactDigest,
|
||||
s.NodeCount,
|
||||
s.EdgeCount,
|
||||
s.BlobSizeBytes,
|
||||
s.CreatedAt,
|
||||
s.Scope
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { ArtifactDigest = artifactDigest, TenantId = tenantId, Limit = effectiveLimit },
|
||||
cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
return entities.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
|
||||
Digest = row.Digest,
|
||||
ArtifactDigest = row.ArtifactDigest,
|
||||
NodeCount = row.NodeCount,
|
||||
EdgeCount = row.EdgeCount,
|
||||
BlobSizeBytes = row.BlobSizeBytes,
|
||||
CreatedAt = new DateTimeOffset(DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc)),
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope(row.Scope)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -62,35 +66,39 @@ public sealed partial class PostgresReachGraphRepository
|
||||
var effectiveLimit = ClampLimit(limit);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE scope->'cves' @> @CveJson::jsonb
|
||||
AND tenant_id = @TenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// The GIN index on scope->'cves' requires raw SQL for jsonb containment (@>).
|
||||
// EF Core LINQ does not translate jsonb containment operators.
|
||||
var cveJson = JsonSerializer.Serialize(new[] { cveId });
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { CveJson = cveJson, TenantId = tenantId, Limit = effectiveLimit },
|
||||
cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
var entities = await dbContext.Subgraphs
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT digest, artifact_digest, tenant_id, scope, node_count, edge_count,
|
||||
blob, blob_size_bytes, provenance, created_at
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE scope->'cves' @> {0}::jsonb
|
||||
AND tenant_id = {1}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT {2}
|
||||
""",
|
||||
cveJson, tenantId, effectiveLimit)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
|
||||
Digest = row.Digest,
|
||||
ArtifactDigest = row.ArtifactDigest,
|
||||
NodeCount = row.NodeCount,
|
||||
EdgeCount = row.EdgeCount,
|
||||
BlobSizeBytes = row.BlobSizeBytes,
|
||||
CreatedAt = new DateTimeOffset(DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc)),
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope(row.Scope)
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
@@ -14,36 +15,36 @@ public sealed partial class PostgresReachGraphRepository
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, entry.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var inputsJson = ReachGraphPersistenceCodec.SerializeInputs(entry.InputDigests);
|
||||
var divergenceJson = ReachGraphPersistenceCodec.SerializeDivergence(entry.Divergence);
|
||||
|
||||
const string sql = """
|
||||
// Use raw SQL for the INSERT with jsonb casts since EF Core
|
||||
// does not natively handle the ::jsonb cast in parameterized inserts.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO reachgraph.replay_log (
|
||||
subgraph_digest, input_digests, computed_digest, matches,
|
||||
divergence, tenant_id, duration_ms
|
||||
)
|
||||
VALUES (
|
||||
@SubgraphDigest, @InputDigests::jsonb, @ComputedDigest, @Matches,
|
||||
@Divergence::jsonb, @TenantId, @DurationMs
|
||||
{0}, {1}::jsonb, {2}, {3},
|
||||
{4}::jsonb, {5}, {6}
|
||||
)
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(sql, new
|
||||
{
|
||||
entry.SubgraphDigest,
|
||||
InputDigests = inputsJson,
|
||||
entry.ComputedDigest,
|
||||
entry.Matches,
|
||||
Divergence = divergenceJson,
|
||||
entry.TenantId,
|
||||
entry.DurationMs
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
await connection.ExecuteAsync(command).ConfigureAwait(false);
|
||||
""",
|
||||
[
|
||||
entry.SubgraphDigest,
|
||||
inputsJson,
|
||||
entry.ComputedDigest,
|
||||
entry.Matches,
|
||||
(object?)divergenceJson ?? DBNull.Value,
|
||||
entry.TenantId,
|
||||
entry.DurationMs
|
||||
],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded replay {Result} for {Digest} (computed: {Computed}, {Duration}ms)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
@@ -24,39 +25,35 @@ public sealed partial class PostgresReachGraphRepository
|
||||
var provenanceJson = ReachGraphPersistenceCodec.SerializeProvenance(graph.Provenance);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = ReachGraphDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
const string sql = """
|
||||
// Use raw SQL for INSERT ON CONFLICT DO NOTHING + RETURNING.
|
||||
// EF Core does not support INSERT ... ON CONFLICT natively.
|
||||
var result = await dbContext.Database.SqlQueryRaw<DateTime?>(
|
||||
"""
|
||||
INSERT INTO reachgraph.subgraphs (
|
||||
digest, artifact_digest, tenant_id, scope, node_count, edge_count,
|
||||
blob, blob_size_bytes, provenance
|
||||
)
|
||||
VALUES (
|
||||
@Digest, @ArtifactDigest, @TenantId, @Scope::jsonb, @NodeCount, @EdgeCount,
|
||||
@Blob, @BlobSizeBytes, @Provenance::jsonb
|
||||
{0}, {1}, {2}, {3}::jsonb, {4}, {5},
|
||||
{6}, {7}, {8}::jsonb
|
||||
)
|
||||
ON CONFLICT (digest) DO NOTHING
|
||||
RETURNING created_at
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(sql, new
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactDigest = graph.Artifact.Digest,
|
||||
TenantId = tenantId,
|
||||
Scope = scopeJson,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
Blob = compressedBlob,
|
||||
BlobSizeBytes = compressedBlob.Length,
|
||||
Provenance = provenanceJson
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
var result = await connection
|
||||
.QuerySingleOrDefaultAsync<DateTime?>(command)
|
||||
.ConfigureAwait(false);
|
||||
""",
|
||||
digest,
|
||||
graph.Artifact.Digest,
|
||||
tenantId,
|
||||
scopeJson,
|
||||
graph.Nodes.Length,
|
||||
graph.Edges.Length,
|
||||
compressedBlob,
|
||||
compressedBlob.Length,
|
||||
provenanceJson
|
||||
).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var created = result.HasValue;
|
||||
var storedAt = result.HasValue
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
private static async Task SetTenantContextAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT set_config('app.tenant_id', @TenantId, false);";
|
||||
command.Parameters.AddWithValue("TenantId", tenantId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
// Tenant context is now managed by DataSourceBase.OpenConnectionAsync(tenantId, role).
|
||||
// The base class sets app.tenant_id and app.current_tenant via ConfigureSessionAsync.
|
||||
// No per-repository tenant context setup is needed.
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the ReachGraph repository.
|
||||
/// PostgreSQL (EF Core) implementation of the ReachGraph repository.
|
||||
/// </summary>
|
||||
public sealed partial class PostgresReachGraphRepository : IReachGraphRepository
|
||||
{
|
||||
private const int MaxLimit = 100;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private readonly ReachGraphDataSource _dataSource;
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
private readonly ReachGraphDigestComputer _digestComputer;
|
||||
private readonly ILogger<PostgresReachGraphRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresReachGraphRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ReachGraphDataSource dataSource,
|
||||
CanonicalReachGraphSerializer serializer,
|
||||
ReachGraphDigestComputer digestComputer,
|
||||
ILogger<PostgresReachGraphRepository> logger,
|
||||
@@ -41,4 +42,6 @@ public sealed partial class PostgresReachGraphRepository : IReachGraphRepository
|
||||
|
||||
return Math.Min(limit, MaxLimit);
|
||||
}
|
||||
|
||||
private string GetSchemaName() => ReachGraphDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -13,17 +13,26 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\ReachGraphDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ReachGraph Persistence Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_076_ReachGraph_persistence_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -10,3 +10,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0104-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Enforced tenant filters in list/get/delete queries, added Intent traits for tests; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Persistence.Tests/StellaOps.ReachGraph.Persistence.Tests.csproj` passed 2026-02-03. |
|
||||
| RGRAPH-EF-01 | DONE | AGENTS.md verified; migration plugin registered in MigrationModulePlugins.cs; Platform.Database.csproj updated with project reference. |
|
||||
| RGRAPH-EF-02 | DONE | EF Core models scaffolded: Subgraph, SliceCache, ReplayLog under EfCore/Models/; ReachGraphDbContext with full OnModelCreating under EfCore/Context/; partial file for FK relationships. |
|
||||
| RGRAPH-EF-03 | DONE | All repository partials converted from Dapper to EF Core: Get, List, Store (INSERT ON CONFLICT via raw SQL), Delete (ExecuteDeleteAsync), Replay (ExecuteSqlRawAsync). Tenant partial simplified (DataSourceBase handles tenant context). Interface unchanged. |
|
||||
| RGRAPH-EF-04 | DONE | Design-time factory created (STELLAOPS_REACHGRAPH_EF_CONNECTION env var). Compiled model stubs created under EfCore/CompiledModels/. Runtime factory with UseModel for default schema. .csproj updated with EF Core packages and assembly attribute exclusion. |
|
||||
| RGRAPH-EF-05 | DONE | Sequential build passed (0 warnings, 0 errors) for both persistence and test projects. TASKS.md and sprint tracker updated. |
|
||||
|
||||
Reference in New Issue
Block a user