Files
electric-cherry/src/lib/ui/provisioning-refresh.ts
T

133 lines
6.1 KiB
TypeScript

export type ProvisioningMutation = 'resume' | 'cancel' | 'cancel-creation' | 'override';
export interface ProvisioningRefreshState<T> {
id: string;
detail: T | null;
refreshing: boolean;
busy: ProvisioningMutation | null;
loadError: string;
actionError: string;
notice: string;
refreshedAt: string | null;
}
interface Options<T> {
onChange: (state: ProvisioningRefreshState<T>) => void;
validate: (value: unknown, id: string) => value is T;
fetcher?: typeof fetch;
now?: () => string;
timeoutMs?: number;
}
/** Reads and action feedback belong to a particular visit, even when its ID is revisited. */
export function createProvisioningRefresh<T>(options: Options<T>) {
const request = options.fetcher ?? fetch;
const now = options.now ?? (() => new Date().toISOString());
let visit = 0;
let read = 0;
let disposed = false;
let pending: { controller: AbortController; promise: Promise<void>; timer?: ReturnType<typeof setTimeout> } | null = null;
let mutation: AbortController | null = null;
let state: ProvisioningRefreshState<T> = {
id: '', detail: null, refreshing: false, busy: null, loadError: '', actionError: '', notice: '', refreshedAt: null
};
const publish = (patch: Partial<ProvisioningRefreshState<T>>) => {
state = { ...state, ...patch };
if (!disposed) options.onChange(state);
};
function invalidateRead() {
read++;
if (pending) {
clearTimeout(pending.timer);
pending.controller.abort();
pending = null;
}
}
async function load(force = false, reconcile = false): Promise<void> {
if (disposed || !state.id || (state.busy && !reconcile)) return;
if (pending && !force) return pending.promise;
invalidateRead();
const currentVisit = visit;
const currentRead = read;
const id = state.id;
const current = () => !disposed && currentVisit === visit && currentRead === read && id === state.id;
const controller = new AbortController();
const entry = { controller, promise: Promise.resolve(), timer: undefined as ReturnType<typeof setTimeout> | undefined };
let timedOut = false;
pending = entry;
publish({ refreshing: true });
entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
entry.promise = (async () => {
try {
const response = await request(`/api/provisioning/${encodeURIComponent(id)}?events=recent`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error(response.status === 404 ? 'Provisioning operation not found.' : 'Could not refresh this operation.');
const detail: unknown = await response.json();
if (!options.validate(detail, id)) throw new Error('The operation response was incomplete. Try refreshing again.');
if (current()) publish({ detail, loadError: '', refreshedAt: now() });
} catch (error) {
if (current() && (!controller.signal.aborted || timedOut)) publish({
loadError: timedOut ? 'Operation refresh timed out. Try again.' : error instanceof Error ? error.message : 'Could not refresh this operation.'
});
} finally {
clearTimeout(entry.timer);
if (pending === entry) pending = null;
if (current()) publish({ refreshing: false });
}
})();
return entry.promise;
}
function setOperation(id: string, detail: T | null = null, refreshedAt: string | null = null) {
if (disposed) return;
visit++;
invalidateRead();
mutation?.abort();
mutation = null;
publish({ id, detail, refreshedAt, refreshing: false, busy: null, loadError: '', actionError: '', notice: '' });
if (!detail) void load();
}
async function act(action: ProvisioningMutation, successMessage: string, payload?: Record<string, unknown>): Promise<void> {
if (disposed || !state.id || state.busy) return;
const currentVisit = visit;
const id = state.id;
const current = () => !disposed && currentVisit === visit && id === state.id;
invalidateRead();
const controller = new AbortController();
mutation = controller;
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
publish({ busy: action, refreshing: false, actionError: '', notice: '' });
try {
const response = await request(`/api/provisioning/${encodeURIComponent(id)}/${action}`, { method: 'POST', signal: controller.signal,
...(payload ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } : {}) });
const body = await response.json() as { error?: string };
if (!response.ok) throw new Error(body.error ?? 'The operation changed before this request could be accepted.');
if (current()) publish({ notice: successMessage });
} catch (error) {
if (current() && (!controller.signal.aborted || timedOut)) publish({ actionError: timedOut
? 'The request timed out and may have reached the server. Review the refreshed operation before trying again.'
: error instanceof Error ? error.message : 'Could not confirm the request. Review the operation before trying again.' });
} finally {
clearTimeout(timer);
if (mutation === controller) mutation = null;
// A failed reconciliation is a read error, never a failed accepted mutation.
if (current()) {
await load(true, true);
if (current()) publish({ busy: null });
}
}
}
function dispose() {
disposed = true;
visit++;
invalidateRead();
mutation?.abort();
mutation = null;
}
return { setOperation, refresh: () => load(), forceRefresh: () => load(true), act, dispose };
}