UNPKG

@equinor/fusion-query

Version:
230 lines 10.7 kB
import { firstValueFrom, Observable, ReplaySubject, 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 from './actions'; import { handleRequests, handleExecution, handleFailure } from './flows'; import { QueryClientError } from './QueryClientError'; import createReducer from './reducer'; import { ConsoleLogger } from '@equinor/fusion-log'; import { QueryClientJob } from './QueryClientJob'; /** * 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 extends Observable { /** Internal state managed by a FlowSubject. */ #state; /** Subscription to manage cleanup of resources on completion. */ #subscription; /** Logger instance for outputting debug and error information. */ #logger; /** * 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. */ get closed() { 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. */ get state$() { 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. */ get action$() { return this.#state.action$; } /** * 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. */ get success$() { return this.action$.pipe(filter(actions.execute.success.match), map(({ payload }) => payload)); } /** * 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. */ get error$() { 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, options) { super((subscriber) => { return this.#state.subscribe(subscriber); }); this.#state = new FlowSubject(createReducer({})); this.#subscription = new Subscription(() => this.#state.complete()); // 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)))); // Initialize the logger or use the provided one. this.#logger = options?.logger ?? new ConsoleLogger('QueryClient'); // Register effects to log different actions. this.#state.addEffect(actions.request.type, ({ payload, meta: { transaction } }) => { this.#logger.debug('Job requested', { transaction, payload }); }); this.#state.addEffect(actions.execute.type, ({ payload, meta: { transaction } }) => { this.#logger.debug('Job executing', { transaction, payload }); }); this.#state.addEffect(actions.execute.success.type, ({ payload }) => { this.#logger.info('Job complete', { ...payload }); }); this.#state.addEffect(actions.execute.failure.type, ({ payload, meta: { transaction } }) => { this.#logger.debug('Job failed', { transaction, payload }); }); this.#state.addEffect(actions.cancel.type, ({ payload: { reason }, meta: { transaction } }) => { this.#logger.debug('Job canceled', { transaction, reason }); }); this.#state.addEffect(actions.error.type, ({ payload: { transaction, error } }) => { this.#logger.error('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. */ next(action) { 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. */ query(args, opt) { 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) => job.cancel(reason) }, complete: { value: () => job.complete() }, }); return job$; } /** * 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. */ async nextAsync(args, opt) { 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. */ getRequest(transaction) { return this.#state.value[transaction]; } getRequestByRef(ref) { 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. */ cancelTaskByRef(ref, reason) { 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. */ cancel(transaction, reason) { 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)); } else { // If a specific transaction is provided but it does not exist in the state, // log a warning indicating that there is nothing to cancel. this.#logger.warn(`Task not registered, nothing to 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. */ on(type, cb) { return this.#state.addEffect(actions[type].type, (action) => { cb(action, 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. */ complete() { this.#subscription.unsubscribe(); } } export default QueryClient; //# sourceMappingURL=QueryClient.js.map