Files
ersatztv/web/src/api/rerunCollections.ts
T
timothyandClaude Opus 5 e605e4006a fix(651): review round 7 — treat "no usable token" as one class, not three values
HIGH: the fail-closed gate rejected `null` but not the adjacent values. `Headers.get('ETag')`
returns `''` for an empty or whitespace-only header, which PASSED the gate and produced an
editable draft; `updateRerunCollection`'s `ifMatch ? … : undefined` then dropped the empty
string as falsy and sent no `If-Match`, silently overwriting a collaborator — the exact class
the gate exists to make unreachable, reached through the value next door. Absent, empty and
whitespace are now one case ("no usable concurrency token"), normalized by a single
`usableEtag` helper that returns the TRIMMED token or null, so `etagRef` can only ever hold
something that will actually be sent. Tested across four blank shapes asserting zero PUTs are
reachable, plus a padded ETag that must be trimmed and USED rather than dropped.

MEDIUM: the deadline abandoned the wait without cancelling the work, so each Retry stacked
another live connection. It now aborts via an AbortSignal (threaded through
`getRerunCollectionWithMeta`) AND clears its timer on settlement and unmount. Both halves are
kept deliberately: aborting cancels the work, while the rejected race stops the UI waiting
even if the abort never propagates — cancellation and giving-up are not the same guarantee,
which the late-settlement test proves by using a stub that ignores its signal.

MEDIUM: `Number.isFinite` accepted ids the API cannot bind — `1.5` and values outside int32
rendered, committed through `onSelect`, and would fail server-side on `selectedId`. Validated
as an int32 integer.

MEDIUM: a malformed or failed page was reported as "No matches", telling the user the library
is empty when the request actually failed and giving no hint that reopening retries. Failures
now surface as a distinct alert.

MEDIUM: `spa-conventions.md` still mandated the deleted "never let a refresh clear an id it
failed to name" guard and said "the client guard stays" — contradicting the initialize-once
bullet 20 lines below it. Rewritten to state that the guard is gone and must not be rebuilt,
with the reason (it only ever preserved a list-seeded value that is null in production).
Grepping the DELETED TERMS across all docs — the lesson from round 6's stale `rule:` — also
caught two stale `signals:` tokens on the record that the rule fix had missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00

85 lines
2.8 KiB
TypeScript

import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type RerunCollection = components['schemas']['RerunCollectionResponseModel'];
export type PagedRerunCollections = components['schemas']['PagedRerunCollectionsResponseModel'];
export type CreateRerunCollectionRequest = components['schemas']['CreateRerunCollectionRequest'];
export type UpdateRerunCollectionRequest = components['schemas']['UpdateRerunCollectionRequest'];
export interface GetRerunCollectionsParams {
query?: string;
pageNum?: number;
pageSize?: number;
}
export function getRerunCollections(params: GetRerunCollectionsParams = {}): Promise<PagedRerunCollections> {
const searchParams = new URLSearchParams();
if (params.query != null) {
searchParams.set('query', params.query);
}
if (params.pageNum != null) {
searchParams.set('pageNum', String(params.pageNum));
}
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
const queryString = searchParams.toString();
return request<PagedRerunCollections>(`/api/v1/rerun-collections${queryString ? `?${queryString}` : ''}`);
}
export function getRerunCollection(id: number): Promise<RerunCollection> {
return request<RerunCollection>(`/api/v1/rerun-collections/${id}`);
}
/** Load a single rerun collection together with its concurrency ETag (issue #253). */
export function getRerunCollectionWithMeta(
id: number,
signal?: AbortSignal
): Promise<ResponseWithMeta<RerunCollection>> {
return requestWithMeta<RerunCollection>(`/api/v1/rerun-collections/${id}`, { signal });
}
export function createRerunCollection(body: CreateRerunCollectionRequest): Promise<RerunCollection> {
return request<RerunCollection>('/api/v1/rerun-collections', { body, method: 'POST' });
}
/**
* Update a rerun collection. 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 updateRerunCollection(
id: number,
body: UpdateRerunCollectionRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<RerunCollection>> {
return requestWithMeta<RerunCollection>(`/api/v1/rerun-collections/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function deleteRerunCollection(id: number): Promise<void> {
return request<void>(`/api/v1/rerun-collections/${id}`, { method: 'DELETE' });
}
export function messageFromRerunCollectionError(
error: unknown,
fallback = 'Unable to load rerun collections'
): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}