fix(drafts): serialize draft session flushes and register reminders via fds__setReminder
Playwright Tests / test (push) Canceled after 0s

Make invoice/reminder live-draft edits concurrency-safe with an explicit
DraftFlushResult, add the fds__setReminder stored procedure, and harden the
date helper functions. Includes concurrency and SQL contract tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Stefan
2026-10-01 13:57:40 +02:00
co-authored by Claude Fable 5.1
parent d0002ccdad
commit 546648533b
21 changed files with 813 additions and 140 deletions
+18
View File
@@ -60,6 +60,24 @@ public sealed class InvoiceDraftSession
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
public List<ChangeHistoryEntry> History { get; } = new();
/// <summary>
/// Per-session lock. The editor fires patches, state fetches and previews concurrently for the
/// same token, and the payload (<see cref="Newtonsoft.Json.Linq.JArray"/>/<c>JObject</c>, the
/// history list) is not thread-safe — the draft service takes this lock around every read and
/// write of the payload, and hands out deep-cloned snapshots for anything serialised later.
/// </summary>
public object SyncRoot { get; } = new();
/// <summary>
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="InvId"/>,
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
/// </summary>
public SemaphoreSlim FlushGate { get; } = new(1, 1);
public void Touch() => LastAccessUtc = DateTime.UtcNow;
}
+1 -1
View File
@@ -1,4 +1,4 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
+17
View File
@@ -60,6 +60,23 @@ public sealed class ReminderDraftSession
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
public List<ChangeHistoryEntry> History { get; } = new();
/// <summary>
/// Per-session lock (see <see cref="InvoiceDraftSession.SyncRoot"/>): the draft service takes it
/// around every read and write of the non-thread-safe payload, so concurrent patch/state/preview
/// requests for the same token cannot interleave.
/// </summary>
public object SyncRoot { get; } = new();
/// <summary>
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="RemId"/>,
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
/// </summary>
public SemaphoreSlim FlushGate { get; } = new(1, 1);
public void Touch() => LastAccessUtc = DateTime.UtcNow;
}