diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c245d47..424a3d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,9 @@ jobs: e2e: # Smoke-level Playwright run against the REAL FE+backend (WP-19) — a fresh - # backend process per run, so in-memory state from a prior run never leaks in. + # runner checkout per run, so there's no bigregister.db (WP-22, gitignored) + # left over from a prior run to leak state in; the backend creates + migrates + # an empty one on this boot, same as a fresh clone always has. runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/CLAUDE.md b/CLAUDE.md index 5a30646..30e7cfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,10 @@ update this file. POC of a Dutch BIG-register self-service portal (healthcare professionals log in, view their registration, apply for re-registration). Angular 22, standalone, signals. Auth is faked; **data and business rules are served by a minimal ASP.NET -Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed -through an NSwag-generated typed client. The FE renders the backend's decisions. +Core backend** (`backend/`, see its README) and consumed through an NSwag-generated +typed client. The FE renders the backend's decisions. Reference data mimicking +BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief +persist to a SQLite file via EF Core (WP-22) — `docs/backlog/WP-22-durable-persistence.md`. ## Commands diff --git a/README.md b/README.md index 598c5ad..c6b33c8 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,9 @@ degrade to an instant navigation. - Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in `public/cibg-huisstijl/`, loaded via ``; `src/styles.scss` holds the `--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency. -- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI - contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`). +- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO + reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE + consumes an **NSwag-generated** typed client (`npm run gen:api`). The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is **dev-only** — it is not wired into production builds. - `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags @@ -194,9 +195,11 @@ We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 2 ### Deliberately out of scope (POC) -Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, NgRx, -licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend -itself _is_ implemented.) i18n's build seam is proven (see above) but +Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL +Server — SQLite persists applications/documents/the brief + a real audit table, +see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font +stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is +proven (see above) but production-quality translation, a runtime locale switcher, and RTL/pluralization edge cases are not — the `en` file is demo-quality, and locale is a build-time choice, not a switch in the running app. diff --git a/backend/.gitignore b/backend/.gitignore index cd42ee3..55e0f04 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,7 @@ bin/ obj/ + +# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data. +bigregister.db +bigregister.db-shm +bigregister.db-wal diff --git a/backend/README.md b/backend/README.md index 7392dff..0fe00ce 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,8 +4,18 @@ The backend that hosts the **business rules** for the BIG-register portal. The frontend renders the decisions this service computes; it does not recompute them (BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`). -No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`), -but the endpoints, DTOs, status codes and error envelope are production-shaped. +No real BRP/DUO: the reference data they'd return (registration, person, diplomas, +notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs, +status codes and error envelope are production-shaped. + +**Applications, documents and the brief persist** to a SQLite file +(`src/BigRegister.Api/bigregister.db`, EF Core-backed — `Data/AppDbContext.cs`, +`Data/Db.cs`) created and migrated on first run; restarting the process (or +`docker compose restart api` — the existing `./backend:/src` bind mount already +covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to +reset demo data back to empty, the same state a fresh clone starts from. This is +a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see +`docs/backlog/WP-22-durable-persistence.md`. ## Run diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json index c5b7a30..8506b79 100644 --- a/backend/dotnet-tools.json +++ b/backend/dotnet-tools.json @@ -8,6 +8,13 @@ "swagger" ], "rollForward": false + }, + "dotnet-ef": { + "version": "10.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/backend/src/BigRegister.Api/BigRegister.Api.csproj b/backend/src/BigRegister.Api/BigRegister.Api.csproj index 213738b..3ebdbfe 100644 --- a/backend/src/BigRegister.Api/BigRegister.Api.csproj +++ b/backend/src/BigRegister.Api/BigRegister.Api.csproj @@ -7,6 +7,15 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/backend/src/BigRegister.Api/Data/AppDbContext.cs b/backend/src/BigRegister.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..2e70ca1 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/AppDbContext.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using BigRegister.Api.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace BigRegister.Api.Data; + +/// +/// EF Core/SQLite persistence for the three stores that used to be static +/// in-memory dictionaries (WP-22): , +/// + , and . Opaque nested shapes +/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as +/// JSON text columns rather than redesigned into relational tables — the backend +/// already treats them as opaque (see BriefStore's own header comment), so a JSON +/// column matches that "don't interpret it" posture with zero schema redesign, +/// per this WP's own decision to relocate shapes, not redesign them. +/// +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Documents => Set(); + public DbSet AuditEntries => Set(); + public DbSet Applications => Set(); + public DbSet Briefs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(d => d.DocumentId); + + modelBuilder.Entity(e => + { + e.HasKey(a => a.Id); + e.Property(a => a.Id).ValueGeneratedOnAdd(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(a => a.Id); + e.Property(a => a.Draft).HasConversion(DraftConverter); + e.Property(a => a.DocumentIds).HasConversion(Json>()); + }); + + modelBuilder.Entity(e => + { + e.HasKey(b => b.BriefId); + e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant) + e.Property(b => b.Placeholders).HasConversion(Json>()); + e.Property(b => b.Sections).HasConversion(Json>()); + e.Property(b => b.Status).HasConversion(Json()); + }); + } + + // A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON + // text. `.Clone()` on read detaches the element from the short-lived JsonDocument + // that parsed it (same rule ApplicationStore.SyncDraft already follows for the + // request body — an uncloned element is invalid once its JsonDocument is GC'd). + private static readonly ValueConverter DraftConverter = new( + v => v.HasValue ? v.Value.GetRawText() : null, + v => string.IsNullOrEmpty(v) ? (JsonElement?)null : JsonDocument.Parse(v).RootElement.Clone()); + + private static ValueConverter Json() => new( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!); +} diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index d39d5da..7002a29 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -29,34 +29,48 @@ public sealed class Aanvraag } /// -/// In-memory application store (no DB), mirrors . -/// ponytail: one global lock — fine for a single-process demo; swap for per-key -/// locks if it ever serves load. +/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary), +/// mirrors . ponytail: one global lock — SQLite +/// tolerates only one writer at a time anyway, and this was already a single +/// coarse gate before the DB existed. /// public static class ApplicationStore { /// After this window an auto-approvable submission reports Goedgekeurd (computed on read). public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8); - private static readonly Dictionary _apps = new(); private static readonly object _gate = new(); public static Aanvraag Create(string type, string owner) { var now = DateTimeOffset.UtcNow; var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; - lock (_gate) _apps[a.Id] = a; + lock (_gate) + { + using var db = Db.Create(); + db.Applications.Add(a); + db.SaveChanges(); + } return a; } public static Aanvraag? Get(string id, string owner) { - lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null; + lock (_gate) + { + using var db = Db.Create(); + var a = db.Applications.Find(id); + return a is not null && a.Owner == owner ? a : null; + } } public static IReadOnlyList List(string owner) { - lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList(); + lock (_gate) + { + using var db = Db.Create(); + return db.Applications.Where(a => a.Owner == owner).ToList(); + } } /// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable. @@ -64,12 +78,15 @@ public static class ApplicationStore { lock (_gate) { - if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false; + using var db = Db.Create(); + var a = db.Applications.Find(id); + if (a is null || a.Owner != owner || a.Submitted) return false; a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call) a.StepIndex = stepIndex; a.StepCount = stepCount; if (documentIds is not null) a.DocumentIds = documentIds.ToList(); a.UpdatedAt = DateTimeOffset.UtcNow; + db.SaveChanges(); return true; } } @@ -81,9 +98,12 @@ public static class ApplicationStore List docs; lock (_gate) { - if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false; + using var db = Db.Create(); + var a = db.Applications.Find(id); + if (a is null || a.Owner != owner) return false; docs = a.DocumentIds.ToList(); - _apps.Remove(id); + db.Applications.Remove(a); + db.SaveChanges(); } foreach (var d in docs) DocumentStore.DeleteOwned(d, owner); return true; @@ -96,7 +116,9 @@ public static class ApplicationStore { lock (_gate) { - if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null; + using var db = Db.Create(); + var a = db.Applications.Find(id); + if (a is null || a.Owner != owner || a.Submitted) return null; a.Submitted = true; a.SubmittedAt = DateTimeOffset.UtcNow; a.UpdatedAt = a.SubmittedAt.Value; @@ -104,6 +126,7 @@ public static class ApplicationStore a.AutoApprovable = autoApprovable; a.Reden = reject; if (documentIds is not null) a.DocumentIds = documentIds.ToList(); + db.SaveChanges(); return a; } } diff --git a/backend/src/BigRegister.Api/Data/BriefStore.cs b/backend/src/BigRegister.Api/Data/BriefStore.cs index 5649bfd..1963f72 100644 --- a/backend/src/BigRegister.Api/Data/BriefStore.cs +++ b/backend/src/BigRegister.Api/Data/BriefStore.cs @@ -1,14 +1,16 @@ using BigRegister.Api.Contracts; using BigRegister.Domain.Authorization; +using Microsoft.EntityFrameworkCore; namespace BigRegister.Api.Data; /// /// The letter (brief) — one demo brief per owner, created from a template on first -/// read. In-memory, mirrors . The status machine and -/// its guards live here (the server is authoritative for transitions); the FE mirrors -/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the -/// stub does not interpret it (no server-side placeholder linting in this slice). +/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors +/// . The status machine and its guards live here (the +/// server is authoritative for transitions); the FE mirrors them in its pure reducer +/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not +/// interpret it (no server-side placeholder linting in this slice). /// public sealed class BriefEntity { @@ -24,6 +26,8 @@ public sealed class BriefEntity public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId); } +/// ponytail: one global lock, same as before this WP — SQLite tolerates +/// only one writer at a time anyway, and this was already a single coarse gate. public static class BriefStore { // Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle). @@ -32,16 +36,18 @@ public static class BriefStore public enum Outcome { Ok, Forbidden, Conflict } - private static readonly Dictionary _byOwner = new(); private static readonly object _gate = new(); public static BriefEntity GetOrCreate(string owner) { lock (_gate) { - if (_byOwner.TryGetValue(owner, out var existing)) return existing; + using var db = Db.Create(); + var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner); + if (existing is not null) return existing; var created = BriefSeed.NewBrief(owner); - _byOwner[owner] = created; + db.Briefs.Add(created); + db.SaveChanges(); return created; } } @@ -52,11 +58,14 @@ public static class BriefStore { lock (_gate) { - if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); + using var db = Db.Create(); + var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); + if (e is null) return (Outcome.Conflict, null); if (!isDrafter) return (Outcome.Forbidden, null); if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null); e.Sections = sections.ToList(); if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft"); + db.SaveChanges(); return (Outcome.Ok, e); } } @@ -65,10 +74,13 @@ public static class BriefStore { lock (_gate) { - if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); + using var db = Db.Create(); + var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); + if (e is null) return (Outcome.Conflict, null); if (!isDrafter) return (Outcome.Forbidden, null); if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null); e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at); + db.SaveChanges(); return (Outcome.Ok, e); } } @@ -85,9 +97,12 @@ public static class BriefStore { lock (_gate) { - if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); + using var db = Db.Create(); + var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); + if (e is null) return (Outcome.Conflict, null); if (e.Status.Tag != "approved") return (Outcome.Conflict, null); e.Status = new BriefStatusDto("sent", SentAt: at); + db.SaveChanges(); return (Outcome.Ok, e); } } @@ -95,7 +110,11 @@ public static class BriefStore /// Test seam: clear the demo brief between tests. public static void Reset() { - lock (_gate) _byOwner.Clear(); + lock (_gate) + { + using var db = Db.Create(); + db.Briefs.ExecuteDelete(); + } } /// Demo affordance: drop the owner's brief and create a fresh one. No guards — a @@ -104,9 +123,11 @@ public static class BriefStore { lock (_gate) { - _byOwner.Remove(owner); + using var db = Db.Create(); + db.Briefs.Where(e => e.Owner == owner).ExecuteDelete(); var created = BriefSeed.NewBrief(owner); - _byOwner[owner] = created; + db.Briefs.Add(created); + db.SaveChanges(); return created; } } @@ -120,10 +141,13 @@ public static class BriefStore { lock (_gate) { - if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); + using var db = Db.Create(); + var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); + if (e is null) return (Outcome.Conflict, null); if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null); if (e.Status.Tag != "submitted") return (Outcome.Conflict, null); e.Status = next(); + db.SaveChanges(); return (Outcome.Ok, e); } } diff --git a/backend/src/BigRegister.Api/Data/Db.cs b/backend/src/BigRegister.Api/Data/Db.cs new file mode 100644 index 0000000..10001c3 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Db.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace BigRegister.Api.Data; + +/// +/// Factory for short-lived instances. The three stores +/// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape +/// predates WP-22 and this WP keeps it — so they can't take a constructor-injected +/// DbContext; each store method opens one here, uses it, and disposes it under its +/// own lock instead. +/// +public static class Db +{ + public static string ConnectionString { get; set; } = "Data Source=bigregister.db"; + + public static AppDbContext Create() => + new(new DbContextOptionsBuilder().UseSqlite(ConnectionString).Options); +} + +/// Lets `dotnet ef migrations add` construct a context at design time +/// without booting the full app (no DI registration exists for AppDbContext — +/// see Db.Create above for why). Auto-discovered by EF Core's tooling. +public sealed class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) => Db.Create(); +} diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 6508ad2..8814f42 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -1,8 +1,8 @@ namespace BigRegister.Api.Data; /// -/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on -/// restart) purely so re-opened wizards can preview/download what was uploaded — a +/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file +/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a /// real backend persists them to blob storage keyed by DocumentId. Bytes are never /// serialized into a JSON response; only the dedicated content endpoint streams them. /// @@ -13,33 +13,48 @@ public sealed record StoredDocument( public bool Linked { get; set; } } -public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor); +/// Id is EF Core's auto-increment key — not part of the positional +/// constructor, so every existing `new AuditEntry(at, action, ...)` call site +/// keeps working unchanged; EF Core assigns it on insert. +public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor) +{ + public long Id { get; init; } +} /// -/// In-memory document store + audit log (no DB). ponytail: one global lock — fine -/// for a single-process demo store; swap for per-key locks if it ever serves load. -/// The audit log holds metadata only (never file content or other PII). +/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static +/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only +/// one writer at a time anyway, and this process already serialized all access +/// through a single gate, so it now doubles as a coarse single-writer guard for +/// the DB file. The audit log holds metadata only (never file content or other PII). /// public static class DocumentStore { /// The single seeded user (the demo has no real auth; ownership = this id). public const string DemoOwner = "19012345601"; - private static readonly Dictionary _docs = new(); - private static readonly List _audit = new(); private static readonly object _gate = new(); public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner) { var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow); - lock (_gate) _docs[doc.DocumentId] = doc; + lock (_gate) + { + using var db = Db.Create(); + db.Documents.Add(doc); + db.SaveChanges(); + } Audit("upload", doc.DocumentId, categoryId, owner); return doc; } public static StoredDocument? Get(string documentId) { - lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null; + lock (_gate) + { + using var db = Db.Create(); + return db.Documents.Find(documentId); + } } /// Status for the poll-on-return pattern: a known localId is "complete" (it @@ -47,15 +62,26 @@ public static class DocumentStore public static IReadOnlyList ByLocalIds(IEnumerable localIds) { var set = localIds.ToHashSet(); - lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList(); + lock (_gate) + { + using var db = Db.Create(); + return db.Documents.Where(d => set.Contains(d.LocalId)).ToList(); + } } /// Mark digital documents as linked to a finalised submission (blocks user delete). public static void Link(IEnumerable documentIds) { lock (_gate) + { + using var db = Db.Create(); foreach (var id in documentIds) - if (_docs.TryGetValue(id, out var d)) d.Linked = true; + { + var d = db.Documents.Find(id); + if (d is not null) d.Linked = true; + } + db.SaveChanges(); + } } public enum DeleteResult { Ok, NotFound, Linked } @@ -66,10 +92,13 @@ public static class DocumentStore string categoryId; lock (_gate) { - if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound; + using var db = Db.Create(); + var d = db.Documents.Find(documentId); + if (d is null || d.Owner != owner) return DeleteResult.NotFound; if (d.Linked) return DeleteResult.Linked; categoryId = d.CategoryId; - _docs.Remove(documentId); + db.Documents.Remove(d); + db.SaveChanges(); } Audit("delete-user", documentId, categoryId, owner); return DeleteResult.Ok; @@ -82,9 +111,12 @@ public static class DocumentStore string categoryId; lock (_gate) { - if (!_docs.TryGetValue(documentId, out var d)) return false; + using var db = Db.Create(); + var d = db.Documents.Find(documentId); + if (d is null) return false; categoryId = d.CategoryId; - _docs.Remove(documentId); + db.Documents.Remove(d); + db.SaveChanges(); } Audit("delete-admin", documentId, categoryId, actor); return true; @@ -92,11 +124,23 @@ public static class DocumentStore public static void Audit(string action, string documentId, string categoryId, string actor) { - lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor)); + lock (_gate) + { + using var db = Db.Create(); + db.AuditEntries.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor)); + db.SaveChanges(); + } } public static IReadOnlyList AuditLog { - get { lock (_gate) return _audit.ToList(); } + get + { + lock (_gate) + { + using var db = Db.Create(); + return db.AuditEntries.ToList(); + } + } } } diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs b/backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs new file mode 100644 index 0000000..a93fb30 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs @@ -0,0 +1,195 @@ +// +using System; +using BigRegister.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260704190822_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AutoApprovable") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Draft") + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Reden") + .HasColumnType("TEXT"); + + b.Property("Referentie") + .HasColumnType("TEXT"); + + b.Property("StepCount") + .HasColumnType("INTEGER"); + + b.Property("StepIndex") + .HasColumnType("INTEGER"); + + b.Property("Submitted") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b => + { + b.Property("BriefId") + .HasColumnType("TEXT"); + + b.Property("Beroep") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DrafterId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Placeholders") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sections") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("BriefId"); + + b.HasIndex("Owner") + .IsUnique(); + + b.ToTable("Briefs"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b => + { + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Linked") + .HasColumnType("INTEGER"); + + b.Property("LocalId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("WizardId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DocumentId"); + + b.ToTable("Documents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.cs b/backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.cs new file mode 100644 index 0000000..04b817a --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.cs @@ -0,0 +1,117 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Applications", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", nullable: false), + Owner = table.Column(type: "TEXT", nullable: false), + Draft = table.Column(type: "TEXT", nullable: true), + StepIndex = table.Column(type: "INTEGER", nullable: false), + StepCount = table.Column(type: "INTEGER", nullable: false), + DocumentIds = table.Column(type: "TEXT", nullable: false), + Referentie = table.Column(type: "TEXT", nullable: true), + AutoApprovable = table.Column(type: "INTEGER", nullable: false), + Reden = table.Column(type: "TEXT", nullable: true), + Submitted = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + SubmittedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Applications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AuditEntries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + At = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", nullable: false), + DocumentId = table.Column(type: "TEXT", nullable: false), + CategoryId = table.Column(type: "TEXT", nullable: false), + Actor = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Briefs", + columns: table => new + { + BriefId = table.Column(type: "TEXT", nullable: false), + Owner = table.Column(type: "TEXT", nullable: false), + Beroep = table.Column(type: "TEXT", nullable: false), + TemplateId = table.Column(type: "TEXT", nullable: false), + DrafterId = table.Column(type: "TEXT", nullable: false), + Placeholders = table.Column(type: "TEXT", nullable: false), + Sections = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Briefs", x => x.BriefId); + }); + + migrationBuilder.CreateTable( + name: "Documents", + columns: table => new + { + DocumentId = table.Column(type: "TEXT", nullable: false), + LocalId = table.Column(type: "TEXT", nullable: false), + CategoryId = table.Column(type: "TEXT", nullable: false), + WizardId = table.Column(type: "TEXT", nullable: false), + FileName = table.Column(type: "TEXT", nullable: false), + SizeBytes = table.Column(type: "INTEGER", nullable: false), + ContentType = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "BLOB", nullable: false), + Owner = table.Column(type: "TEXT", nullable: false), + UploadedAt = table.Column(type: "TEXT", nullable: false), + Linked = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Documents", x => x.DocumentId); + }); + + migrationBuilder.CreateIndex( + name: "IX_Briefs_Owner", + table: "Briefs", + column: "Owner", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Applications"); + + migrationBuilder.DropTable( + name: "AuditEntries"); + + migrationBuilder.DropTable( + name: "Briefs"); + + migrationBuilder.DropTable( + name: "Documents"); + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..b66e06b --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,192 @@ +// +using System; +using BigRegister.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AutoApprovable") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Draft") + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Reden") + .HasColumnType("TEXT"); + + b.Property("Referentie") + .HasColumnType("TEXT"); + + b.Property("StepCount") + .HasColumnType("INTEGER"); + + b.Property("StepIndex") + .HasColumnType("INTEGER"); + + b.Property("Submitted") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b => + { + b.Property("BriefId") + .HasColumnType("TEXT"); + + b.Property("Beroep") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DrafterId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Placeholders") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sections") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("BriefId"); + + b.HasIndex("Owner") + .IsUnique(); + + b.ToTable("Briefs"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b => + { + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Linked") + .HasColumnType("INTEGER"); + + b.Property("LocalId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("WizardId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DocumentId"); + + b.ToTable("Documents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index b67e323..0b353fa 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -8,6 +8,7 @@ using BigRegister.Domain.Documents; using BigRegister.Domain.Intake; using BigRegister.Domain.Registrations; using BigRegister.Domain.Submissions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Console; var builder = WebApplication.CreateBuilder(args); @@ -29,8 +30,23 @@ const string SpaCors = "spa"; builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod())); +// WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static +// classes that open their own short-lived AppDbContext per call (see Db.Create), +// not DI-injected, so there's no builder.Services.AddDbContext here. Configuring +// the connection string still goes through IConfiguration so tests/deployments can +// override it (ConnectionStrings:AppDb) without touching this file. +Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString; + var app = builder.Build(); +// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only +// reference fixtures (registration/diplomas/notes — untouched by this WP, still +// static in-memory), Applications/Documents/Briefs never had seed data — they +// started empty and accumulated through normal use before this WP too. A fresh +// SQLite file just starts empty again, same as the old in-memory dictionaries did. +using (var db = Db.Create()) + db.Database.Migrate(); + // Every request gets a correlation id (client-supplied X-Correlation-Id if present, // else generated), pushed into the logging scope for every log line the request // produces (not just the Submit helper's) and echoed back as a response header for diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs index 716ba4b..76d3abc 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; -public class ApplicationTests(WebApplicationFactory factory) : IClassFixture> +public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); diff --git a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs index 2c965c3..07fff0a 100644 --- a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs @@ -11,7 +11,7 @@ namespace BigRegister.Tests; /// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role /// header: absent = drafter, "approver" = a different identity. /// -public class BriefEndpointTests(WebApplicationFactory factory) : IClassFixture> +public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index 84cfc8d..4e6b256 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; -public class EndpointTests(WebApplicationFactory factory) : IClassFixture> +public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); diff --git a/backend/tests/BigRegister.Tests/IdempotencyTests.cs b/backend/tests/BigRegister.Tests/IdempotencyTests.cs index a800e5e..aafffe2 100644 --- a/backend/tests/BigRegister.Tests/IdempotencyTests.cs +++ b/backend/tests/BigRegister.Tests/IdempotencyTests.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; -public class IdempotencyTests(WebApplicationFactory factory) : IClassFixture> +public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); diff --git a/backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs b/backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs new file mode 100644 index 0000000..01eb232 --- /dev/null +++ b/backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +// WP-22's stores read a single static Db.ConnectionString (there's no DI, matching +// their pre-WP-22 static-Dictionary shape — see Data/Db.cs). That's correct for a +// real single-instance process, but xUnit's default parallel-across-classes +// execution would run multiple WebApplicationFactory hosts concurrently in this +// ONE test process, each overwriting that same static field with its own temp-file +// path — a real race (caught as "table already exists" from two Migrate() calls +// interleaving on whichever file won the race), not a hypothetical one. Serializing +// test classes is the fix, not a redesign of the stores for a test-only concern. +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace BigRegister.Tests; + +/// +/// WP-22 moved Applications/Documents/Briefs off in-memory dictionaries onto a real +/// SQLite file (see Data/Db.cs). Unlike static dictionaries, a shared file path +/// would let concurrent test classes' WebApplicationFactory instances hit the same +/// file at once — xUnit runs different test classes in parallel by default, and +/// SQLite tolerates only one writer at a time, so that's a real "database is +/// locked" flake risk, not a hypothetical one. Every test class below points at its +/// own throwaway file instead, deleted when the factory (and its class's tests) are +/// done — the same one-store-per-class isolation the old dictionaries gave for free. +/// +public sealed class TestWebApplicationFactory : WebApplicationFactory +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) => + builder.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}"); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (!disposing) return; + File.Delete(_dbPath); + File.Delete(_dbPath + "-shm"); // ponytail: best-effort — WAL sidecar files if SQLite created any. + File.Delete(_dbPath + "-wal"); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d373adf..bdfa772 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,10 @@ services: - ASPNETCORE_ENVIRONMENT=Development volumes: # ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts. + # WP-22: no separate volume needed for the SQLite file — `dotnet run` sets + # its cwd to the project dir (src/BigRegister.Api), which this bind mount + # already covers, so bigregister.db lands on the host and survives + # `docker compose restart api` / `down` + `up` for free (gitignored). - ./backend:/src:z - api-bin:/src/src/BigRegister.Api/bin - api-obj:/src/src/BigRegister.Api/obj diff --git a/docs/backlog/WP-22-durable-persistence.md b/docs/backlog/WP-22-durable-persistence.md index 312f2a4..b748c3f 100644 --- a/docs/backlog/WP-22-durable-persistence.md +++ b/docs/backlog/WP-22-durable-persistence.md @@ -1,6 +1,6 @@ # WP-22 — Durable persistence (optional tier) -Status: todo +Status: done (pending commit) Phase: 5 — productie-volwassenheid ## Why @@ -98,22 +98,30 @@ speculatively. ## Acceptance criteria -- [ ] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in +- [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in `Data/*.cs` for application/document/brief state. -- [ ] Every existing backend test passes unchanged (signatures didn't change). -- [ ] Restarting the backend process preserves previously created applications, +- [x] Every existing backend test passes unchanged (signatures didn't change). + 84/84 green, stable across repeated runs (see Deviations for a real race this + surfaced). +- [x] Restarting the backend process preserves previously created applications, documents, and brief drafts (manually verified). -- [ ] The audit log survives a restart and is queryable (even if no new endpoint - exposes it yet — persistence is the bar, not a new audit UI). -- [ ] `docker compose up` with the new volume also survives a container restart. +- [x] The audit log survives a restart and is queryable (even if no new endpoint + exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries` + is a real table now; not separately re-verified across restart beyond the + applications/brief checks (same store mechanism, same `Db.Create()` seam). +- [x] `docker compose up` with a container restart preserves data — **no new + volume** turned out to be needed (see Deviations). ## Verification -`cd backend && dotnet test`. Manual: `dotnet run --project src/BigRegister.Api`, -create an application via the FE or a curl request, kill and restart the process, -confirm `GET /api/v1/applications` still returns it. Repeat with `docker compose up` - -- `docker compose restart api`. +`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project +src/BigRegister.Api`, created an application via curl, killed and restarted the +process, confirmed `GET /api/v1/applications` still returned it (repeated for the +brief). Repeated the same check against the **real** `docker compose up` stack +(this environment has an actual podman-backed compose, not a mock) — created an +application via `curl localhost:5000`, ran `docker compose restart api`, confirmed +it survived, and confirmed on the host that `backend/src/BigRegister.Api/bigregister.db` +is the file being written (gitignored, not tracked). ## Out of scope @@ -131,3 +139,47 @@ synchronously; converting to `async`/`await` may ripple further than "just the Data/ layer" if minimal-API handlers aren't already `async`. Check this before starting and budget for handler signature changes (still not a _behavior_ change, but a wider diff than the Files section implies if handlers need `async` added). + +**Resolved**: didn't ripple at all. EF Core's SQLite provider fully supports +synchronous APIs (`.Find()`, `.ToList()`, `.SaveChanges()`, `.ExecuteDelete()`); every +store method stayed synchronous, so `Program.cs`'s minimal-API handlers needed zero +changes. The stores stayed **static classes** with no DI — each method opens its +own short-lived `AppDbContext` via a small `Db.Create()` factory (`Data/Db.cs`) under +the same `lock (_gate)` each store already had, which now doubles as a single-writer +guard for the SQLite file (SQLite tolerates only one writer at a time anyway). + +## Deviations from the plan + +- **No SeedData → DB seed step.** The WP's own "Decisions"/"Files" sections assumed + `SeedData` populates the three stores and needs a "seed if empty" migration. It + doesn't — `SeedData` only backs the read-only BRP/DUO-mimicking GET endpoints + (registration, person, diplomas, notes), which stay in-memory and are untouched by + this WP. Applications/Documents/Briefs never had seed data; they started empty + before this WP and still do. One less step than planned. +- **No new docker-compose volume.** The existing `./backend:/src` bind mount already + covers `bigregister.db` (it's written under `src/BigRegister.Api/`, itself inside + the bind-mounted tree — confirmed empirically, not just by reading the compose + file), so a container restart already persists it for free. Added a comment + instead of a redundant `volumes:` entry. +- **Opaque nested shapes (wizard draft, brief sections/placeholders/status) became + JSON text columns**, not new relational tables — matches the WP's own "relocate + the shape, don't redesign it" instruction and the existing "the backend treats + brief content as opaque" posture. +- **Found and fixed a real test race, not a hypothetical one.** The stores read a + single static `Db.ConnectionString` (matching their pre-WP-22 static-Dictionary + shape — no DI). xUnit's default parallel-across-classes execution ran multiple + `WebApplicationFactory` hosts concurrently in the one test process, each + overwriting that same static field with its own temp-file path — caught as a + `SQLite Error 1: 'table "Applications" already exists'` from two `Migrate()` calls + interleaving on whichever file won the race. Fixed with + `[assembly: CollectionBehavior(DisableTestParallelization = true)]` + (`TestWebApplicationFactory.cs`) rather than redesigning the stores' DI shape for + a test-only concern. Reran `dotnet test` 3× in a row to confirm the race was + actually gone, not just less likely. +- **Pinned `SQLitePCLRaw.bundle_e_sqlite3` to 3.0.3** — `Microsoft.EntityFrameworkCore.Sqlite` + 10.0.9's own transitive default (2.1.11) bundles a pre-3.50.2 SQLite with a known + high-severity memory-corruption advisory (GHSA-2m69-gcr7-jv3q); 3.0.3 bundles a + patched one and built/tested cleanly as a drop-in. +- **`dotnet-ef` added to the existing `backend/dotnet-tools.json`** (not a new + `.config/dotnet-tools.json`) — this repo already keeps its one CLI tool manifest + there (`swashbuckle.aspnetcore.cli`); matched that convention.