Use local dotnet-ef tooling and strict EF migration checks

This commit is contained in:
2026-02-26 08:57:02 +01:00
parent 5763c67f34
commit 2d0df7948c
11 changed files with 470 additions and 71 deletions

View File

@@ -13,4 +13,6 @@ Also see the other related technical documentation: TECH.md, REQUIREMENTS.md and
- After every iteration, do a git commit with a brief summary of the changes as a commit message. - After every iteration, do a git commit with a brief summary of the changes as a commit message.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master. - Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent. - If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
- If a required tool is missing (for example `dotnet-ef`), install/configure the tool (prefer repo-local setup such as `dotnet tool manifest`) instead of weakening validations or muting warnings. If installation is blocked, stop and ask before changing validation strictness.
- After changing the database, if your build is blocked by a running dotnet process, feel free to kill the process and retry the operation once. - After changing the database, if your build is blocked by a running dotnet process, feel free to kill the process and retry the operation once.

9
FAQ.md
View File

@@ -30,6 +30,15 @@ To start with a clean backend state, stop the app and remove the corresponding S
Usually no. Startup now uses EF Core migration-based schema upgrades (`Database.Migrate`) and applies pending migrations automatically. Usually no. Startup now uses EF Core migration-based schema upgrades (`Database.Migrate`) and applies pending migrations automatically.
## How do I add a new EF migration in this repo?
Use the repo-local tool manifest:
```powershell
dotnet tool restore
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
```
## Does the backend read SQLite on every API call? ## Does the backend read SQLite on every API call?
No. The backend loads state from SQLite once during startup into in-memory state and serves requests from memory. Successful state mutations are then written back to SQLite. No. The backend loads state from SQLite once during startup into in-memory state and serves requests from memory. Successful state mutations are then written back to SQLite.

View File

@@ -42,6 +42,7 @@ Backend state persistence:
- .NET SDK 10.0+ - .NET SDK 10.0+
- PowerShell 7+ - PowerShell 7+
- Run `dotnet tool restore` once to enable the repo-local `dotnet-ef` command.
## Local Development ## Local Development
@@ -63,6 +64,12 @@ VS Code F5 debug profiles are available in `.vscode/launch.json`:
To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`. To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
For migration authoring, use the local tool command form:
```powershell
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
```
## Frontend Runtime ## Frontend Runtime
- Runtime frontend is Blazor Server with interactive components. - Runtime frontend is Blazor Server with interactive components.

View File

@@ -2,7 +2,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using RpgRoller.Hosting; using RpgRoller.Hosting;
using RpgRoller.Data; using RpgRoller.Data;
@@ -98,7 +97,6 @@ public sealed class HostingCoverageTests
var options = new DbContextOptionsBuilder<RpgRollerDbContext>() var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
.UseSqlite(connectionString) .UseSqlite(connectionString)
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))
.Options; .Options;
using (var db = new RpgRollerDbContext(options)) using (var db = new RpgRollerDbContext(options))
@@ -125,5 +123,10 @@ public sealed class HostingCoverageTests
historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';"; historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';";
var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar()); var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar());
Assert.Equal(1, historyCount); Assert.Equal(1, historyCount);
using var modelSyncHistoryCommand = verifyConnection.CreateCommand();
modelSyncHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226090000_ModelSync';";
var modelSyncHistoryCount = Convert.ToInt32(modelSyncHistoryCommand.ExecuteScalar());
Assert.Equal(1, modelSyncHistoryCount);
} }
} }

View File

@@ -2,7 +2,6 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -34,8 +33,7 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
services.AddDbContextFactory<RpgRollerDbContext>(options => services.AddDbContextFactory<RpgRollerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}") options.UseSqlite($"Data Source={dbPath}"));
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)));
})); }));
} }

View File

@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain; using RpgRoller.Domain;
using RpgRoller.Services; using RpgRoller.Services;
@@ -20,8 +19,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>(); services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
services.AddDbContextFactory<RpgRollerDbContext>(options => services.AddDbContextFactory<RpgRollerDbContext>(options =>
options.UseSqlite(sqliteConnectionString) options.UseSqlite(sqliteConnectionString));
.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)));
services.AddSingleton<IDiceRoller, RandomDiceRoller>(); services.AddSingleton<IDiceRoller, RandomDiceRoller>();
services.AddSingleton<IGameService, GameService>(); services.AddSingleton<IGameService, GameService>();

View File

@@ -0,0 +1,212 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260226090000_ModelSync")]
partial class _20260226090000_ModelSync
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class _20260226090000_ModelSync : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// No-op migration generated to sync EF snapshot to existing schema.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Intentionally empty.
}
}
}

View File

@@ -1,78 +1,209 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain;
namespace RpgRoller.Migrations; #nullable disable
[DbContext(typeof(RpgRollerDbContext))] namespace RpgRoller.Migrations
internal sealed class RpgRollerDbContextModelSnapshot : ModelSnapshot
{ {
protected override void BuildModel(ModelBuilder modelBuilder) [DbContext(typeof(RpgRollerDbContext))]
partial class RpgRollerDbContextModelSnapshot : ModelSnapshot
{ {
modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); protected override void BuildModel(ModelBuilder modelBuilder)
modelBuilder.Entity<Campaign>(entity =>
{ {
entity.HasKey(x => x.Id); #pragma warning disable 612, 618
entity.Property(x => x.Name).IsRequired().HasMaxLength(128); modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
entity.Property(x => x.Ruleset).HasConversion<string>().IsRequired();
entity.Property(x => x.Version).IsRequired();
entity.HasIndex(x => x.GmUserId);
entity.ToTable("Campaigns");
});
modelBuilder.Entity<Character>(entity => modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{ {
entity.HasKey(x => x.Id); b.Property<Guid>("Id")
entity.Property(x => x.Name).IsRequired().HasMaxLength(128); .ValueGeneratedOnAdd()
entity.HasIndex(x => x.OwnerUserId); .HasColumnType("TEXT");
entity.HasIndex(x => x.CampaignId);
entity.ToTable("Characters");
});
modelBuilder.Entity<RollLogEntry>(entity => b.Property<Guid>("GmUserId")
{ .HasColumnType("TEXT");
entity.HasKey(x => x.Id);
entity.Property(x => x.Visibility).HasConversion<string>().IsRequired();
entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256);
entity.Property(x => x.TimestampUtc).IsRequired();
entity.HasIndex(x => x.CampaignId);
entity.HasIndex(x => x.RollerUserId);
entity.HasIndex(x => x.SkillId);
entity.HasIndex(x => x.CharacterId);
entity.ToTable("RollLogEntries");
});
modelBuilder.Entity<Skill>(entity => b.Property<string>("Name")
{ .IsRequired()
entity.HasKey(x => x.Id); .HasMaxLength(128)
entity.Property(x => x.Name).IsRequired().HasMaxLength(128); .HasColumnType("TEXT");
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired();
entity.HasIndex(x => x.CharacterId);
entity.ToTable("Skills");
});
modelBuilder.Entity<UserAccount>(entity => b.Property<string>("Ruleset")
{ .IsRequired()
entity.HasKey(x => x.Id); .HasColumnType("TEXT");
entity.Property(x => x.Username).IsRequired().HasMaxLength(64);
entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64);
entity.Property(x => x.PasswordHash).IsRequired();
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
entity.ToTable("Users");
});
modelBuilder.Entity<UserSession>(entity => b.Property<long>("Version")
{ .HasColumnType("INTEGER");
entity.HasKey(x => x.Token);
entity.Property(x => x.Token).HasMaxLength(64); b.HasKey("Id");
entity.Property(x => x.CreatedAtUtc).IsRequired();
entity.HasIndex(x => x.UserId); b.HasIndex("GmUserId");
entity.ToTable("Sessions");
}); b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
} }
} }

View File

@@ -7,6 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
</ItemGroup> </ItemGroup>

13
dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.3",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}