wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.ExportCenter.Infrastructure.EfCore.Models;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Context;
public partial class ExportCenterDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
// Profile -> Runs relationship (FK: export_runs.profile_id -> export_profiles.profile_id)
modelBuilder.Entity<ExportRunEntity>(entity =>
{
entity.HasOne(e => e.Profile)
.WithMany(p => p.Runs)
.HasForeignKey(e => e.ProfileId)
.HasConstraintName("fk_runs_profile");
});
// Run -> Inputs relationship (FK: export_inputs.run_id -> export_runs.run_id, ON DELETE CASCADE)
modelBuilder.Entity<ExportInputEntity>(entity =>
{
entity.HasOne(e => e.Run)
.WithMany(r => r.Inputs)
.HasForeignKey(e => e.RunId)
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_inputs_run");
});
// Run -> Distributions relationship (FK: export_distributions.run_id -> export_runs.run_id, ON DELETE CASCADE)
modelBuilder.Entity<ExportDistributionEntity>(entity =>
{
entity.HasOne(e => e.Run)
.WithMany(r => r.Distributions)
.HasForeignKey(e => e.RunId)
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_distributions_run");
});
}
}

View File

@@ -0,0 +1,188 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.ExportCenter.Infrastructure.EfCore.Models;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Context;
public partial class ExportCenterDbContext : DbContext
{
private readonly string _schemaName;
public ExportCenterDbContext(DbContextOptions<ExportCenterDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "export_center"
: schemaName.Trim();
}
public virtual DbSet<ExportProfileEntity> ExportProfiles { get; set; }
public virtual DbSet<ExportRunEntity> ExportRuns { get; set; }
public virtual DbSet<ExportInputEntity> ExportInputs { get; set; }
public virtual DbSet<ExportDistributionEntity> ExportDistributions { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
modelBuilder.Entity<ExportProfileEntity>(entity =>
{
entity.HasKey(e => e.ProfileId).HasName("export_profiles_pkey");
entity.ToTable("export_profiles", schemaName);
entity.HasIndex(e => new { e.TenantId, e.Status }, "ix_export_profiles_tenant_status");
entity.HasIndex(e => new { e.TenantId, e.Name }, "uq_export_profiles_tenant_name")
.IsUnique()
.HasFilter("(archived_at IS NULL)");
entity.Property(e => e.ProfileId).HasColumnName("profile_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.ScopeJson)
.HasColumnType("jsonb")
.HasColumnName("scope_json");
entity.Property(e => e.FormatJson)
.HasColumnType("jsonb")
.HasColumnName("format_json");
entity.Property(e => e.SigningJson)
.HasColumnType("jsonb")
.HasColumnName("signing_json");
entity.Property(e => e.Schedule).HasColumnName("schedule");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(NOW() AT TIME ZONE 'UTC')")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("(NOW() AT TIME ZONE 'UTC')")
.HasColumnName("updated_at");
entity.Property(e => e.ArchivedAt).HasColumnName("archived_at");
});
modelBuilder.Entity<ExportRunEntity>(entity =>
{
entity.HasKey(e => e.RunId).HasName("export_runs_pkey");
entity.ToTable("export_runs", schemaName);
entity.HasIndex(e => new { e.TenantId, e.Status }, "ix_export_runs_tenant_status");
entity.HasIndex(e => new { e.ProfileId, e.CreatedAt }, "ix_export_runs_profile_created")
.IsDescending(false, true);
entity.HasIndex(e => e.CorrelationId, "ix_export_runs_correlation")
.HasFilter("(correlation_id IS NOT NULL)");
entity.Property(e => e.RunId).HasColumnName("run_id");
entity.Property(e => e.ProfileId).HasColumnName("profile_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Trigger).HasColumnName("trigger");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.InitiatedBy).HasColumnName("initiated_by");
entity.Property(e => e.TotalItems)
.HasDefaultValue(0)
.HasColumnName("total_items");
entity.Property(e => e.ProcessedItems)
.HasDefaultValue(0)
.HasColumnName("processed_items");
entity.Property(e => e.FailedItems)
.HasDefaultValue(0)
.HasColumnName("failed_items");
entity.Property(e => e.TotalSizeBytes)
.HasDefaultValue(0L)
.HasColumnName("total_size_bytes");
entity.Property(e => e.ErrorJson)
.HasColumnType("jsonb")
.HasColumnName("error_json");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(NOW() AT TIME ZONE 'UTC')")
.HasColumnName("created_at");
entity.Property(e => e.StartedAt).HasColumnName("started_at");
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
});
modelBuilder.Entity<ExportInputEntity>(entity =>
{
entity.HasKey(e => e.InputId).HasName("export_inputs_pkey");
entity.ToTable("export_inputs", schemaName);
entity.HasIndex(e => new { e.RunId, e.Status }, "ix_export_inputs_run_status");
entity.HasIndex(e => new { e.TenantId, e.Kind }, "ix_export_inputs_tenant_kind");
entity.HasIndex(e => new { e.TenantId, e.SourceRef }, "ix_export_inputs_source_ref");
entity.Property(e => e.InputId).HasColumnName("input_id");
entity.Property(e => e.RunId).HasColumnName("run_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.SourceRef).HasColumnName("source_ref");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.ContentHash).HasColumnName("content_hash");
entity.Property(e => e.SizeBytes)
.HasDefaultValue(0L)
.HasColumnName("size_bytes");
entity.Property(e => e.MetadataJson)
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
entity.Property(e => e.ErrorJson)
.HasColumnType("jsonb")
.HasColumnName("error_json");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(NOW() AT TIME ZONE 'UTC')")
.HasColumnName("created_at");
entity.Property(e => e.ProcessedAt).HasColumnName("processed_at");
});
modelBuilder.Entity<ExportDistributionEntity>(entity =>
{
entity.HasKey(e => e.DistributionId).HasName("export_distributions_pkey");
entity.ToTable("export_distributions", schemaName);
entity.HasIndex(e => new { e.RunId, e.Status }, "ix_export_distributions_run_status");
entity.HasIndex(e => new { e.TenantId, e.Kind }, "ix_export_distributions_tenant_kind");
entity.Property(e => e.DistributionId).HasColumnName("distribution_id");
entity.Property(e => e.RunId).HasColumnName("run_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Target).HasColumnName("target");
entity.Property(e => e.ArtifactPath).HasColumnName("artifact_path");
entity.Property(e => e.ArtifactHash).HasColumnName("artifact_hash");
entity.Property(e => e.SizeBytes)
.HasDefaultValue(0L)
.HasColumnName("size_bytes");
entity.Property(e => e.ContentType).HasColumnName("content_type");
entity.Property(e => e.MetadataJson)
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
entity.Property(e => e.ErrorJson)
.HasColumnType("jsonb")
.HasColumnName("error_json");
entity.Property(e => e.AttemptCount)
.HasDefaultValue(0)
.HasColumnName("attempt_count");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(NOW() AT TIME ZONE 'UTC')")
.HasColumnName("created_at");
entity.Property(e => e.DistributedAt).HasColumnName("distributed_at");
entity.Property(e => e.VerifiedAt).HasColumnName("verified_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

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

View File

@@ -0,0 +1,9 @@
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
public partial class ExportDistributionEntity
{
/// <summary>
/// Navigation: run this distribution belongs to.
/// </summary>
public virtual ExportRunEntity? Run { get; set; }
}

View File

@@ -0,0 +1,41 @@
using System;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
/// <summary>
/// EF Core entity mapping to export_center.export_distributions table.
/// </summary>
public partial class ExportDistributionEntity
{
public Guid DistributionId { get; set; }
public Guid RunId { get; set; }
public Guid TenantId { get; set; }
public short Kind { get; set; }
public short Status { get; set; }
public string Target { get; set; } = null!;
public string ArtifactPath { get; set; } = null!;
public string? ArtifactHash { get; set; }
public long SizeBytes { get; set; }
public string? ContentType { get; set; }
public string? MetadataJson { get; set; }
public string? ErrorJson { get; set; }
public int AttemptCount { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? DistributedAt { get; set; }
public DateTime? VerifiedAt { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
public partial class ExportInputEntity
{
/// <summary>
/// Navigation: run this input belongs to.
/// </summary>
public virtual ExportRunEntity? Run { get; set; }
}

View File

@@ -0,0 +1,35 @@
using System;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
/// <summary>
/// EF Core entity mapping to export_center.export_inputs table.
/// </summary>
public partial class ExportInputEntity
{
public Guid InputId { get; set; }
public Guid RunId { get; set; }
public Guid TenantId { get; set; }
public short Kind { get; set; }
public short Status { get; set; }
public string SourceRef { get; set; } = null!;
public string? Name { get; set; }
public string? ContentHash { get; set; }
public long SizeBytes { get; set; }
public string? MetadataJson { get; set; }
public string? ErrorJson { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
public partial class ExportProfileEntity
{
/// <summary>
/// Navigation: runs belonging to this profile.
/// </summary>
public virtual ICollection<ExportRunEntity> Runs { get; set; } = new List<ExportRunEntity>();
}

View File

@@ -0,0 +1,35 @@
using System;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
/// <summary>
/// EF Core entity mapping to export_center.export_profiles table.
/// </summary>
public partial class ExportProfileEntity
{
public Guid ProfileId { get; set; }
public Guid TenantId { get; set; }
public string Name { get; set; } = null!;
public string? Description { get; set; }
public short Kind { get; set; }
public short Status { get; set; }
public string? ScopeJson { get; set; }
public string? FormatJson { get; set; }
public string? SigningJson { get; set; }
public string? Schedule { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? ArchivedAt { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
public partial class ExportRunEntity
{
/// <summary>
/// Navigation: profile this run belongs to.
/// </summary>
public virtual ExportProfileEntity? Profile { get; set; }
/// <summary>
/// Navigation: inputs belonging to this run.
/// </summary>
public virtual ICollection<ExportInputEntity> Inputs { get; set; } = new List<ExportInputEntity>();
/// <summary>
/// Navigation: distributions belonging to this run.
/// </summary>
public virtual ICollection<ExportDistributionEntity> Distributions { get; set; } = new List<ExportDistributionEntity>();
}

View File

@@ -0,0 +1,41 @@
using System;
namespace StellaOps.ExportCenter.Infrastructure.EfCore.Models;
/// <summary>
/// EF Core entity mapping to export_center.export_runs table.
/// </summary>
public partial class ExportRunEntity
{
public Guid RunId { get; set; }
public Guid ProfileId { get; set; }
public Guid TenantId { get; set; }
public short Status { get; set; }
public short Trigger { get; set; }
public string? CorrelationId { get; set; }
public string? InitiatedBy { get; set; }
public int TotalItems { get; set; }
public int ProcessedItems { get; set; }
public int FailedItems { get; set; }
public long TotalSizeBytes { get; set; }
public string? ErrorJson { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,30 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.ExportCenter.Infrastructure.EfCore.Context;
namespace StellaOps.ExportCenter.Infrastructure.Postgres;
internal static class ExportCenterDbContextFactory
{
public const string DefaultSchemaName = "export_center";
public static ExportCenterDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<ExportCenterDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
// Compiled model hookup point: when compiled models are generated,
// uncomment the following to use them for default schema:
// if (string.Equals(normalizedSchema, DefaultSchemaName, StringComparison.Ordinal))
// {
// optionsBuilder.UseModel(ExportCenterDbContextModel.Instance);
// }
return new ExportCenterDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -0,0 +1,313 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.ExportCenter.Core.Domain;
using StellaOps.ExportCenter.Infrastructure.Db;
using StellaOps.ExportCenter.Infrastructure.EfCore.Models;
using StellaOps.ExportCenter.WebService.Distribution;
namespace StellaOps.ExportCenter.Infrastructure.Postgres.Repositories;
/// <summary>
/// EF Core-backed implementation of IExportDistributionRepository.
/// </summary>
public sealed class PostgresExportDistributionRepository : IExportDistributionRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly ExportCenterDataSource _dataSource;
private readonly ILogger<PostgresExportDistributionRepository> _logger;
public PostgresExportDistributionRepository(
ExportCenterDataSource dataSource,
ILogger<PostgresExportDistributionRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ExportDistribution?> GetByIdAsync(
Guid tenantId,
Guid distributionId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entity = await dbContext.ExportDistributions
.AsNoTracking()
.FirstOrDefaultAsync(d => d.DistributionId == distributionId && d.TenantId == tenantId, cancellationToken);
return entity is null ? null : MapToDomain(entity);
}
public async Task<ExportDistribution?> GetByIdempotencyKeyAsync(
Guid tenantId,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
// The distribution table doesn't have an idempotency_key column in the SQL schema.
// The idempotency_key is a domain concept used in the domain model but not persisted.
// We search by artifact_path + target as a proxy for idempotency.
// For full idempotency support, a future migration would add the column.
// For now, return null (callers fall through to create).
return null;
}
public async Task<IReadOnlyList<ExportDistribution>> ListByRunAsync(
Guid tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entities = await dbContext.ExportDistributions
.AsNoTracking()
.Where(d => d.TenantId == tenantId && d.RunId == runId)
.OrderBy(d => d.CreatedAt)
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
public async Task<IReadOnlyList<ExportDistribution>> ListByStatusAsync(
Guid tenantId,
ExportDistributionStatus status,
int limit = 100,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entities = await dbContext.ExportDistributions
.AsNoTracking()
.Where(d => d.TenantId == tenantId && d.Status == (short)status)
.OrderBy(d => d.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
public async Task<IReadOnlyList<ExportDistribution>> ListExpiredAsync(
DateTimeOffset asOf,
int limit = 100,
CancellationToken cancellationToken = default)
{
// The SQL schema doesn't have a retention_expires_at column on the distributions table.
// This is a domain concept tracked outside the DB schema.
// Return empty for now until a future migration adds retention columns.
return [];
}
public async Task<ExportDistribution> CreateAsync(
ExportDistribution distribution,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(distribution.TenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entity = MapToEntity(distribution);
dbContext.ExportDistributions.Add(entity);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
throw new InvalidOperationException(
$"Distribution {distribution.DistributionId} already exists.", ex);
}
_logger.LogDebug("Created export distribution {DistributionId} for run {RunId}",
distribution.DistributionId, distribution.RunId);
return distribution;
}
public async Task<ExportDistribution?> UpdateAsync(
ExportDistribution distribution,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(distribution.TenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var existing = await dbContext.ExportDistributions
.FirstOrDefaultAsync(d => d.DistributionId == distribution.DistributionId && d.TenantId == distribution.TenantId, cancellationToken);
if (existing is null)
return null;
existing.Status = (short)distribution.Status;
existing.ArtifactHash = distribution.ArtifactHash;
existing.SizeBytes = distribution.SizeBytes;
existing.ContentType = distribution.ContentType;
existing.MetadataJson = distribution.MetadataJson;
existing.ErrorJson = distribution.ErrorJson;
existing.AttemptCount = distribution.AttemptCount;
existing.DistributedAt = distribution.DistributedAt?.UtcDateTime;
existing.VerifiedAt = distribution.VerifiedAt?.UtcDateTime;
await dbContext.SaveChangesAsync(cancellationToken);
return MapToDomain(existing);
}
public async Task<(ExportDistribution Distribution, bool WasCreated)> UpsertByIdempotencyKeyAsync(
ExportDistribution distribution,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(distribution.IdempotencyKey))
{
throw new ArgumentException("Idempotency key is required for upsert", nameof(distribution));
}
// The SQL schema doesn't include an idempotency_key column.
// Attempt insert; if unique violation on PK, fetch existing.
try
{
var created = await CreateAsync(distribution, cancellationToken);
return (created, true);
}
catch (InvalidOperationException)
{
// Distribution already exists by PK
var existing = await GetByIdAsync(distribution.TenantId, distribution.DistributionId, cancellationToken);
if (existing is not null)
return (existing, false);
throw;
}
}
public async Task<bool> MarkForDeletionAsync(
Guid tenantId,
Guid distributionId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var existing = await dbContext.ExportDistributions
.FirstOrDefaultAsync(d => d.DistributionId == distributionId && d.TenantId == tenantId, cancellationToken);
if (existing is null)
return false;
// Mark as cancelled status (soft delete equivalent in the SQL schema)
existing.Status = (short)ExportDistributionStatus.Cancelled;
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> DeleteAsync(
Guid tenantId,
Guid distributionId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var existing = await dbContext.ExportDistributions
.FirstOrDefaultAsync(d => d.DistributionId == distributionId && d.TenantId == tenantId, cancellationToken);
if (existing is null)
return false;
dbContext.ExportDistributions.Remove(existing);
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<ExportDistributionStats> GetStatsAsync(
Guid tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var distributions = await dbContext.ExportDistributions
.AsNoTracking()
.Where(d => d.TenantId == tenantId && d.RunId == runId)
.ToListAsync(cancellationToken);
return new ExportDistributionStats
{
Total = distributions.Count,
Pending = distributions.Count(d => d.Status == (short)ExportDistributionStatus.Pending),
Distributing = distributions.Count(d => d.Status == (short)ExportDistributionStatus.Distributing),
Distributed = distributions.Count(d => d.Status == (short)ExportDistributionStatus.Distributed),
Verified = distributions.Count(d => d.Status == (short)ExportDistributionStatus.Verified),
Failed = distributions.Count(d => d.Status == (short)ExportDistributionStatus.Failed),
Cancelled = distributions.Count(d => d.Status == (short)ExportDistributionStatus.Cancelled),
TotalSizeBytes = distributions.Sum(d => d.SizeBytes)
};
}
private static ExportDistribution MapToDomain(ExportDistributionEntity entity)
{
return new ExportDistribution
{
DistributionId = entity.DistributionId,
RunId = entity.RunId,
TenantId = entity.TenantId,
Kind = (ExportDistributionKind)entity.Kind,
Status = (ExportDistributionStatus)entity.Status,
Target = entity.Target,
ArtifactPath = entity.ArtifactPath,
ArtifactHash = entity.ArtifactHash,
SizeBytes = entity.SizeBytes,
ContentType = entity.ContentType,
MetadataJson = entity.MetadataJson,
ErrorJson = entity.ErrorJson,
AttemptCount = entity.AttemptCount,
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
DistributedAt = entity.DistributedAt.HasValue
? new DateTimeOffset(entity.DistributedAt.Value, TimeSpan.Zero)
: null,
VerifiedAt = entity.VerifiedAt.HasValue
? new DateTimeOffset(entity.VerifiedAt.Value, TimeSpan.Zero)
: null
};
}
private static ExportDistributionEntity MapToEntity(ExportDistribution distribution)
{
return new ExportDistributionEntity
{
DistributionId = distribution.DistributionId,
RunId = distribution.RunId,
TenantId = distribution.TenantId,
Kind = (short)distribution.Kind,
Status = (short)distribution.Status,
Target = distribution.Target,
ArtifactPath = distribution.ArtifactPath,
ArtifactHash = distribution.ArtifactHash,
SizeBytes = distribution.SizeBytes,
ContentType = distribution.ContentType,
MetadataJson = distribution.MetadataJson,
ErrorJson = distribution.ErrorJson,
AttemptCount = distribution.AttemptCount,
CreatedAt = distribution.CreatedAt.UtcDateTime,
DistributedAt = distribution.DistributedAt?.UtcDateTime,
VerifiedAt = distribution.VerifiedAt?.UtcDateTime
};
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,271 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.ExportCenter.Core.Domain;
using StellaOps.ExportCenter.Infrastructure.Db;
using StellaOps.ExportCenter.Infrastructure.EfCore.Models;
using StellaOps.ExportCenter.WebService.Api;
namespace StellaOps.ExportCenter.Infrastructure.Postgres.Repositories;
/// <summary>
/// EF Core-backed implementation of IExportProfileRepository.
/// </summary>
public sealed class PostgresExportProfileRepository : IExportProfileRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly ExportCenterDataSource _dataSource;
private readonly ILogger<PostgresExportProfileRepository> _logger;
public PostgresExportProfileRepository(
ExportCenterDataSource dataSource,
ILogger<PostgresExportProfileRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ExportProfile?> GetByIdAsync(
Guid tenantId,
Guid profileId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entity = await dbContext.ExportProfiles
.AsNoTracking()
.FirstOrDefaultAsync(p => p.ProfileId == profileId && p.TenantId == tenantId, cancellationToken);
return entity is null ? null : MapToDomain(entity);
}
public async Task<(IReadOnlyList<ExportProfile> Items, int TotalCount)> ListAsync(
Guid tenantId,
ExportProfileStatus? status = null,
ExportProfileKind? kind = null,
string? search = null,
int offset = 0,
int limit = 50,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var query = dbContext.ExportProfiles
.AsNoTracking()
.Where(p => p.TenantId == tenantId);
if (status.HasValue)
query = query.Where(p => p.Status == (short)status.Value);
if (kind.HasValue)
query = query.Where(p => p.Kind == (short)kind.Value);
if (!string.IsNullOrWhiteSpace(search))
{
var searchLower = search.ToLowerInvariant();
query = query.Where(p =>
p.Name.ToLower().Contains(searchLower) ||
(p.Description != null && p.Description.ToLower().Contains(searchLower)));
}
var totalCount = await query.CountAsync(cancellationToken);
var entities = await query
.OrderByDescending(p => p.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken);
var items = entities.Select(MapToDomain).ToList();
return (items, totalCount);
}
public async Task<ExportProfile> CreateAsync(
ExportProfile profile,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(profile.TenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entity = MapToEntity(profile);
dbContext.ExportProfiles.Add(entity);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
throw new InvalidOperationException($"Profile with name '{profile.Name}' already exists for tenant.", ex);
}
_logger.LogDebug("Created export profile {ProfileId} for tenant {TenantId}",
profile.ProfileId, profile.TenantId);
return profile;
}
public async Task<ExportProfile?> UpdateAsync(
ExportProfile profile,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(profile.TenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var existing = await dbContext.ExportProfiles
.FirstOrDefaultAsync(p => p.ProfileId == profile.ProfileId && p.TenantId == profile.TenantId, cancellationToken);
if (existing is null)
return null;
existing.Name = profile.Name;
existing.Description = profile.Description;
existing.Kind = (short)profile.Kind;
existing.Status = (short)profile.Status;
existing.ScopeJson = profile.ScopeJson;
existing.FormatJson = profile.FormatJson;
existing.SigningJson = profile.SigningJson;
existing.Schedule = profile.Schedule;
existing.ArchivedAt = profile.ArchivedAt.HasValue
? profile.ArchivedAt.Value.UtcDateTime
: null;
// updated_at is handled by the DB trigger
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
throw new InvalidOperationException($"Profile with name '{profile.Name}' already exists for tenant.", ex);
}
_logger.LogDebug("Updated export profile {ProfileId} for tenant {TenantId}",
profile.ProfileId, profile.TenantId);
return MapToDomain(existing);
}
public async Task<bool> ArchiveAsync(
Guid tenantId,
Guid profileId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var existing = await dbContext.ExportProfiles
.FirstOrDefaultAsync(p => p.ProfileId == profileId && p.TenantId == tenantId, cancellationToken);
if (existing is null)
return false;
existing.Status = (short)ExportProfileStatus.Archived;
existing.ArchivedAt = DateTime.UtcNow;
// updated_at is handled by the DB trigger
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Archived export profile {ProfileId} for tenant {TenantId}",
profileId, tenantId);
return true;
}
public async Task<bool> IsNameUniqueAsync(
Guid tenantId,
string name,
Guid? excludeProfileId = null,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var query = dbContext.ExportProfiles
.AsNoTracking()
.Where(p =>
p.TenantId == tenantId &&
p.Name.ToLower() == name.ToLowerInvariant() &&
p.Status != (short)ExportProfileStatus.Archived);
if (excludeProfileId.HasValue)
query = query.Where(p => p.ProfileId != excludeProfileId.Value);
var exists = await query.AnyAsync(cancellationToken);
return !exists;
}
public async Task<IReadOnlyList<ExportProfile>> GetScheduledProfilesAsync(
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entities = await dbContext.ExportProfiles
.AsNoTracking()
.Where(p =>
p.Status == (short)ExportProfileStatus.Active &&
p.Kind == (short)ExportProfileKind.Scheduled &&
p.Schedule != null && p.Schedule != "")
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
private static ExportProfile MapToDomain(ExportProfileEntity entity)
{
return new ExportProfile
{
ProfileId = entity.ProfileId,
TenantId = entity.TenantId,
Name = entity.Name,
Description = entity.Description,
Kind = (ExportProfileKind)entity.Kind,
Status = (ExportProfileStatus)entity.Status,
ScopeJson = entity.ScopeJson,
FormatJson = entity.FormatJson,
SigningJson = entity.SigningJson,
Schedule = entity.Schedule,
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(entity.UpdatedAt, TimeSpan.Zero),
ArchivedAt = entity.ArchivedAt.HasValue
? new DateTimeOffset(entity.ArchivedAt.Value, TimeSpan.Zero)
: null
};
}
private static ExportProfileEntity MapToEntity(ExportProfile profile)
{
return new ExportProfileEntity
{
ProfileId = profile.ProfileId,
TenantId = profile.TenantId,
Name = profile.Name,
Description = profile.Description,
Kind = (short)profile.Kind,
Status = (short)profile.Status,
ScopeJson = profile.ScopeJson,
FormatJson = profile.FormatJson,
SigningJson = profile.SigningJson,
Schedule = profile.Schedule,
CreatedAt = profile.CreatedAt.UtcDateTime,
UpdatedAt = profile.UpdatedAt.UtcDateTime,
ArchivedAt = profile.ArchivedAt?.UtcDateTime
};
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,301 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.ExportCenter.Core.Domain;
using StellaOps.ExportCenter.Infrastructure.Db;
using StellaOps.ExportCenter.Infrastructure.EfCore.Models;
using StellaOps.ExportCenter.WebService.Api;
namespace StellaOps.ExportCenter.Infrastructure.Postgres.Repositories;
/// <summary>
/// EF Core-backed implementation of IExportRunRepository.
/// </summary>
public sealed class PostgresExportRunRepository : IExportRunRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly ExportCenterDataSource _dataSource;
private readonly ILogger<PostgresExportRunRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresExportRunRepository(
ExportCenterDataSource dataSource,
ILogger<PostgresExportRunRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ExportRun?> GetByIdAsync(
Guid tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entity = await dbContext.ExportRuns
.AsNoTracking()
.FirstOrDefaultAsync(r => r.RunId == runId && r.TenantId == tenantId, cancellationToken);
return entity is null ? null : MapToDomain(entity);
}
public async Task<(IReadOnlyList<ExportRun> Items, int TotalCount)> ListAsync(
Guid tenantId,
Guid? profileId = null,
ExportRunStatus? status = null,
ExportRunTrigger? trigger = null,
DateTimeOffset? createdAfter = null,
DateTimeOffset? createdBefore = null,
string? correlationId = null,
int offset = 0,
int limit = 50,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var query = dbContext.ExportRuns
.AsNoTracking()
.Where(r => r.TenantId == tenantId);
if (profileId.HasValue)
query = query.Where(r => r.ProfileId == profileId.Value);
if (status.HasValue)
query = query.Where(r => r.Status == (short)status.Value);
if (trigger.HasValue)
query = query.Where(r => r.Trigger == (short)trigger.Value);
if (createdAfter.HasValue)
query = query.Where(r => r.CreatedAt >= createdAfter.Value.UtcDateTime);
if (createdBefore.HasValue)
query = query.Where(r => r.CreatedAt <= createdBefore.Value.UtcDateTime);
if (!string.IsNullOrWhiteSpace(correlationId))
{
var corrLower = correlationId.ToLowerInvariant();
query = query.Where(r => r.CorrelationId != null && r.CorrelationId.ToLower() == corrLower);
}
var totalCount = await query.CountAsync(cancellationToken);
var entities = await query
.OrderByDescending(r => r.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken);
var items = entities.Select(MapToDomain).ToList();
return (items, totalCount);
}
public async Task<ExportRun> CreateAsync(
ExportRun run,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(run.TenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var entity = MapToEntity(run);
dbContext.ExportRuns.Add(entity);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
throw new InvalidOperationException($"Run {run.RunId} already exists.", ex);
}
_logger.LogDebug("Created export run {RunId} for tenant {TenantId}",
run.RunId, run.TenantId);
return run;
}
public async Task<ExportRun?> UpdateAsync(
ExportRun run,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(run.TenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var existing = await dbContext.ExportRuns
.FirstOrDefaultAsync(r => r.RunId == run.RunId && r.TenantId == run.TenantId, cancellationToken);
if (existing is null)
return null;
existing.Status = (short)run.Status;
existing.TotalItems = run.TotalItems;
existing.ProcessedItems = run.ProcessedItems;
existing.FailedItems = run.FailedItems;
existing.TotalSizeBytes = run.TotalSizeBytes;
existing.ErrorJson = run.ErrorJson;
existing.StartedAt = run.StartedAt?.UtcDateTime;
existing.CompletedAt = run.CompletedAt?.UtcDateTime;
existing.ExpiresAt = run.ExpiresAt?.UtcDateTime;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogDebug("Updated export run {RunId} status to {Status}",
run.RunId, run.Status);
return MapToDomain(existing);
}
public async Task<bool> CancelAsync(
Guid tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var existing = await dbContext.ExportRuns
.FirstOrDefaultAsync(r => r.RunId == runId && r.TenantId == tenantId, cancellationToken);
if (existing is null)
return false;
// Can only cancel queued or running runs
if (existing.Status != (short)ExportRunStatus.Queued &&
existing.Status != (short)ExportRunStatus.Running)
return false;
existing.Status = (short)ExportRunStatus.Cancelled;
existing.CompletedAt = _timeProvider.GetUtcNow().UtcDateTime;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Cancelled export run {RunId} for tenant {TenantId}",
runId, tenantId);
return true;
}
public async Task<int> GetActiveRunsCountAsync(
Guid tenantId,
Guid? profileId = null,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var query = dbContext.ExportRuns
.AsNoTracking()
.Where(r => r.TenantId == tenantId && r.Status == (short)ExportRunStatus.Running);
if (profileId.HasValue)
query = query.Where(r => r.ProfileId == profileId.Value);
return await query.CountAsync(cancellationToken);
}
public async Task<int> GetQueuedRunsCountAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
return await dbContext.ExportRuns
.AsNoTracking()
.CountAsync(r => r.TenantId == tenantId && r.Status == (short)ExportRunStatus.Queued, cancellationToken);
}
public async Task<ExportRun?> DequeueNextRunAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var dbContext = ExportCenterDbContextFactory.Create(connection, CommandTimeoutSeconds, ExportCenterDbContextFactory.DefaultSchemaName);
var candidate = await dbContext.ExportRuns
.Where(r => r.TenantId == tenantId && r.Status == (short)ExportRunStatus.Queued)
.OrderBy(r => r.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (candidate is null)
return null;
candidate.Status = (short)ExportRunStatus.Running;
candidate.StartedAt ??= _timeProvider.GetUtcNow().UtcDateTime;
await dbContext.SaveChangesAsync(cancellationToken);
return MapToDomain(candidate);
}
private static ExportRun MapToDomain(ExportRunEntity entity)
{
return new ExportRun
{
RunId = entity.RunId,
ProfileId = entity.ProfileId,
TenantId = entity.TenantId,
Status = (ExportRunStatus)entity.Status,
Trigger = (ExportRunTrigger)entity.Trigger,
CorrelationId = entity.CorrelationId,
InitiatedBy = entity.InitiatedBy,
TotalItems = entity.TotalItems,
ProcessedItems = entity.ProcessedItems,
FailedItems = entity.FailedItems,
TotalSizeBytes = entity.TotalSizeBytes,
ErrorJson = entity.ErrorJson,
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
StartedAt = entity.StartedAt.HasValue
? new DateTimeOffset(entity.StartedAt.Value, TimeSpan.Zero)
: null,
CompletedAt = entity.CompletedAt.HasValue
? new DateTimeOffset(entity.CompletedAt.Value, TimeSpan.Zero)
: null,
ExpiresAt = entity.ExpiresAt.HasValue
? new DateTimeOffset(entity.ExpiresAt.Value, TimeSpan.Zero)
: null
};
}
private static ExportRunEntity MapToEntity(ExportRun run)
{
return new ExportRunEntity
{
RunId = run.RunId,
ProfileId = run.ProfileId,
TenantId = run.TenantId,
Status = (short)run.Status,
Trigger = (short)run.Trigger,
CorrelationId = run.CorrelationId,
InitiatedBy = run.InitiatedBy,
TotalItems = run.TotalItems,
ProcessedItems = run.ProcessedItems,
FailedItems = run.FailedItems,
TotalSizeBytes = run.TotalSizeBytes,
ErrorJson = run.ErrorJson,
CreatedAt = run.CreatedAt.UtcDateTime,
StartedAt = run.StartedAt?.UtcDateTime,
CompletedAt = run.CompletedAt?.UtcDateTime,
ExpiresAt = run.ExpiresAt?.UtcDateTime
};
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
}

View File

@@ -12,6 +12,16 @@
<InternalsVisibleTo Include="StellaOps.ExportCenter.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Embed SQL migrations as resources -->
<EmbeddedResource Include="Db\Migrations\**\*.sql" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\ExportCenterDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj" />
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
@@ -19,14 +29,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Db\Migrations\**\*.sql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>

View File

@@ -26,7 +26,7 @@ public static class ExportApiEndpoints
{
var group = app.MapGroup("/v1/exports")
.WithTags("Exports")
.RequireAuthorization();
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Profile endpoints
MapProfileEndpoints(group);
@@ -54,13 +54,15 @@ public static class ExportApiEndpoints
profiles.MapGet("/", ListProfiles)
.WithName("ListExportProfiles")
.WithSummary("List export profiles")
.WithDescription("Lists export profiles for the current tenant with optional filtering.");
.WithDescription("Lists export profiles for the current tenant with optional filtering.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Get profile by ID
profiles.MapGet("/{profileId:guid}", GetProfile)
.WithName("GetExportProfile")
.WithSummary("Get export profile")
.WithDescription("Gets a specific export profile by ID.");
.WithDescription("Gets a specific export profile by ID.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Create profile
profiles.MapPost("/", CreateProfile)
@@ -99,13 +101,15 @@ public static class ExportApiEndpoints
runs.MapGet("/", ListRuns)
.WithName("ListExportRuns")
.WithSummary("List export runs")
.WithDescription("Lists export runs for the current tenant with optional filtering.");
.WithDescription("Lists export runs for the current tenant with optional filtering.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Get run by ID
runs.MapGet("/{runId:guid}", GetRun)
.WithName("GetExportRun")
.WithSummary("Get export run")
.WithDescription("Gets a specific export run by ID.");
.WithDescription("Gets a specific export run by ID.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Cancel run
runs.MapPost("/{runId:guid}/cancel", CancelRun)
@@ -123,19 +127,22 @@ public static class ExportApiEndpoints
artifacts.MapGet("/", ListArtifacts)
.WithName("ListExportArtifacts")
.WithSummary("List export artifacts")
.WithDescription("Lists artifacts produced by an export run.");
.WithDescription("Lists artifacts produced by an export run.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Get artifact by ID
artifacts.MapGet("/{artifactId:guid}", GetArtifact)
.WithName("GetExportArtifact")
.WithSummary("Get export artifact")
.WithDescription("Gets metadata for a specific export artifact.");
.WithDescription("Gets metadata for a specific export artifact.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Download artifact
artifacts.MapGet("/{artifactId:guid}/download", DownloadArtifact)
.WithName("DownloadExportArtifact")
.WithSummary("Download export artifact")
.WithDescription("Downloads an export artifact file.");
.WithDescription("Downloads an export artifact file.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
}
private static void MapSseEndpoints(RouteGroupBuilder group)
@@ -144,7 +151,8 @@ public static class ExportApiEndpoints
group.MapGet("/runs/{runId:guid}/events", StreamRunEvents)
.WithName("StreamExportRunEvents")
.WithSummary("Stream export run events")
.WithDescription("Streams real-time events for an export run via Server-Sent Events.");
.WithDescription("Streams real-time events for an export run via Server-Sent Events.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
}
// ========================================================================
@@ -890,25 +898,29 @@ public static class ExportApiEndpoints
verify.MapPost("/", VerifyRun)
.WithName("VerifyExportRun")
.WithSummary("Verify export run")
.WithDescription("Verifies an export run's manifest, signatures, and content hashes.");
.WithDescription("Verifies an export run's manifest, signatures, and content hashes.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Get manifest
verify.MapGet("/manifest", GetRunManifest)
.WithName("GetExportRunManifest")
.WithSummary("Get export run manifest")
.WithDescription("Gets the manifest for an export run.");
.WithDescription("Gets the manifest for an export run.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Get attestation status
verify.MapGet("/attestation", GetAttestationStatus)
.WithName("GetExportAttestationStatus")
.WithSummary("Get attestation status")
.WithDescription("Gets the attestation status for an export run.");
.WithDescription("Gets the attestation status for an export run.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// Stream verification progress
verify.MapPost("/stream", StreamVerification)
.WithName("StreamExportVerification")
.WithSummary("Stream verification progress")
.WithDescription("Streams verification progress events via Server-Sent Events.");
.WithDescription("Streams verification progress events via Server-Sent Events.")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
}
// ========================================================================