feat(fp): WP-22 — durable persistence (SQLite/EF Core)

Applications, documents (+ audit log) and the brief move off static in-memory
Dictionaries onto a real SQLite file via EF Core, so demo data survives a
process restart or `docker compose restart api` for the first time. The three
stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public
signatures and static-class shape — no DI, no async ripple into Program.cs's
minimal-API handlers — each method just opens a short-lived AppDbContext via
Db.Create() under the same lock it already had. 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, matching the
existing "don't interpret it" posture.

Found two things the WP's own text got wrong, corrected in
docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never
seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which
stay in-memory) so there's no seed step; and no new docker-compose volume is
needed since the existing bind mount already covers the SQLite file — verified
against this environment's real podman-backed compose stack, not just by
reading the file.

Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own
transitive default bundles a pre-3.50.2 SQLite with a known high-severity
memory-corruption advisory); found and fixed a real xUnit test race where
concurrent test-class hosts stomped a shared static connection-string field,
fixed by disabling cross-class test parallelization rather than adding DI the
stores don't otherwise need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:19:23 +02:00
parent 40dbcb2606
commit 556f2f47bf
23 changed files with 905 additions and 69 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -167,8 +167,9 @@ degrade to an instant navigation.
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
`public/cibg-huisstijl/`, loaded via `<link>`; `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.

5
backend/.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -8,6 +8,13 @@
"swagger"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -7,6 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<!-- Pin over EF Core Sqlite's own transitive default (2.1.11): that version
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 SQLite. -->
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>

View File

@@ -0,0 +1,63 @@
using System.Text.Json;
using BigRegister.Api.Contracts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace BigRegister.Api.Data;
/// <summary>
/// EF Core/SQLite persistence for the three stores that used to be static
/// in-memory dictionaries (WP-22): <see cref="Aanvraag"/>, <see cref="StoredDocument"/>
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. 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.
/// </summary>
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<StoredDocument> Documents => Set<StoredDocument>();
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<StoredDocument>().HasKey(d => d.DocumentId);
modelBuilder.Entity<AuditEntry>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Id).ValueGeneratedOnAdd();
});
modelBuilder.Entity<Aanvraag>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Draft).HasConversion(DraftConverter);
e.Property(a => a.DocumentIds).HasConversion(Json<List<string>>());
});
modelBuilder.Entity<BriefEntity>(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<IReadOnlyList<PlaceholderDefDto>>());
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
});
}
// 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<JsonElement?, string?> DraftConverter = new(
v => v.HasValue ? v.Value.GetRawText() : null,
v => string.IsNullOrEmpty(v) ? (JsonElement?)null : JsonDocument.Parse(v).RootElement.Clone());
private static ValueConverter<T, string> Json<T>() => new(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<T>(v, (JsonSerializerOptions?)null)!);
}

View File

@@ -29,34 +29,48 @@ public sealed class Aanvraag
}
/// <summary>
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
/// 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 <see cref="DocumentStore"/>. 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.
/// </summary>
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<string, Aanvraag> _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<Aanvraag> 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<string> 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;
}
}

View File

@@ -1,14 +1,16 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
using Microsoft.EntityFrameworkCore;
namespace BigRegister.Api.Data;
/// <summary>
/// The letter (brief) — one demo brief per owner, created from a template on first
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. 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
/// <see cref="ApplicationStore"/>. 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).
/// </summary>
public sealed class BriefEntity
{
@@ -24,6 +26,8 @@ public sealed class BriefEntity
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
}
/// <summary>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.</summary>
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<string, BriefEntity> _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);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace BigRegister.Api.Data;
/// <summary>
/// Factory for short-lived <see cref="AppDbContext"/> 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.
/// </summary>
public static class Db
{
public static string ConnectionString { get; set; } = "Data Source=bigregister.db";
public static AppDbContext Create() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(ConnectionString).Options);
}
/// <summary>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.</summary>
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args) => Db.Create();
}

View File

@@ -1,8 +1,8 @@
namespace BigRegister.Api.Data;
/// <summary>
/// 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.
/// </summary>
@@ -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);
/// <summary>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.</summary>
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor)
{
public long Id { get; init; }
}
/// <summary>
/// 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).
/// </summary>
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<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _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<StoredDocument> ByLocalIds(IEnumerable<string> 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<string> 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<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
get
{
lock (_gate)
{
using var db = Db.Create();
return db.AuditEntries.ToList();
}
}
}
}

View File

@@ -0,0 +1,195 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Applications",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
Draft = table.Column<string>(type: "TEXT", nullable: true),
StepIndex = table.Column<int>(type: "INTEGER", nullable: false),
StepCount = table.Column<int>(type: "INTEGER", nullable: false),
DocumentIds = table.Column<string>(type: "TEXT", nullable: false),
Referentie = table.Column<string>(type: "TEXT", nullable: true),
AutoApprovable = table.Column<bool>(type: "INTEGER", nullable: false),
Reden = table.Column<string>(type: "TEXT", nullable: true),
Submitted = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Applications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AuditEntries",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
At = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Action = table.Column<string>(type: "TEXT", nullable: false),
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
Actor = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditEntries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Briefs",
columns: table => new
{
BriefId = table.Column<string>(type: "TEXT", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
Beroep = table.Column<string>(type: "TEXT", nullable: false),
TemplateId = table.Column<string>(type: "TEXT", nullable: false),
DrafterId = table.Column<string>(type: "TEXT", nullable: false),
Placeholders = table.Column<string>(type: "TEXT", nullable: false),
Sections = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Briefs", x => x.BriefId);
});
migrationBuilder.CreateTable(
name: "Documents",
columns: table => new
{
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
LocalId = table.Column<string>(type: "TEXT", nullable: false),
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
WizardId = table.Column<string>(type: "TEXT", nullable: false),
FileName = table.Column<string>(type: "TEXT", nullable: false),
SizeBytes = table.Column<long>(type: "INTEGER", nullable: false),
ContentType = table.Column<string>(type: "TEXT", nullable: false),
Content = table.Column<byte[]>(type: "BLOB", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
UploadedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Linked = table.Column<bool>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Applications");
migrationBuilder.DropTable(
name: "AuditEntries");
migrationBuilder.DropTable(
name: "Briefs");
migrationBuilder.DropTable(
name: "Documents");
}
}
}

View File

@@ -0,0 +1,192 @@
// <auto-generated />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -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

View File

@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();

View File

@@ -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.
/// </summary>
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();

View File

@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();

View File

@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class IdempotencyTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
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");
}
}

View File

@@ -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

View File

@@ -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.