Phase 9 Wave 1: add ReleaseMedium discriminator + Session/Mix metadata

Add ReleaseMedium enum (Cut/Session/Mix) and two 1:1 satellite entities
(SessionMetadata, MixMetadata) with EF configs and an additive migration.
ReleaseDto.ReleaseType is now nullable, nulled for non-Cut at the converter.
Existing releases default to Cut via column default; no data migration.
This commit is contained in:
daniel-c-harvey
2026-06-12 21:47:04 -04:00
parent 6f63fe7d7c
commit 5d6b54d2fc
16 changed files with 767 additions and 4 deletions
@@ -0,0 +1,48 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class MixMetadataConfiguration : BaseEntityConfiguration<MixMetadata>
{
public override void Configure(EntityTypeBuilder<MixMetadata> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("mix_metadata");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.WaveformEntryKey)
.IsRequired()
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
.HasColumnName("waveform_entry_key");
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
builder.HasOne(e => e.Release)
.WithOne(r => r.MixMetadata)
.HasForeignKey<MixMetadata>(e => e.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(e => e.ReleaseId)
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_mix_metadata_is_deleted" regardless of auto-naming conventions.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_mix_metadata_is_deleted");
}
}
@@ -42,6 +42,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(500)
.HasColumnName("image_path");
// ReleaseType is meaningful ONLY when Medium == Cut. It is the Cut medium's discriminator
// data and lives on the base table by deliberate, named exception:
// A CutMetadata satellite (mirroring SessionMetadata/MixMetadata) was considered and
// rejected. ReleaseType is read on every card of the /cuts browse — the highest-traffic
// read in the system. Moving it to a satellite would put a join on that hot path. So it
// stays here. Future media MUST NOT copy this pattern: the default is a satellite metadata
// table; this is the one allowed exception, justified solely by the /cuts read volume.
//
// The "ReleaseType only for Cut" invariant is advisory — enforced at the service layer and
// surfaced via the nullable ReleaseDto.ReleaseType (nulled for non-Cut at the converter).
// It is NOT a DB check constraint by choice, not necessity: EF supports HasCheckConstraint,
// but the invariant is advisory and we keep the schema free of it.
builder.Property(e => e.ReleaseType)
.IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal
@@ -49,6 +61,13 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasColumnName("release_type")
.HasDefaultValue(ReleaseType.Single);
builder.Property(e => e.Medium)
.IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal
.HasMaxLength(20)
.HasColumnName("medium")
.HasDefaultValue(ReleaseMedium.Cut); // Existing rows migrate to Cut with no data migration.
builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id");
@@ -0,0 +1,48 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class SessionMetadataConfiguration : BaseEntityConfiguration<SessionMetadata>
{
public override void Configure(EntityTypeBuilder<SessionMetadata> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("session_metadata");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.HeroImageEntryKey)
.IsRequired()
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
.HasColumnName("hero_image_entry_key");
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
builder.HasOne(e => e.Release)
.WithOne(r => r.SessionMetadata)
.HasForeignKey<SessionMetadata>(e => e.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(e => e.ReleaseId)
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_session_metadata_is_deleted" regardless of auto-naming conventions.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_session_metadata_is_deleted");
}
}
+4
View File
@@ -12,6 +12,8 @@ public class DeepDrftContext : DbContext
public DbSet<TrackEntity> Tracks { get; set; }
public DbSet<ReleaseEntity> Releases { get; set; }
public DbSet<SessionMetadata> SessionMetadata { get; set; }
public DbSet<MixMetadata> MixMetadata { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -19,5 +21,7 @@ public class DeepDrftContext : DbContext
modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
}
}