consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
|
||||
public sealed record TimelineIngestResult(bool Inserted);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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. |
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)`. |
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user