import { ref, type Ref } from 'vue' import { readErrorMessage as readSharedErrorMessage } from '../utils/errors' export type RequestSyncSuccessKind = 'load' | 'save' | 'run' /** * The unit of work `run()` drives. * * It receives an `AbortSignal` that fires when the run is superseded by a * newer `run()` or by `cancel()`. Zero-argument operations stay valid and * simply ignore it. */ export type RequestSyncOperation = (signal: AbortSignal) => Promise export interface RequestSyncRunOptions { success?: RequestSyncSuccessKind errorMessage?: string /** * Abort the previously started run when this one starts. Default `true`; * pass `false` to let overlapping runs finish their own work (the stale * guard on shared state still applies either way). */ abortPrevious?: boolean /** Caller-owned signal; aborting it aborts this run's signal too. */ signal?: AbortSignal } export interface UseRequestSyncStateReturn { /** Whether a request is in progress. */ loading: Ref /** Message from the last failed request, or null. */ error: Ref /** Timestamp of the last successful load operation, or null. */ lastLoadedAt: Ref /** Timestamp of the last successful save operation, or null. */ lastSavedAt: Ref /** Timestamp of the last successful run operation, or null. */ lastRunAt: Ref /** Clear the current error without changing loading or timestamps. */ clearError: () => void /** Convert thrown values into a developer-facing error message. */ readErrorMessage: (value: unknown, fallback?: string) => string /** Store and return a normalized error message. */ setError: (value: unknown, fallback?: string) => string /** Mark the resource as successfully loaded. */ markLoaded: (date?: Date) => void /** Mark the resource as successfully saved. */ markSaved: (date?: Date) => void /** Mark the operation as successfully run. */ markRun: (date?: Date) => void /** * Run a request with shared loading/error handling and optional sync marking. * * ## Stale-response semantics * * Every call takes a monotonically increasing token. Starting a run * supersedes any run started before it, and a superseded run writes **no** * shared state when it finally settles: it does not clear `loading`, does not * set `error`, and does not move `lastLoadedAt` / `lastSavedAt` / `lastRunAt`. * Only the newest run owns those. * * The caller's own promise is untouched by the guard — a superseded run still * resolves with its value or rejects with its own error, exactly as the * operation did. The guard never invents a rejection, so a caller awaiting * `run()` never sees a "superseded" error it did not cause. * * The operation additionally receives an `AbortSignal` that fires when its * run is superseded or `cancel()` is called, so an operation that forwards * the signal (`fetch(url, { signal })`) stops work instead of racing. */ run: (operation: RequestSyncOperation, options?: RequestSyncRunOptions) => Promise /** * Abort the in-flight run and drop `loading`. The aborted run's completion * is treated as stale and writes no state. */ cancel: () => void } /** Shared loading/error/timestamp state for generated plugin request helpers. */ export function useRequestSyncState( defaultErrorMessage = 'Request failed.', ): UseRequestSyncStateReturn { const loading = ref(false) const error = ref(null) const lastLoadedAt = ref(null) const lastSavedAt = ref(null) const lastRunAt = ref(null) function clearError(): void { error.value = null } function readErrorMessage(value: unknown, fallback = defaultErrorMessage): string { return readSharedErrorMessage(value, fallback) } function setError(value: unknown, fallback?: string): string { const message = readErrorMessage(value, fallback) error.value = message return message } function markLoaded(date = new Date()): void { lastLoadedAt.value = date } function markSaved(date = new Date()): void { lastSavedAt.value = date } function markRun(date = new Date()): void { lastRunAt.value = date } // Monotonic token guarding every write to the shared loading/error/timestamp // state. Overlapping runs settle in any order, and a superseded one must not // clear a newer run's loading flag or overwrite its error. let runToken = 0 let activeController: AbortController | null = null function cancel(): void { runToken += 1 activeController?.abort() activeController = null loading.value = false } async function run( operation: RequestSyncOperation, options: RequestSyncRunOptions = {}, ): Promise { if (options.abortPrevious !== false) { activeController?.abort() } const token = (runToken += 1) const controller = new AbortController() activeController = controller const callerSignal = options.signal if (callerSignal) { if (callerSignal.aborted) controller.abort(callerSignal.reason) else callerSignal.addEventListener( 'abort', () => controller.abort(callerSignal.reason), { once: true }, ) } const isCurrent = (): boolean => token === runToken loading.value = true clearError() try { const response = await operation(controller.signal) if (isCurrent()) { if (options.success === 'load') { markLoaded() } else if (options.success === 'save') { markSaved() } else if (options.success === 'run') { markRun() } } return response } catch (err) { if (isCurrent()) { setError(err, options.errorMessage) } throw err } finally { if (isCurrent()) { loading.value = false } if (activeController === controller) { activeController = null } } } return { loading, error, lastLoadedAt, lastSavedAt, lastRunAt, clearError, readErrorMessage, setError, markLoaded, markSaved, markRun, run, cancel, } }