UNPKG

@equinor/fusion-query

Version:

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

640 lines 35 kB
import { EMPTY, firstValueFrom, fromEvent, interval, lastValueFrom, Observable, Subject, Subscription, } from 'rxjs'; import { catchError, distinctUntilChanged, filter, map, takeWhile, tap, throwIfEmpty, } from 'rxjs/operators'; import { v4 as generateGUID, v5 as generateUniqueKey } from 'uuid'; import { QueryClient } from './client'; import { QueryCache } from './cache'; import { concatQueue, mergeQueue, queryValue, switchQueue } from './operators'; import { filterAction } from '@equinor/fusion-observable/operators'; import { QueryTask } from './QueryTask'; import { QueryEvent } from './events'; /** * The default function for validating cache entries based on expiration time. * This function generates a validator that checks whether the cache entry has expired based on the current time * and the expiration time provided. If the expiration time is 0, the cache entry is always considered invalid. * * @template TDataType - The type of the data in the cache entry. * @template TQueryArguments - The type of the arguments used to validate the cache entry. * * @param expires - The expiration time in milliseconds. If 0, the cache entry is always considered invalid. * * @returns A function that takes a cache entry and returns a boolean indicating whether the entry is still valid. */ const defaultCacheValidator = (expires = 0) => (entry) => (entry.updated ?? 0) + expires > Date.now(); /** * A utility function that returns a query queue function based on the provided operator type or custom function. * The queue function is responsible for controlling how query requests are processed in relation to each other. * Depending on the chosen strategy, requests can be canceled, run in parallel, or executed sequentially. * * The 'switch' operator (default) cancels any ongoing request when a new one comes in, ensuring that only the latest request is processed. * The 'merge' operator allows multiple requests to be processed in parallel without waiting for any to complete. * The 'concat' operator processes requests one after another, in the order they were added to the queue, waiting for each to complete before starting the next. * * @template TDataType - The type of the data returned by the query. * @template TQueryArguments - The type of the arguments passed to the query function. * * @param type - The operator type ('switch', 'merge', 'concat') or custom function to use for queuing requests. Defaults to 'switch'. * * @returns A function that takes a query request and returns an Observable representing the queued request. */ const getQueueOperator = (type = 'switch') => { if (typeof type === 'function') { return type; } return (() => { switch (type) { case 'concat': return concatQueue; case 'merge': return mergeQueue; case 'switch': return switchQueue; default: throw new Error(`Invalid queue operator: ${type}`); } })(); }; /** * 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. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export class Query { /** * 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 = queryValue; /** * A private Subscription instance that holds all internal subscriptions. * It is used for cleanup when the query is completed or no longer needed. * This ensures that all resources are properly released and no memory leaks occur. */ #subscription = new Subscription(); /** * A private instance of QueryClient that is used to fetch data. * This client encapsulates the logic for making the actual query requests and handling their responses. */ #client; /** * A private instance of QueryCache that is used to cache query results. * This cache stores the results of queries, allowing for quick retrieval of data without the need to make additional network requests. */ #cache; /** * A private Subject that represents the queue of query requests. * This queue is used to manage the execution of concurrent query requests according to the chosen queuing strategy. */ #queue$ = new Subject(); /** * A private record object that stores ongoing query tasks, indexed by a unique reference key. * This record keeps track of all active query tasks, allowing for management and coordination of their execution. */ #tasks = {}; /** * A private function that generates a unique cache key for the provided arguments. * This key is used to store and retrieve cache entries, ensuring that each set of arguments has its own distinct entry. */ #generateCacheKey; /** * A private function that validates cache entries. * This function is called to determine whether a cache entry is still valid or if it should be considered stale and discarded. */ #validateCacheEntry; /** * A private unique namespace string generated using a UUID. * It is used to ensure cache keys are unique across different instances of the query. * This prevents cache collisions where different queries could otherwise end up using the same cache entry. */ #namespace = generateGUID(); /** * An optional observer for emitting query events. * If provided, events will be emitted instead of or in addition to logging. */ #event$; /** * 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() { // TODO: Proxy return this.#client; } /** * Protected helper method to register events if an event observer is configured. * @param type - The event type * @param data - The event data */ _registerEvent(type, key, data) { this.#event$.next(new QueryEvent(type, key, data)); } /** * 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() { // TODO: Proxy return this.#cache; } /** * 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$() { return this.#event$.asObservable(); } /** * 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) { this.#event$ = new Subject(); this.#generateCacheKey = (args) => { // Use the provided key generation function from options and namespace to ensure unique cache keys across instances return generateUniqueKey(options.key(args), this.#namespace); }; // Set the cache entry validation function. Use the provided one or the default based on expiration time this.#validateCacheEntry = options?.validate ?? defaultCacheValidator(options?.expire); // Initialize the query client. Use the provided client instance or create a new one with provided function and options if (options.client instanceof QueryClient) { this.#client = options.client; } else { this.#client = new QueryClient(options.client.fn, { // Spread any additional options provided for the client ...options.client.options, }); // Ensure client resources are cleaned up when the query instance is disposed this.#subscription.add(() => this.#client.complete()); } this.#subscription.add(this.#client.event$.subscribe({ next: (event) => this.#event$.next(event), })); // Initialize the query cache. Use the provided cache instance or create a new one with provided constructor arguments if (options.cache instanceof QueryCache) { this.#cache = options.cache; } else { // If no cache is provided, create a new instance with default or provided constructor arguments this.#cache = new QueryCache(options.cache || {}); // Ensure cache resources are cleaned up when the query instance is disposed this.#subscription.add(() => this.#cache.complete()); } this.#subscription.add(this.#cache.event$.subscribe({ next: (event) => this.#event$.next(event), })); // The queueOperator is a function that determines how query requests are handled when multiple requests are made concurrently. // It is derived from the useQueueOperator utility function, which takes the provided queueOperatorType or custom function // from the QueryCtorOptions and returns the corresponding queue function. // The queue function is then used in the observable pipeline to manage the execution of query requests according to the chosen strategy. const queueOperator = getQueueOperator(options.queueOperator); // shutdown the queue when the query is completed this.#subscription.add(() => this.#queue$.complete()); this.#subscription.add(this.#queue$ .pipe(tap((key) => { this._registerEvent('query_queued', key); }), // skip tasks that are not in the ongoing tasks record filter((key) => !!(key in this.#tasks)), // Apply the queue operator to the query requests, which controls the execution order queueOperator((key) => { const task = this.#tasks[key]; const { args, options, uuid } = task; this._registerEvent('query_job_selected', key, { taskId: uuid, args, options }); // Check if the task is still observed, if not, skip it if (!task?.observed) { this._registerEvent('query_job_skipped', key, { taskId: uuid, args, options }); delete this.#tasks[key]; return EMPTY; } // Initiate a new query using the client, passing in the arguments and options // The 'ref' option is used to associate the task with a specific query request const job = this.#client.query(args, { ...options, ref: task.uuid, }); return new Observable((subscriber) => { const { transaction } = job; this._registerEvent('query_job_started', key, { taskId: uuid, transaction, args, options, }); // Add a cleanup function to the subscriber that will be called when the subscription is closed. // This function logs the task closure, cancels the job to prevent further processing, and removes the task from the ongoing tasks record. subscriber.add(() => { this._registerEvent('query_job_closed', key, { taskId: uuid, transaction, args, options, }); job.complete('task closed'); delete this.#tasks[key]; }); // Add a periodic check to the subscriber that will cancel the job if it is no longer observed. // This is to ensure resources are not wasted on tasks that are no longer of interest. subscriber.add(interval(10) .pipe(filter(() => !task.observed)) .subscribe(() => { job.cancel(`task: ${task.uuid} is not observed`); subscriber.complete(); })); // Process the job using the task's custom logic, which includes handling of the query response and any errors. // This processing is specific to the task and may involve updating the cache, logging, or other side effects. subscriber.add(task.processJob(job).add(() => { this._registerEvent('query_job_completed', key, { taskId: uuid, transaction, args, options, }); })); // Map the job Observable to a QueryQueueResult object, which includes the result and request details. // This mapping allows the subscriber to receive a structured response including the task details and the query result. job .pipe(map((result) => ({ result, task, })), // Catch any errors that occur during the job processing and complete the observable to prevent hanging subscriptions. // This ensures that errors are handled gracefully and do not prevent the completion of the observable chain. catchError(() => EMPTY)) .subscribe(subscriber); }); }), takeWhile(() => !this.#client.closed)) // Subscribe to the processed tasks and update the cache with their results .subscribe((task) => { const { value, transaction } = task.result; const { args, key, uuid } = task.task; this._registerEvent('query_cache_added', key, { data: value, taskId: uuid, args, transaction, }); this._registerEvent('query_completed', key, { data: value, hasValidCache: true }); // Update the cache item with the new value, arguments, and transaction this.#cache.setItem(key, { value, args, transaction, }); })); } /** * 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, options) { return this._query(args, options); } /** * 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, opt) { const { skipResolve, ...args } = opt || {}; const fn = skipResolve ? firstValueFrom : lastValueFrom; return new Promise((resolve, reject) => { if (opt?.signal) { opt.signal.addEventListener('abort', () => reject(new Error('Query aborted'))); } fn(this._query(payload, args).pipe(throwIfEmpty())).then(resolve, reject); }); } /** * 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, options) { const key = this.#generateCacheKey(args); const original = this._query(args, options); return new Observable((subscriber) => { subscriber.add(original.subscribe({ next: subscriber.next.bind(subscriber), error: subscriber.error.bind(subscriber), })); // Use the provided validation function or the default cache validator to determine if the cache entry is valid. const validateCache = options?.cache?.validate || this.#validateCacheEntry; // Subscribe to the cache state and filter for the specific cache entry based on the key. subscriber.add(this.cache.state$ .pipe(filter((x) => key in x), map((x) => ({ ...x[key], key, status: 'cache', hasValidCache: validateCache(x[key], args), }))) .subscribe(subscriber)); }).pipe( // only emit when the transaction changes distinctUntilChanged((a, b) => a.transaction === b.transaction && a.mutated === b.mutated)); } /** * 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) { return this.#generateCacheKey(args); } /** * 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, changes, options) { const key = this.#generateCacheKey(args); if (key in this.cache.state === false) { if (options?.allowCreation === undefined) { throw new Error(`Cannot mutate cache item with key ${key}: item not found and option "allowCreation" is false`); } else if (options.allowCreation === false) { /** does not allow creation, can not mutate */ return () => { }; } const { value } = typeof changes === 'function' ? changes() : changes; this.cache.setItem(key, { args, transaction: generateGUID(), value, }); } return this.#cache.mutate(key, changes); } /** * 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) { this.#cache.invalidate(args && this.#generateCacheKey(args)); } /** * 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() { this.#subscription.unsubscribe(); this.#event$?.complete(); } //#region Event Handlers /** * 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) { const subscription = this.#cache.action$ .pipe(filterAction('cache/invalidate')) .subscribe((action) => cb({ detail: { item: action.meta.item } })); return () => subscription.unsubscribe(); } /** * 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) { const subscription = this.#cache.action$ .pipe(filterAction('cache/mutate')) .subscribe((action) => cb({ detail: { changes: action.payload, current: action.meta.item } })); return () => subscription.unsubscribe(); } //#endregion /** * 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. */ _query(args, options) { const key = this.#generateCacheKey(args); const task = this._createTask(key, args, options); this._registerEvent('query_created', key, { args, options }); return task; } /** * 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. */ _createTask(key, args, options) { // Create a new Observable that represents the query task and will be returned to the caller. return new Observable((subscriber) => { if (options?.signal) { if (options?.signal.aborted) { this._registerEvent('query_aborted', key); return subscriber.complete(); } subscriber.add(fromEvent(options?.signal, 'abort').subscribe(() => { this._registerEvent('query_aborted', key); subscriber.complete(); })); } // Attempt to retrieve the cache entry associated with the provided key. const cacheEntry = this.#cache.getItem(key); // If a cache entry exists and is valid, emit it as the next value to the subscriber. if (cacheEntry) { this._registerEvent('query_cache_hit', key, { cacheEntry }); const suppressInvalid = options?.cache?.suppressInvalid ?? false; // Use the provided validation function or the default cache validator to determine if the cache entry is valid. const validateCache = options?.cache?.validate || this.#validateCacheEntry; // Check if the cache entry is valid based on the provided validation function. const hasValidCache = validateCache(cacheEntry, args); const record = { ...cacheEntry, key, status: 'cache', hasValidCache, }; // Emit the cache entry as the next value to the subscriber. This step is crucial as it allows // the subscriber to receive the cached data immediately, without waiting for a new fetch operation. // The emitted record contains the cache entry data, along with metadata such as the cache key, // the status indicating that this is a cached response, and a flag indicating whether the cache // is considered valid based on the validation logic. subscriber.next(record); if (hasValidCache || suppressInvalid) { this._registerEvent('query_completed', key, { data: cacheEntry.value, hasValidCache }); // If the cache is valid, or if invalid cache entries should be suppressed (not re-fetched), // complete the subscription to prevent further actions. // This ensures that if the cache data is sufficient or if the strategy is to avoid using invalid cache without refetching, // the observable sequence completes here. return subscriber.complete(); } // This will fetch new data and update the cache entry with the latest result. this._registerEvent('query_cache_miss', key); } // If the cache entry does not exist or is invalid, proceed to queue a new query request. const isExistingTask = key in this.#tasks; if (!isExistingTask) { this.#tasks[key] = new QueryTask(key, args, options); this._registerEvent('query_job_created', key, { taskId: this.#tasks[key].uuid, args, options, }); } else { this._registerEvent('query_connected', key, { isExistingTask: true }); } const task = this.#tasks[key]; // Connect the subscriber to the task to receive updates on the query's execution and results. subscriber.add(task.subscribe(subscriber)); // If this is a new task, add it to the query queue to be processed. if (!isExistingTask) { this.#queue$.next(key); } }); } } export default Query; //# sourceMappingURL=Query.js.map