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
+39
View File
@@ -8,6 +8,7 @@ import {
getBlock,
getBlockGroups,
getBlockItems,
getBlockItemsWithMeta,
getBlocks,
previewBlock,
replaceBlock,
@@ -119,6 +120,44 @@ describe('blocks api client', () => {
expect(body.items).toHaveLength(1);
});
it('getBlockItemsWithMeta returns the items and the ETag', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify([{ id: 1 }]), {
headers: { ETag: '"5"', 'Content-Type': 'application/json' },
status: 200
})
);
const result = await getBlockItemsWithMeta(4);
expect(result.etag).toBe('"5"');
expect(result.data).toHaveLength(1);
});
it('replaceBlock sends If-Match when an ETag is supplied and returns the new ETag', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 4, items: [] }), {
headers: { ETag: '"6"', 'Content-Type': 'application/json' },
status: 200
})
);
const result = await replaceBlock(4, sampleReplace, '"5"');
const [, init] = fetchMock.mock.calls[0];
expect(init?.headers).toMatchObject({ 'If-Match': '"5"' });
expect(result.etag).toBe('"6"');
});
it('replaceBlock omits If-Match when no ETag is supplied', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, items: [] }));
await replaceBlock(4, sampleReplace);
const [, init] = fetchMock.mock.calls[0];
expect(init?.headers).not.toHaveProperty('If-Match');
});
it('previewBlock POSTs to the preview route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await previewBlock(4, sampleReplace);
+20 -3
View File
@@ -1,4 +1,4 @@
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type BlockGroup = components['schemas']['BlockGroupResponseModel'];
@@ -49,8 +49,25 @@ export function getBlockItems(id: number): Promise<BlockItem[]> {
return request<BlockItem[]>(`/api/blocks/${id}/items`);
}
export function replaceBlock(id: number, body: ReplaceBlockRequest): Promise<BlockWithItems> {
return request<BlockWithItems>(`/api/blocks/${id}`, { body, method: 'PUT' });
/** Load block items together with the block's concurrency ETag (issue #253). */
export function getBlockItemsWithMeta(id: number): Promise<ResponseWithMeta<BlockItem[]>> {
return requestWithMeta<BlockItem[]>(`/api/blocks/${id}/items`);
}
/**
* Replace a block. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with 412;
* the resolved value carries the new ETag for a subsequent save (issue #253).
*/
export function replaceBlock(
id: number,
body: ReplaceBlockRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<BlockWithItems>> {
return requestWithMeta<BlockWithItems>(`/api/blocks/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function previewBlock(id: number, body: ReplaceBlockRequest): Promise<BlockPreviewItem[]> {
+37 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { setStoredApiKey } from './auth';
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta } from './client';
describe('API request client', () => {
beforeEach(() => {
@@ -94,4 +94,40 @@ describe('API request client', () => {
})
);
});
it('requestWithMeta returns the response ETag alongside the parsed body', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify([{ id: 1 }]), {
headers: { ETag: '"7"', 'Content-Type': 'application/json' },
status: 200
})
);
const result = await requestWithMeta<{ id: number }[]>('/api/blocks/1/items');
expect(result.etag).toBe('"7"');
expect(result.data).toEqual([{ id: 1 }]);
});
it('requestWithMeta returns a null ETag when the response has none', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
const result = await requestWithMeta('/api/blocks/1/items');
expect(result.etag).toBeNull();
});
it('requestWithMeta surfaces the ETag on a 204 (no body) response', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { headers: { ETag: '"3"' }, status: 204 }));
const result = await requestWithMeta('/api/blocks/1', { method: 'PUT' });
expect(result.data).toBeUndefined();
expect(result.etag).toBe('"3"');
});
});
+25 -4
View File
@@ -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> {