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