M0: scaffold solution, EF+Timescale schema, /healthz

Five-project Clean Architecture solution (Core/Infrastructure/App + Core.Tests/
Integration.Tests) on .NET 10 with central package management, snake_case EF mapping,
and shared build/style config.

- Full domain entity set + EF DbContext for the SDD §5.3 schema (singular table names).
- InitialSchema migration (relational) + TimescaleHypertables migration (raw SQL:
  create_hypertable + compression on reading, hypertable on consumption).
- App wiring: Serilog (actually wired, unlike MQTTower), DbContext, migrate-on-startup,
  /healthz. Serves plain HTTP behind a reverse proxy (no HTTPS redirect).
- deploy/Dockerfile (2-stage, ICU-capable) + docker-compose (app + timescaledb).
- Integration.Tests: shared TimescaleFixture (Testcontainers) — migrations, hypertables,
  compression policy, and /healthz all verified green (4/4).

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 11:02:21 +02:00
commit 48a7f5a825
65 changed files with 5896 additions and 0 deletions
@@ -0,0 +1,188 @@
using MeterVault.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Persistence;
/// <summary>
/// The EF Core context owning all relational tables. TimescaleDB-specific objects
/// (hypertables, compression, continuous aggregates) are created via raw SQL in the
/// migrations — they are not expressible through the model builder (SDD §5.3).
/// Snake_case table/column naming is applied at the options level
/// (<c>UseSnakeCaseNamingConvention</c>) so it matches the SDD schema.
/// </summary>
public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> options)
: DbContext(options)
{
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
public DbSet<Meter> Meters => Set<Meter>();
public DbSet<MeterSource> MeterSources => Set<MeterSource>();
public DbSet<Reading> Readings => Set<Reading>();
public DbSet<Consumption> Consumption => Set<Consumption>();
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
public DbSet<Tank> Tanks => Set<Tank>();
public DbSet<Tariff> Tariffs => Set<Tariff>();
public DbSet<CostCategory> CostCategories => Set<CostCategory>();
public DbSet<CostCategoryMember> CostCategoryMembers => Set<CostCategoryMember>();
public DbSet<ManualCost> ManualCosts => Set<ManualCost>();
public DbSet<IngestionEndpoint> IngestionEndpoints => Set<IngestionEndpoint>();
public DbSet<ImportBatch> ImportBatches => Set<ImportBatch>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
protected override void OnModelCreating(ModelBuilder b)
{
b.HasPostgresExtension("timescaledb");
b.Entity<EnergyType>(e =>
{
e.ToTable("energy_type");
e.HasKey(x => x.Id);
e.Property(x => x.Id).UseIdentityByDefaultColumn();
e.Property(x => x.Key).HasMaxLength(64);
e.Property(x => x.DisplayName).HasMaxLength(128);
e.Property(x => x.BaseUnit).HasMaxLength(16);
e.Property(x => x.DefaultMode).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
e.HasIndex(x => x.Key).IsUnique();
});
b.Entity<Meter>(e =>
{
e.ToTable("meter");
e.HasKey(x => x.Id);
e.Property(x => x.Mode).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
e.Property(x => x.IsActive).HasDefaultValue(true);
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
e.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
e.HasOne(x => x.EnergyType).WithMany(x => x.Meters)
.HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.EnergyTypeId, x.IsActive });
});
b.Entity<MeterSource>(e =>
{
e.ToTable("meter_source");
e.HasKey(x => x.Id);
e.Property(x => x.SourceType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.ValueKind).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
e.Property(x => x.Scale).HasDefaultValue(1d);
e.HasOne(x => x.Meter).WithMany(x => x.Sources)
.HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Endpoint).WithMany()
.HasForeignKey(x => x.EndpointId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(x => x.MeterId);
});
// Hypertable — the (meter_id, time) PK contains the partition column (time),
// which Timescale requires. Converted to a hypertable in a raw-SQL migration.
b.Entity<Reading>(e =>
{
e.ToTable("reading");
e.HasKey(x => new { x.MeterId, x.Time });
e.Property(x => x.Quality).HasConversion<short>();
e.Property(x => x.Flags).HasConversion<int>();
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ImportBatchId);
});
// Hypertable — (meter_id, time, kind) PK.
b.Entity<Consumption>(e =>
{
e.ToTable("consumption");
e.HasKey(x => new { x.MeterId, x.Time, x.Kind });
e.Property(x => x.Kind).HasConversion<short>();
e.Property(x => x.Quality).HasConversion<short>();
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ImportBatchId);
});
b.Entity<MeterEvent>(e =>
{
e.ToTable("meter_event");
e.HasKey(x => x.Id);
e.Property(x => x.EventType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.MeterId, x.Time });
e.HasIndex(x => x.ImportBatchId);
});
b.Entity<Tank>(e =>
{
e.ToTable("tank");
e.HasKey(x => x.Id);
e.Property(x => x.RateMode).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Unit).HasMaxLength(16);
e.Property(x => x.Calibration).HasColumnType("jsonb");
e.HasOne(x => x.Meter).WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.MeterId).IsUnique();
});
b.Entity<Tariff>(e =>
{
e.ToTable("tariff");
e.HasKey(x => x.Id);
e.Property(x => x.ScopeType).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Component).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Unit).HasMaxLength(16);
e.Property(x => x.Currency).HasMaxLength(8);
e.HasIndex(x => new { x.ScopeType, x.ScopeId, x.Component, x.ValidFrom });
});
b.Entity<CostCategory>(e =>
{
e.ToTable("cost_category");
e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(128);
});
b.Entity<CostCategoryMember>(e =>
{
e.ToTable("cost_category_member");
e.HasKey(x => x.Id);
e.HasOne(x => x.Category).WithMany(x => x.Members)
.HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<EnergyType>().WithMany().HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Cascade);
e.ToTable(t => t.HasCheckConstraint(
"ck_cost_category_member_target",
"meter_id IS NOT NULL OR energy_type_id IS NOT NULL"));
});
b.Entity<ManualCost>(e =>
{
e.ToTable("manual_cost");
e.HasKey(x => x.Id);
e.Property(x => x.Currency).HasMaxLength(8);
e.HasOne<CostCategory>().WithMany().HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.SetNull);
e.HasOne<Meter>().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(x => x.ImportBatchId);
});
b.Entity<IngestionEndpoint>(e =>
{
e.ToTable("ingestion_endpoint");
e.HasKey(x => x.Id);
e.Property(x => x.Type).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
});
b.Entity<ImportBatch>(e =>
{
e.ToTable("import_batch");
e.HasKey(x => x.Id);
e.Property(x => x.Mapping).HasColumnType("jsonb");
e.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
});
b.Entity<AppSetting>(e =>
{
e.ToTable("app_setting");
e.HasKey(x => x.Key);
e.Property(x => x.Key).HasMaxLength(128);
e.Property(x => x.Value).HasColumnType("jsonb");
});
}
}