feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { reportRequestOutcome } from "../offline/connectivity";
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 4_000; // GET and writes with Idempotency-Key
|
||||
export const NON_IDEMPOTENT_WRITE_TIMEOUT_MS = 15_000;
|
||||
export const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
export const DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function timeoutFor(method: string, headers: HeadersInit | undefined): number {
|
||||
if (method.toUpperCase() === "GET" || new Headers(headers).has("Idempotency-Key")) {
|
||||
return DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
return NON_IDEMPOTENT_WRITE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// Statuses that must not carry a body when a Response is rebuilt.
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
// Rejects as soon as the signal aborts, so a stalled stream (which ignores the
|
||||
// abort on its own) still ends in the abort reason.
|
||||
function rejectOnAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason);
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
||||
});
|
||||
}
|
||||
|
||||
// The timeout is a TOTAL deadline: it also covers receiving the body. The body
|
||||
// is buffered inside the deadline and handed back as a fresh Response, so every
|
||||
// caller can keep using json()/text()/blob() without re-arming anything.
|
||||
export async function fetchWithTimeout(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit & { timeoutMs: number },
|
||||
): Promise<Response> {
|
||||
const { timeoutMs, signal: callerSignal, ...rest } = init;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new DOMException("Zeitüberschreitung der Anfrage", "TimeoutError")),
|
||||
timeoutMs,
|
||||
);
|
||||
const forwardAbort = () => controller.abort(callerSignal?.reason);
|
||||
if (callerSignal?.aborted) {
|
||||
forwardAbort();
|
||||
} else {
|
||||
callerSignal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
}
|
||||
try {
|
||||
const response = await rejectOnAbort(
|
||||
fetch(input, { ...rest, signal: controller.signal }),
|
||||
controller.signal,
|
||||
);
|
||||
const body = NULL_BODY_STATUSES.has(response.status)
|
||||
? null
|
||||
: await rejectOnAbort(response.arrayBuffer(), controller.signal);
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
callerSignal?.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
// TypeError = fetch failed at network level; TimeoutError / AbortError = the
|
||||
// request was cut off. Never an ApiError (that means the server answered).
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof TypeError) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")
|
||||
);
|
||||
}
|
||||
|
||||
// fetchWithTimeout plus the connectivity signal: ANY HTTP answer (including
|
||||
// 502/503/504 from a proxy) counts as "reached-server"; a timeout or network
|
||||
// TypeError counts as "network-error". A caller-initiated AbortError
|
||||
// (unmount/cancel) says nothing about the network and is not reported.
|
||||
export async function fetchReportingOutcome(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit & { timeoutMs: number },
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const response = await fetchWithTimeout(input, init);
|
||||
reportRequestOutcome("reached-server");
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isNetworkError(error) && !(error instanceof DOMException && error.name === "AbortError")) {
|
||||
reportRequestOutcome("network-error");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user