UNPKG

@equinor/fusion-query

Version:

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

354 lines (353 loc) 21.5 kB
import { Observable } from 'rxjs'; import { QueryClient, type QueryClientCtorOptions } from './client'; import { QueryCache, type QueryCacheMutation, type QueryCacheRecord } from './cache'; import type { CacheOptions, QueryFn, QueryOptions, QueryQueueFn, QueryTaskCached, QueryTaskCompleted } from './types'; import type { QueryCacheCtorArgs } from './cache/QueryCache'; import { QueryEvent, type IQueryEvent, type QueryEvents } from './events'; /** * Defines the constructor options for a QueryClient object. * It includes a query function and optional additional options for the client. * * @template TDataType - The type of the data returned by the query. * @template TQueryArguments - The type of the arguments passed to the query function. * * @property fn - The query function to be used for fetching data. * @property options - Optional additional options for the client. * * @see {@link QueryClient} - The QueryClient class. * @see {@link QueryClientCtorOptions} - The constructor options for the QueryClient class. */ interface QueryClientOptions<TDataType, TQueryArguments> { fn: QueryFn<TDataType, TQueryArguments>; options?: QueryClientCtorOptions; } /** * Defines the constructor options for a Query object. * * @template TDataType - The type of the data returned by the query. * @template TQueryArguments - The type of the arguments passed to the query function. * * @property client - The client instance or configuration to be used for fetching data. * @property key - A function that generates a unique key for caching query results based on the arguments. * @property cache - The cache instance or configuration to be used for caching query results. * @property validate - A function that validates a cache entry before it is used. * @property expire - The number of milliseconds before a cache entry expires. * @property queue - The queueing strategy to use for the query. * @property logger - The logger instance to use for logging events and operations within the Query class. * * @see {@link QueryClient} - The QueryClient class. * @see {@link CacheOptions} - The options for the cache instance. * @see {@link QueryCache} - The QueryCache class. * @see {@link QueueOperatorType} - The type of queueing strategy. * @see {@link QueryQueueFn} - The type of the queueing strategy function. */ export type QueryCtorOptions<TDataType, TQueryArguments> = { /** * The client instance or configuration to be used for fetching data. * It can either be an instance of QueryClient or an object with a 'fn' property * that is a query function and an optional 'options' object for additional QueryClient options. */ client: QueryClient<TDataType, TQueryArguments> | QueryClientOptions<TDataType, TQueryArguments>; /** * A function that generates a unique key for caching query results based on the arguments. * This key is used to store and retrieve cache entries. */ key: CacheOptions<TDataType, TQueryArguments>['key']; /** * An optional function to validate cache entries. It receives a cache entry and the arguments * and returns a boolean indicating whether the cache entry is still valid. */ validate?: CacheOptions<TDataType, TQueryArguments>['validate']; /** * An optional instance of QueryCache or constructor arguments for creating a new QueryCache instance. * If not provided, a new QueryCache will be created with default options. */ cache?: QueryCache<TDataType, TQueryArguments> | QueryCacheCtorArgs<TDataType, TQueryArguments>; /** * The expiration time of the cache in milliseconds. * If undefined or 0, caching is disabled. * This attribute is used only when the 'validate' function is not provided. */ expire?: number; /** * Queue strategies determine how multiple concurrent query requests are handled. * * - 'switch': This strategy cancels the current active request when a new request comes in. * Only the result from the latest request will be returned. This is useful when only the latest data is relevant. * * - 'merge': With this strategy, multiple requests can run in parallel without canceling each other. * All responses will be returned as they arrive. This is useful when all requests need to be resolved, * regardless of the order in which they were initiated. * * - 'concat': This strategy queues requests and executes them one after another in a sequential manner. * A new request will only start after the previous one has completed. This is useful when the order of * execution is important and each request must be completed before the next begins. */ queueOperator?: QueueOperatorType | QueryQueueFn<TDataType, TQueryArguments>; }; /** * A type alias for the predefined operator types that can be used to control query request queuing. * These operator types are used to define different strategies for handling concurrent query requests. */ type QueueOperatorType = 'switch' | 'merge' | 'concat'; /** * The primary use case for `Query` involves: * - Asynchronous Data Fetching: Seamlessly fetching data from APIs or databases asynchronously without blocking the UI, improving the user experience. * - Caching: Storing fetched data in a cache to improve performance by reducing the number of redundant requests to the server. * - Automatic Updates: Automatically updating the UI when the underlying data changes, without requiring explicit refresh actions from the user. * - Concurrent Requests Management: Efficiently handling multiple, concurrent data fetches through strategies like merging, switching, or concatenating requests. * - Retry and Error Handling: Automatically retrying failed requests and handling errors gracefully to ensure application stability. * * ## Benefits * * Using a `Query` mechanism offers numerous benefits, including: * 1. Improved Performance and Efficiency: By caching responses and reducing unnecessary server requests, applications load faster and use fewer resources, both on the client and server side. * 2. Simplified Data Fetching Logic: It abstracts away the boilerplate code associated with fetching data, handling errors, and managing response states, leading to cleaner and more maintainable code. * 3. Automatic Synchronization: `Query` libraries often come with features to automatically refetch data on certain triggers (e.g., window focus), ensuring the UI is always up-to-date with the latest server state without manual intervention. * 4. Built-in Asynchronous Management: Handling asynchronous data fetches becomes straightforward, with built-in support for loading states, error handling, and data updates. * 5. Scalability: Easily scalable for complex applications, supporting various fetching strategies to manage multiple data sources, endpoints, and concurrent requests effectively. * 6. Developer Experience: By standardizing the approach to data fetching and state management, it enhances developer experience, reducing the cognitive load and making it easier to onboard new developers. * 7. Robust Error and Retry Handling: Features to automatically retry requests and sophisticated mechanisms for error handling improve application reliability. * 8. Customizable and Extendable: While offering sensible defaults for most use cases, `Query` implementations are usually highly customizable, allowing developers to tailor their behavior for specific needs, such as custom caching strategies, query deduplication, and more. * * ## Examples * Example of creating a basic query with a query function: * @example * ```typescript * import { Query, QueryFn } from '@equinor/fusion-query'; * * type ExampleData = { * id: string; * name: string; * value: number; * } * * // create a query function * const queryFn: QueryFn<ExampleData, {id: string}> = async (args, signal) => { * const response = await fetch(`https://api.example.com/data?id=${args.id}`, {signal}); * return response.json(); * }; * * const queryOptions = QueryCtorOptions<ExampleData, {id: string}> = { * client: { fn: queryFn }, * key: (args) => args.id, * // optional cache options * // optional queue operator * // optional logger * }; * * // create a new Query instance with the options * const query = new Query(queryOptions); * * ``` * * @see {@link QueryCtorOptions} for more details on the constructor options. * @see {@link Query.query} for more details on executing a query. * @see {@link Query.queryAsync} for more details on executing a query asynchronously. * @see {@link Query.mutate} for more details on mutating cache entries. * @see {@link Query.invalidate} for more details on invalidating cache entries. * @see {@link QueueOperatorType} for more details on the available queue operators. */ export declare class Query<TDataType, TQueryArguments = any> { #private; /** * Static utility that extracts the raw value from a query result Observable. * * Transforms a stream of `QueryTaskValue<TType>` into a plain `Observable<TType>`, * stripping away query metadata such as status, transaction, and timestamps. * * @see {@link queryValue} for the standalone operator function. */ static extractQueryValue: <TType, TArgs>(source$: ReturnType<Query<TType, TArgs>["query"]>) => Observable<TType>; /** * A public getter for the client instance. * TODO: Implement a proxy to control access to the client. * This proxy would allow for additional functionality or restrictions when accessing the client instance. */ get client(): QueryClient<TDataType, TQueryArguments>; /** * Protected helper method to register events if an event observer is configured. * @param type - The event type * @param data - The event data */ protected _registerEvent<TKey extends keyof QueryEvents>(type: TKey, key: string, data?: QueryEvents[TKey] extends QueryEvent<infer T> ? T : undefined): void; /** * A public getter for the cache instance. * TODO: Implement a proxy to control access to the cache. * This proxy would allow for additional functionality or restrictions when accessing the cache instance. */ get cache(): QueryCache<TDataType, TQueryArguments>; /** * An Observable stream of Query events. It allows subscribers to react to * query lifecycle events such as query creation, cache hits, task execution, etc. * Also includes QueryClient events for complete observability. * @returns {Observable<CombinedQueryEvent<TDataType, TQueryArguments>>} An Observable stream of events. */ get event$(): Observable<IQueryEvent>; /** * The constructor for the Query class. * It initializes the query client, cache, and sets up the query request queue. * The constructor takes an options object which can include a custom client, cache, key generation function, * cache validation function, cache expiration time, queuing strategy, and a logger. * * @param options - The constructor options for the Query instance. */ constructor(options: QueryCtorOptions<TDataType, TQueryArguments>); /** * Executes a query and returns an Observable that emits the result. * It will throw an error if the query was skipped or canceled. * The returned Observable can be subscribed to in order to receive updates on the query's execution and results. * * @param args - The arguments to be passed to the query function. * @param options - Optional additional options for the query. * @returns An Observable that emits the result of the query. */ query(args: TQueryArguments, options?: QueryOptions<TDataType, TQueryArguments>): Observable<QueryTaskCached<TDataType> | QueryTaskCompleted<TDataType>>; /** * Executes an asynchronous query and returns a Promise that resolves with the result. * If `skipResolve` is set to true, the Promise resolves as soon as the query is sent (using `firstValueFrom`). * If `skipResolve` is false or not provided, the Promise resolves with the final result of the query (using `lastValueFrom`). * This method is useful for cases where an asynchronous, one-time result is needed rather than a stream of updates. * Note that skipping resolution may result in returning invalid cache. * * @example * ```typescript * try{ * const result = await query.queryAsync({ id: '123' }); * console.log(result); * } catch (error) { * console.error(error); * } * ``` * * @param payload - The arguments to be passed to the query function. * @param opt - Optional additional options for the query. The `skipResolve` option determines the resolution behavior of the Promise. * @returns A Promise that resolves with the result of the query or rejects with an Error. */ queryAsync(payload: TQueryArguments, opt?: QueryOptions<TDataType, TQueryArguments> & { skipResolve?: boolean; }): Promise<QueryTaskCached<TDataType> | QueryTaskCompleted<TDataType>>; /** * Executes a query that remains subscribed to cache mutations after the initial fetch completes. * * Unlike {@link Query.query}, which completes after emitting the result, `persistentQuery` * continues to emit whenever the underlying cache entry is updated or mutated. * This is useful for scenarios where the UI must reflect optimistic updates, * background refetches, or external cache mutations in real time. * * The returned Observable deduplicates emissions based on the transaction identifier * and mutation timestamp, so subscribers only receive meaningful state changes. * * @param args - The arguments to pass to the query function. * @param options - Optional query options including signal, retry, and cache validation overrides. * @returns An Observable that emits cached and completed results, and continues emitting on cache mutations. */ persistentQuery(args: TQueryArguments, options?: QueryOptions<TDataType, TQueryArguments>): Observable<QueryTaskCached<TDataType> | QueryTaskCompleted<TDataType>>; /** * Generates a cache key based on the provided query arguments. * This method is used internally to uniquely identify cache entries. * * @param args - The query arguments to be used for generating the cache key. * @returns A string representing the cache key. */ generateCacheKey(args: TQueryArguments): string; /** * Performs a mutation on the cache entry associated with the given arguments. * This method allows for updating the state of a cache entry without needing to perform a new query. * The changes are applied by invoking the `mutate` method on the cache with the generated key and the changes function. * * @param args - The arguments that identify the specific cache entry to be mutated. * @param changes - A function that defines the changes to be applied to the cache entry. */ mutate(args: TQueryArguments, changes: Parameters<QueryCache<TDataType, TQueryArguments>['mutate']>[1], options?: { allowCreation?: boolean; }): VoidFunction; /** * Invalidates a specific cache record or all records if no arguments are provided. * When a specific record is invalidated, it is identified by the provided arguments. * If no arguments are provided, all records in the cache are invalidated, effectively clearing the cache. * * @param args - Optional arguments that identify the specific cache record to be invalidated. * If not provided, all cache records will be invalidated. */ invalidate(args?: TQueryArguments): void; /** * Completes all subscriptions and cleans up resources. * This method should be called when the Query instance is no longer needed, to ensure that all resources are properly released. * Failing to call `complete` could result in memory leaks due to lingering subscriptions. */ complete(): void; /** * Registers a callback function that will be invoked when a cache invalidation occurs. * The callback function will receive an event object containing the details of the invalidation, * including the affected cache entry, if available. * The returned function can be called to unsubscribe the callback from further invalidation events. * * @param cb - The callback function to be registered. * @returns A function that, when called, will unsubscribe the callback from further invalidation events. */ onInvalidate(cb: (e: { detail: { item?: QueryCacheRecord; }; }) => void): VoidFunction; /** * Registers a callback function that will be invoked when a mutation occurs on the cache. * The callback function will receive an event object containing the details of the mutation, * including the changes made and the current state of the cache entry, if available. * * @param cb - The callback function to be registered. * @returns A function that, when called, will unsubscribe the callback from further mutation events. */ onMutate(cb: (e: { detail: { changes: QueryCacheMutation; current?: QueryCacheRecord<TDataType, TQueryArguments>; }; }) => void): VoidFunction; /** * Internal method that executes a query and returns an Observable with the result. * * @param args - The arguments to be passed to the query function. * @param options - Optional additional options for the query. * @returns An Observable that emits the result of the query. */ protected _query(args: TQueryArguments, options?: QueryOptions<TDataType, TQueryArguments>): Observable<QueryTaskCached<TDataType> | QueryTaskCompleted<TDataType>>; /** * The `_createTask` method is responsible for initiating a query task, which involves either returning a cached result or * starting a new query request. This method encapsulates the logic for creating and managing the lifecycle of a query task. * It ensures that if a query with the same arguments is initiated multiple times, they will all share the same task observable, * thus avoiding duplicate network requests and unnecessary computation. * * @param key - A string that uniquely identifies the cache entry associated with the query arguments. This key is used to * check if there is already a cached result that can be returned immediately, avoiding the need for a new network request. * @param args - The arguments that will be passed to the query function. These arguments are used to generate the cache key * and are also passed to the query function when making a new request. * @param options - An optional object containing additional options for the query. This can include custom cache validation * logic and retry strategies. For example, options can specify whether to suppress the emission of invalid * cache entries or to define a custom function for validating the cache. * * @returns An Observable that emits the result of the query task. If a valid cache entry is found, the Observable will emit * the cached result. If no valid cache entry exists, the Observable will emit the result of a new query request once * it completes. Subscribers to this Observable will receive updates on the query's execution and results. * * The method performs the following steps: * 1. Creates a new Observable that represents the task to be executed. * 2. Attempts to retrieve a cache entry using the provided `key`. * 3. If a cache entry is found, it checks whether the entry is still valid using the provided validation function or the default one. * - If the cache entry is valid, it emits the cached result and completes the Observable. * - If the cache entry is not valid or does not exist, it proceeds to the next step. * 4. Checks if there is already an ongoing task for the same query (identified by the `key`). * - If there is an ongoing task, it subscribes the new Observable to the existing task. * - If there is no ongoing task, it creates a new `QueryTask` instance and adds it to the ongoing tasks record. * 5. Subscribes to the new or existing task, forwarding any emissions to the subscriber of the returned Observable. * 6. Adds the new task to the query queue by emitting the `key` on the `#queue$` Subject, which will eventually trigger the processing * of the task based on the queuing strategy. * 7. The task is finalized (removed from the ongoing tasks record) when it completes or errors out. * * Note: The subscribers to the returned Observable are responsible for subscribing and unsubscribing to manage the lifecycle of the * subscription. The method ensures that the task is properly cleaned up when it is no longer needed. */ protected _createTask(key: string, args: TQueryArguments, options?: QueryOptions<TDataType, TQueryArguments>): Observable<QueryTaskCached<TDataType> | QueryTaskCompleted<TDataType>>; } export default Query;