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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user