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:
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user