using Npgsql;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
///
/// PostgreSQL implementation of time-travel repository.
///
public sealed class PostgresTimeTravelRepository : ITimeTravelRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly ISnapshotRepository _snapshotRepository;
private readonly JsonSerializerOptions _jsonOptions;
public PostgresTimeTravelRepository(
NpgsqlDataSource dataSource,
ISnapshotRepository snapshotRepository)
{
_dataSource = dataSource;
_snapshotRepository = snapshotRepository;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
public async Task GetCurrentPointAsync(
string tenantId,
CancellationToken ct = default)
{
const string sql = """
SELECT COALESCE(MAX(sequence_number), 0) as seq,
COALESCE(MAX(recorded_at), NOW()) as ts
FROM ledger_events
WHERE tenant_id = @tenantId
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("tenantId", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
await reader.ReadAsync(ct);
return new QueryPoint(
Timestamp: reader.GetFieldValue(reader.GetOrdinal("ts")),
SequenceNumber: reader.GetInt64(reader.GetOrdinal("seq")));
}
public async Task ResolveQueryPointAsync(
string tenantId,
DateTimeOffset? timestamp,
long? sequence,
Guid? snapshotId,
CancellationToken ct = default)
{
// If snapshot ID is provided, get point from snapshot
if (snapshotId.HasValue)
{
var snapshot = await _snapshotRepository.GetByIdAsync(tenantId, snapshotId.Value, ct);
if (snapshot == null)
return null;
return new QueryPoint(
Timestamp: snapshot.Timestamp,
SequenceNumber: snapshot.SequenceNumber,
SnapshotId: snapshotId);
}
// If sequence is provided, get timestamp for that sequence
if (sequence.HasValue)
{
const string sql = """
SELECT recorded_at FROM ledger_events
WHERE tenant_id = @tenantId AND sequence_number = @seq
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("seq", sequence.Value);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
return null;
return new QueryPoint(
Timestamp: reader.GetFieldValue(0),
SequenceNumber: sequence.Value);
}
// If timestamp is provided, find the sequence at that point
if (timestamp.HasValue)
{
const string sql = """
SELECT sequence_number, recorded_at FROM ledger_events
WHERE tenant_id = @tenantId AND recorded_at <= @ts
ORDER BY sequence_number DESC
LIMIT 1
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("ts", timestamp.Value);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
{
// No events before timestamp, return point at 0
return new QueryPoint(timestamp.Value, 0);
}
return new QueryPoint(
Timestamp: reader.GetFieldValue(1),
SequenceNumber: reader.GetInt64(0));
}
// No constraints - return current point
return await GetCurrentPointAsync(tenantId, ct);
}
public async Task> QueryFindingsAsync(
HistoricalQueryRequest request,
CancellationToken ct = default)
{
var queryPoint = await ResolveQueryPointAsync(
request.TenantId,
request.AtTimestamp,
request.AtSequence,
request.SnapshotId,
ct);
if (queryPoint == null)
{
return new HistoricalQueryResponse(
new QueryPoint(DateTimeOffset.UtcNow, 0),
EntityType.Finding,
Array.Empty(),
null,
0);
}
// Query findings state at the sequence point using event sourcing
var sql = new StringBuilder("""
WITH finding_state AS (
SELECT
e.finding_id,
e.artifact_id,
e.payload->>'vulnId' as vuln_id,
e.payload->>'status' as status,
(e.payload->>'severity')::decimal as severity,
e.policy_version,
MIN(e.recorded_at) OVER (PARTITION BY e.finding_id) as first_seen,
e.recorded_at as last_updated,
e.payload->'labels' as labels,
ROW_NUMBER() OVER (PARTITION BY e.finding_id ORDER BY e.sequence_number DESC) as rn
FROM ledger_events e
WHERE e.tenant_id = @tenantId
AND e.sequence_number <= @seq
AND e.finding_id IS NOT NULL
)
SELECT finding_id, artifact_id, vuln_id, status, severity,
policy_version, first_seen, last_updated, labels
FROM finding_state
WHERE rn = 1
""");
var parameters = new List
{
new("tenantId", request.TenantId),
new("seq", queryPoint.SequenceNumber)
};
// Apply filters
if (request.Filters != null)
{
if (!string.IsNullOrEmpty(request.Filters.Status))
{
sql.Append(" AND status = @status");
parameters.Add(new NpgsqlParameter("status", request.Filters.Status));
}
if (request.Filters.SeverityMin.HasValue)
{
sql.Append(" AND severity >= @sevMin");
parameters.Add(new NpgsqlParameter("sevMin", request.Filters.SeverityMin.Value));
}
if (request.Filters.SeverityMax.HasValue)
{
sql.Append(" AND severity <= @sevMax");
parameters.Add(new NpgsqlParameter("sevMax", request.Filters.SeverityMax.Value));
}
if (!string.IsNullOrEmpty(request.Filters.ArtifactId))
{
sql.Append(" AND artifact_id = @artifactId");
parameters.Add(new NpgsqlParameter("artifactId", request.Filters.ArtifactId));
}
if (!string.IsNullOrEmpty(request.Filters.VulnId))
{
sql.Append(" AND vuln_id = @vulnId");
parameters.Add(new NpgsqlParameter("vulnId", request.Filters.VulnId));
}
}
// Pagination
if (!string.IsNullOrEmpty(request.PageToken))
{
sql.Append(" AND finding_id > @lastId");
parameters.Add(new NpgsqlParameter("lastId", request.PageToken));
}
sql.Append(" ORDER BY finding_id LIMIT @limit");
parameters.Add(new NpgsqlParameter("limit", request.PageSize + 1));
await using var cmd = _dataSource.CreateCommand(sql.ToString());
cmd.Parameters.AddRange(parameters.ToArray());
var items = new List();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct) && items.Count < request.PageSize)
{
var labelsJson = reader.IsDBNull(reader.GetOrdinal("labels"))
? null
: reader.GetString(reader.GetOrdinal("labels"));
items.Add(new FindingHistoryItem(
FindingId: reader.GetString(reader.GetOrdinal("finding_id")),
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
VulnId: reader.GetString(reader.GetOrdinal("vuln_id")),
Status: reader.GetString(reader.GetOrdinal("status")),
Severity: reader.IsDBNull(reader.GetOrdinal("severity")) ? null : reader.GetDecimal(reader.GetOrdinal("severity")),
PolicyVersion: reader.IsDBNull(reader.GetOrdinal("policy_version")) ? null : reader.GetString(reader.GetOrdinal("policy_version")),
FirstSeen: reader.GetFieldValue(reader.GetOrdinal("first_seen")),
LastUpdated: reader.GetFieldValue(reader.GetOrdinal("last_updated")),
Labels: string.IsNullOrEmpty(labelsJson)
? null
: JsonSerializer.Deserialize>(labelsJson, _jsonOptions)));
}
string? nextPageToken = null;
if (await reader.ReadAsync(ct))
{
nextPageToken = items.Last().FindingId;
}
return new HistoricalQueryResponse(
queryPoint,
EntityType.Finding,
items,
nextPageToken,
items.Count);
}
public async Task> QueryVexAsync(
HistoricalQueryRequest request,
CancellationToken ct = default)
{
var queryPoint = await ResolveQueryPointAsync(
request.TenantId,
request.AtTimestamp,
request.AtSequence,
request.SnapshotId,
ct);
if (queryPoint == null)
{
return new HistoricalQueryResponse(
new QueryPoint(DateTimeOffset.UtcNow, 0),
EntityType.Vex,
Array.Empty(),
null,
0);
}
const string sql = """
WITH vex_state AS (
SELECT
e.payload->>'statementId' as statement_id,
e.payload->>'vulnId' as vuln_id,
e.payload->>'productId' as product_id,
e.payload->>'status' as status,
e.payload->>'justification' as justification,
(e.payload->>'issuedAt')::timestamptz as issued_at,
(e.payload->>'expiresAt')::timestamptz as expires_at,
ROW_NUMBER() OVER (PARTITION BY e.payload->>'statementId' ORDER BY e.sequence_number DESC) as rn
FROM ledger_events e
WHERE e.tenant_id = @tenantId
AND e.sequence_number <= @seq
AND e.event_type LIKE 'vex.%'
)
SELECT statement_id, vuln_id, product_id, status, justification, issued_at, expires_at
FROM vex_state
WHERE rn = 1
ORDER BY statement_id
LIMIT @limit
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
cmd.Parameters.AddWithValue("seq", queryPoint.SequenceNumber);
cmd.Parameters.AddWithValue("limit", request.PageSize);
var items = new List();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
items.Add(new VexHistoryItem(
StatementId: reader.GetString(reader.GetOrdinal("statement_id")),
VulnId: reader.GetString(reader.GetOrdinal("vuln_id")),
ProductId: reader.GetString(reader.GetOrdinal("product_id")),
Status: reader.GetString(reader.GetOrdinal("status")),
Justification: reader.IsDBNull(reader.GetOrdinal("justification")) ? null : reader.GetString(reader.GetOrdinal("justification")),
IssuedAt: reader.GetFieldValue(reader.GetOrdinal("issued_at")),
ExpiresAt: reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetFieldValue(reader.GetOrdinal("expires_at"))));
}
return new HistoricalQueryResponse(
queryPoint,
EntityType.Vex,
items,
null,
items.Count);
}
public async Task> QueryAdvisoriesAsync(
HistoricalQueryRequest request,
CancellationToken ct = default)
{
var queryPoint = await ResolveQueryPointAsync(
request.TenantId,
request.AtTimestamp,
request.AtSequence,
request.SnapshotId,
ct);
if (queryPoint == null)
{
return new HistoricalQueryResponse(
new QueryPoint(DateTimeOffset.UtcNow, 0),
EntityType.Advisory,
Array.Empty(),
null,
0);
}
const string sql = """
WITH advisory_state AS (
SELECT
e.payload->>'advisoryId' as advisory_id,
e.payload->>'source' as source,
e.payload->>'title' as title,
(e.payload->>'cvssScore')::decimal as cvss_score,
(e.payload->>'publishedAt')::timestamptz as published_at,
(e.payload->>'modifiedAt')::timestamptz as modified_at,
ROW_NUMBER() OVER (PARTITION BY e.payload->>'advisoryId' ORDER BY e.sequence_number DESC) as rn
FROM ledger_events e
WHERE e.tenant_id = @tenantId
AND e.sequence_number <= @seq
AND e.event_type LIKE 'advisory.%'
)
SELECT advisory_id, source, title, cvss_score, published_at, modified_at
FROM advisory_state
WHERE rn = 1
ORDER BY advisory_id
LIMIT @limit
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
cmd.Parameters.AddWithValue("seq", queryPoint.SequenceNumber);
cmd.Parameters.AddWithValue("limit", request.PageSize);
var items = new List();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
items.Add(new AdvisoryHistoryItem(
AdvisoryId: reader.GetString(reader.GetOrdinal("advisory_id")),
Source: reader.GetString(reader.GetOrdinal("source")),
Title: reader.GetString(reader.GetOrdinal("title")),
CvssScore: reader.IsDBNull(reader.GetOrdinal("cvss_score")) ? null : reader.GetDecimal(reader.GetOrdinal("cvss_score")),
PublishedAt: reader.GetFieldValue(reader.GetOrdinal("published_at")),
ModifiedAt: reader.IsDBNull(reader.GetOrdinal("modified_at")) ? null : reader.GetFieldValue(reader.GetOrdinal("modified_at"))));
}
return new HistoricalQueryResponse(
queryPoint,
EntityType.Advisory,
items,
null,
items.Count);
}
public async Task<(IReadOnlyList Events, ReplayMetadata Metadata)> ReplayEventsAsync(
ReplayRequest request,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
var sql = new StringBuilder("""
SELECT event_id, sequence_number, chain_id, chain_sequence,
event_type, occurred_at, recorded_at,
actor_id, actor_type, artifact_id, finding_id,
policy_version, event_hash, previous_hash, payload
FROM ledger_events
WHERE tenant_id = @tenantId
""");
var parameters = new List
{
new("tenantId", request.TenantId)
};
if (request.FromSequence.HasValue)
{
sql.Append(" AND sequence_number >= @fromSeq");
parameters.Add(new NpgsqlParameter("fromSeq", request.FromSequence.Value));
}
if (request.ToSequence.HasValue)
{
sql.Append(" AND sequence_number <= @toSeq");
parameters.Add(new NpgsqlParameter("toSeq", request.ToSequence.Value));
}
if (request.FromTimestamp.HasValue)
{
sql.Append(" AND recorded_at >= @fromTs");
parameters.Add(new NpgsqlParameter("fromTs", request.FromTimestamp.Value));
}
if (request.ToTimestamp.HasValue)
{
sql.Append(" AND recorded_at <= @toTs");
parameters.Add(new NpgsqlParameter("toTs", request.ToTimestamp.Value));
}
if (request.ChainIds?.Count > 0)
{
sql.Append(" AND chain_id = ANY(@chainIds)");
parameters.Add(new NpgsqlParameter("chainIds", request.ChainIds.ToArray()));
}
if (request.EventTypes?.Count > 0)
{
sql.Append(" AND event_type = ANY(@eventTypes)");
parameters.Add(new NpgsqlParameter("eventTypes", request.EventTypes.ToArray()));
}
sql.Append(" ORDER BY sequence_number LIMIT @limit");
parameters.Add(new NpgsqlParameter("limit", request.PageSize + 1));
await using var cmd = _dataSource.CreateCommand(sql.ToString());
cmd.Parameters.AddRange(parameters.ToArray());
var events = new List();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct) && events.Count < request.PageSize)
{
object? payload = null;
if (request.IncludePayload && !reader.IsDBNull(reader.GetOrdinal("payload")))
{
var payloadJson = reader.GetString(reader.GetOrdinal("payload"));
payload = JsonSerializer.Deserialize