diff --git a/AGENTS.md b/AGENTS.md index c186522..1c6cb21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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. +- 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. diff --git a/FAQ.md b/FAQ.md index f142819..a469677 100644 --- a/FAQ.md +++ b/FAQ.md @@ -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. +## 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 --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj +``` + ## 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. diff --git a/README.md b/README.md index bdfc891..ff8e852 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Backend state persistence: - .NET SDK 10.0+ - PowerShell 7+ +- Run `dotnet tool restore` once to enable the repo-local `dotnet-ef` command. ## 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`. +For migration authoring, use the local tool command form: + +```powershell +dotnet dotnet-ef migrations add --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj +``` + ## Frontend Runtime - Runtime frontend is Blazor Server with interactive components. diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs index f803282..c8d1dc2 100644 --- a/RpgRoller.Tests/HostingCoverageTests.cs +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using RpgRoller.Hosting; using RpgRoller.Data; @@ -98,7 +97,6 @@ public sealed class HostingCoverageTests var options = new DbContextOptionsBuilder() .UseSqlite(connectionString) - .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)) .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';"; var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar()); 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); } } diff --git a/RpgRoller.Tests/Support/ApiTestBase.cs b/RpgRoller.Tests/Support/ApiTestBase.cs index 563b966..ef5bdff 100644 --- a/RpgRoller.Tests/Support/ApiTestBase.cs +++ b/RpgRoller.Tests/Support/ApiTestBase.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Http.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using RpgRoller.Contracts; @@ -34,8 +33,7 @@ public abstract class ApiTestBase : IClassFixture var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); services.AddDbContextFactory(options => - options.UseSqlite($"Data Source={dbPath}") - .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))); + options.UseSqlite($"Data Source={dbPath}")); })); } diff --git a/RpgRoller/Hosting/ServiceCollectionExtensions.cs b/RpgRoller/Hosting/ServiceCollectionExtensions.cs index f094bd1..21a8eba 100644 --- a/RpgRoller/Hosting/ServiceCollectionExtensions.cs +++ b/RpgRoller/Hosting/ServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using RpgRoller.Data; using RpgRoller.Domain; using RpgRoller.Services; @@ -20,8 +19,7 @@ public static class ServiceCollectionExtensions services.AddSingleton, PasswordHasher>(); services.AddDbContextFactory(options => - options.UseSqlite(sqliteConnectionString) - .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))); + options.UseSqlite(sqliteConnectionString)); services.AddSingleton(); services.AddSingleton(); diff --git a/RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.Designer.cs b/RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.Designer.cs new file mode 100644 index 0000000..962aa86 --- /dev/null +++ b/RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.Designer.cs @@ -0,0 +1,212 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GmUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Ruleset") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GmUserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Breakdown") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Result") + .HasColumnType("INTEGER"); + + b.Property("RollerUserId") + .HasColumnType("TEXT"); + + b.Property("SkillId") + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCharacterId") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsernameNormalized") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UsernameNormalized") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserSession", b => + { + b.Property("Token") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.cs b/RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.cs new file mode 100644 index 0000000..ca74c9c --- /dev/null +++ b/RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RpgRoller.Migrations +{ + /// + public partial class _20260226090000_ModelSync : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // No-op migration generated to sync EF snapshot to existing schema. + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Intentionally empty. + } + } +} diff --git a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs index 427445f..a2e9c5c 100644 --- a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs +++ b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs @@ -1,78 +1,209 @@ +// +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using RpgRoller.Data; -using RpgRoller.Domain; -namespace RpgRoller.Migrations; +#nullable disable -[DbContext(typeof(RpgRollerDbContext))] -internal sealed class RpgRollerDbContextModelSnapshot : ModelSnapshot +namespace RpgRoller.Migrations { - protected override void BuildModel(ModelBuilder modelBuilder) + [DbContext(typeof(RpgRollerDbContext))] + partial class RpgRollerDbContextModelSnapshot : ModelSnapshot { - modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); - - modelBuilder.Entity(entity => + protected override void BuildModel(ModelBuilder modelBuilder) { - entity.HasKey(x => x.Id); - entity.Property(x => x.Name).IsRequired().HasMaxLength(128); - entity.Property(x => x.Ruleset).HasConversion().IsRequired(); - entity.Property(x => x.Version).IsRequired(); - entity.HasIndex(x => x.GmUserId); - entity.ToTable("Campaigns"); - }); +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Id); - entity.Property(x => x.Name).IsRequired().HasMaxLength(128); - entity.HasIndex(x => x.OwnerUserId); - entity.HasIndex(x => x.CampaignId); - entity.ToTable("Characters"); - }); + modelBuilder.Entity("RpgRoller.Domain.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Id); - entity.Property(x => x.Visibility).HasConversion().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"); - }); + b.Property("GmUserId") + .HasColumnType("TEXT"); - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Id); - entity.Property(x => x.Name).IsRequired().HasMaxLength(128); - 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"); - }); + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Id); - 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"); - }); + b.Property("Ruleset") + .IsRequired() + .HasColumnType("TEXT"); - modelBuilder.Entity(entity => - { - entity.HasKey(x => x.Token); - entity.Property(x => x.Token).HasMaxLength(64); - entity.Property(x => x.CreatedAtUtc).IsRequired(); - entity.HasIndex(x => x.UserId); - entity.ToTable("Sessions"); - }); + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GmUserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Breakdown") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Result") + .HasColumnType("INTEGER"); + + b.Property("RollerUserId") + .HasColumnType("TEXT"); + + b.Property("SkillId") + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCharacterId") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsernameNormalized") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UsernameNormalized") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserSession", b => + { + b.Property("Token") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("Sessions"); + }); +#pragma warning restore 612, 618 + } } } diff --git a/RpgRoller/RpgRoller.csproj b/RpgRoller/RpgRoller.csproj index b66ecb9..3c2ada3 100644 --- a/RpgRoller/RpgRoller.csproj +++ b/RpgRoller/RpgRoller.csproj @@ -7,6 +7,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..bffb60c --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.3", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file