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

@@ -8,13 +8,36 @@
- `docs/operations/artifact-migration-runbook.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/technical/testing/TEST_SUITE_OVERVIEW.md`
- `docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md`
- `docs/db/EF_CORE_RUNTIME_CUTOVER_STRATEGY.md`
## Working Agreements
- Deterministic outputs (ordering, timestamps, hashing).
- Offline-friendly; avoid runtime network calls.
- Note cross-module impacts in the active sprint tracker.
## DAL Technology
- **EF Core v10** for PostgreSQL artifact index repository (converted from raw Npgsql in Sprint 077).
- Schema: `evidence` (shared with Evidence.Persistence module).
- SQL migrations remain authoritative; no EF auto-migrations at runtime.
- Compiled model used for default schema path; reflection-based model building for non-default schemas.
- UPSERT operations use `ExecuteSqlRawAsync` for the multi-column ON CONFLICT pattern.
## EF Core Directory Structure
```
EfCore/
Context/
ArtifactDbContext.cs # Main DbContext
ArtifactDesignTimeDbContextFactory.cs # For dotnet ef CLI
Models/
ArtifactIndexEntity.cs # Entity POCO
CompiledModels/
ArtifactDbContextModel.cs # Compiled model stub
Postgres/
ArtifactDbContextFactory.cs # Runtime factory with UseModel()
```
## Testing Expectations
- Add or update unit tests under `src/__Libraries/__Tests`.
- Run `dotnet test` for affected test projects when changes are made.
- Build sequentially (`-p:BuildInParallel=false` or `--no-dependencies`).

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Artifact.Infrastructure.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for ArtifactDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(Context.ArtifactDbContext))]
public partial class ArtifactDbContextModel : RuntimeModel
{
private static ArtifactDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new ArtifactDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Artifact.Infrastructure.EfCore.Models;
namespace StellaOps.Artifact.Infrastructure.EfCore.Context;
/// <summary>
/// EF Core DbContext for the Artifact Infrastructure module.
/// Maps to the evidence PostgreSQL schema: artifact_index table.
/// Scaffolded from SQL migration 001_artifact_index_schema.sql.
/// </summary>
public partial class ArtifactDbContext : DbContext
{
private readonly string _schemaName;
public ArtifactDbContext(DbContextOptions<ArtifactDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "evidence"
: schemaName.Trim();
}
public virtual DbSet<ArtifactIndexEntity> ArtifactIndexes { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
// -- artifact_index --------------------------------------------------
modelBuilder.Entity<ArtifactIndexEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("artifact_index_pkey");
entity.ToTable("artifact_index", schemaName);
// Unique constraint for UPSERT conflict target
entity.HasAlternateKey(e => new { e.TenantId, e.BomRef, e.SerialNumber, e.ArtifactId })
.HasName("uq_artifact_index_key");
// Indexes matching SQL migration
entity.HasIndex(e => new { e.TenantId, e.BomRef }, "idx_artifact_index_bom_ref")
.HasFilter("(NOT is_deleted)");
entity.HasIndex(e => e.Sha256, "idx_artifact_index_sha256")
.HasFilter("(NOT is_deleted)");
entity.HasIndex(e => new { e.TenantId, e.ArtifactType }, "idx_artifact_index_type")
.HasFilter("(NOT is_deleted)");
entity.HasIndex(e => new { e.TenantId, e.BomRef, e.SerialNumber }, "idx_artifact_index_serial")
.HasFilter("(NOT is_deleted)");
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_artifact_index_created")
.IsDescending(false, true)
.HasFilter("(NOT is_deleted)");
// Column mappings
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId)
.HasColumnName("tenant_id");
entity.Property(e => e.BomRef)
.HasColumnName("bom_ref");
entity.Property(e => e.SerialNumber)
.HasColumnName("serial_number");
entity.Property(e => e.ArtifactId)
.HasColumnName("artifact_id");
entity.Property(e => e.StorageKey)
.HasColumnName("storage_key");
entity.Property(e => e.ArtifactType)
.HasColumnName("artifact_type");
entity.Property(e => e.ContentType)
.HasColumnName("content_type");
entity.Property(e => e.Sha256)
.HasColumnName("sha256");
entity.Property(e => e.SizeBytes)
.HasColumnName("size_bytes");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasColumnName("updated_at");
entity.Property(e => e.IsDeleted)
.HasDefaultValue(false)
.HasColumnName("is_deleted");
entity.Property(e => e.DeletedAt)
.HasColumnName("deleted_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Artifact.Infrastructure.EfCore.Context;
/// <summary>
/// Design-time factory for <see cref="ArtifactDbContext"/>.
/// Used by dotnet ef CLI tooling (scaffold, optimize, migrations).
/// </summary>
public sealed class ArtifactDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ArtifactDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=evidence,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_ARTIFACT_EF_CONNECTION";
public ArtifactDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<ArtifactDbContext>()
.UseNpgsql(connectionString)
.Options;
return new ArtifactDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Artifact.Infrastructure.EfCore.Models;
/// <summary>
/// EF Core entity for the evidence.artifact_index table.
/// Scaffolded from SQL migration 001_artifact_index_schema.sql.
/// </summary>
public partial class ArtifactIndexEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string BomRef { get; set; } = null!;
public string SerialNumber { get; set; } = null!;
public string ArtifactId { get; set; } = null!;
public string StorageKey { get; set; } = null!;
public string ArtifactType { get; set; } = null!;
public string ContentType { get; set; } = null!;
public string Sha256 { get; set; } = null!;
public long SizeBytes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Artifact.Infrastructure.EfCore.CompiledModels;
using StellaOps.Artifact.Infrastructure.EfCore.Context;
namespace StellaOps.Artifact.Infrastructure.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="ArtifactDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class ArtifactDbContextFactory
{
public static ArtifactDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? ArtifactDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<ArtifactDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, ArtifactDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(ArtifactDbContextModel.Instance);
}
return new ArtifactDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,9 +1,10 @@
// -----------------------------------------------------------------------------
// PostgresArtifactIndexRepository.Find.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Task: AS-003 - Create ArtifactStore PostgreSQL index
// Description: Query operations for the artifact repository
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
// Description: Query operations for the artifact repository (EF Core)
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using StellaOps.Artifact.Core;
namespace StellaOps.Artifact.Infrastructure;
@@ -13,11 +14,16 @@ public sealed partial class PostgresArtifactIndexRepository
/// <inheritdoc />
public async Task<IReadOnlyList<ArtifactIndexEntry>> FindByBomRefAsync(string bomRef, CancellationToken ct = default)
{
return await QueryAsync(_tenantKey, ArtifactIndexSql.SelectByBomRef, cmd =>
{
AddParameter(cmd, "tenant_id", _tenantId);
AddParameter(cmd, "bom_ref", bomRef);
}, MapEntry, ct).ConfigureAwait(false);
await using var dbContext = await CreateReadContextAsync(ct);
var entities = await dbContext.ArtifactIndexes
.AsNoTracking()
.Where(e => e.TenantId == _tenantId && e.BomRef == bomRef && !e.IsDeleted)
.OrderByDescending(e => e.CreatedAt)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapToEntry).ToList();
}
/// <inheritdoc />
@@ -26,21 +32,32 @@ public sealed partial class PostgresArtifactIndexRepository
string serialNumber,
CancellationToken ct = default)
{
return await QueryAsync(_tenantKey, ArtifactIndexSql.SelectByBomRefAndSerial, cmd =>
{
AddParameter(cmd, "tenant_id", _tenantId);
AddParameter(cmd, "bom_ref", bomRef);
AddParameter(cmd, "serial_number", serialNumber);
}, MapEntry, ct).ConfigureAwait(false);
await using var dbContext = await CreateReadContextAsync(ct);
var entities = await dbContext.ArtifactIndexes
.AsNoTracking()
.Where(e => e.TenantId == _tenantId && e.BomRef == bomRef && e.SerialNumber == serialNumber && !e.IsDeleted)
.OrderByDescending(e => e.CreatedAt)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapToEntry).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<ArtifactIndexEntry>> FindBySha256Async(string sha256, CancellationToken ct = default)
{
return await QueryAsync(_tenantKey, ArtifactIndexSql.SelectBySha256, cmd =>
{
AddParameter(cmd, "sha256", sha256);
}, MapEntry, ct).ConfigureAwait(false);
await using var dbContext = await CreateReadContextAsync(ct);
var entities = await dbContext.ArtifactIndexes
.AsNoTracking()
.Where(e => e.Sha256 == sha256 && !e.IsDeleted)
.OrderByDescending(e => e.CreatedAt)
.Take(100)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapToEntry).ToList();
}
/// <inheritdoc />
@@ -51,12 +68,19 @@ public sealed partial class PostgresArtifactIndexRepository
CancellationToken ct = default)
{
var tenantKey = tenantId.ToString("D");
return await QueryAsync(tenantKey, ArtifactIndexSql.SelectByType, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "artifact_type", type.ToString());
AddParameter(cmd, "limit", limit);
}, MapEntry, ct).ConfigureAwait(false);
await using var dbContext = await CreateReadContextAsync(tenantKey, ct);
var typeString = type.ToString();
var entities = await dbContext.ArtifactIndexes
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ArtifactType == typeString && !e.IsDeleted)
.OrderByDescending(e => e.CreatedAt)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapToEntry).ToList();
}
/// <summary>
@@ -70,12 +94,19 @@ public sealed partial class PostgresArtifactIndexRepository
CancellationToken ct = default)
{
var tenantKey = tenantId.ToString("D");
return await QueryAsync(tenantKey, ArtifactIndexSql.SelectByTimeRange, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
AddParameter(cmd, "limit", limit);
}, MapEntry, ct).ConfigureAwait(false);
await using var dbContext = await CreateReadContextAsync(tenantKey, ct);
var fromUtc = from.UtcDateTime;
var toUtc = to.UtcDateTime;
var entities = await dbContext.ArtifactIndexes
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.CreatedAt >= fromUtc && e.CreatedAt < toUtc && !e.IsDeleted)
.OrderByDescending(e => e.CreatedAt)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapToEntry).ToList();
}
}

View File

@@ -1,9 +1,11 @@
// -----------------------------------------------------------------------------
// PostgresArtifactIndexRepository.Index.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Task: AS-003 - Create ArtifactStore PostgreSQL index
// Description: Index write operations for the artifact repository
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
// Description: Index write operations for the artifact repository (EF Core)
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Artifact.Infrastructure;
public sealed partial class PostgresArtifactIndexRepository
@@ -12,22 +14,42 @@ public sealed partial class PostgresArtifactIndexRepository
public async Task IndexAsync(ArtifactIndexEntry entry, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(entry);
await using var connection = await DataSource.OpenConnectionAsync(_tenantKey, "writer", ct)
.ConfigureAwait(false);
await using var command = CreateCommand(ArtifactIndexSql.Insert, connection);
AddParameter(command, "id", entry.Id);
AddParameter(command, "tenant_id", entry.TenantId);
AddParameter(command, "bom_ref", entry.BomRef);
AddParameter(command, "serial_number", entry.SerialNumber);
AddParameter(command, "artifact_id", entry.ArtifactId);
AddParameter(command, "storage_key", entry.StorageKey);
AddParameter(command, "artifact_type", entry.Type.ToString());
AddParameter(command, "content_type", entry.ContentType);
AddParameter(command, "sha256", entry.Sha256);
AddParameter(command, "size_bytes", entry.SizeBytes);
AddParameter(command, "created_at", entry.CreatedAt);
// The original SQL used INSERT ... ON CONFLICT DO UPDATE (multi-column conflict clause).
// Using ExecuteSqlRawAsync to preserve the exact upsert semantics per cutover strategy guidance.
await using var dbContext = await CreateWriteContextAsync(ct);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO evidence.artifact_index (
id, tenant_id, bom_ref, serial_number, artifact_id, storage_key,
artifact_type, content_type, sha256, size_bytes, created_at
) VALUES (
{0}, {1}, {2}, {3}, {4}, {5},
{6}, {7}, {8}, {9}, {10}
)
ON CONFLICT (tenant_id, bom_ref, serial_number, artifact_id)
DO UPDATE SET
storage_key = EXCLUDED.storage_key,
artifact_type = EXCLUDED.artifact_type,
content_type = EXCLUDED.content_type,
sha256 = EXCLUDED.sha256,
size_bytes = EXCLUDED.size_bytes,
updated_at = NOW(),
is_deleted = FALSE,
deleted_at = NULL
""",
entry.Id,
entry.TenantId,
entry.BomRef,
entry.SerialNumber,
entry.ArtifactId,
entry.StorageKey,
entry.Type.ToString(),
entry.ContentType,
entry.Sha256,
entry.SizeBytes,
entry.CreatedAt.UtcDateTime,
ct).ConfigureAwait(false);
}
}

View File

@@ -1,39 +1,73 @@
// -----------------------------------------------------------------------------
// PostgresArtifactIndexRepository.Mapping.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Task: AS-003 - Create ArtifactStore PostgreSQL index
// Description: Row mapping helpers for artifact index repository
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
// Description: Mapping helpers between EF Core entities and domain models
// -----------------------------------------------------------------------------
using Npgsql;
using StellaOps.Artifact.Core;
using StellaOps.Artifact.Infrastructure.EfCore.Models;
namespace StellaOps.Artifact.Infrastructure;
public sealed partial class PostgresArtifactIndexRepository
{
private static ArtifactIndexEntry MapEntry(NpgsqlDataReader reader)
private static ArtifactIndexEntry MapToEntry(ArtifactIndexEntity entity)
{
var artifactTypeString = reader.GetString(6);
var artifactType = Enum.TryParse<ArtifactType>(artifactTypeString, out var parsedType)
var artifactType = Enum.TryParse<ArtifactType>(entity.ArtifactType, out var parsedType)
? parsedType
: ArtifactType.Unknown;
return new ArtifactIndexEntry
{
Id = reader.GetGuid(0),
TenantId = reader.GetGuid(1),
BomRef = reader.GetString(2),
SerialNumber = reader.GetString(3),
ArtifactId = reader.GetString(4),
StorageKey = reader.GetString(5),
Id = entity.Id,
TenantId = entity.TenantId,
BomRef = entity.BomRef,
SerialNumber = entity.SerialNumber,
ArtifactId = entity.ArtifactId,
StorageKey = entity.StorageKey,
Type = artifactType,
ContentType = reader.GetString(7),
Sha256 = reader.GetString(8),
SizeBytes = reader.GetInt64(9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
UpdatedAt = reader.IsDBNull(11) ? null : reader.GetFieldValue<DateTimeOffset>(11),
IsDeleted = reader.GetBoolean(12),
DeletedAt = reader.IsDBNull(13) ? null : reader.GetFieldValue<DateTimeOffset>(13)
ContentType = entity.ContentType,
Sha256 = entity.Sha256,
SizeBytes = entity.SizeBytes,
CreatedAt = entity.CreatedAt.Kind == DateTimeKind.Utc
? new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero)
: new DateTimeOffset(DateTime.SpecifyKind(entity.CreatedAt, DateTimeKind.Utc), TimeSpan.Zero),
UpdatedAt = entity.UpdatedAt.HasValue
? new DateTimeOffset(
entity.UpdatedAt.Value.Kind == DateTimeKind.Utc
? entity.UpdatedAt.Value
: DateTime.SpecifyKind(entity.UpdatedAt.Value, DateTimeKind.Utc),
TimeSpan.Zero)
: null,
IsDeleted = entity.IsDeleted,
DeletedAt = entity.DeletedAt.HasValue
? new DateTimeOffset(
entity.DeletedAt.Value.Kind == DateTimeKind.Utc
? entity.DeletedAt.Value
: DateTime.SpecifyKind(entity.DeletedAt.Value, DateTimeKind.Utc),
TimeSpan.Zero)
: null
};
}
private static ArtifactIndexEntity MapToEntity(ArtifactIndexEntry entry)
{
return new ArtifactIndexEntity
{
Id = entry.Id,
TenantId = entry.TenantId,
BomRef = entry.BomRef,
SerialNumber = entry.SerialNumber,
ArtifactId = entry.ArtifactId,
StorageKey = entry.StorageKey,
ArtifactType = entry.Type.ToString(),
ContentType = entry.ContentType,
Sha256 = entry.Sha256,
SizeBytes = entry.SizeBytes,
CreatedAt = entry.CreatedAt.UtcDateTime,
UpdatedAt = entry.UpdatedAt?.UtcDateTime,
IsDeleted = entry.IsDeleted,
DeletedAt = entry.DeletedAt?.UtcDateTime
};
}
}

View File

@@ -1,9 +1,11 @@
// -----------------------------------------------------------------------------
// PostgresArtifactIndexRepository.Mutate.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Task: AS-003 - Create ArtifactStore PostgreSQL index
// Description: Mutation operations for the artifact repository
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
// Description: Mutation operations for the artifact repository (EF Core)
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Artifact.Infrastructure;
public sealed partial class PostgresArtifactIndexRepository
@@ -15,15 +17,19 @@ public sealed partial class PostgresArtifactIndexRepository
string artifactId,
CancellationToken ct = default)
{
var results = await QueryAsync(_tenantKey, ArtifactIndexSql.SelectByKey, cmd =>
{
AddParameter(cmd, "tenant_id", _tenantId);
AddParameter(cmd, "bom_ref", bomRef);
AddParameter(cmd, "serial_number", serialNumber);
AddParameter(cmd, "artifact_id", artifactId);
}, MapEntry, ct).ConfigureAwait(false);
await using var dbContext = await CreateReadContextAsync(ct);
return results.Count > 0 ? results[0] : null;
var entity = await dbContext.ArtifactIndexes
.AsNoTracking()
.Where(e => e.TenantId == _tenantId
&& e.BomRef == bomRef
&& e.SerialNumber == serialNumber
&& e.ArtifactId == artifactId
&& !e.IsDeleted)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
return entity is null ? null : MapToEntry(entity);
}
/// <inheritdoc />
@@ -33,13 +39,20 @@ public sealed partial class PostgresArtifactIndexRepository
string artifactId,
CancellationToken ct = default)
{
var rowsAffected = await ExecuteAsync(_tenantKey, ArtifactIndexSql.UpdateSoftDelete, cmd =>
{
AddParameter(cmd, "tenant_id", _tenantId);
AddParameter(cmd, "bom_ref", bomRef);
AddParameter(cmd, "serial_number", serialNumber);
AddParameter(cmd, "artifact_id", artifactId);
}, ct).ConfigureAwait(false);
await using var dbContext = await CreateWriteContextAsync(ct);
var rowsAffected = await dbContext.ArtifactIndexes
.Where(e => e.TenantId == _tenantId
&& e.BomRef == bomRef
&& e.SerialNumber == serialNumber
&& e.ArtifactId == artifactId
&& !e.IsDeleted)
.ExecuteUpdateAsync(setters => setters
.SetProperty(e => e.IsDeleted, true)
.SetProperty(e => e.DeletedAt, DateTime.UtcNow)
.SetProperty(e => e.UpdatedAt, DateTime.UtcNow),
ct)
.ConfigureAwait(false);
return rowsAffected > 0;
}
@@ -50,11 +63,14 @@ public sealed partial class PostgresArtifactIndexRepository
public async Task<int> CountAsync(Guid tenantId, CancellationToken ct = default)
{
var tenantKey = tenantId.ToString("D");
var result = await ExecuteScalarAsync<long>(tenantKey, ArtifactIndexSql.CountByTenant, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
}, ct).ConfigureAwait(false);
await using var dbContext = await CreateReadContextAsync(tenantKey, ct);
return (int)result;
var count = await dbContext.ArtifactIndexes
.AsNoTracking()
.Where(e => e.TenantId == tenantId && !e.IsDeleted)
.LongCountAsync(ct)
.ConfigureAwait(false);
return (int)count;
}
}

View File

@@ -1,19 +1,22 @@
// -----------------------------------------------------------------------------
// PostgresArtifactIndexRepository.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Task: AS-003 - Create ArtifactStore PostgreSQL index
// Description: PostgreSQL implementation of artifact index repository
// Sprint: SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore
// Task: ARTIF-EF-03 - Convert DAL repositories to EF Core
// Description: PostgreSQL (EF Core) implementation of artifact index repository
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Artifact.Infrastructure.EfCore.Context;
using StellaOps.Artifact.Infrastructure.Postgres;
namespace StellaOps.Artifact.Infrastructure;
/// <summary>
/// PostgreSQL implementation of <see cref="IArtifactIndexRepository"/>.
/// PostgreSQL (EF Core) implementation of <see cref="IArtifactIndexRepository"/>.
/// </summary>
public sealed partial class PostgresArtifactIndexRepository : RepositoryBase<ArtifactDataSource>, IArtifactIndexRepository
public sealed partial class PostgresArtifactIndexRepository : IArtifactIndexRepository
{
private readonly ArtifactDataSource _dataSource;
private readonly ILogger<PostgresArtifactIndexRepository> _logger;
private readonly Guid _tenantId;
private readonly string _tenantKey;
@@ -21,10 +24,38 @@ public sealed partial class PostgresArtifactIndexRepository : RepositoryBase<Art
ArtifactDataSource dataSource,
ILogger<PostgresArtifactIndexRepository> logger,
IArtifactTenantContext tenantContext)
: base(dataSource, logger)
{
ArgumentNullException.ThrowIfNull(dataSource);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(tenantContext);
_dataSource = dataSource;
_logger = logger;
_tenantId = tenantContext.TenantId;
_tenantKey = tenantContext.TenantIdValue;
}
private int CommandTimeoutSeconds => _dataSource.CommandTimeoutSeconds;
private string GetSchemaName() => ArtifactDataSource.DefaultSchemaName;
private async Task<ArtifactDbContext> CreateReadContextAsync(CancellationToken ct)
{
var connection = await _dataSource.OpenConnectionAsync(_tenantKey, "reader", ct)
.ConfigureAwait(false);
return ArtifactDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
}
private async Task<ArtifactDbContext> CreateWriteContextAsync(CancellationToken ct)
{
var connection = await _dataSource.OpenConnectionAsync(_tenantKey, "writer", ct)
.ConfigureAwait(false);
return ArtifactDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
}
private async Task<ArtifactDbContext> CreateReadContextAsync(string tenantKey, CancellationToken ct)
{
var connection = await _dataSource.OpenConnectionAsync(tenantKey, "reader", ct)
.ConfigureAwait(false);
return ArtifactDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
}
}

View File

@@ -13,7 +13,10 @@
<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
@@ -22,10 +25,16 @@
<ProjectReference Include="..\StellaOps.Artifact.Core\StellaOps.Artifact.Core.csproj" />
<ProjectReference Include="..\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\ArtifactDbContextAssemblyAttributes.cs" />
</ItemGroup>
</Project>

View File

@@ -1,8 +1,13 @@
# StellaOps.Artifact.Infrastructure Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
Source of truth: `docs/implplan/SPRINT_20260222_077_Artifact_infrastructure_dal_to_efcore.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | DONE | Remediation complete; split store/migration/index, tenant context + deterministic time/ID, S3 integration tests added; dotnet test src/__Libraries/StellaOps.Artifact.Core.Tests/StellaOps.Artifact.Core.Tests.csproj passed 2026-02-03 (25 tests, MTP0001 warning). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| ARTIF-EF-01 | DONE | AGENTS.md verified; migration plugin added to Evidence module (multi-source) in Platform MigrationModulePlugins.cs. |
| ARTIF-EF-02 | DONE | EF Core model scaffolded: ArtifactDbContext, ArtifactIndexEntity, design-time factory, compiled model stub. |
| ARTIF-EF-03 | DONE | PostgresArtifactIndexRepository converted from Npgsql/RepositoryBase to EF Core. Interface preserved. UPSERT via ExecuteSqlRawAsync per cutover strategy. |
| ARTIF-EF-04 | DONE | Compiled model stub, design-time factory, runtime factory with UseModel() for default schema. Assembly attribute exclusion in csproj. |
| ARTIF-EF-05 | DONE | Sequential build (0 errors, 0 warnings); tests pass (25/25); docs updated. |