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.