consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,16 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
/// <summary>
/// Persistence contract for timeline event ingestion.
/// Implementations must enforce tenant isolation and idempotency on (tenant_id, event_id).
/// </summary>
public interface ITimelineEventStore
{
/// <summary>
/// Inserts the event atomically (headers, payloads, digests).
/// Must be idempotent on (tenant_id, event_id) and return whether a new row was created.
/// </summary>
Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
/// <summary>
/// Abstraction over transport-specific event subscriptions (NATS/Redis/etc.).
/// Implementations yield tenant-scoped timeline event envelopes in publish order.
/// </summary>
public interface ITimelineEventSubscriber
{
IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Models.Results;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
/// <summary>
/// High-level ingestion service that validates, hashes, and persists timeline events.
/// </summary>
public interface ITimelineIngestionService
{
Task<TimelineIngestResult> IngestAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
public interface ITimelineQueryService
{
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default);
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
public interface ITimelineQueryStore
{
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken);
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken);
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,3 @@
namespace StellaOps.TimelineIndexer.Core.Models.Results;
public sealed record TimelineIngestResult(bool Inserted);

View File

@@ -0,0 +1,29 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Canonical ingestion envelope for timeline events.
/// Maps closely to orchestrator/notify envelopes while remaining transport-agnostic.
/// </summary>
public sealed class TimelineEventEnvelope
{
public required string EventId { get; init; }
public required string TenantId { get; init; }
public required string EventType { get; init; }
public required string Source { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Actor { get; init; }
public string Severity { get; init; } = "info";
public string? PayloadHash { get; set; }
public string RawPayloadJson { get; init; } = "{}";
public string? NormalizedPayloadJson { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? BundleDigest { get; init; }
public Guid? BundleId { get; init; }
public string? AttestationSubject { get; init; }
public string? AttestationDigest { get; init; }
public string? ManifestUri { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Projected timeline event for query responses.
/// </summary>
public sealed class TimelineEventView
{
public required long EventSeq { get; init; }
public required string EventId { get; init; }
public required string TenantId { get; init; }
public required string EventType { get; init; }
public required string Source { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public required DateTimeOffset ReceivedAt { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Actor { get; init; }
public string Severity { get; init; } = "info";
public string? PayloadHash { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? RawPayloadJson { get; init; }
public string? NormalizedPayloadJson { get; init; }
public Guid? BundleId { get; init; }
public string? BundleDigest { get; init; }
public string? AttestationSubject { get; init; }
public string? AttestationDigest { get; init; }
public string? ManifestUri { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Evidence linkage for a timeline event, pointing to sealed bundle/attestation artifacts.
/// </summary>
public sealed class TimelineEvidenceView
{
public required string EventId { get; init; }
public required string TenantId { get; init; }
public Guid? BundleId { get; init; }
public string? BundleDigest { get; init; }
public string? AttestationSubject { get; init; }
public string? AttestationDigest { get; init; }
public string? ManifestUri { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Query filters for timeline listing.
/// </summary>
public sealed record TimelineQueryOptions
{
public string? EventType { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Source { get; init; }
public string? Severity { get; init; }
public DateTimeOffset? Since { get; init; }
public long? AfterEventSeq { get; init; }
public int Limit { get; init; } = 100;
}

View File

@@ -0,0 +1,49 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Models.Results;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.TimelineIndexer.Core.Services;
/// <summary>
/// Validates and persists timeline events with deterministic hashing.
/// </summary>
public sealed class TimelineIngestionService(ITimelineEventStore store) : ITimelineIngestionService
{
public async Task<TimelineIngestResult> IngestAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
Validate(envelope);
if (string.IsNullOrWhiteSpace(envelope.PayloadHash))
{
envelope.PayloadHash = ComputePayloadHash(envelope.RawPayloadJson);
}
var inserted = await store.InsertAsync(envelope, cancellationToken).ConfigureAwait(false);
return new TimelineIngestResult(inserted);
}
private static void Validate(TimelineEventEnvelope envelope)
{
if (string.IsNullOrWhiteSpace(envelope.EventId))
throw new ArgumentException("event_id is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.TenantId))
throw new ArgumentException("tenant_id is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.EventType))
throw new ArgumentException("event_type is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.Source))
throw new ArgumentException("source is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.RawPayloadJson))
throw new ArgumentException("raw payload is required", nameof(envelope));
}
internal static string ComputePayloadHash(string payloadJson)
{
var bytes = Encoding.UTF8.GetBytes(payloadJson ?? string.Empty);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,60 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Services;
public sealed class TimelineQueryService(ITimelineQueryStore store) : ITimelineQueryService
{
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
return store.QueryAsync(tenantId, Normalize(options), cancellationToken);
}
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
return store.GetAsync(tenantId, eventId, cancellationToken);
}
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
var evidence = await store.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
if (evidence is null)
{
return null;
}
var manifest = evidence.ManifestUri;
if (manifest is null && evidence.BundleId is not null)
{
manifest = $"bundles/{evidence.BundleId:N}/manifest.dsse.json";
}
var subject = evidence.AttestationSubject ?? evidence.BundleDigest;
return new TimelineEvidenceView
{
EventId = evidence.EventId,
TenantId = evidence.TenantId,
BundleId = evidence.BundleId,
BundleDigest = evidence.BundleDigest,
AttestationSubject = subject,
AttestationDigest = evidence.AttestationDigest,
ManifestUri = manifest,
CreatedAt = evidence.CreatedAt
};
}
private static TimelineQueryOptions Normalize(TimelineQueryOptions options)
{
var limit = options.Limit;
if (limit <= 0) limit = 100;
if (limit > 500) limit = 500;
return options with { Limit = limit };
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.TimelineIndexer.Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Core/StellaOps.TimelineIndexer.Core.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,114 @@
-- 001_initial_schema.sql
-- Establishes Timeline Indexer schema, RLS scaffolding, and evidence linkage tables.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS timeline;
CREATE SCHEMA IF NOT EXISTS timeline_app;
-- Enforce tenant context for all RLS policies
CREATE OR REPLACE FUNCTION timeline_app.require_current_tenant()
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
tenant_text text;
BEGIN
tenant_text := current_setting('app.current_tenant', true);
IF tenant_text IS NULL OR length(tenant_text) = 0 THEN
RAISE EXCEPTION 'app.current_tenant is not set for the current session';
END IF;
RETURN tenant_text;
END;
$$;
-- Severity enum keeps ordering deterministic and compact
DO $$
BEGIN
CREATE TYPE timeline.event_severity AS ENUM ('info', 'notice', 'warn', 'error', 'critical');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
-- Core event header table (dedupe + ordering)
CREATE TABLE IF NOT EXISTS timeline.timeline_events
(
event_seq bigserial PRIMARY KEY,
event_id text NOT NULL,
tenant_id text NOT NULL,
source text NOT NULL,
event_type text NOT NULL,
occurred_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
correlation_id text,
trace_id text,
actor text,
severity timeline.event_severity NOT NULL DEFAULT 'info',
payload_hash text CHECK (payload_hash IS NULL OR payload_hash ~ '^sha256:[0-9a-f]{64}$'),
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (tenant_id, event_id)
);
CREATE INDEX IF NOT EXISTS ix_timeline_events_tenant_occurred
ON timeline.timeline_events (tenant_id, occurred_at DESC, event_seq DESC);
CREATE INDEX IF NOT EXISTS ix_timeline_events_type
ON timeline.timeline_events (tenant_id, event_type, occurred_at DESC);
ALTER TABLE timeline.timeline_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS timeline_events_isolation ON timeline.timeline_events;
CREATE POLICY timeline_events_isolation
ON timeline.timeline_events
USING (tenant_id = timeline_app.require_current_tenant())
WITH CHECK (tenant_id = timeline_app.require_current_tenant());
-- Raw and normalized payloads per event
CREATE TABLE IF NOT EXISTS timeline.timeline_event_details
(
event_id text NOT NULL,
tenant_id text NOT NULL,
envelope_version text NOT NULL,
raw_payload jsonb NOT NULL,
normalized_payload jsonb,
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT fk_event_details FOREIGN KEY (event_id, tenant_id)
REFERENCES timeline.timeline_events (event_id, tenant_id) ON DELETE CASCADE,
PRIMARY KEY (event_id, tenant_id)
);
ALTER TABLE timeline.timeline_event_details ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS timeline_event_details_isolation ON timeline.timeline_event_details;
CREATE POLICY timeline_event_details_isolation
ON timeline.timeline_event_details
USING (tenant_id = timeline_app.require_current_tenant())
WITH CHECK (tenant_id = timeline_app.require_current_tenant());
-- Evidence linkage (bundle/attestation manifests)
CREATE TABLE IF NOT EXISTS timeline.timeline_event_digests
(
digest_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id text NOT NULL,
event_id text NOT NULL,
bundle_id uuid,
bundle_digest text,
attestation_subject text,
attestation_digest text,
manifest_uri text,
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT fk_event_digest_event FOREIGN KEY (event_id, tenant_id)
REFERENCES timeline.timeline_events (event_id, tenant_id) ON DELETE CASCADE,
CONSTRAINT ck_bundle_digest_sha CHECK (bundle_digest IS NULL OR bundle_digest ~ '^sha256:[0-9a-f]{64}$'),
CONSTRAINT ck_attestation_digest_sha CHECK (attestation_digest IS NULL OR attestation_digest ~ '^sha256:[0-9a-f]{64}$')
);
CREATE INDEX IF NOT EXISTS ix_timeline_digests_event
ON timeline.timeline_event_digests (tenant_id, event_id);
CREATE INDEX IF NOT EXISTS ix_timeline_digests_bundle
ON timeline.timeline_event_digests (tenant_id, bundle_digest);
ALTER TABLE timeline.timeline_event_digests ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS timeline_event_digests_isolation ON timeline.timeline_event_digests;
CREATE POLICY timeline_event_digests_isolation
ON timeline.timeline_event_digests
USING (tenant_id = timeline_app.require_current_tenant())
WITH CHECK (tenant_id = timeline_app.require_current_tenant());

View File

@@ -0,0 +1,119 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
using System.Text.Json;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
/// <summary>
/// Postgres-backed implementation of ITimelineEventStore.
/// </summary>
public sealed class TimelineEventStore(TimelineIndexerDataSource dataSource, ILogger<TimelineEventStore> logger)
: RepositoryBase<TimelineIndexerDataSource>(dataSource, logger), ITimelineEventStore
{
public async Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenConnectionAsync(envelope.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
dbContext.timeline_events.Add(CreateTimelineEvent(envelope));
try
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
return false;
}
dbContext.timeline_event_details.Add(CreateTimelineEventDetail(envelope));
if (HasDigestPayload(envelope))
{
dbContext.timeline_event_digests.Add(CreateTimelineEventDigest(envelope));
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return true;
}
private static timeline_event CreateTimelineEvent(TimelineEventEnvelope envelope)
{
return new timeline_event
{
event_id = envelope.EventId,
tenant_id = envelope.TenantId,
source = envelope.Source,
event_type = envelope.EventType,
occurred_at = envelope.OccurredAt.UtcDateTime,
correlation_id = envelope.CorrelationId,
trace_id = envelope.TraceId,
actor = envelope.Actor,
severity = TimelineEventSeverityExtensions.ParseOrDefault(envelope.Severity),
payload_hash = envelope.PayloadHash,
attributes = envelope.Attributes is null
? "{}"
: JsonSerializer.Serialize(envelope.Attributes)
};
}
private static timeline_event_detail CreateTimelineEventDetail(TimelineEventEnvelope envelope)
{
return new timeline_event_detail
{
event_id = envelope.EventId,
tenant_id = envelope.TenantId,
envelope_version = "orch.event.v1",
raw_payload = envelope.RawPayloadJson,
normalized_payload = envelope.NormalizedPayloadJson
};
}
private static timeline_event_digest CreateTimelineEventDigest(TimelineEventEnvelope envelope)
{
return new timeline_event_digest
{
tenant_id = envelope.TenantId,
event_id = envelope.EventId,
bundle_id = envelope.BundleId,
bundle_digest = envelope.BundleDigest,
attestation_subject = envelope.AttestationSubject,
attestation_digest = envelope.AttestationDigest,
manifest_uri = envelope.ManifestUri
};
}
private static bool HasDigestPayload(TimelineEventEnvelope envelope)
{
return envelope.BundleDigest is not null
|| envelope.AttestationDigest is not null
|| envelope.ManifestUri is not null
|| envelope.BundleId is not null;
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return true;
}
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
internal static class TimelineIndexerDbContextFactory
{
public static TimelineIndexerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds)
{
var optionsBuilder = new DbContextOptionsBuilder<TimelineIndexerDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
// Force usage of the static compiled model module for fast startup and deterministic metadata initialization.
optionsBuilder.UseModel(TimelineIndexerDbContextModel.Instance);
var options = optionsBuilder.Options;
return new TimelineIndexerDbContext(options);
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
using System.Reflection;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
/// <summary>
/// Runs embedded SQL migrations for the Timeline Indexer schema.
/// </summary>
public sealed class TimelineIndexerMigrationRunner
{
private readonly PostgresOptions _options;
private readonly ILogger<TimelineIndexerMigrationRunner> _logger;
private const string ResourcePrefix = "StellaOps.TimelineIndexer.Infrastructure.Db.Migrations";
public TimelineIndexerMigrationRunner(
IOptions<PostgresOptions> options,
ILogger<TimelineIndexerMigrationRunner> logger)
{
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Apply all pending migrations from embedded resources.
/// </summary>
public Task<int> RunAsync(CancellationToken cancellationToken = default)
{
var schema = string.IsNullOrWhiteSpace(_options.SchemaName)
? TimelineIndexerDataSource.DefaultSchemaName
: _options.SchemaName!;
var runner = new MigrationRunner(
_options.ConnectionString,
schema,
moduleName: "TimelineIndexer",
_logger);
return runner.RunFromAssemblyAsync(
assembly: Assembly.GetExecutingAssembly(),
resourcePrefix: ResourcePrefix,
cancellationToken);
}
}

View File

@@ -0,0 +1,236 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
using System.Text.Json;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILogger<TimelineQueryStore> logger)
: RepositoryBase<TimelineIndexerDataSource>(dataSource, logger), ITimelineQueryStore
{
public async Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
var query = dbContext.timeline_events
.AsNoTracking()
.Where(e => e.tenant_id == tenantId);
if (!string.IsNullOrWhiteSpace(options.EventType))
{
query = query.Where(e => e.event_type == options.EventType);
}
if (!string.IsNullOrWhiteSpace(options.Source))
{
query = query.Where(e => e.source == options.Source);
}
if (!string.IsNullOrWhiteSpace(options.CorrelationId))
{
query = query.Where(e => e.correlation_id == options.CorrelationId);
}
if (!string.IsNullOrWhiteSpace(options.TraceId))
{
query = query.Where(e => e.trace_id == options.TraceId);
}
if (!string.IsNullOrWhiteSpace(options.Severity))
{
if (!TimelineEventSeverityExtensions.TryParse(options.Severity, out var severity))
{
return [];
}
query = query.Where(e => e.severity == severity);
}
if (options.Since is not null)
{
var sinceUtc = options.Since.Value.UtcDateTime;
query = query.Where(e => e.occurred_at >= sinceUtc);
}
if (options.AfterEventSeq is not null)
{
var afterSeq = options.AfterEventSeq.Value;
query = query.Where(e => e.event_seq < afterSeq);
}
var rows = await query
.OrderByDescending(e => e.occurred_at)
.ThenByDescending(e => e.event_seq)
.Take(Math.Clamp(options.Limit, 1, 500))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows
.Select(MapEvent)
.ToList();
}
public async Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
var eventRow = await dbContext.timeline_events
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.tenant_id == tenantId && e.event_id == eventId,
cancellationToken)
.ConfigureAwait(false);
if (eventRow is null)
{
return null;
}
var detailRow = await dbContext.timeline_event_details
.AsNoTracking()
.FirstOrDefaultAsync(
d => d.tenant_id == tenantId && d.event_id == eventId,
cancellationToken)
.ConfigureAwait(false);
var digestRow = await dbContext.timeline_event_digests
.AsNoTracking()
.Where(d => d.tenant_id == tenantId && d.event_id == eventId)
.OrderByDescending(d => d.created_at)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return MapEventDetail(eventRow, detailRow, digestRow);
}
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
var digest = await dbContext.timeline_event_digests
.AsNoTracking()
.Where(d => d.tenant_id == tenantId && d.event_id == eventId)
.OrderByDescending(d => d.created_at)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return digest is null ? null : MapEvidence(digest);
}
private static TimelineEventView MapEvent(timeline_event row) => new()
{
EventSeq = row.event_seq,
EventId = row.event_id,
TenantId = row.tenant_id,
EventType = row.event_type,
Source = row.source,
OccurredAt = ToUtcOffset(row.occurred_at),
ReceivedAt = ToUtcOffset(row.received_at),
CorrelationId = row.correlation_id,
TraceId = row.trace_id,
Actor = row.actor,
Severity = row.severity.ToWireValue(),
PayloadHash = row.payload_hash
};
private static TimelineEventView MapEventDetail(
timeline_event eventRow,
timeline_event_detail? detailRow,
timeline_event_digest? digestRow)
{
return new TimelineEventView
{
EventSeq = eventRow.event_seq,
EventId = eventRow.event_id,
TenantId = eventRow.tenant_id,
EventType = eventRow.event_type,
Source = eventRow.source,
OccurredAt = ToUtcOffset(eventRow.occurred_at),
ReceivedAt = ToUtcOffset(eventRow.received_at),
CorrelationId = eventRow.correlation_id,
TraceId = eventRow.trace_id,
Actor = eventRow.actor,
Severity = eventRow.severity.ToWireValue(),
PayloadHash = eventRow.payload_hash,
Attributes = DeserializeAttributes(eventRow.attributes),
RawPayloadJson = detailRow?.raw_payload,
NormalizedPayloadJson = detailRow?.normalized_payload,
BundleId = digestRow?.bundle_id,
BundleDigest = digestRow?.bundle_digest,
AttestationSubject = digestRow?.attestation_subject,
AttestationDigest = digestRow?.attestation_digest,
ManifestUri = digestRow?.manifest_uri
};
}
private static TimelineEvidenceView MapEvidence(timeline_event_digest row)
{
var bundleDigest = row.bundle_digest;
var attestationSubject = row.attestation_subject;
if (string.IsNullOrWhiteSpace(attestationSubject))
{
attestationSubject = bundleDigest;
}
var bundleId = row.bundle_id;
var manifestUri = row.manifest_uri;
if (manifestUri is null && bundleId is not null)
{
manifestUri = $"bundles/{bundleId:N}/manifest.dsse.json";
}
return new TimelineEvidenceView
{
EventId = row.event_id,
TenantId = row.tenant_id,
BundleId = bundleId,
BundleDigest = bundleDigest,
AttestationSubject = attestationSubject,
AttestationDigest = row.attestation_digest,
ManifestUri = manifestUri,
CreatedAt = ToUtcOffset(row.created_at)
};
}
private static IDictionary<string, string>? DeserializeAttributes(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(raw);
}
catch
{
return null;
}
}
private static DateTimeOffset ToUtcOffset(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
{
return new DateTimeOffset(value, TimeSpan.Zero);
}
if (value.Kind == DateTimeKind.Local)
{
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
}
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Services;
using StellaOps.TimelineIndexer.Infrastructure.Db;
namespace StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
/// <summary>
/// Timeline Indexer PostgreSQL service registration helpers.
/// </summary>
public static class ServiceCollectionExtensions
{
private const string DefaultSection = "Postgres:Timeline";
/// <summary>
/// Registers Postgres options, data source, and migration runner for the Timeline Indexer.
/// </summary>
public static IServiceCollection AddTimelineIndexerPostgres(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = DefaultSection)
{
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
services.AddSingleton<TimelineIndexerDataSource>();
services.AddSingleton<TimelineIndexerMigrationRunner>();
services.AddHostedService<TimelineIndexerMigrationHostedService>();
services.AddScoped<ITimelineEventStore, TimelineEventStore>();
services.AddScoped<ITimelineIngestionService, TimelineIngestionService>();
services.AddScoped<ITimelineQueryStore, TimelineQueryStore>();
services.AddScoped<ITimelineQueryService, TimelineQueryService>();
return services;
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.TimelineIndexer.Infrastructure.Db;
namespace StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
/// <summary>
/// Executes TimelineIndexer schema migrations during application startup.
/// </summary>
internal sealed class TimelineIndexerMigrationHostedService(
TimelineIndexerMigrationRunner migrationRunner,
ILogger<TimelineIndexerMigrationHostedService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var applied = await migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"TimelineIndexer startup migrations completed; applied {AppliedCount} migration(s).",
applied);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(TimelineIndexerDbContext), typeof(TimelineIndexerDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[DbContext(typeof(TimelineIndexerDbContext))]
public partial class TimelineIndexerDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static TimelineIndexerDbContextModel()
{
var model = new TimelineIndexerDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (TimelineIndexerDbContextModel)model.FinalizeModel();
}
private static TimelineIndexerDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,37 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
public partial class TimelineIndexerDbContextModel
{
private TimelineIndexerDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("f31ee807-87a4-4417-9dd0-2d0ae1361676"), entityTypeCount: 3)
{
}
partial void Initialize()
{
var timeline_event = Timeline_eventEntityType.Create(this);
var timeline_event_detail = Timeline_event_detailEntityType.Create(this);
var timeline_event_digest = Timeline_event_digestEntityType.Create(this);
Timeline_event_detailEntityType.CreateForeignKey1(timeline_event_detail, timeline_event);
Timeline_event_digestEntityType.CreateForeignKey1(timeline_event_digest, timeline_event);
Timeline_eventEntityType.CreateAnnotations(timeline_event);
Timeline_event_detailEntityType.CreateAnnotations(timeline_event_detail);
Timeline_event_digestEntityType.CreateAnnotations(timeline_event_digest);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,177 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class Timeline_eventEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event",
typeof(timeline_event),
baseEntityType,
propertyCount: 13,
navigationCount: 2,
namedIndexCount: 3,
keyCount: 2);
var event_seq = runtimeEntityType.AddProperty(
"event_seq",
typeof(long),
propertyInfo: typeof(timeline_event).GetProperty("event_seq", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<event_seq>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: 0L);
event_seq.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
var actor = runtimeEntityType.AddProperty(
"actor",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("actor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<actor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
actor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var attributes = runtimeEntityType.AddProperty(
"attributes",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("attributes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<attributes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
attributes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
attributes.AddAnnotation("Relational:ColumnType", "jsonb");
attributes.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var correlation_id = runtimeEntityType.AddProperty(
"correlation_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("correlation_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<correlation_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
correlation_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var event_id = runtimeEntityType.AddProperty(
"event_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var event_type = runtimeEntityType.AddProperty(
"event_type",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("event_type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<event_type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
event_type.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var occurred_at = runtimeEntityType.AddProperty(
"occurred_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event).GetProperty("occurred_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<occurred_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
occurred_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var payload_hash = runtimeEntityType.AddProperty(
"payload_hash",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("payload_hash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<payload_hash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
payload_hash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var received_at = runtimeEntityType.AddProperty(
"received_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event).GetProperty("received_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<received_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
received_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
received_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
var severity = runtimeEntityType.AddProperty(
"severity",
typeof(TimelineEventSeverity),
propertyInfo: typeof(timeline_event).GetProperty("severity", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<severity>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
severity.SetSentinelFromProviderValue(0);
severity.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
severity.AddAnnotation("Relational:ColumnType", "timeline.event_severity");
severity.AddAnnotation("Relational:DefaultValue", TimelineEventSeverity.Info);
var source = runtimeEntityType.AddProperty(
"source",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("source", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<source>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
source.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var tenant_id = runtimeEntityType.AddProperty(
"tenant_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var trace_id = runtimeEntityType.AddProperty(
"trace_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("trace_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<trace_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
trace_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var key = runtimeEntityType.AddKey(
new[] { event_seq });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "timeline_events_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { event_id, tenant_id });
var ix_timeline_events_tenant_occurred = runtimeEntityType.AddIndex(
new[] { tenant_id, occurred_at, event_seq },
name: "ix_timeline_events_tenant_occurred");
var ix_timeline_events_type = runtimeEntityType.AddIndex(
new[] { tenant_id, event_type, occurred_at },
name: "ix_timeline_events_type");
var timeline_events_tenant_id_event_id_key = runtimeEntityType.AddIndex(
new[] { tenant_id, event_id },
name: "timeline_events_tenant_id_event_id_key",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_events");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,128 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class Timeline_event_detailEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event_detail",
typeof(timeline_event_detail),
baseEntityType,
propertyCount: 6,
navigationCount: 1,
foreignKeyCount: 1,
keyCount: 1);
var event_id = runtimeEntityType.AddProperty(
"event_id",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var tenant_id = runtimeEntityType.AddProperty(
"tenant_id",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var created_at = runtimeEntityType.AddProperty(
"created_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event_detail).GetProperty("created_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<created_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
created_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
created_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
var envelope_version = runtimeEntityType.AddProperty(
"envelope_version",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("envelope_version", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<envelope_version>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
envelope_version.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var normalized_payload = runtimeEntityType.AddProperty(
"normalized_payload",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("normalized_payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<normalized_payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
normalized_payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
normalized_payload.AddAnnotation("Relational:ColumnType", "jsonb");
var raw_payload = runtimeEntityType.AddProperty(
"raw_payload",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("raw_payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<raw_payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
raw_payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
raw_payload.AddAnnotation("Relational:ColumnType", "jsonb");
var key = runtimeEntityType.AddKey(
new[] { event_id, tenant_id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "timeline_event_details_pkey");
return runtimeEntityType;
}
public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType)
{
var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("event_id"), declaringEntityType.FindProperty("tenant_id") },
principalEntityType.FindKey(new[] { principalEntityType.FindProperty("event_id"), principalEntityType.FindProperty("tenant_id") }),
principalEntityType,
deleteBehavior: DeleteBehavior.Cascade,
unique: true,
required: true);
var timeline_event = declaringEntityType.AddNavigation("timeline_event",
runtimeForeignKey,
onDependent: true,
typeof(timeline_event),
propertyInfo: typeof(timeline_event_detail).GetProperty("timeline_event", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<timeline_event>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
var timeline_event_detail = principalEntityType.AddNavigation("timeline_event_detail",
runtimeForeignKey,
onDependent: false,
typeof(timeline_event_detail),
propertyInfo: typeof(timeline_event).GetProperty("timeline_event_detail", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<timeline_event_detail>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
runtimeForeignKey.AddAnnotation("Relational:Name", "fk_event_details");
return runtimeForeignKey;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_event_details");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,166 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class Timeline_event_digestEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event_digest",
typeof(timeline_event_digest),
baseEntityType,
propertyCount: 9,
navigationCount: 1,
foreignKeyCount: 1,
unnamedIndexCount: 1,
namedIndexCount: 2,
keyCount: 1);
var digest_id = runtimeEntityType.AddProperty(
"digest_id",
typeof(Guid),
propertyInfo: typeof(timeline_event_digest).GetProperty("digest_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<digest_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
digest_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
digest_id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var attestation_digest = runtimeEntityType.AddProperty(
"attestation_digest",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("attestation_digest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<attestation_digest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
attestation_digest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var attestation_subject = runtimeEntityType.AddProperty(
"attestation_subject",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("attestation_subject", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<attestation_subject>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
attestation_subject.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var bundle_digest = runtimeEntityType.AddProperty(
"bundle_digest",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("bundle_digest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<bundle_digest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
bundle_digest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var bundle_id = runtimeEntityType.AddProperty(
"bundle_id",
typeof(Guid?),
propertyInfo: typeof(timeline_event_digest).GetProperty("bundle_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<bundle_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
bundle_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var created_at = runtimeEntityType.AddProperty(
"created_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event_digest).GetProperty("created_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<created_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
created_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
created_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
var event_id = runtimeEntityType.AddProperty(
"event_id",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var manifest_uri = runtimeEntityType.AddProperty(
"manifest_uri",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("manifest_uri", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<manifest_uri>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
manifest_uri.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var tenant_id = runtimeEntityType.AddProperty(
"tenant_id",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var key = runtimeEntityType.AddKey(
new[] { digest_id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "timeline_event_digests_pkey");
var index = runtimeEntityType.AddIndex(
new[] { event_id, tenant_id });
var ix_timeline_digests_bundle = runtimeEntityType.AddIndex(
new[] { tenant_id, bundle_digest },
name: "ix_timeline_digests_bundle");
var ix_timeline_digests_event = runtimeEntityType.AddIndex(
new[] { tenant_id, event_id },
name: "ix_timeline_digests_event");
return runtimeEntityType;
}
public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType)
{
var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("event_id"), declaringEntityType.FindProperty("tenant_id") },
principalEntityType.FindKey(new[] { principalEntityType.FindProperty("event_id"), principalEntityType.FindProperty("tenant_id") }),
principalEntityType,
deleteBehavior: DeleteBehavior.Cascade,
required: true);
var timeline_event = declaringEntityType.AddNavigation("timeline_event",
runtimeForeignKey,
onDependent: true,
typeof(timeline_event),
propertyInfo: typeof(timeline_event_digest).GetProperty("timeline_event", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<timeline_event>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
var timeline_event_digests = principalEntityType.AddNavigation("timeline_event_digests",
runtimeForeignKey,
onDependent: false,
typeof(ICollection<timeline_event_digest>),
propertyInfo: typeof(timeline_event).GetProperty("timeline_event_digests", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<timeline_event_digests>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
runtimeForeignKey.AddAnnotation("Relational:Name", "fk_event_digest_event");
return runtimeForeignKey;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_event_digests");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
public partial class TimelineIndexerDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
modelBuilder.Entity<timeline_event>(entity =>
{
entity.Property(e => e.severity)
.HasColumnType("timeline.event_severity")
.HasDefaultValue(TimelineEventSeverity.Info);
entity.HasOne(e => e.timeline_event_detail)
.WithOne(d => d.timeline_event)
.HasPrincipalKey<timeline_event>(e => new { e.event_id, e.tenant_id })
.HasForeignKey<timeline_event_detail>(d => new { d.event_id, d.tenant_id })
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_event_details");
entity.HasMany(e => e.timeline_event_digests)
.WithOne(d => d.timeline_event)
.HasPrincipalKey(e => new { e.event_id, e.tenant_id })
.HasForeignKey(d => new { d.event_id, d.tenant_id })
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_event_digest_event");
});
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
public partial class TimelineIndexerDbContext : DbContext
{
public TimelineIndexerDbContext(DbContextOptions<TimelineIndexerDbContext> options)
: base(options)
{
}
public virtual DbSet<timeline_event> timeline_events { get; set; }
public virtual DbSet<timeline_event_detail> timeline_event_details { get; set; }
public virtual DbSet<timeline_event_digest> timeline_event_digests { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.HasPostgresEnum("timeline", "event_severity", new[] { "info", "notice", "warn", "error", "critical" })
.HasPostgresExtension("pgcrypto");
modelBuilder.Entity<timeline_event>(entity =>
{
entity.HasKey(e => e.event_seq).HasName("timeline_events_pkey");
entity.ToTable("timeline_events", "timeline");
entity.HasIndex(e => new { e.tenant_id, e.occurred_at, e.event_seq }, "ix_timeline_events_tenant_occurred").IsDescending(false, true, true);
entity.HasIndex(e => new { e.tenant_id, e.event_type, e.occurred_at }, "ix_timeline_events_type").IsDescending(false, false, true);
entity.HasIndex(e => new { e.tenant_id, e.event_id }, "timeline_events_tenant_id_event_id_key").IsUnique();
entity.Property(e => e.attributes)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb");
entity.Property(e => e.received_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
});
modelBuilder.Entity<timeline_event_detail>(entity =>
{
entity.HasKey(e => new { e.event_id, e.tenant_id }).HasName("timeline_event_details_pkey");
entity.ToTable("timeline_event_details", "timeline");
entity.Property(e => e.created_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
entity.Property(e => e.normalized_payload).HasColumnType("jsonb");
entity.Property(e => e.raw_payload).HasColumnType("jsonb");
});
modelBuilder.Entity<timeline_event_digest>(entity =>
{
entity.HasKey(e => e.digest_id).HasName("timeline_event_digests_pkey");
entity.ToTable("timeline_event_digests", "timeline");
entity.HasIndex(e => new { e.tenant_id, e.bundle_digest }, "ix_timeline_digests_bundle");
entity.HasIndex(e => new { e.tenant_id, e.event_id }, "ix_timeline_digests_event");
entity.Property(e => e.digest_id).HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.created_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
public sealed class TimelineIndexerDesignTimeDbContextFactory : IDesignTimeDbContextFactory<TimelineIndexerDbContext>
{
private const string DefaultConnectionString = "Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_TIMELINEINDEXER_EF_CONNECTION";
public TimelineIndexerDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<TimelineIndexerDbContext>()
.UseNpgsql(connectionString)
.Options;
return new TimelineIndexerDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,24 @@
using NpgsqlTypes;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
/// <summary>
/// CLR mapping for timeline.event_severity enum in PostgreSQL.
/// </summary>
public enum TimelineEventSeverity
{
[PgName("info")]
Info,
[PgName("notice")]
Notice,
[PgName("warn")]
Warn,
[PgName("error")]
Error,
[PgName("critical")]
Critical
}

View File

@@ -0,0 +1,30 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
internal static class TimelineEventSeverityExtensions
{
public static TimelineEventSeverity ParseOrDefault(string? value)
=> TryParse(value, out var parsed) ? parsed : TimelineEventSeverity.Info;
public static bool TryParse(string? value, out TimelineEventSeverity parsed)
{
parsed = TimelineEventSeverity.Info;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return Enum.TryParse(value, ignoreCase: true, out parsed);
}
public static string ToWireValue(this TimelineEventSeverity value)
=> value switch
{
TimelineEventSeverity.Info => "info",
TimelineEventSeverity.Notice => "notice",
TimelineEventSeverity.Warn => "warn",
TimelineEventSeverity.Error => "error",
TimelineEventSeverity.Critical => "critical",
_ => "info"
};
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event
{
public TimelineEventSeverity severity { get; set; } = TimelineEventSeverity.Info;
public virtual timeline_event_detail? timeline_event_detail { get; set; }
public virtual ICollection<timeline_event_digest> timeline_event_digests { get; set; } = new List<timeline_event_digest>();
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event
{
public long event_seq { get; set; }
public string event_id { get; set; } = null!;
public string tenant_id { get; set; } = null!;
public string source { get; set; } = null!;
public string event_type { get; set; } = null!;
public DateTime occurred_at { get; set; }
public DateTime received_at { get; set; }
public string? correlation_id { get; set; }
public string? trace_id { get; set; }
public string? actor { get; set; }
public string? payload_hash { get; set; }
public string attributes { get; set; } = null!;
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_detail
{
public virtual timeline_event timeline_event { get; set; } = null!;
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_detail
{
public string event_id { get; set; } = null!;
public string tenant_id { get; set; } = null!;
public string envelope_version { get; set; } = null!;
public string raw_payload { get; set; } = null!;
public string? normalized_payload { get; set; }
public DateTime created_at { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_digest
{
public virtual timeline_event timeline_event { get; set; } = null!;
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_digest
{
public Guid digest_id { get; set; }
public string tenant_id { get; set; } = null!;
public string event_id { get; set; } = null!;
public Guid? bundle_id { get; set; }
public string? bundle_digest { get; set; }
public string? attestation_subject { get; set; }
public string? attestation_digest { get; set; }
public string? manifest_uri { get; set; }
public DateTime created_at { get; set; }
}

View File

@@ -0,0 +1,83 @@
using System;
namespace StellaOps.TimelineIndexer.Infrastructure.Options;
/// <summary>
/// Configuration for timeline ingestion transports (NATS, Redis).
/// </summary>
public sealed class TimelineIngestionOptions
{
public NatsIngestionOptions Nats { get; init; } = new();
public RedisIngestionOptions Redis { get; init; } = new();
}
public sealed class NatsIngestionOptions
{
/// <summary>
/// Enables NATS subscription when true.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// NATS server URL (e.g., nats://localhost:4222).
/// </summary>
public string Url { get; init; } = "nats://localhost:4222";
/// <summary>
/// Subject to subscribe to for orchestrator events.
/// </summary>
public string Subject { get; init; } = "orch.event";
/// <summary>
/// Queue group for shared subscriptions to preserve ordering per subject.
/// </summary>
public string QueueGroup { get; init; } = "timeline-indexer";
/// <summary>
/// Maximum in-flight messages per subscriber.
/// </summary>
public int Prefetch { get; init; } = 64;
}
public sealed class RedisIngestionOptions
{
/// <summary>
/// Enables Redis Stream subscription when true.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Redis connection string (e.g., localhost:6379 or rediss://...).
/// </summary>
public string ConnectionString { get; init; } = "localhost:6379";
/// <summary>
/// Stream name carrying timeline events.
/// </summary>
public string Stream { get; init; } = "timeline.events";
/// <summary>
/// Consumer group used for ordered consumption.
/// </summary>
public string ConsumerGroup { get; init; } = "timeline-indexer";
/// <summary>
/// Consumer name used when reading from the group.
/// </summary>
public string ConsumerName { get; init; } = Environment.MachineName ?? "timeline-indexer";
/// <summary>
/// Field that contains the JSON payload within the stream entry.
/// </summary>
public string ValueField { get; init; } = "data";
/// <summary>
/// Maximum entries fetched per polling iteration.
/// </summary>
public int MaxBatchSize { get; init; } = 128;
/// <summary>
/// Polling interval in milliseconds when no entries are available.
/// </summary>
public int PollIntervalMilliseconds { get; init; } = 250;
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj"/>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Db/Migrations/*.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NATS.Client.Core;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.Options;
using System.Runtime.CompilerServices;
using System.Text;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// NATS-based subscriber that yields orchestrator envelopes for ingestion.
/// </summary>
public sealed class NatsTimelineEventSubscriber : ITimelineEventSubscriber
{
private readonly IOptions<TimelineIngestionOptions> _options;
private readonly TimelineEnvelopeParser _parser;
private readonly ILogger<NatsTimelineEventSubscriber> _logger;
public NatsTimelineEventSubscriber(
IOptions<TimelineIngestionOptions> options,
TimelineEnvelopeParser parser,
ILogger<NatsTimelineEventSubscriber> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var cfg = _options.Value.Nats;
if (!cfg.Enabled)
{
yield break;
}
await using var connection = new NatsConnection(new NatsOpts
{
Url = cfg.Url,
Name = "timeline-indexer"
});
await foreach (var msg in connection.SubscribeAsync<byte[]>(
cfg.Subject,
queueGroup: cfg.QueueGroup,
cancellationToken: cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
var json = msg.Data is { Length: > 0 }
? Encoding.UTF8.GetString(msg.Data)
: string.Empty;
if (_parser.TryParse(json, out var envelope, out var reason))
{
yield return envelope;
}
else
{
_logger.LogWarning("Dropped NATS event on {Subject}: {Reason}", cfg.Subject, reason);
}
}
}
}

View File

@@ -0,0 +1,16 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// Default no-op subscriber used until transport bindings are configured.
/// Keeps the ingestion worker running without requiring live brokers.
/// </summary>
public sealed class NullTimelineEventSubscriber : ITimelineEventSubscriber
{
public IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default)
{
return AsyncEnumerable.Empty<TimelineEventEnvelope>();
}
}

View File

@@ -0,0 +1,127 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.Options;
using System.Runtime.CompilerServices;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// Redis Stream subscriber that reads orchestrator events and yields timeline envelopes.
/// </summary>
public sealed class RedisTimelineEventSubscriber : ITimelineEventSubscriber, IAsyncDisposable
{
private readonly IOptions<TimelineIngestionOptions> _options;
private readonly TimelineEnvelopeParser _parser;
private readonly ILogger<RedisTimelineEventSubscriber> _logger;
private ConnectionMultiplexer? _connection;
public RedisTimelineEventSubscriber(
IOptions<TimelineIngestionOptions> options,
TimelineEnvelopeParser parser,
ILogger<RedisTimelineEventSubscriber> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var cfg = _options.Value.Redis;
if (!cfg.Enabled)
{
yield break;
}
_connection = await ConnectionMultiplexer.ConnectAsync(cfg.ConnectionString);
var db = _connection.GetDatabase();
await EnsureGroupAsync(db, cfg, cancellationToken).ConfigureAwait(false);
while (!cancellationToken.IsCancellationRequested)
{
StreamEntry[] entries;
try
{
entries = await db.StreamReadGroupAsync(
cfg.Stream,
cfg.ConsumerGroup,
cfg.ConsumerName,
">",
count: cfg.MaxBatchSize,
flags: CommandFlags.DemandMaster).ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("NOGROUP", StringComparison.OrdinalIgnoreCase))
{
await EnsureGroupAsync(db, cfg, cancellationToken).ConfigureAwait(false);
continue;
}
if (entries.Length == 0)
{
await Task.Delay(cfg.PollIntervalMilliseconds, cancellationToken).ConfigureAwait(false);
continue;
}
foreach (var entry in entries)
{
if (!TryGetValue(entry, cfg.ValueField, out var json))
{
_logger.LogWarning("Redis entry {EntryId} missing expected field {Field}", entry.Id, cfg.ValueField);
await db.StreamAcknowledgeAsync(cfg.Stream, cfg.ConsumerGroup, entry.Id).ConfigureAwait(false);
continue;
}
if (_parser.TryParse(json!, out var envelope, out var reason))
{
yield return envelope;
}
else
{
_logger.LogWarning("Redis entry {EntryId} dropped: {Reason}", entry.Id, reason);
}
await db.StreamAcknowledgeAsync(cfg.Stream, cfg.ConsumerGroup, entry.Id).ConfigureAwait(false);
}
}
}
private static async Task EnsureGroupAsync(IDatabase db, RedisIngestionOptions cfg, CancellationToken cancellationToken)
{
try
{
await db.StreamCreateConsumerGroupAsync(cfg.Stream, cfg.ConsumerGroup, "$", true).ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// Group already exists; nothing to do.
}
}
private static bool TryGetValue(in StreamEntry entry, string fieldName, out string? value)
{
foreach (var nv in entry.Values)
{
if (string.Equals(nv.Name, fieldName, StringComparison.OrdinalIgnoreCase))
{
value = nv.Value.HasValue ? nv.Value.ToString() : null;
return true;
}
}
value = null;
return false;
}
public async ValueTask DisposeAsync()
{
if (_connection is not null)
{
await _connection.DisposeAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,217 @@
using StellaOps.TimelineIndexer.Core.Models;
using System.Text.Json;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// Normalises incoming orchestrator/notification envelopes into <see cref="TimelineEventEnvelope"/> instances.
/// </summary>
public sealed class TimelineEnvelopeParser
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNameCaseInsensitive = true
};
private readonly TimeProvider _timeProvider;
public TimelineEnvelopeParser(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public bool TryParse(string rawJson, out TimelineEventEnvelope envelope, out string? failureReason)
{
if (string.IsNullOrWhiteSpace(rawJson))
{
envelope = default!;
failureReason = "Payload was empty";
return false;
}
try
{
using var doc = JsonDocument.Parse(rawJson);
var root = doc.RootElement;
var eventId = FirstString(root, "eventId", "event_id", "id", "messageId");
var tenantId = FirstString(root, "tenant", "tenantId", "tenant_id");
var eventType = FirstString(root, "kind", "eventType", "event_type", "type");
var source = FirstString(root, "source", "producer") ?? "unknown";
var correlationId = FirstString(root, "correlationId", "correlation_id");
var traceId = FirstString(root, "traceId", "trace_id");
var actor = ExtractActor(root);
var severity = (FirstString(root, "severity") ?? "info").ToLowerInvariant();
var occurredAt = FirstDateTime(root, "occurredAt", "occurred_at", "timestamp", "ts") ?? _timeProvider.GetUtcNow();
var normalizedPayload = ExtractNormalizedPayload(root);
var attributes = ExtractAttributes(root);
envelope = new TimelineEventEnvelope
{
EventId = eventId ?? throw new InvalidOperationException("event_id is required"),
TenantId = tenantId ?? throw new InvalidOperationException("tenant_id is required"),
EventType = eventType ?? throw new InvalidOperationException("event_type is required"),
Source = source,
OccurredAt = occurredAt,
CorrelationId = correlationId,
TraceId = traceId,
Actor = actor,
Severity = severity,
RawPayloadJson = JsonSerializer.Serialize(root, SerializerOptions),
NormalizedPayloadJson = normalizedPayload,
Attributes = attributes,
BundleDigest = FirstString(root, "bundleDigest"),
BundleId = FirstGuid(root, "bundleId"),
AttestationSubject = FirstString(root, "attestationSubject"),
AttestationDigest = FirstString(root, "attestationDigest"),
ManifestUri = FirstString(root, "manifestUri")
};
failureReason = null;
return true;
}
catch (Exception ex)
{
envelope = default!;
failureReason = ex.Message;
return false;
}
}
private static string? ExtractActor(JsonElement root)
{
if (TryGetProperty(root, "actor", out var actorElement))
{
if (actorElement.ValueKind == JsonValueKind.String)
{
return actorElement.GetString();
}
if (actorElement.ValueKind == JsonValueKind.Object)
{
if (actorElement.TryGetProperty("subject", out var subject) && subject.ValueKind == JsonValueKind.String)
{
return subject.GetString();
}
if (actorElement.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.String)
{
return user.GetString();
}
}
}
return null;
}
private static string? ExtractNormalizedPayload(JsonElement root)
{
if (TryGetProperty(root, "payload", out var payload))
{
return JsonSerializer.Serialize(payload, SerializerOptions);
}
if (TryGetProperty(root, "data", out var data) && data.ValueKind is JsonValueKind.Object)
{
return JsonSerializer.Serialize(data, SerializerOptions);
}
return null;
}
private static IDictionary<string, string>? ExtractAttributes(JsonElement root)
{
if (!TryGetProperty(root, "attributes", out var attributes) || attributes.ValueKind != JsonValueKind.Object)
{
return null;
}
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in attributes.EnumerateObject())
{
var value = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
if (!string.IsNullOrWhiteSpace(value))
{
dict[property.Name] = value!;
}
}
return dict.Count == 0 ? null : dict;
}
private static bool TryGetProperty(JsonElement root, string name, out JsonElement value)
{
if (root.TryGetProperty(name, out value))
{
return true;
}
foreach (var property in root.EnumerateObject())
{
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
private static string? FirstString(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (TryGetProperty(root, name, out var value) && value.ValueKind == JsonValueKind.String)
{
var str = value.GetString();
if (!string.IsNullOrWhiteSpace(str))
{
return str;
}
}
}
return null;
}
private static Guid? FirstGuid(JsonElement root, params string[] names)
{
var text = FirstString(root, names);
if (Guid.TryParse(text, out var guid))
{
return guid;
}
return null;
}
private static DateTimeOffset? FirstDateTime(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (TryGetProperty(root, name, out var value) && value.ValueKind == JsonValueKind.String)
{
var text = value.GetString();
if (!string.IsNullOrWhiteSpace(text) && DateTimeOffset.TryParse(text, out var dto))
{
return dto;
}
}
}
return null;
}
}

View File

@@ -0,0 +1,14 @@
# StellaOps.TimelineIndexer.Infrastructure Task Board
This board mirrors active sprint tasks for this module.
Source of truth:
- `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`
- `docs/implplan/SPRINT_20260222_063_TimelineIndexer_smallest_webservice_dal_to_efcore.md`
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Infrastructure/StellaOps.TimelineIndexer.Infrastructure.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| TLI-EF-01 | DONE | EF Core scaffold baseline generated for timeline schema tables. |
| TLI-EF-02 | DONE | `TimelineEventStore` and `TimelineQueryStore` converted from raw SQL/Npgsql commands to EF Core DAL. |
| TLI-EF-03 | DONE | Sequential build/test validation complete and TimelineIndexer docs updated for EF persistence flow. |
| TLI-EF-04 | DONE | Compiled model generated (`EfCore/CompiledModels`) and static module wired via `UseModel(TimelineIndexerDbContextModel.Instance)`. |

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
namespace StellaOps.TimelineIndexer.Infrastructure;
/// <summary>
/// PostgreSQL data source for the Timeline Indexer module.
/// Sets the default schema and carries tenant context via app.current_tenant.
/// </summary>
public sealed class TimelineIndexerDataSource : DataSourceBase
{
public const string DefaultSchemaName = "timeline";
public TimelineIndexerDataSource(IOptions<PostgresOptions> options, ILogger<TimelineIndexerDataSource> logger)
: base(EnsureSchema(options.Value), logger)
{
}
protected override string ModuleName => "TimelineIndexer";
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
builder.MapEnum<TimelineEventSeverity>("timeline.event_severity");
}
private static PostgresOptions EnsureSchema(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}