UNPKG

@equinor/fusion-query

Version:

Reactive data fetching and caching library with observable streams and comprehensive event system

364 lines (325 loc) 14.5 kB
import { firstValueFrom, Observable, ReplaySubject, Subject, Subscription } from 'rxjs'; import { filter, map, share, withLatestFrom } from 'rxjs/operators'; import { FlowSubject } from '@equinor/fusion-observable'; import { filterAction } from '@equinor/fusion-observable/operators'; import actions, { type ActionMap, type Actions } from './actions'; import { handleRequests, handleExecution, handleFailure } from './flows'; import { QueryClientError } from './QueryClientError'; import createReducer from './reducer'; import type { QueryClientState, RetryOptions, QueryFn, QueryClientResult, QueryClientRequest, } from './types'; import { QueryClientJob } from './QueryClientJob'; import { QueryClientEvent, type QueryClientEventData, type QueryClientEvents } from './events'; /** * Options for configuring the behavior of the `QueryClient`. * * @property {AbortSignal} [signal] - An instance of AbortSignal to cancel the request. [optional] * @property {Partial<RetryOptions>} [retry] - Configuration for retry behavior, including number of retries and delay strategy. * @property {string} [ref] - A reference string to associate with a query, which can be used for cancellation or tracking purposes. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type QueryClientOptions = { signal?: AbortSignal; retry?: Partial<RetryOptions>; /** Reference to a query for tracking or cancellation. */ ref?: string; }; export type QueryClientCtorOptions = { /** Configuration for retry behavior, including number of retries and delay strategy. */ retry?: Partial<RetryOptions>; }; /** * QueryClient is a generic class that manages the state of asynchronous queries * and their lifecycle. It extends Observable to allow subscribers to react to state changes. * * It uses a FlowSubject to manage a stream of actions that describe the state changes and * side effects such as logging. The internal state is managed by a reducer function that * processes these actions. * * The QueryClient provides several specialized Observables (success$, error$, etc.) to * observe particular aspects of the query lifecycle, such as successful results or errors. * * @template TType The type of the data returned by the query function. * @template TArgs The type of the arguments passed to the query function. */ export class QueryClient<TType, TArgs> extends Observable<QueryClientState<TArgs>> { /** Internal state managed by a FlowSubject. */ #state: FlowSubject<QueryClientState<TArgs>, Actions>; /** Subscription to manage cleanup of resources on completion. */ #subscription: Subscription; /** * Events subject for emitting QueryClient lifecycle events. * * This subject broadcasts events that track query execution stages, * allowing external observers to monitor query progress, success, and failures. */ #events: Subject<QueryClientEvent>; /** * Protected method to emit QueryClient lifecycle events. * * This method creates and emits events that track the various stages of query execution, * allowing external observers to monitor query progress, success, failure, and cancellation. * * @template TType - The specific event type from QueryClientEvents * @param type - The event type identifier * @param transaction - Unique transaction identifier for the query operation * @param data - Optional event-specific data payload (type-safe based on event type) * @protected */ protected _registerEvent<TType extends keyof QueryClientEvents>( type: TType, transaction: string, data?: QueryClientEventData<TType>, ): void { this.#events.next(new QueryClientEvent(type, transaction, data)); } /** * Indicates whether the QueryClient has been closed and is no longer accepting new actions. * @returns {boolean} A boolean value indicating if the state is closed. */ public get closed(): boolean { return this.#state.closed; } /** * An Observable of the current state. It emits the current state of the query client * whenever it changes. * @returns {Observable<QueryClientState<TArgs>>} An Observable stream of the current state. */ public get state$(): Observable<QueryClientState<TArgs>> { return this.#state.asObservable(); } /** * An Observable stream of dispatched actions. It allows subscribers to react to * specific actions that are dispatched in the system. * @returns {Observable<Actions<TType, TArgs>>} An Observable stream of actions. */ public get action$(): Observable<Actions<TType, TArgs>> { return this.#state.action$; } /** * An Observable stream of QueryClient lifecycle events. * * This stream emits events that track the various stages of query execution, * including job requested, executing, completed, failed, canceled, and error events. * Subscribers can monitor query progress and handle different lifecycle events. * * @returns An Observable stream of QueryClient events */ public get event$(): Observable<QueryClientEvent> { return this.#events.asObservable(); } /** * An Observable stream of successful query results. It emits the result of a query * when it completes successfully. * @returns {Observable<TType>} An Observable stream of successful results. */ public get success$(): Observable<TType> { return this.action$.pipe( filter(actions.execute.success.match), map(({ payload }) => payload as TType), ); } /** * An Observable stream of query errors. It emits an instance of QueryClientError * when a query fails to complete. * @returns {Observable<QueryClientError>} An Observable stream of query errors. */ public get error$(): Observable<QueryClientError<TArgs>> { return this.action$.pipe( filterAction('client/error'), withLatestFrom(this.#state), map(([action, state]) => { const { payload, meta: { transaction }, } = action; const request = state[transaction]; return new QueryClientError('error', { request, message: `job: ${transaction} failed`, cause: payload, }); }), ); } /** * Creates an instance of QueryClient. * @param {QueryFn<TType, TArgs>} queryFn A function that performs the asynchronous query. * @param {QueryClientCtorOptions} [options] Optional configuration options for the QueryClient. */ constructor(queryFn: QueryFn<TType, TArgs>, options?: QueryClientCtorOptions) { super((subscriber) => { return this.#state.subscribe(subscriber); }); this.#state = new FlowSubject(createReducer({})); // Set up subscription for cleanup, ensuring both state and events subjects are completed this.#subscription = new Subscription(() => { this.#state.complete(); this.#events.complete(); }); // Initialize events subject for broadcasting lifecycle events this.#events = new Subject<QueryClientEvent>(); // Add flows to handle different aspects of the query lifecycle. this.#subscription.add(this.#state.addFlow(handleRequests)); this.#subscription.add(this.#state.addFlow(handleExecution(queryFn))); this.#subscription.add( this.#state.addFlow(handleFailure(Object.assign({ count: 0, delay: 0 }, options?.retry))), ); /** * Register effects to emit lifecycle events for different query actions. * * These effects monitor the internal state changes and emit corresponding events * that external observers can subscribe to for tracking query progress. */ // Emit event when a query request is initiated this.#state.addEffect(actions.request.type, ({ payload, meta: { transaction } }) => { this._registerEvent('query_client_job_requested', transaction, payload); }); // Emit event when query execution begins this.#state.addEffect(actions.execute.type, ({ meta: { transaction } }) => { this._registerEvent('query_client_job_executing', transaction); }); // Emit event when query execution completes successfully this.#state.addEffect(actions.execute.success.type, ({ payload, meta: { transaction } }) => { this._registerEvent('query_client_job_completed', transaction, { payload }); }); // Emit event when query execution fails this.#state.addEffect(actions.execute.failure.type, ({ payload, meta: { transaction } }) => { const error = payload.error instanceof Error ? payload.error : new Error(String(payload.error)); this._registerEvent('query_client_job_failed', transaction, { error }); }); // Emit event when a query is canceled this.#state.addEffect(actions.cancel.type, ({ payload: { reason }, meta: { transaction } }) => { this._registerEvent('query_client_job_canceled', transaction, { reason: reason || 'no reason provided', }); }); // Emit event when a general error occurs during query processing this.#state.addEffect(actions.error.type, ({ payload, meta: { transaction } }) => { const error = payload.error instanceof Error ? payload.error : new Error(String(payload.error)); this._registerEvent('query_client_job_error', transaction, { error }); }); } /** * Dispatches an action to the internal state subject, which will be processed by the reducer * and any registered effects. * @param {Actions} action The action to dispatch. */ public next(action: Actions): void { this.#state.next(action); } /** * Initiates a query with the provided arguments and options. It returns a QueryClientJob * which is an Observable that represents the lifecycle of the query. * @param {TArgs} args Call arguments for the query function. * @param {Partial<QueryClientOptions>} [opt] Query options. * @returns {QueryClientJob<TType, TArgs>} The QueryClientJob representing the initiated query. */ public query(args: TArgs, opt?: Partial<QueryClientOptions>): QueryClientJob<TType, TArgs> { const job = new QueryClientJob(this, args, opt); const job$ = job.pipe( share({ connector: () => new ReplaySubject(), resetOnRefCountZero: false }), ); Object.defineProperties(job$, { transaction: { get: () => job.transaction }, created: { get: () => job.created }, status: { get: () => job.status }, closed: { get: () => job.closed }, cancel: { value: (reason: string) => job.cancel(reason) }, complete: { value: () => job.complete() }, }); return job$ as unknown as QueryClientJob<TType, TArgs>; } /** * Initiates a query and returns a promise that resolves with the result of the query. * It is a convenience method that wraps the query method for use with async/await syntax. * @param {TArgs} args Call arguments for the query function. * @param {QueryClientOptions} [opt] Query options. * @returns {Promise<QueryClientResult<TType>>} A promise that resolves with the result of the query. */ public async nextAsync(args: TArgs, opt?: QueryClientOptions): Promise<QueryClientResult<TType>> { return firstValueFrom(this.query(args, opt)); } /** * Retrieves the request associated with the specified transaction. * * @param transaction - The transaction identifier. * @returns The request associated with the specified transaction. */ public getRequest(transaction: string): QueryClientRequest<TArgs> | undefined { return this.#state.value[transaction]; } /** * Retrieves the request associated with the specified reference string. * * @param ref - The reference identifier to search for. * @returns The request matching the reference, or `undefined` if not found. */ public getRequestByRef(ref: string): QueryClientRequest<TArgs> | undefined { return Object.values(this.#state.value).find((x) => x.ref === ref); } /** * Cancels a task by its reference string. If a reason is provided, it will be included * in the cancellation effect. * @param {string} ref The reference string of the task to cancel. * @param {string} [reason] The reason for cancellation. */ public cancelTaskByRef(ref: string, reason?: string): void { const entry = this.getRequestByRef(ref); if (entry?.ref) { this.cancel(entry.transaction, reason); } } /** * Cancels a query. If a transaction id is provided, it cancels the specific query. * Otherwise, it cancels all queries. * @param {string} [transaction] The transaction id of the query to cancel. * @param {string} [reason] The reason for cancellation. */ public cancel(transaction?: string, reason?: string): void { if (!transaction) { // If no specific transaction is provided, iterate through all transactions // in the state and cancel each one, applying a generic cancellation reason. for (const key of Object.keys(this.#state.value)) { this.cancel(key, 'all transactions requested canceled'); } } else if (this.#state.value[transaction]) { // If a specific transaction is provided and it exists in the state, // dispatch a cancellation action with an optional reason or a default message. reason ??= `cancelation requested for job: ${transaction}`; this.#state.next(actions.cancel(transaction, reason)); } } /** * Registers a callback to be executed when an action of the specified type is dispatched. * It allows for custom behavior to be defined in response to specific actions. * @template TAction The type of the action to listen for. * @param {TAction} type The type of the action. * @param {(action: ActionMap[TAction], subject: QueryClient<TType, TArgs>) => void} cb The callback to execute. * @returns {Subscription} A Subscription that can be used to unregister the effect. */ public on<TAction extends keyof ActionMap>( type: TAction, cb: (action: ActionMap[TAction], subject: QueryClient<TType, TArgs>) => void, ) { return this.#state.addEffect(actions[type].type, (action) => { cb(action as ActionMap[TAction], this); }); } /** * Completes the QueryClient's internal state subject, effectively cleaning up all * resources and subscriptions. After calling complete, the QueryClient will no longer * accept new actions or emit new state changes. */ public complete() { this.#subscription.unsubscribe(); } } export default QueryClient;