import type { ApiFailure } from "../../contracts/errors.js"; export const ASYNC_BASE_STATES = Object.freeze([ "initial-loading", "success", "empty", "terminal-error", ] as const); export const ASYNC_OVERLAYS = Object.freeze([ "refreshing", "stale-degraded", "mutation-pending", "mutation-conflict", ] as const); export type AsyncOverlay = | Readonly<{ refreshing: false; staleDegraded: false; mutationPending: false; mutationConflict: false; }> | Readonly<{ refreshing: true; staleDegraded: false; mutationPending: false; mutationConflict: false; }> | Readonly<{ refreshing: false; staleDegraded: true; mutationPending: false; mutationConflict: false; }> | Readonly<{ refreshing: false; staleDegraded: false; mutationPending: true; mutationConflict: false; }> | Readonly<{ refreshing: false; staleDegraded: false; mutationPending: false; mutationConflict: true; }>; export type AsyncSignals = Readonly<{ data?: unknown; isInitialLoading?: boolean; failure?: ApiFailure; isFetching?: boolean; isStale?: boolean; isDegraded?: boolean; isMutationPending?: boolean; hasMutationConflict?: boolean; }>; export type AsyncState = Readonly<{ base: (typeof ASYNC_BASE_STATES)[number]; data?: unknown; failure?: ApiFailure; overlay: AsyncOverlay; indicator: (typeof ASYNC_OVERLAYS)[number] | null; }>; export function deriveAsyncState(signals: AsyncSignals): AsyncState { const hasData = signals.data !== undefined && signals.data !== null; const empty = hasData && ((Array.isArray(signals.data) && signals.data.length === 0) || signals.data === ""); const base = signals.isInitialLoading && !hasData ? "initial-loading" : signals.failure && !hasData ? "terminal-error" : empty ? "empty" : hasData ? "success" : "initial-loading"; const indicator = signals.hasMutationConflict && hasData ? "mutation-conflict" : signals.isMutationPending && hasData ? "mutation-pending" : signals.isStale && signals.isDegraded && hasData ? "stale-degraded" : signals.isFetching && hasData ? "refreshing" : null; const overlay = overlayFor(indicator); return Object.freeze({ base, data: base === "success" || base === "empty" ? signals.data : undefined, failure: base === "terminal-error" ? signals.failure : undefined, overlay, indicator, }); } export function selectOverlayIndicator( overlay: AsyncOverlay, ): AsyncState["indicator"] { if (overlay.mutationConflict) return "mutation-conflict"; if (overlay.mutationPending) return "mutation-pending"; if (overlay.staleDegraded) return "stale-degraded"; if (overlay.refreshing) return "refreshing"; return null; } function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { if (indicator === "refreshing") { return Object.freeze({ refreshing: true, staleDegraded: false, mutationPending: false, mutationConflict: false, }); } if (indicator === "stale-degraded") { return Object.freeze({ refreshing: false, staleDegraded: true, mutationPending: false, mutationConflict: false, }); } if (indicator === "mutation-pending") { return Object.freeze({ refreshing: false, staleDegraded: false, mutationPending: true, mutationConflict: false, }); } if (indicator === "mutation-conflict") { return Object.freeze({ refreshing: false, staleDegraded: false, mutationPending: false, mutationConflict: true, }); } return Object.freeze({ refreshing: false, staleDegraded: false, mutationPending: false, mutationConflict: false, }); }