export type ProvisioningMutation = 'resume' | 'cancel'; export interface ProvisioningRefreshState { id: string; detail: T | null; refreshing: boolean; busy: ProvisioningMutation | null; loadError: string; actionError: string; notice: string; refreshedAt: string | null; } interface Options { onChange: (state: ProvisioningRefreshState) => 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(options: Options) { 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; timer?: ReturnType } | null = null; let mutation: AbortController | null = null; let state: ProvisioningRefreshState = { id: '', detail: null, refreshing: false, busy: null, loadError: '', actionError: '', notice: '', refreshedAt: null }; const publish = (patch: Partial>) => { 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 { 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 | 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): Promise { 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 }); 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 }; }