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:
+25
-4
@@ -24,10 +24,21 @@ export interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
|
||||
const mutatingMethods = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
|
||||
|
||||
export async function request<TResponse = unknown>(
|
||||
export interface ResponseWithMeta<T> {
|
||||
data: T;
|
||||
/** Strong ETag of the resource's version, when the endpoint emits one (issue #253). */
|
||||
etag: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link request} but also returns the response's ETag. Used by the optimistic-concurrency
|
||||
* editors: read the ETag from the load GET, send it back as `If-Match` on the replace PUT, and
|
||||
* replace it from the PUT response's ETag on every successful save (issue #253 / spa-conventions).
|
||||
*/
|
||||
export async function requestWithMeta<TResponse = unknown>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<TResponse> {
|
||||
): Promise<ResponseWithMeta<TResponse>> {
|
||||
const method = (options.method ?? 'GET').toUpperCase();
|
||||
const headers = normalizeHeaders({
|
||||
Accept: 'application/json',
|
||||
@@ -52,11 +63,21 @@ export async function request<TResponse = unknown>(
|
||||
throw new ApiError(response.status, await readProblemDetails(response));
|
||||
}
|
||||
|
||||
const etag = response.headers.get('ETag');
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as TResponse;
|
||||
return { data: undefined as TResponse, etag };
|
||||
}
|
||||
|
||||
return await readJsonResponse(response) as TResponse;
|
||||
return { data: (await readJsonResponse(response)) as TResponse, etag };
|
||||
}
|
||||
|
||||
export async function request<TResponse = unknown>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<TResponse> {
|
||||
const { data } = await requestWithMeta<TResponse>(path, options);
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
|
||||
Reference in New Issue
Block a user