fix(ui): schema-library delete-audit name + busy guard + edit-row guard + sanitized create-race test (#260)

This commit is contained in:
Joseph Doherty
2026-06-19 03:28:11 -04:00
parent e3b83f8561
commit 8f85cce298
5 changed files with 121 additions and 11 deletions
@@ -93,10 +93,13 @@
<td>@(string.IsNullOrWhiteSpace(s.Scope) ? "—" : s.Scope)</td>
<td><code>lib:@s.Name</code></td>
<td class="text-end">
@* Row actions are disabled while the editor is open so the
row under edit (and its siblings) can't be deleted out from
under the form, and while a delete is in flight (_busy). *@
<button class="btn btn-outline-primary btn-sm me-1"
@onclick="() => BeginEdit(s)">Edit</button>
@onclick="() => BeginEdit(s)" disabled="@(_editing || _busy)">Edit</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteSchema(s)">Delete</button>
@onclick="() => DeleteSchema(s)" disabled="@(_editing || _busy)">Delete</button>
</td>
</tr>
}
@@ -208,21 +211,34 @@
private async Task DeleteSchema(SharedSchema schema)
{
// In-flight guard: an editor-open row action is already disabled in the markup,
// but the _busy gate is the authoritative guard against a double-invoked delete
// (and mirrors the Save path's guard).
if (_busy) return;
var confirmed = await Dialog.ConfirmAsync(
"Delete Schema",
$"Delete library schema '{schema.Name}'? References to lib:{schema.Name} will no longer resolve.",
danger: true);
if (!confirmed) return;
var result = await SchemaLibraryService.DeleteAsync(schema.Id);
if (result.Success)
_busy = true;
try
{
_toast.ShowSuccess($"Schema '{schema.Name}' deleted.");
await LoadAsync();
var result = await SchemaLibraryService.DeleteAsync(schema.Id);
if (result.Success)
{
_toast.ShowSuccess($"Schema '{schema.Name}' deleted.");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error ?? "Delete failed.");
}
}
else
finally
{
_toast.ShowError(result.Error ?? "Delete failed.");
_busy = false;
}
}
}
@@ -31,8 +31,11 @@ public sealed class SchemaLibraryQueryService : ISchemaLibraryQueryService
var repo = scope.ServiceProvider.GetRequiredService<ISharedSchemaRepository>();
var all = await repo.ListAsync(cancellationToken);
// Ordinal-keyed to match the lib:Name resolver's exact-name lookup. Last-wins on
// the (DB-unique) name guards against a transient duplicate read.
// Ordinal-keyed to match the lib:Name resolver's exact-name lookup. Name is
// DB-unique, so a list yields at most one row per name and no real collision
// occurs; the indexer assignment is defensive only — should two rows ever share
// a name (e.g. a mid-write transient read), the later one in enumeration order
// overwrites the earlier rather than throwing.
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var schema in all)
{
@@ -2323,8 +2323,13 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleDeleteSharedSchema(IServiceProvider sp, DeleteSharedSchemaCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
// Pre-fetch the human-readable name before the row is gone so the audit
// EntityName records "Address" rather than the numeric id — mirroring the
// Site delete handler. Falls back to the id when the row is already absent.
var schema = await repo.GetByIdAsync(cmd.SharedSchemaId);
await repo.DeleteAsync(cmd.SharedSchemaId);
await AuditAsync(sp, user, "Delete", "SharedSchema", cmd.SharedSchemaId.ToString(), cmd.SharedSchemaId.ToString(), null);
await AuditAsync(sp, user, "Delete", "SharedSchema", cmd.SharedSchemaId.ToString(),
schema?.Name ?? cmd.SharedSchemaId.ToString(), null);
return true;
}