UNPKG

@equinor/fusion-query

Version:
596 lines 32.8 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 { ConsoleLogger } from '@equinor/fusion-log'; import { QueryTask } from './QueryTask'; /** * 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 useQueueOperator = (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 { /** * A static method that extracts the value from a query task. * It is a utility function that can be used externally to access the result of a task. */ 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(); /** * A private logger instance used for logging events and operations within the Query class. * If a logger is not provided in the constructor options, a default ConsoleLogger is used. */ #logger; /** * 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; } /** * 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; } /** * 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.#logger = options.logger ?? new ConsoleLogger('Query', this.#namespace); 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, { // Create a sub-logger for the client for more granular logging logger: this.#logger.createSubLogger('Client'), // 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()); } // 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()); } // 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 = useQueueOperator(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.#logger.debug('Task added to queue', { 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.#logger.debug('Task selected from queue', { key, task: task.uuid, args, options, }); // Check if the task is still observed, if not, skip it if (!task?.observed) { this.#logger.debug('Task skipped', { task: uuid, }); 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.#logger.info('Task stated', { task: uuid, transaction, }); // 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.#logger.debug('Task closed', { task: uuid, transaction, jobStatus: job.status, }); 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, this.#logger)); // 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.#logger.debug('Task output added to cache', { uuid, args, key, transaction, value, }); // 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); }); } 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(); } //#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.#logger.debug('New 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.#logger.debug('Abort signal already triggered by caller', { key }); return subscriber.complete(); } subscriber.add(fromEvent(options?.signal, 'abort').subscribe(() => { this.#logger.debug('Abort signal triggered by caller', { 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.#logger.debug('Query has cache', { 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, }; this.#logger.info('Query cache valid, completing', { hasValidCache, suppressInvalid, record, }); // 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) { // 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.#logger.debug('Query cache entry is invalid, proceeding to fetch new data', { key, }); } // If the cache entry does not exist or is invalid, proceed to queue a new query request. const isExistingTask = key in this.#tasks; this.#tasks[key] ??= new QueryTask(key, args, options); 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)); this.#logger.info(isExistingTask ? 'Query connected to existing task' : 'Query started new task', { key, }); // 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