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