@equinor/fusion-query
Version:
Reactive data fetching and caching library with observable streams and comprehensive event system
364 lines (325 loc) • 14.5 kB
text/typescript
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;