feat(api): optimistic-concurrency contract for replace-all PUTs — PR1 infra + Block reference (#253)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Adds the shared optimistic-concurrency contract so a stale second tab can no longer
silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate;
PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472).

Contract
- `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule,
  Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection,
  RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration
  `AddAggregateVersions` (nullable:false, default 0).
- Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch →
  412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag.
- `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`;
  `ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent =
  Phase-1 force-write.

Block reference wiring
- Handler: standalone `Either` via `CheckVersion` AFTER validation (never through
  `Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`,
  `SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412).
- `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the
  ETag on GET items and on the successful PUT.
- SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock`
  If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412
  opens a blocking "changed elsewhere — reload" dialog.

Tests
- Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success
  + bump, no-op save still bumps, and a two-context racing save → 412; proven
  non-vacuous (drop `.IsConcurrencyToken()` → the race test fails).
- Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on
  GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog.

Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log.

Refs #253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:51:59 +02:00
co-authored by Claude Opus 4.8
parent 06c877b5fc
commit 94ebf34ccd
50 changed files with 15473 additions and 47 deletions
+30 -4
View File
@@ -128,6 +128,9 @@ public class BlockController(IMediator mediator) : ControllerBase
[HttpGet("/api/blocks/{id:int}/items")]
[Tags("Blocks")]
[EndpointSummary("Get block items")]
[EndpointDescription(
"Returns the block's items and a strong ETag of the block's version. Pass that ETag back as " +
"If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<BlockItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -139,6 +142,9 @@ public class BlockController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
// The items GET returns children, not the root, so read the block's version for the ETag.
ConcurrencyHeaders.SetETag(Response, block.Map(b => b.Version).IfNone(0));
List<BlockItemViewModel> items = await mediator.Send(new GetBlockItems(id), cancellationToken);
return new OkObjectResult(items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList());
}
@@ -148,16 +154,32 @@ public class BlockController(IMediator mediator) : ControllerBase
[EndpointSummary("Replace a block and its items")]
[EndpointDescription(
"Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned " +
"from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.")]
"from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours. " +
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
"a successful response carries the new ETag.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(BlockWithItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Replace(
int id,
[Required] [FromBody] ReplaceBlockRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return new BadRequestObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid If-Match header",
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
});
}
Option<BlockViewModel> maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken);
if (maybeBlock.IsNone)
{
@@ -167,7 +189,7 @@ public class BlockController(IMediator mediator) : ControllerBase
int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0);
Either<BaseError, Unit> result =
await mediator.Send(request.ToCommand(groupId, id), cancellationToken);
await mediator.Send(request.ToCommand(groupId, id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
@@ -176,8 +198,12 @@ public class BlockController(IMediator mediator) : ControllerBase
Option<BlockViewModel> refreshed = await mediator.Send(new GetBlockById(id), cancellationToken);
List<BlockItemViewModel> items = await mediator.Send(new GetBlockItems(id), cancellationToken);
return refreshed.Match(
Some: vm => (IActionResult)new OkObjectResult(
ProjectToWithItemsResponseModel(vm, items)),
Some: vm =>
{
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
ConcurrencyHeaders.SetETag(Response, vm.Version);
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
},
None: () => ApiResults.NotFoundProblem());
});
}
@@ -9,12 +9,13 @@ public record ReplaceBlockRequest(
BlockStopScheduling StopScheduling,
List<BlockItemRequest> Items)
{
public ReplaceBlockItems ToCommand(int blockGroupId, int blockId) =>
public ReplaceBlockItems ToCommand(int blockGroupId, int blockId, Option<int> expectedVersion = default) =>
new(
blockGroupId,
blockId,
Name,
Minutes,
StopScheduling,
(Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList());
(Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList(),
expectedVersion);
}
+18 -4
View File
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Extensions;
@@ -14,11 +15,24 @@ namespace ErsatzTV.Extensions;
[SuppressMessage("ReSharper", "VSTHRD003")]
public static class ApiResults
{
/// <summary>Maps a failure to 404 when it is a <see cref="NotFoundError" />, otherwise 422.</summary>
/// <summary>
/// Maps a failure to 404 when it is a <see cref="NotFoundError" />, 412 when it is a
/// <see cref="PreconditionFailedError" /> (optimistic-concurrency mismatch, issue #253),
/// otherwise 422.
/// </summary>
public static IActionResult ToErrorResult(this BaseError error) =>
error is NotFoundError
? new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", error.Value))
: new UnprocessableEntityObjectResult(CreateProblemDetails(422, "Validation failed", error.Value));
error switch
{
NotFoundError =>
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", error.Value)),
PreconditionFailedError =>
new ObjectResult(CreateProblemDetails(412, "Precondition Failed", error.Value))
{
StatusCode = StatusCodes.Status412PreconditionFailed
},
_ =>
new UnprocessableEntityObjectResult(CreateProblemDetails(422, "Validation failed", error.Value))
};
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToCreatedResult<TR>(
+63
View File
@@ -0,0 +1,63 @@
using LanguageExt;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Extensions;
/// <summary>Classification of a request's <c>If-Match</c> header for the #253 concurrency contract.</summary>
public enum IfMatchKind
{
/// <summary>No <c>If-Match</c> header — Phase 1 force-write (Phase 2 will make this a 428).</summary>
Absent,
/// <summary><c>If-Match: *</c> — the scripted force-write escape hatch; skip the version check.</summary>
Any,
/// <summary>A strong entity-tag of a decimal aggregate version, e.g. <c>"3"</c>.</summary>
Version,
/// <summary>An unparseable value — the controller returns 400.</summary>
Malformed
}
public readonly record struct IfMatchCondition(IfMatchKind Kind, int Version)
{
/// <summary>The version to check against, or <c>None</c> for absent/wildcard (force-write).</summary>
public Option<int> ExpectedVersion => Kind == IfMatchKind.Version ? Version : Option<int>.None;
}
/// <summary>
/// Parse/emit the optimistic-concurrency HTTP headers (issue #253). GET responses carry a strong
/// <c>ETag</c> of the aggregate's integer <c>Version</c>; PUT requests carry the last-seen version
/// in <c>If-Match</c>. See <c>docs/api-conventions.md</c> §7a.
/// </summary>
public static class ConcurrencyHeaders
{
public static IfMatchCondition ParseIfMatch(HttpRequest request)
{
StringValues raw = request.Headers.IfMatch;
if (StringValues.IsNullOrEmpty(raw))
{
return new IfMatchCondition(IfMatchKind.Absent, 0);
}
string value = raw.ToString().Trim();
if (value == "*")
{
return new IfMatchCondition(IfMatchKind.Any, 0);
}
// Strong entity-tag of a decimal version, e.g. "3". Weak tags (W/"…") are not honored:
// this contract's ETags are always strong.
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"' &&
int.TryParse(value[1..^1], out int version))
{
return new IfMatchCondition(IfMatchKind.Version, version);
}
return new IfMatchCondition(IfMatchKind.Malformed, 0);
}
public static void SetETag(HttpResponse response, int version) =>
response.Headers.ETag = $"\"{version}\"";
}
+42 -1
View File
@@ -565,7 +565,7 @@
"Blocks"
],
"summary": "Replace a block and its items",
"description": "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.",
"description": "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.",
"parameters": [
{
"name": "id",
@@ -623,6 +623,26 @@
}
}
},
"400": {
"description": "Bad Request",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
@@ -643,6 +663,26 @@
}
}
},
"412": {
"description": "Precondition Failed",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
@@ -672,6 +712,7 @@
"Blocks"
],
"summary": "Get block items",
"description": "Returns the block's items and a strong ETag of the block's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).",
"parameters": [
{
"name": "id",