feat(graph): add Postgres graph runtime repository + compatibility endpoints

Introduces IGraphRuntimeRepository + PostgresGraphRuntimeRepository that back
runtime-path graph reads with real persistence. Graph.Api Program.cs wires
the new repository into the DI graph. InMemory* services get small cleanups
so they remain viable for tests and local dev.

CompatibilityEndpoints: extends the integration-test surface.

Tests: GraphPostgresRuntimeIntegrationTests,
GraphRuntimeRepositoryRegistrationTests, expanded
GraphCompatibilityEndpointsIntegrationTests.

Docs: graph architecture page updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-15 11:15:07 +03:00
parent 786d09b88f
commit ee93c0bac2
19 changed files with 760 additions and 116 deletions

View File

@@ -31,6 +31,7 @@
- `POST /graph/diff` — compares `snapshotA` vs `snapshotB`, streaming node/edge added/removed/changed tiles plus stats; budget enforcement mirrors `/graph/query`. - `POST /graph/diff` — compares `snapshotA` vs `snapshotB`, streaming node/edge added/removed/changed tiles plus stats; budget enforcement mirrors `/graph/query`.
- `POST /graph/export` — async job producing deterministic manifests (`sha256`, size, format) for `ndjson/csv/graphml/png/svg`; download via `/graph/export/{jobId}`. - `POST /graph/export` — async job producing deterministic manifests (`sha256`, size, format) for `ndjson/csv/graphml/png/svg`; download via `/graph/export/{jobId}`.
- `POST /graph/lineage` - returns SBOM lineage nodes/edges anchored by `artifactDigest` or `sbomDigest`, with optional relationship filters and depth limits. - `POST /graph/lineage` - returns SBOM lineage nodes/edges anchored by `artifactDigest` or `sbomDigest`, with optional relationship filters and depth limits.
- Runtime repository selection is config-driven at service resolution time: when `Postgres:Graph` is configured, the live `/graph/query`, `/graph/diff`, and shipped `/graphs*` compatibility surfaces materialize persisted rows from `graph.graph_nodes`, `graph.graph_edges`, and `graph.pending_snapshots`; when it is not configured, the runtime fallback is an empty in-memory repository rather than historical demo-seeded graph data.
- Compatibility facade for the shipped Angular explorer: - Compatibility facade for the shipped Angular explorer:
- `GET /graphs`, `GET /graphs/{graphId}`, `GET /graphs/{graphId}/tiles` - `GET /graphs`, `GET /graphs/{graphId}`, `GET /graphs/{graphId}/tiles`
- `GET /search`, `GET /paths` - `GET /search`, `GET /paths`

View File

@@ -13,7 +13,7 @@ public static class CompatibilityEndpoints
{ {
app.MapGet("/graphs", async Task<IResult> ( app.MapGet("/graphs", async Task<IResult> (
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
CancellationToken ct) => CancellationToken ct) =>
{ {
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
@@ -26,7 +26,7 @@ public static class CompatibilityEndpoints
var nodes = repository.GetCompatibilityNodes(tenantId); var nodes = repository.GetCompatibilityNodes(tenantId);
var edges = repository.GetCompatibilityEdges(tenantId); var edges = repository.GetCompatibilityEdges(tenantId);
var graphId = InMemoryGraphRepository.BuildGraphId(tenantId); var graphId = InMemoryGraphRepository.BuildGraphId(tenantId);
var snapshotAt = repository.GetSnapshotTimestamp(); var snapshotAt = repository.GetSnapshotTimestamp(tenantId);
return Results.Ok(new return Results.Ok(new
{ {
@@ -55,7 +55,7 @@ public static class CompatibilityEndpoints
app.MapGet("/graphs/{graphId}", async Task<IResult> ( app.MapGet("/graphs/{graphId}", async Task<IResult> (
string graphId, string graphId,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
CancellationToken ct) => CancellationToken ct) =>
{ {
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
@@ -72,7 +72,7 @@ public static class CompatibilityEndpoints
var nodes = repository.GetCompatibilityNodes(tenantId); var nodes = repository.GetCompatibilityNodes(tenantId);
var edges = repository.GetCompatibilityEdges(tenantId); var edges = repository.GetCompatibilityEdges(tenantId);
var snapshotAt = repository.GetSnapshotTimestamp(); var snapshotAt = repository.GetSnapshotTimestamp(tenantId);
return Results.Ok(new return Results.Ok(new
{ {
@@ -95,7 +95,7 @@ public static class CompatibilityEndpoints
string graphId, string graphId,
bool? includeOverlays, bool? includeOverlays,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
IOverlayService overlayService, IOverlayService overlayService,
CancellationToken ct) => CancellationToken ct) =>
{ {
@@ -152,7 +152,7 @@ public static class CompatibilityEndpoints
string? graphId, string? graphId,
int? pageSize, int? pageSize,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
CancellationToken ct) => CancellationToken ct) =>
{ {
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
@@ -211,7 +211,7 @@ public static class CompatibilityEndpoints
int? maxDepth, int? maxDepth,
string? graphId, string? graphId,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
CancellationToken ct) => CancellationToken ct) =>
{ {
var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct); var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct);
@@ -257,7 +257,7 @@ public static class CompatibilityEndpoints
string graphId, string graphId,
string? format, string? format,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
IGraphExportService exportService, IGraphExportService exportService,
CancellationToken ct) => CancellationToken ct) =>
{ {
@@ -295,7 +295,7 @@ public static class CompatibilityEndpoints
app.MapGet("/assets/{assetId}/snapshot", async Task<IResult> ( app.MapGet("/assets/{assetId}/snapshot", async Task<IResult> (
string assetId, string assetId,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
CancellationToken ct) => CancellationToken ct) =>
{ {
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
@@ -331,7 +331,7 @@ public static class CompatibilityEndpoints
kind = NormalizeNodeKind(asset.Kind), kind = NormalizeNodeKind(asset.Kind),
components = componentIds, components = componentIds,
vulnerabilities, vulnerabilities,
snapshotAt = repository.GetSnapshotTimestamp() snapshotAt = repository.GetSnapshotTimestamp(tenantId)
}); });
}) })
.RequireTenant(); .RequireTenant();
@@ -340,7 +340,7 @@ public static class CompatibilityEndpoints
string nodeId, string nodeId,
string? graphId, string? graphId,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
CancellationToken ct) => CancellationToken ct) =>
{ {
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
@@ -381,7 +381,7 @@ public static class CompatibilityEndpoints
app.MapGet("/graphs/{graphId}/saved-views", async Task<IResult> ( app.MapGet("/graphs/{graphId}/saved-views", async Task<IResult> (
string graphId, string graphId,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
IGraphSavedViewStore store, IGraphSavedViewStore store,
CancellationToken ct) => CancellationToken ct) =>
{ {
@@ -405,7 +405,7 @@ public static class CompatibilityEndpoints
string graphId, string graphId,
CreateGraphSavedViewRequest request, CreateGraphSavedViewRequest request,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
IGraphSavedViewStore store, IGraphSavedViewStore store,
CancellationToken ct) => CancellationToken ct) =>
{ {
@@ -439,7 +439,7 @@ public static class CompatibilityEndpoints
string graphId, string graphId,
string viewId, string viewId,
HttpContext context, HttpContext context,
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
IGraphSavedViewStore store, IGraphSavedViewStore store,
CancellationToken ct) => CancellationToken ct) =>
{ {

View File

@@ -22,12 +22,16 @@ using static StellaOps.Localization.T;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
// When Postgres is configured, start with an empty repository (data loaded from DB by hosted service). builder.Services.AddSingleton<IGraphRuntimeRepository>(sp =>
// When no Postgres, fall back to hardcoded seed data for demo/development. {
var hasPostgres = !string.IsNullOrWhiteSpace(ResolveGraphConnectionString(builder.Configuration)); var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
builder.Services.AddSingleton(_ => hasPostgres if (string.IsNullOrWhiteSpace(options.ConnectionString))
? new InMemoryGraphRepository(seed: Array.Empty<NodeTile>(), edges: Array.Empty<EdgeTile>()) {
: new InMemoryGraphRepository()); return new InMemoryGraphRepository(seed: Array.Empty<NodeTile>(), edges: Array.Empty<EdgeTile>());
}
return ActivatorUtilities.CreateInstance<PostgresGraphRuntimeRepository>(sp);
});
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>(); builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>(); builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>(); builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
@@ -84,12 +88,6 @@ builder.Services.AddSingleton<PostgresGraphRepository>(sp =>
return null!; return null!;
} }
}); });
// When Postgres is configured, run a hosted service that loads graph data from the DB
// into the InMemoryGraphRepository on startup and refreshes it periodically.
if (hasPostgres)
{
builder.Services.AddHostedService<GraphDataLoaderHostedService>();
}
builder.Services builder.Services
.AddAuthentication(options => .AddAuthentication(options =>
{ {

View File

@@ -0,0 +1,18 @@
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public interface IGraphRuntimeRepository
{
IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request);
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request);
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) GetLineage(string tenant, GraphLineageRequest request);
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId);
IReadOnlyList<NodeTile> GetCompatibilityNodes(string tenant);
IReadOnlyList<EdgeTile> GetCompatibilityEdges(string tenant);
bool GraphExists(string tenant, string graphId);
NodeTile? GetNode(string tenant, string nodeId);
(IReadOnlyList<EdgeTile> Incoming, IReadOnlyList<EdgeTile> Outgoing) GetAdjacency(string tenant, string nodeId);
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth);
DateTimeOffset GetSnapshotTimestamp(string tenant);
}

View File

@@ -15,7 +15,7 @@ namespace StellaOps.Graph.Api.Services;
/// </summary> /// </summary>
public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
{ {
private readonly InMemoryGraphRepository _repository; private readonly IGraphRuntimeRepository _repository;
private readonly ILogger<InMemoryEdgeMetadataService> _logger; private readonly ILogger<InMemoryEdgeMetadataService> _logger;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
@@ -23,7 +23,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
private readonly Dictionary<string, EdgeExplanationPayload> _explanations; private readonly Dictionary<string, EdgeExplanationPayload> _explanations;
public InMemoryEdgeMetadataService( public InMemoryEdgeMetadataService(
InMemoryGraphRepository repository, IGraphRuntimeRepository repository,
ILogger<InMemoryEdgeMetadataService> logger, ILogger<InMemoryEdgeMetadataService> logger,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
@@ -31,8 +31,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
// Seed with default explanations for demo/test data _explanations = new Dictionary<string, EdgeExplanationPayload>(StringComparer.Ordinal);
_explanations = SeedDefaultExplanations();
} }
public Task<EdgeMetadataResponse> GetEdgeMetadataAsync( public Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
@@ -121,11 +120,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
foreach (var edge in allEdges) foreach (var edge in allEdges)
{ {
if (!_explanations.TryGetValue(edge.Id, out var explanation)) var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
{
continue;
}
if (explanation.Reason == reason) if (explanation.Reason == reason)
{ {
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation)); matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
@@ -159,11 +154,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
foreach (var edge in allEdges) foreach (var edge in allEdges)
{ {
if (!_explanations.TryGetValue(edge.Id, out var explanation)) var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
{
continue;
}
if (explanation.Evidence is not null && if (explanation.Evidence is not null &&
explanation.Evidence.TryGetValue(evidenceType, out var refValue) && explanation.Evidence.TryGetValue(evidenceType, out var refValue) &&
string.Equals(refValue, evidenceRef, StringComparison.OrdinalIgnoreCase)) string.Equals(refValue, evidenceRef, StringComparison.OrdinalIgnoreCase))
@@ -239,6 +230,13 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
ConfidenceBps = 10000 ConfidenceBps = 10000
}, },
Summary = summary, Summary = summary,
Provenance = new EdgeProvenanceRef
{
Source = "graph-indexer",
CollectedAt = now,
SbomDigest = TryResolveEvidence(edge, "sbom", "sbom_digest")
},
Evidence = BuildEvidence(edge),
Tags = [edge.Kind] Tags = [edge.Kind]
}; };
} }
@@ -335,64 +333,35 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
return Array.Empty<EdgeTile>(); return Array.Empty<EdgeTile>();
} }
private Dictionary<string, EdgeExplanationPayload> SeedDefaultExplanations() private static Dictionary<string, string>? BuildEvidence(EdgeTile edge)
{ {
var now = _timeProvider.GetUtcNow(); var evidence = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
AddEvidence(evidence, "sbom", edge, "sbom_digest");
AddEvidence(evidence, "artifact", edge, "artifact_digest");
AddEvidence(evidence, "advisory", edge, "advisory_id");
AddEvidence(evidence, "vex", edge, "statement_id");
return evidence.Count == 0 ? null : evidence;
}
return new Dictionary<string, EdgeExplanationPayload>(StringComparer.Ordinal) private static void AddEvidence(Dictionary<string, string> evidence, string evidenceType, EdgeTile edge, string attributeName)
{
var value = TryResolveEvidence(edge, evidenceType, attributeName);
if (!string.IsNullOrWhiteSpace(value))
{ {
["ge:acme:artifact->component"] = EdgeExplanationFactory.FromSbomDependency( evidence[evidenceType] = value;
"sha256:sbom-a", }
"sbom-parser", }
now.AddHours(-1),
"Build artifact produces component"),
["ge:acme:component->component"] = new EdgeExplanationPayload private static string? TryResolveEvidence(EdgeTile edge, string evidenceType, string attributeName)
{ {
Reason = EdgeReason.SbomDependency, if (edge.Attributes.TryGetValue(attributeName, out var attributeValue) && attributeValue is not null)
Via = new EdgeVia {
{ return attributeValue.ToString();
Method = "sbom-parser", }
Timestamp = now.AddHours(-1),
ConfidenceBps = 10000,
EvidenceRef = "sha256:sbom-a"
},
Summary = "example@1.0.0 depends on widget@2.0.0 for runtime",
Evidence = new Dictionary<string, string> { ["sbom"] = "sha256:sbom-a" },
Provenance = new EdgeProvenanceRef
{
Source = "sbom-parser",
CollectedAt = now.AddHours(-1),
SbomDigest = "sha256:sbom-a"
},
Tags = ["runtime", "dependency"]
},
["ge:acme:sbom->artifact"] = new EdgeExplanationPayload return edge.Attributes.TryGetValue(evidenceType, out var directValue) && directValue is not null
{ ? directValue.ToString()
Reason = EdgeReason.SbomDependency, : null;
Via = new EdgeVia
{
Method = "sbom-linker",
Timestamp = now.AddHours(-2),
ConfidenceBps = 10000
},
Summary = "SBOM describes artifact sha256:abc"
},
["ge:acme:sbom->sbom"] = new EdgeExplanationPayload
{
Reason = EdgeReason.Provenance,
Via = new EdgeVia
{
Method = "lineage-tracker",
Timestamp = now.AddDays(-1),
ConfidenceBps = 10000
},
Summary = "SBOM lineage: sbom-b derives from sbom-a",
Tags = ["lineage", "provenance"]
}
};
} }
} }
@@ -404,7 +373,7 @@ public static class InMemoryGraphRepositoryExtensions
/// <summary> /// <summary>
/// Gets a single edge by ID. /// Gets a single edge by ID.
/// </summary> /// </summary>
public static EdgeTile? GetEdge(this InMemoryGraphRepository repository, string tenant, string edgeId) public static EdgeTile? GetEdge(this IGraphRuntimeRepository repository, string tenant, string edgeId)
{ {
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
{ {
@@ -419,7 +388,7 @@ public static class InMemoryGraphRepositoryExtensions
/// <summary> /// <summary>
/// Gets all edges for a tenant. /// Gets all edges for a tenant.
/// </summary> /// </summary>
public static IReadOnlyList<EdgeTile> GetAllEdges(this InMemoryGraphRepository repository, string tenant) public static IReadOnlyList<EdgeTile> GetAllEdges(this IGraphRuntimeRepository repository, string tenant)
{ {
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
{ {

View File

@@ -8,14 +8,14 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphDiffService : IGraphDiffService public sealed class InMemoryGraphDiffService : IGraphDiffService
{ {
private readonly InMemoryGraphRepository _repository; private readonly IGraphRuntimeRepository _repository;
private readonly IGraphMetrics _metrics; private readonly IGraphMetrics _metrics;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{ {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}; };
public InMemoryGraphDiffService(InMemoryGraphRepository repository, IGraphMetrics metrics) public InMemoryGraphDiffService(IGraphRuntimeRepository repository, IGraphMetrics metrics)
{ {
_repository = repository; _repository = repository;
_metrics = metrics; _metrics = metrics;

View File

@@ -8,11 +8,11 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphExportService : IGraphExportService public sealed class InMemoryGraphExportService : IGraphExportService
{ {
private readonly InMemoryGraphRepository _repository; private readonly IGraphRuntimeRepository _repository;
private readonly IGraphMetrics _metrics; private readonly IGraphMetrics _metrics;
private readonly Dictionary<string, GraphExportJob> _jobs = new(StringComparer.Ordinal); private readonly Dictionary<string, GraphExportJob> _jobs = new(StringComparer.Ordinal);
public InMemoryGraphExportService(InMemoryGraphRepository repository, IGraphMetrics metrics) public InMemoryGraphExportService(IGraphRuntimeRepository repository, IGraphMetrics metrics)
{ {
_repository = repository; _repository = repository;
_metrics = metrics; _metrics = metrics;

View File

@@ -7,9 +7,9 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphLineageService : IGraphLineageService public sealed class InMemoryGraphLineageService : IGraphLineageService
{ {
private readonly InMemoryGraphRepository _repository; private readonly IGraphRuntimeRepository _repository;
public InMemoryGraphLineageService(InMemoryGraphRepository repository) public InMemoryGraphLineageService(IGraphRuntimeRepository repository)
{ {
_repository = repository; _repository = repository;
} }

View File

@@ -8,7 +8,7 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphPathService : IGraphPathService public sealed class InMemoryGraphPathService : IGraphPathService
{ {
private readonly InMemoryGraphRepository _repository; private readonly IGraphRuntimeRepository _repository;
private readonly IOverlayService _overlayService; private readonly IOverlayService _overlayService;
private readonly IGraphMetrics _metrics; private readonly IGraphMetrics _metrics;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
@@ -16,7 +16,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}; };
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService, IGraphMetrics metrics) public InMemoryGraphPathService(IGraphRuntimeRepository repository, IOverlayService overlayService, IGraphMetrics metrics)
{ {
_repository = repository; _repository = repository;
_overlayService = overlayService; _overlayService = overlayService;

View File

@@ -10,7 +10,7 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphQueryService : IGraphQueryService public sealed class InMemoryGraphQueryService : IGraphQueryService
{ {
private readonly InMemoryGraphRepository _repository; private readonly IGraphRuntimeRepository _repository;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly IOverlayService _overlayService; private readonly IOverlayService _overlayService;
private readonly IGraphMetrics _metrics; private readonly IGraphMetrics _metrics;
@@ -19,7 +19,7 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}; };
public InMemoryGraphQueryService(InMemoryGraphRepository repository, IMemoryCache cache, IOverlayService overlayService, IGraphMetrics metrics) public InMemoryGraphQueryService(IGraphRuntimeRepository repository, IMemoryCache cache, IOverlayService overlayService, IGraphMetrics metrics)
{ {
_repository = repository; _repository = repository;
_cache = cache; _cache = cache;

View File

@@ -2,7 +2,7 @@ using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services; namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphRepository public sealed class InMemoryGraphRepository : IGraphRuntimeRepository
{ {
private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"]; private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"];
private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero);
@@ -310,7 +310,7 @@ public sealed class InMemoryGraphRepository
return null; return null;
} }
public DateTimeOffset GetSnapshotTimestamp() public DateTimeOffset GetSnapshotTimestamp(string tenant)
=> FixedSnapshotAt; => FixedSnapshotAt;
/// <summary> /// <summary>

View File

@@ -9,14 +9,14 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphSearchService : IGraphSearchService public sealed class InMemoryGraphSearchService : IGraphSearchService
{ {
private readonly InMemoryGraphRepository _repository; private readonly IGraphRuntimeRepository _repository;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{ {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}; };
public InMemoryGraphSearchService(InMemoryGraphRepository repository, IMemoryCache cache) public InMemoryGraphSearchService(IGraphRuntimeRepository repository, IMemoryCache cache)
{ {
_repository = repository; _repository = repository;
_cache = cache; _cache = cache;

View File

@@ -41,7 +41,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
{ {
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
var sql = $"SELECT id, document_json FROM {_schemaName}.graph_nodes ORDER BY id"; var sql = $"SELECT id, document_json::text FROM {_schemaName}.graph_nodes ORDER BY id";
await using var cmd = new NpgsqlCommand(sql, connection); await using var cmd = new NpgsqlCommand(sql, connection);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
@@ -80,7 +80,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
{ {
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
var sql = $"SELECT id, source_id, target_id, document_json FROM {_schemaName}.graph_edges ORDER BY id"; var sql = $"SELECT id, source_id, target_id, document_json::text FROM {_schemaName}.graph_edges ORDER BY id";
await using var cmd = new NpgsqlCommand(sql, connection); await using var cmd = new NpgsqlCommand(sql, connection);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
@@ -115,7 +115,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
{ {
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
var sql = $"SELECT id, document_json FROM {_schemaName}.graph_nodes ORDER BY id"; var sql = $"SELECT id, document_json::text FROM {_schemaName}.graph_nodes ORDER BY id";
await using var cmd = new NpgsqlCommand(sql, connection); await using var cmd = new NpgsqlCommand(sql, connection);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
@@ -148,7 +148,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
{ {
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
var sql = $"SELECT id, source_id, target_id, document_json FROM {_schemaName}.graph_edges ORDER BY id"; var sql = $"SELECT id, source_id, target_id, document_json::text FROM {_schemaName}.graph_edges ORDER BY id";
await using var cmd = new NpgsqlCommand(sql, connection); await using var cmd = new NpgsqlCommand(sql, connection);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
@@ -219,7 +219,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
await _dataSource.DisposeAsync().ConfigureAwait(false); await _dataSource.DisposeAsync().ConfigureAwait(false);
} }
private static NodeTile? ParseNodeTile(string json) internal static NodeTile? ParseNodeTile(string json)
{ {
try try
{ {
@@ -261,7 +261,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
} }
} }
private static EdgeTile? ParseEdgeTile(string json) internal static EdgeTile? ParseEdgeTile(string json)
{ {
try try
{ {

View File

@@ -0,0 +1,213 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Graph.Api.Services;
public sealed class PostgresGraphRuntimeRepository : IGraphRuntimeRepository, IAsyncDisposable
{
private readonly PostgresGraphRepository _graphRepository;
private readonly NpgsqlDataSource _dataSource;
private readonly string _schemaName;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresGraphRuntimeRepository> _logger;
public PostgresGraphRuntimeRepository(
IOptions<PostgresOptions> options,
TimeProvider timeProvider,
ILogger<PostgresGraphRuntimeRepository> logger,
ILogger<PostgresGraphRepository> graphRepositoryLogger)
{
ArgumentNullException.ThrowIfNull(options);
var connectionString = options.Value.ConnectionString
?? throw new InvalidOperationException("Graph Postgres connection string is required for PostgresGraphRuntimeRepository.");
_graphRepository = new PostgresGraphRepository(options, graphRepositoryLogger);
_dataSource = NpgsqlDataSource.Create(connectionString);
_schemaName = string.IsNullOrWhiteSpace(options.Value.SchemaName) ? "graph" : options.Value.SchemaName.Trim();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request)
=> LoadTenantRepository(tenant).Query(tenant, request);
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request)
=> LoadTenantRepository(tenant).QueryGraph(tenant, request);
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) GetLineage(string tenant, GraphLineageRequest request)
=> LoadTenantRepository(tenant).GetLineage(tenant, request);
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId)
=> GetSnapshotAsync(tenant, snapshotId).GetAwaiter().GetResult();
public IReadOnlyList<NodeTile> GetCompatibilityNodes(string tenant)
=> LoadTenantRepository(tenant).GetCompatibilityNodes(tenant);
public IReadOnlyList<EdgeTile> GetCompatibilityEdges(string tenant)
=> LoadTenantRepository(tenant).GetCompatibilityEdges(tenant);
public bool GraphExists(string tenant, string graphId)
=> string.Equals(InMemoryGraphRepository.BuildGraphId(tenant), graphId, StringComparison.OrdinalIgnoreCase);
public NodeTile? GetNode(string tenant, string nodeId)
=> LoadTenantRepository(tenant).GetNode(tenant, nodeId);
public (IReadOnlyList<EdgeTile> Incoming, IReadOnlyList<EdgeTile> Outgoing) GetAdjacency(string tenant, string nodeId)
=> LoadTenantRepository(tenant).GetAdjacency(tenant, nodeId);
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth)
=> LoadTenantRepository(tenant).FindCompatibilityPath(tenant, sourceId, targetId, maxDepth);
public DateTimeOffset GetSnapshotTimestamp(string tenant)
=> GetSnapshotTimestampAsync(tenant).GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
private InMemoryGraphRepository LoadTenantRepository(string tenant)
{
try
{
var nodes = _graphRepository.GetNodesAsync(tenant).GetAwaiter().GetResult();
var edges = _graphRepository.GetEdgesAsync(tenant).GetAwaiter().GetResult();
_logger.LogInformation(
"Graph runtime loaded {NodeCount} nodes and {EdgeCount} edges for tenant {Tenant} from schema {Schema}",
nodes.Count,
edges.Count,
tenant,
_schemaName);
return new InMemoryGraphRepository(nodes, edges);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Graph runtime repository fell back to an empty graph for tenant {Tenant}", tenant);
return new InMemoryGraphRepository(Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
}
}
private async Task<(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)?> GetSnapshotAsync(
string tenant,
string snapshotId,
CancellationToken cancellationToken = default)
{
try
{
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var sql = $"""
SELECT nodes_json, edges_json
FROM {_schemaName}.pending_snapshots
WHERE tenant = @tenant AND snapshot_id = @snapshotId
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenant", tenant);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
var nodesJson = reader.GetString(0);
var edgesJson = reader.GetString(1);
return (ParseNodeArray(nodesJson), ParseEdgeArray(edgesJson));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read graph snapshot {SnapshotId} for tenant {Tenant}", snapshotId, tenant);
return null;
}
}
private async Task<DateTimeOffset> GetSnapshotTimestampAsync(string tenant, CancellationToken cancellationToken = default)
{
try
{
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var sql = $"""
SELECT COALESCE(
(SELECT MAX(generated_at) FROM {_schemaName}.pending_snapshots WHERE tenant = @tenant),
(SELECT MAX(written_at) FROM {_schemaName}.graph_nodes WHERE document_json->>'tenant' = @tenant),
(SELECT MAX(written_at) FROM {_schemaName}.graph_edges WHERE document_json->>'tenant' = @tenant),
@fallback)
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenant", tenant);
command.Parameters.AddWithValue("fallback", _timeProvider.GetUtcNow());
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return value is DateTimeOffset timestamp ? timestamp : _timeProvider.GetUtcNow();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to resolve graph snapshot timestamp for tenant {Tenant}", tenant);
return _timeProvider.GetUtcNow();
}
}
private static IReadOnlyList<NodeTile> ParseNodeArray(string json)
{
var items = ParseSnapshotItems(json);
var nodes = new List<NodeTile>(items.Count);
foreach (var item in items)
{
var node = PostgresGraphRepository.ParseNodeTile(item);
if (node is not null)
{
nodes.Add(node);
}
}
return nodes;
}
private static IReadOnlyList<EdgeTile> ParseEdgeArray(string json)
{
var items = ParseSnapshotItems(json);
var edges = new List<EdgeTile>(items.Count);
foreach (var item in items)
{
var edge = PostgresGraphRepository.ParseEdgeTile(item);
if (edge is not null)
{
edges.Add(edge);
}
}
return edges;
}
private static List<string> ParseSnapshotItems(string json)
{
var items = new List<string>();
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return items;
}
foreach (var element in document.RootElement.EnumerateArray())
{
switch (element.ValueKind)
{
case JsonValueKind.String:
items.Add(element.GetString() ?? string.Empty);
break;
case JsonValueKind.Object:
items.Add(element.GetRawText());
break;
}
}
return items;
}
}

View File

@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260222-058-GRAPH-TEN | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: migrated Graph endpoint tenant/scope checks to shared resolver + policy-driven authorization (tenant-aware limiter/audit included). | | SPRINT-20260222-058-GRAPH-TEN | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: migrated Graph endpoint tenant/scope checks to shared resolver + policy-driven authorization (tenant-aware limiter/audit included). |
| SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Graph API and localized selected edge/export validation messages (`en-US`/`de-DE`). | | SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Graph API and localized selected edge/export validation messages (`en-US`/`de-DE`). |
| SPRINT-20260410-001-GRAPH-RUNTIME | DONE | `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: Graph API runtime repository selection now resolves from final `Postgres:Graph` options, and the live query/diff/compatibility surfaces no longer fall back to demo-seeded graph data when Postgres is configured. |

View File

@@ -5,11 +5,13 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Npgsql;
namespace StellaOps.Graph.Api.Tests; namespace StellaOps.Graph.Api.Tests;
public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<GraphApiPostgresFixture> public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<GraphApiPostgresFixture>
{ {
private const string RuntimeSchema = "graph";
private readonly GraphApiPostgresFixture _fixture; private readonly GraphApiPostgresFixture _fixture;
public GraphCompatibilityEndpointsIntegrationTests(GraphApiPostgresFixture fixture) public GraphCompatibilityEndpointsIntegrationTests(GraphApiPostgresFixture fixture)
@@ -25,6 +27,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
await _fixture.TruncateAllTablesAsync(); await _fixture.TruncateAllTablesAsync();
using var factory = CreateFactory(); using var factory = CreateFactory();
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await ClearRuntimeTablesAsync();
await SeedBasicGraphAsync();
using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read"); using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read");
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
@@ -36,6 +40,7 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
var items = document.RootElement.GetProperty("items"); var items = document.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() > 0); Assert.True(items.GetArrayLength() > 0);
Assert.Equal("ready", items[0].GetProperty("status").GetString()); Assert.Equal("ready", items[0].GetProperty("status").GetString());
Assert.Equal(3, items[0].GetProperty("nodeCount").GetInt32());
} }
[Fact] [Fact]
@@ -46,6 +51,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
await _fixture.TruncateAllTablesAsync(); await _fixture.TruncateAllTablesAsync();
using var factory = CreateFactory(); using var factory = CreateFactory();
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await ClearRuntimeTablesAsync();
await SeedBasicGraphAsync();
var graphId = await GetGraphIdAsync(client); var graphId = await GetGraphIdAsync(client);
using var request = CreateRequest( using var request = CreateRequest(
HttpMethod.Get, HttpMethod.Get,
@@ -75,6 +82,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
await _fixture.TruncateAllTablesAsync(); await _fixture.TruncateAllTablesAsync();
using var factory = CreateFactory(); using var factory = CreateFactory();
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await ClearRuntimeTablesAsync();
await SeedBasicGraphAsync();
var graphId = await GetGraphIdAsync(client); var graphId = await GetGraphIdAsync(client);
var viewName = $"Priority view {Guid.NewGuid():N}"; var viewName = $"Priority view {Guid.NewGuid():N}";
@@ -136,7 +145,7 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?> configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{ {
["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString, ["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString,
["Postgres:Graph:SchemaName"] = _fixture.SchemaName, ["Postgres:Graph:SchemaName"] = RuntimeSchema,
}); });
}); });
}); });
@@ -161,4 +170,150 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
return document.RootElement.GetProperty("items")[0].GetProperty("graphId").GetString() return document.RootElement.GetProperty("items")[0].GetProperty("graphId").GetString()
?? throw new InvalidOperationException("Compatibility graph id was not returned."); ?? throw new InvalidOperationException("Compatibility graph id was not returned.");
} }
private async Task SeedBasicGraphAsync()
{
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
var writtenAt = DateTimeOffset.Parse("2026-04-14T09:30:00Z");
await InsertNodeAsync(
connection,
"gn:acme:artifact:sha256:abc",
"""
{
"id": "gn:acme:artifact:sha256:abc",
"kind": "artifact",
"tenant": "acme",
"attributes": {
"displayName": "auth-service",
"artifact_digest": "sha256:abc"
}
}
""",
writtenAt);
await InsertNodeAsync(
connection,
"gn:acme:component:widget",
"""
{
"id": "gn:acme:component:widget",
"kind": "component",
"tenant": "acme",
"attributes": {
"displayName": "widget",
"purl": "pkg:npm/widget@2.0.0",
"version": "2.0.0"
}
}
""",
writtenAt);
await InsertNodeAsync(
connection,
"gn:acme:vuln:CVE-2026-1234",
"""
{
"id": "gn:acme:vuln:CVE-2026-1234",
"kind": "vuln",
"tenant": "acme",
"attributes": {
"displayName": "CVE-2026-1234",
"severity": "critical"
}
}
""",
writtenAt);
await InsertEdgeAsync(
connection,
"ge:acme:artifact->component",
"gn:acme:artifact:sha256:abc",
"gn:acme:component:widget",
"""
{
"id": "ge:acme:artifact->component",
"type": "builds",
"tenant": "acme",
"source": "gn:acme:artifact:sha256:abc",
"target": "gn:acme:component:widget",
"attributes": {
"sbom_digest": "sha256:sbom-a"
}
}
""",
writtenAt);
await InsertEdgeAsync(
connection,
"ge:acme:component->vuln",
"gn:acme:component:widget",
"gn:acme:vuln:CVE-2026-1234",
"""
{
"id": "ge:acme:component->vuln",
"type": "affects",
"tenant": "acme",
"source": "gn:acme:component:widget",
"target": "gn:acme:vuln:CVE-2026-1234",
"attributes": {
"advisory_id": "CVE-2026-1234"
}
}
""",
writtenAt);
}
private async Task InsertNodeAsync(NpgsqlConnection connection, string id, string documentJson, DateTimeOffset writtenAt)
{
var sql = $"""
INSERT INTO {RuntimeSchema}.graph_nodes (id, tenant_id, node_type, data, created_at, batch_id, document_json, written_at)
VALUES (@id, @tenantId, @nodeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("tenantId", "acme");
command.Parameters.AddWithValue("nodeType", "runtime");
command.Parameters.AddWithValue("batchId", $"seed:{id}");
command.Parameters.AddWithValue("documentJson", documentJson);
command.Parameters.AddWithValue("writtenAt", writtenAt);
await command.ExecuteNonQueryAsync();
}
private async Task InsertEdgeAsync(NpgsqlConnection connection, string id, string sourceId, string targetId, string documentJson, DateTimeOffset writtenAt)
{
var sql = $"""
INSERT INTO {RuntimeSchema}.graph_edges (id, tenant_id, source_id, target_id, edge_type, data, created_at, batch_id, document_json, written_at)
VALUES (@id, @tenantId, @sourceId, @targetId, @edgeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("tenantId", "acme");
command.Parameters.AddWithValue("batchId", $"seed:{id}");
command.Parameters.AddWithValue("sourceId", sourceId);
command.Parameters.AddWithValue("targetId", targetId);
command.Parameters.AddWithValue("edgeType", "runtime");
command.Parameters.AddWithValue("documentJson", documentJson);
command.Parameters.AddWithValue("writtenAt", writtenAt);
await command.ExecuteNonQueryAsync();
}
private async Task ClearRuntimeTablesAsync()
{
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
var sql = """
TRUNCATE TABLE
graph.saved_views,
graph.pending_snapshots,
graph.graph_edges,
graph.graph_nodes
RESTART IDENTITY
""";
await using var command = new NpgsqlCommand(sql, connection);
await command.ExecuteNonQueryAsync();
}
} }

View File

@@ -0,0 +1,225 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace StellaOps.Graph.Api.Tests;
public sealed class GraphPostgresRuntimeIntegrationTests : IClassFixture<GraphApiPostgresFixture>
{
private const string RuntimeSchema = "graph";
private readonly GraphApiPostgresFixture _fixture;
public GraphPostgresRuntimeIntegrationTests(GraphApiPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Intent", "Runtime")]
public async Task QueryEndpoint_ReadsPersistedGraphRows()
{
await _fixture.TruncateAllTablesAsync();
using var factory = CreateFactory();
using var client = factory.CreateClient();
await ClearRuntimeTablesAsync();
await SeedGraphAsync();
using var request = CreateRequest(HttpMethod.Post, "/graph/query", "graph:query");
request.Content = JsonContent.Create(new
{
kinds = new[] { "artifact", "component" },
query = "gn:acme",
includeEdges = true,
includeStats = true,
limit = 10
});
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("gn:acme:artifact:sha256:abc", body, StringComparison.Ordinal);
Assert.Contains("gn:acme:component:widget", body, StringComparison.Ordinal);
Assert.Contains("\"type\":\"edge\"", body, StringComparison.Ordinal);
Assert.Contains("\"kind\":\"builds\"", body, StringComparison.Ordinal);
Assert.Contains("\"source\":\"gn:acme:artifact:sha256:abc\"", body, StringComparison.Ordinal);
Assert.Contains("\"target\":\"gn:acme:component:widget\"", body, StringComparison.Ordinal);
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Intent", "Runtime")]
public async Task DiffEndpoint_ReadsPersistedPendingSnapshots()
{
await _fixture.TruncateAllTablesAsync();
using var factory = CreateFactory();
using var client = factory.CreateClient();
await ClearRuntimeTablesAsync();
await SeedSnapshotsAsync();
using var request = CreateRequest(HttpMethod.Post, "/graph/diff", "graph:query");
request.Content = JsonContent.Create(new
{
snapshotA = "snapA",
snapshotB = "snapB",
includeEdges = true,
includeStats = true
});
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("\"type\":\"node_added\"", body, StringComparison.Ordinal);
Assert.Contains("gn:acme:component:newlib", body, StringComparison.Ordinal);
Assert.Contains("\"type\":\"edge_added\"", body, StringComparison.Ordinal);
}
private WebApplicationFactory<Program> CreateFactory()
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString,
["Postgres:Graph:SchemaName"] = RuntimeSchema,
});
});
});
}
private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string scopes)
{
var request = new HttpRequestMessage(method, url);
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", scopes);
return request;
}
private async Task SeedGraphAsync()
{
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
var writtenAt = DateTimeOffset.Parse("2026-04-14T10:00:00Z");
await InsertNodeAsync(connection, "gn:acme:artifact:sha256:abc", """
{"id":"gn:acme:artifact:sha256:abc","kind":"artifact","tenant":"acme","attributes":{"displayName":"auth-service","artifact_digest":"sha256:abc"}}
""", writtenAt);
await InsertNodeAsync(connection, "gn:acme:component:widget", """
{"id":"gn:acme:component:widget","kind":"component","tenant":"acme","attributes":{"displayName":"widget","purl":"pkg:npm/widget@2.0.0","version":"2.0.0"}}
""", writtenAt);
await InsertEdgeAsync(connection, "ge:acme:artifact->component", "gn:acme:artifact:sha256:abc", "gn:acme:component:widget", """
{"id":"ge:acme:artifact->component","type":"builds","tenant":"acme","source":"gn:acme:artifact:sha256:abc","target":"gn:acme:component:widget","attributes":{"sbom_digest":"sha256:sbom-a"}}
""", writtenAt);
}
private async Task SeedSnapshotsAsync()
{
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
var snapshotA = JsonSerializer.Serialize(new[]
{
"""
{"id":"gn:acme:component:widget","kind":"component","tenant":"acme","attributes":{"displayName":"widget","version":"2.0.0"}}
"""
});
var edgesA = JsonSerializer.Serialize(Array.Empty<string>());
var snapshotB = JsonSerializer.Serialize(new[]
{
"""
{"id":"gn:acme:component:widget","kind":"component","tenant":"acme","attributes":{"displayName":"widget","version":"2.1.0"}}
""",
"""
{"id":"gn:acme:component:newlib","kind":"component","tenant":"acme","attributes":{"displayName":"newlib","version":"1.0.0"}}
"""
});
var edgesB = JsonSerializer.Serialize(new[]
{
"""
{"id":"ge:acme:component->component:new","type":"depends_on","tenant":"acme","source":"gn:acme:component:widget","target":"gn:acme:component:newlib","attributes":{"scope":"runtime"}}
"""
});
await InsertSnapshotAsync(connection, "snapA", snapshotA, edgesA, DateTimeOffset.Parse("2026-04-14T09:55:00Z"));
await InsertSnapshotAsync(connection, "snapB", snapshotB, edgesB, DateTimeOffset.Parse("2026-04-14T10:05:00Z"));
}
private async Task InsertNodeAsync(NpgsqlConnection connection, string id, string documentJson, DateTimeOffset writtenAt)
{
var sql = $"""
INSERT INTO {RuntimeSchema}.graph_nodes (id, tenant_id, node_type, data, created_at, batch_id, document_json, written_at)
VALUES (@id, @tenantId, @nodeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("tenantId", "acme");
command.Parameters.AddWithValue("nodeType", "runtime");
command.Parameters.AddWithValue("batchId", $"seed:{id}");
command.Parameters.AddWithValue("documentJson", documentJson);
command.Parameters.AddWithValue("writtenAt", writtenAt);
await command.ExecuteNonQueryAsync();
}
private async Task InsertEdgeAsync(NpgsqlConnection connection, string id, string sourceId, string targetId, string documentJson, DateTimeOffset writtenAt)
{
var sql = $"""
INSERT INTO {RuntimeSchema}.graph_edges (id, tenant_id, source_id, target_id, edge_type, data, created_at, batch_id, document_json, written_at)
VALUES (@id, @tenantId, @sourceId, @targetId, @edgeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("tenantId", "acme");
command.Parameters.AddWithValue("batchId", $"seed:{id}");
command.Parameters.AddWithValue("sourceId", sourceId);
command.Parameters.AddWithValue("targetId", targetId);
command.Parameters.AddWithValue("edgeType", "runtime");
command.Parameters.AddWithValue("documentJson", documentJson);
command.Parameters.AddWithValue("writtenAt", writtenAt);
await command.ExecuteNonQueryAsync();
}
private async Task InsertSnapshotAsync(NpgsqlConnection connection, string snapshotId, string nodesJson, string edgesJson, DateTimeOffset generatedAt)
{
var sql = $"""
INSERT INTO {RuntimeSchema}.pending_snapshots (tenant, snapshot_id, generated_at, nodes_json, edges_json, queued_at)
VALUES ('acme', @snapshotId, @generatedAt, @nodesJson::jsonb, @edgesJson::jsonb, @generatedAt)
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("snapshotId", snapshotId);
command.Parameters.AddWithValue("generatedAt", generatedAt);
command.Parameters.AddWithValue("nodesJson", nodesJson);
command.Parameters.AddWithValue("edgesJson", edgesJson);
await command.ExecuteNonQueryAsync();
}
private async Task ClearRuntimeTablesAsync()
{
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
var sql = """
TRUNCATE TABLE
graph.saved_views,
graph.pending_snapshots,
graph.graph_edges,
graph.graph_nodes
RESTART IDENTITY
""";
await using var command = new NpgsqlCommand(sql, connection);
await command.ExecuteNonQueryAsync();
}
}

View File

@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Graph.Api.Services;
namespace StellaOps.Graph.Api.Tests;
public sealed class GraphRuntimeRepositoryRegistrationTests : IClassFixture<GraphApiPostgresFixture>
{
private readonly GraphApiPostgresFixture _fixture;
public GraphRuntimeRepositoryRegistrationTests(GraphApiPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Intent", "Registration")]
public void Resolves_PostgresRuntimeRepository_WhenConnectionStringIsConfigured()
{
using var factory = CreateFactory(_fixture.ConnectionString);
using var scope = factory.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IGraphRuntimeRepository>();
Assert.IsType<PostgresGraphRuntimeRepository>(repository);
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Intent", "Registration")]
public void Resolves_EmptyInMemoryRuntimeRepository_WhenConnectionStringIsMissing()
{
using var factory = CreateFactory(string.Empty);
using var scope = factory.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IGraphRuntimeRepository>();
var inMemoryRepository = Assert.IsType<InMemoryGraphRepository>(repository);
Assert.Empty(inMemoryRepository.GetCompatibilityNodes("acme"));
Assert.Empty(inMemoryRepository.GetCompatibilityEdges("acme"));
}
private static WebApplicationFactory<Program> CreateFactory(string? connectionString)
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Postgres:Graph:ConnectionString"] = connectionString,
["Postgres:Graph:SchemaName"] = "graph",
});
});
});
}
}

View File

@@ -15,3 +15,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| QA-GRAPH-RECHECK-006 | DONE | SPRINT_20260210_005: known-edge metadata positive-path integration test added to catch empty-runtime-data regressions. | | QA-GRAPH-RECHECK-006 | DONE | SPRINT_20260210_005: known-edge metadata positive-path integration test added to catch empty-runtime-data regressions. |
| SPRINT-20260222-058-GRAPH-TEN-05 | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: added focused Graph tenant/auth alignment tests and executed Graph API test project evidence run (`73 passed`). | | SPRINT-20260222-058-GRAPH-TEN-05 | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: added focused Graph tenant/auth alignment tests and executed Graph API test project evidence run (`73 passed`). |
| SPRINT-20260224-002-LOC-101-T | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: added focused Graph API locale-aware unknown-edge test and validated German localized error text. | | SPRINT-20260224-002-LOC-101-T | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: added focused Graph API locale-aware unknown-edge test and validated German localized error text. |
| SPRINT-20260410-001-GRAPH-RUNTIME-T | DONE | `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: added focused Graph runtime registration, compatibility, and Postgres integration tests proving the live host resolves persisted graph state instead of the demo/in-memory path when `Postgres:Graph` is configured. |