132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
export type ProvisioningMutation = 'resume' | 'cancel';
|
|
|
|
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): 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 });
|
|
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 };
|
|
}
|