From 9eda2942fb3c82565e9518750f0827b9c88b3833 Mon Sep 17 00:00:00 2001 From: Saihajpreet Singh Date: Tue, 3 Dec 2024 18:29:17 +0000 Subject: [PATCH] releas 2.2.3 --- .changeset/smooth-crabs-lie.md | 5 - CHANGELOG.md | 6 + index.d.ts | 138 +++++++++ index.js | 441 +++++++++++++++++++++++++++++ index.js.flow | 498 +++++++++++++++++++++++++++++++++ package.json | 2 +- 6 files changed, 1084 insertions(+), 6 deletions(-) delete mode 100644 .changeset/smooth-crabs-lie.md create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 index.js.flow diff --git a/.changeset/smooth-crabs-lie.md b/.changeset/smooth-crabs-lie.md deleted file mode 100644 index f0edcd3..0000000 --- a/.changeset/smooth-crabs-lie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'dataloader': patch ---- - -Ensure `cacheKeyFn` is not called when caching is disabled, since the key is not utilized in that case. diff --git a/CHANGELOG.md b/CHANGELOG.md index 136f364..0509b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # dataloader +## 2.2.3 + +### Patch Changes + +- [#342](https://github.com/graphql/dataloader/pull/342) [`38fedd4`](https://github.com/graphql/dataloader/commit/38fedd4106e9e3e7eb77bd68e42abc088110bd43) Thanks [@abendi](https://github.com/abendi)! - Ensure `cacheKeyFn` is not called when caching is disabled, since the key is not utilized in that case. + ## 2.2.2 ### Patch Changes diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..136ad88 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2019-present, GraphQL Foundation + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * DataLoader creates a public API for loading data from a particular + * data back-end with unique keys such as the id column of a SQL table + * or document name in a MongoDB database, given a batch loading function. + * + * Each DataLoader instance contains a unique memoized cache. Use caution + * when used in long-lived applications or those which serve many users + * with different access permissions and consider creating a new instance + * per web request. + */ +declare class DataLoader { + constructor( + batchLoadFn: DataLoader.BatchLoadFn, + options?: DataLoader.Options, + ); + + /** + * Loads a key, returning a `Promise` for the value represented by that key. + */ + load(key: K): Promise; + + /** + * Loads multiple keys, promising an array of values: + * + * var [ a, b ] = await myLoader.loadMany([ 'a', 'b' ]); + * + * This is equivalent to the more verbose: + * + * var [ a, b ] = await Promise.all([ + * myLoader.load('a'), + * myLoader.load('b') + * ]); + * + */ + loadMany(keys: ArrayLike): Promise>; + + /** + * Clears the value at `key` from the cache, if it exists. Returns itself for + * method chaining. + */ + clear(key: K): this; + + /** + * Clears the entire cache. To be used when some event results in unknown + * invalidations across this particular `DataLoader`. Returns itself for + * method chaining. + */ + clearAll(): this; + + /** + * Adds the provided key and value to the cache. If the key already exists, no + * change is made. Returns itself for method chaining. + */ + prime(key: K, value: V | PromiseLike | Error): this; + + + /** + * The name given to this `DataLoader` instance. Useful for APM tools. + * + * Is `null` if not set in the constructor. + */ + name: string | null; +} + +declare namespace DataLoader { + // If a custom cache is provided, it must be of this type (a subset of ES6 Map). + export type CacheMap = { + get(key: K): V | void; + set(key: K, value: V): any; + delete(key: K): any; + clear(): any; + }; + + // A Function, which when given an Array of keys, returns a Promise of an Array + // of values or Errors. + export type BatchLoadFn = ( + keys: ReadonlyArray, + ) => PromiseLike>; + + // Optionally turn off batching or caching or provide a cache key function or a + // custom cache instance. + export type Options = { + /** + * Default `true`. Set to `false` to disable batching, invoking + * `batchLoadFn` with a single load key. This is equivalent to setting + * `maxBatchSize` to `1`. + */ + batch?: boolean; + + /** + * Default `Infinity`. Limits the number of items that get passed in to the + * `batchLoadFn`. May be set to `1` to disable batching. + */ + maxBatchSize?: number; + + /** + * Default see https://github.com/graphql/dataloader#batch-scheduling. + * A function to schedule the later execution of a batch. The function is + * expected to call the provided callback in the immediate future. + */ + batchScheduleFn?: (callback: () => void) => void; + + /** + * Default `true`. Set to `false` to disable memoization caching, creating a + * new Promise and new key in the `batchLoadFn` for every load of the same + * key. This is equivalent to setting `cacheMap` to `null`. + */ + cache?: boolean; + + /** + * Default `key => key`. Produces cache key for a given load key. Useful + * when keys are objects and two objects should be considered equivalent. + */ + cacheKeyFn?: (key: K) => C; + + /** + * Default `new Map()`. Instance of `Map` (or an object with a similar API) + * to be used as cache. May be set to `null` to disable caching. + */ + cacheMap?: CacheMap> | null; + + /** + * The name given to this `DataLoader` instance. Useful for APM tools. + * + * Is `null` if not set in the constructor. + */ + name?: string | null; + }; +} + +export = DataLoader; diff --git a/index.js b/index.js new file mode 100644 index 0000000..a39235a --- /dev/null +++ b/index.js @@ -0,0 +1,441 @@ +"use strict"; + +/** + * Copyright (c) 2019-present, GraphQL Foundation + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ +// A Function, which when given an Array of keys, returns a Promise of an Array +// of values or Errors. +// Optionally turn off batching or caching or provide a cache key function or a +// custom cache instance. +// If a custom cache is provided, it must be of this type (a subset of ES6 Map). + +/** + * A `DataLoader` creates a public API for loading data from a particular + * data back-end with unique keys such as the `id` column of a SQL table or + * document name in a MongoDB database, given a batch loading function. + * + * Each `DataLoader` instance contains a unique memoized cache. Use caution when + * used in long-lived applications or those which serve many users with + * different access permissions and consider creating a new instance per + * web request. + */ +var DataLoader = +/*#__PURE__*/ +function () { + function DataLoader(batchLoadFn, options) { + if (typeof batchLoadFn !== 'function') { + throw new TypeError('DataLoader must be constructed with a function which accepts ' + ("Array and returns Promise>, but got: " + batchLoadFn + ".")); + } + + this._batchLoadFn = batchLoadFn; + this._maxBatchSize = getValidMaxBatchSize(options); + this._batchScheduleFn = getValidBatchScheduleFn(options); + this._cacheKeyFn = getValidCacheKeyFn(options); + this._cacheMap = getValidCacheMap(options); + this._batch = null; + this.name = getValidName(options); + } // Private + + + var _proto = DataLoader.prototype; + + /** + * Loads a key, returning a `Promise` for the value represented by that key. + */ + _proto.load = function load(key) { + if (key === null || key === undefined) { + throw new TypeError('The loader.load() function must be called with a value, ' + ("but got: " + String(key) + ".")); + } + + var batch = getCurrentBatch(this); + var cacheMap = this._cacheMap; + var cacheKey; // If caching and there is a cache-hit, return cached Promise. + + if (cacheMap) { + cacheKey = this._cacheKeyFn(key); + var cachedPromise = cacheMap.get(cacheKey); + + if (cachedPromise) { + var cacheHits = batch.cacheHits || (batch.cacheHits = []); + return new Promise(function (resolve) { + cacheHits.push(function () { + resolve(cachedPromise); + }); + }); + } + } // Otherwise, produce a new Promise for this key, and enqueue it to be + // dispatched along with the current batch. + + + batch.keys.push(key); + var promise = new Promise(function (resolve, reject) { + batch.callbacks.push({ + resolve: resolve, + reject: reject + }); + }); // If caching, cache this promise. + + if (cacheMap) { + cacheMap.set(cacheKey, promise); + } + + return promise; + } + /** + * Loads multiple keys, promising an array of values: + * + * var [ a, b ] = await myLoader.loadMany([ 'a', 'b' ]); + * + * This is similar to the more verbose: + * + * var [ a, b ] = await Promise.all([ + * myLoader.load('a'), + * myLoader.load('b') + * ]); + * + * However it is different in the case where any load fails. Where + * Promise.all() would reject, loadMany() always resolves, however each result + * is either a value or an Error instance. + * + * var [ a, b, c ] = await myLoader.loadMany([ 'a', 'b', 'badkey' ]); + * // c instanceof Error + * + */ + ; + + _proto.loadMany = function loadMany(keys) { + if (!isArrayLike(keys)) { + throw new TypeError('The loader.loadMany() function must be called with Array ' + ("but got: " + keys + ".")); + } // Support ArrayLike by using only minimal property access + + + var loadPromises = []; + + for (var i = 0; i < keys.length; i++) { + loadPromises.push(this.load(keys[i])["catch"](function (error) { + return error; + })); + } + + return Promise.all(loadPromises); + } + /** + * Clears the value at `key` from the cache, if it exists. Returns itself for + * method chaining. + */ + ; + + _proto.clear = function clear(key) { + var cacheMap = this._cacheMap; + + if (cacheMap) { + var cacheKey = this._cacheKeyFn(key); + + cacheMap["delete"](cacheKey); + } + + return this; + } + /** + * Clears the entire cache. To be used when some event results in unknown + * invalidations across this particular `DataLoader`. Returns itself for + * method chaining. + */ + ; + + _proto.clearAll = function clearAll() { + var cacheMap = this._cacheMap; + + if (cacheMap) { + cacheMap.clear(); + } + + return this; + } + /** + * Adds the provided key and value to the cache. If the key already + * exists, no change is made. Returns itself for method chaining. + * + * To prime the cache with an error at a key, provide an Error instance. + */ + ; + + _proto.prime = function prime(key, value) { + var cacheMap = this._cacheMap; + + if (cacheMap) { + var cacheKey = this._cacheKeyFn(key); // Only add the key if it does not already exist. + + + if (cacheMap.get(cacheKey) === undefined) { + // Cache a rejected promise if the value is an Error, in order to match + // the behavior of load(key). + var promise; + + if (value instanceof Error) { + promise = Promise.reject(value); // Since this is a case where an Error is intentionally being primed + // for a given key, we want to disable unhandled promise rejection. + + promise["catch"](function () {}); + } else { + promise = Promise.resolve(value); + } + + cacheMap.set(cacheKey, promise); + } + } + + return this; + } + /** + * The name given to this `DataLoader` instance. Useful for APM tools. + * + * Is `null` if not set in the constructor. + */ + ; + + return DataLoader; +}(); // Private: Enqueue a Job to be executed after all "PromiseJobs" Jobs. +// +// ES6 JavaScript uses the concepts Job and JobQueue to schedule work to occur +// after the current execution context has completed: +// http://www.ecma-international.org/ecma-262/6.0/#sec-jobs-and-job-queues +// +// Node.js uses the `process.nextTick` mechanism to implement the concept of a +// Job, maintaining a global FIFO JobQueue for all Jobs, which is flushed after +// the current call stack ends. +// +// When calling `then` on a Promise, it enqueues a Job on a specific +// "PromiseJobs" JobQueue which is flushed in Node as a single Job on the +// global JobQueue. +// +// DataLoader batches all loads which occur in a single frame of execution, but +// should include in the batch all loads which occur during the flushing of the +// "PromiseJobs" JobQueue after that same execution frame. +// +// In order to avoid the DataLoader dispatch Job occuring before "PromiseJobs", +// A Promise Job is created with the sole purpose of enqueuing a global Job, +// ensuring that it always occurs after "PromiseJobs" ends. +// +// Node.js's job queue is unique. Browsers do not have an equivalent mechanism +// for enqueuing a job to be performed after promise microtasks and before the +// next macrotask. For browser environments, a macrotask is used (via +// setImmediate or setTimeout) at a potential performance penalty. + + +var enqueuePostPromiseJob = typeof process === 'object' && typeof process.nextTick === 'function' ? function (fn) { + if (!resolvedPromise) { + resolvedPromise = Promise.resolve(); + } + + resolvedPromise.then(function () { + process.nextTick(fn); + }); +} : typeof setImmediate === 'function' ? function (fn) { + setImmediate(fn); +} : function (fn) { + setTimeout(fn); +}; // Private: cached resolved Promise instance + +var resolvedPromise; // Private: Describes a batch of requests + +// Private: Either returns the current batch, or creates and schedules a +// dispatch of a new batch for the given loader. +function getCurrentBatch(loader) { + // If there is an existing batch which has not yet dispatched and is within + // the limit of the batch size, then return it. + var existingBatch = loader._batch; + + if (existingBatch !== null && !existingBatch.hasDispatched && existingBatch.keys.length < loader._maxBatchSize) { + return existingBatch; + } // Otherwise, create a new batch for this loader. + + + var newBatch = { + hasDispatched: false, + keys: [], + callbacks: [] + }; // Store it on the loader so it may be reused. + + loader._batch = newBatch; // Then schedule a task to dispatch this batch of requests. + + loader._batchScheduleFn(function () { + dispatchBatch(loader, newBatch); + }); + + return newBatch; +} + +function dispatchBatch(loader, batch) { + // Mark this batch as having been dispatched. + batch.hasDispatched = true; // If there's nothing to load, resolve any cache hits and return early. + + if (batch.keys.length === 0) { + resolveCacheHits(batch); + return; + } // Call the provided batchLoadFn for this loader with the batch's keys and + // with the loader as the `this` context. + + + var batchPromise; + + try { + batchPromise = loader._batchLoadFn(batch.keys); + } catch (e) { + return failedDispatch(loader, batch, new TypeError('DataLoader must be constructed with a function which accepts ' + 'Array and returns Promise>, but the function ' + ("errored synchronously: " + String(e) + "."))); + } // Assert the expected response from batchLoadFn + + + if (!batchPromise || typeof batchPromise.then !== 'function') { + return failedDispatch(loader, batch, new TypeError('DataLoader must be constructed with a function which accepts ' + 'Array and returns Promise>, but the function did ' + ("not return a Promise: " + String(batchPromise) + "."))); + } // Await the resolution of the call to batchLoadFn. + + + batchPromise.then(function (values) { + // Assert the expected resolution from batchLoadFn. + if (!isArrayLike(values)) { + throw new TypeError('DataLoader must be constructed with a function which accepts ' + 'Array and returns Promise>, but the function did ' + ("not return a Promise of an Array: " + String(values) + ".")); + } + + if (values.length !== batch.keys.length) { + throw new TypeError('DataLoader must be constructed with a function which accepts ' + 'Array and returns Promise>, but the function did ' + 'not return a Promise of an Array of the same length as the Array ' + 'of keys.' + ("\n\nKeys:\n" + String(batch.keys)) + ("\n\nValues:\n" + String(values))); + } // Resolve all cache hits in the same micro-task as freshly loaded values. + + + resolveCacheHits(batch); // Step through values, resolving or rejecting each Promise in the batch. + + for (var i = 0; i < batch.callbacks.length; i++) { + var _value = values[i]; + + if (_value instanceof Error) { + batch.callbacks[i].reject(_value); + } else { + batch.callbacks[i].resolve(_value); + } + } + })["catch"](function (error) { + failedDispatch(loader, batch, error); + }); +} // Private: do not cache individual loads if the entire batch dispatch fails, +// but still reject each request so they do not hang. + + +function failedDispatch(loader, batch, error) { + // Cache hits are resolved, even though the batch failed. + resolveCacheHits(batch); + + for (var i = 0; i < batch.keys.length; i++) { + loader.clear(batch.keys[i]); + batch.callbacks[i].reject(error); + } +} // Private: Resolves the Promises for any cache hits in this batch. + + +function resolveCacheHits(batch) { + if (batch.cacheHits) { + for (var i = 0; i < batch.cacheHits.length; i++) { + batch.cacheHits[i](); + } + } +} // Private: given the DataLoader's options, produce a valid max batch size. + + +function getValidMaxBatchSize(options) { + var shouldBatch = !options || options.batch !== false; + + if (!shouldBatch) { + return 1; + } + + var maxBatchSize = options && options.maxBatchSize; + + if (maxBatchSize === undefined) { + return Infinity; + } + + if (typeof maxBatchSize !== 'number' || maxBatchSize < 1) { + throw new TypeError("maxBatchSize must be a positive number: " + maxBatchSize); + } + + return maxBatchSize; +} // Private + + +function getValidBatchScheduleFn(options) { + var batchScheduleFn = options && options.batchScheduleFn; + + if (batchScheduleFn === undefined) { + return enqueuePostPromiseJob; + } + + if (typeof batchScheduleFn !== 'function') { + throw new TypeError("batchScheduleFn must be a function: " + batchScheduleFn); + } + + return batchScheduleFn; +} // Private: given the DataLoader's options, produce a cache key function. + + +function getValidCacheKeyFn(options) { + var cacheKeyFn = options && options.cacheKeyFn; + + if (cacheKeyFn === undefined) { + return function (key) { + return key; + }; + } + + if (typeof cacheKeyFn !== 'function') { + throw new TypeError("cacheKeyFn must be a function: " + cacheKeyFn); + } + + return cacheKeyFn; +} // Private: given the DataLoader's options, produce a CacheMap to be used. + + +function getValidCacheMap(options) { + var shouldCache = !options || options.cache !== false; + + if (!shouldCache) { + return null; + } + + var cacheMap = options && options.cacheMap; + + if (cacheMap === undefined) { + return new Map(); + } + + if (cacheMap !== null) { + var cacheFunctions = ['get', 'set', 'delete', 'clear']; + var missingFunctions = cacheFunctions.filter(function (fnName) { + return cacheMap && typeof cacheMap[fnName] !== 'function'; + }); + + if (missingFunctions.length !== 0) { + throw new TypeError('Custom cacheMap missing methods: ' + missingFunctions.join(', ')); + } + } + + return cacheMap; +} + +function getValidName(options) { + if (options && options.name) { + return options.name; + } + + return null; +} // Private + + +function isArrayLike(x) { + return typeof x === 'object' && x !== null && typeof x.length === 'number' && (x.length === 0 || x.length > 0 && Object.prototype.hasOwnProperty.call(x, x.length - 1)); +} + +module.exports = DataLoader; \ No newline at end of file diff --git a/index.js.flow b/index.js.flow new file mode 100644 index 0000000..997d85a --- /dev/null +++ b/index.js.flow @@ -0,0 +1,498 @@ +/** + * Copyright (c) 2019-present, GraphQL Foundation + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// A Function, which when given an Array of keys, returns a Promise of an Array +// of values or Errors. +export type BatchLoadFn = ( + keys: $ReadOnlyArray, +) => Promise<$ReadOnlyArray>; + +// Optionally turn off batching or caching or provide a cache key function or a +// custom cache instance. +export type Options = { + batch?: boolean, + maxBatchSize?: number, + batchScheduleFn?: (callback: () => void) => void, + cache?: boolean, + cacheKeyFn?: (key: K) => C, + cacheMap?: CacheMap> | null, + name?: string, +}; + +// If a custom cache is provided, it must be of this type (a subset of ES6 Map). +export type CacheMap = { + get(key: K): V | void, + set(key: K, value: V): any, + delete(key: K): any, + clear(): any, +}; + +/** + * A `DataLoader` creates a public API for loading data from a particular + * data back-end with unique keys such as the `id` column of a SQL table or + * document name in a MongoDB database, given a batch loading function. + * + * Each `DataLoader` instance contains a unique memoized cache. Use caution when + * used in long-lived applications or those which serve many users with + * different access permissions and consider creating a new instance per + * web request. + */ +class DataLoader { + constructor(batchLoadFn: BatchLoadFn, options?: Options) { + if (typeof batchLoadFn !== 'function') { + throw new TypeError( + 'DataLoader must be constructed with a function which accepts ' + + `Array and returns Promise>, but got: ${batchLoadFn}.`, + ); + } + this._batchLoadFn = batchLoadFn; + this._maxBatchSize = getValidMaxBatchSize(options); + this._batchScheduleFn = getValidBatchScheduleFn(options); + this._cacheKeyFn = getValidCacheKeyFn(options); + this._cacheMap = getValidCacheMap(options); + this._batch = null; + this.name = getValidName(options); + } + + // Private + _batchLoadFn: BatchLoadFn; + _maxBatchSize: number; + _batchScheduleFn: (() => void) => void; + _cacheKeyFn: K => C; + _cacheMap: CacheMap> | null; + _batch: Batch | null; + + /** + * Loads a key, returning a `Promise` for the value represented by that key. + */ + load(key: K): Promise { + if (key === null || key === undefined) { + throw new TypeError( + 'The loader.load() function must be called with a value, ' + + `but got: ${String(key)}.`, + ); + } + + const batch = getCurrentBatch(this); + const cacheMap = this._cacheMap; + let cacheKey: ?C; + + // If caching and there is a cache-hit, return cached Promise. + if (cacheMap) { + cacheKey = this._cacheKeyFn(key); + const cachedPromise = cacheMap.get(cacheKey); + if (cachedPromise) { + const cacheHits = batch.cacheHits || (batch.cacheHits = []); + return new Promise(resolve => { + cacheHits.push(() => { + resolve(cachedPromise); + }); + }); + } + } + + // Otherwise, produce a new Promise for this key, and enqueue it to be + // dispatched along with the current batch. + batch.keys.push(key); + const promise = new Promise((resolve, reject) => { + batch.callbacks.push({ resolve, reject }); + }); + + // If caching, cache this promise. + if (cacheMap) { + cacheMap.set((cacheKey: any), promise); + } + + return promise; + } + + /** + * Loads multiple keys, promising an array of values: + * + * var [ a, b ] = await myLoader.loadMany([ 'a', 'b' ]); + * + * This is similar to the more verbose: + * + * var [ a, b ] = await Promise.all([ + * myLoader.load('a'), + * myLoader.load('b') + * ]); + * + * However it is different in the case where any load fails. Where + * Promise.all() would reject, loadMany() always resolves, however each result + * is either a value or an Error instance. + * + * var [ a, b, c ] = await myLoader.loadMany([ 'a', 'b', 'badkey' ]); + * // c instanceof Error + * + */ + loadMany(keys: $ReadOnlyArray): Promise> { + if (!isArrayLike(keys)) { + throw new TypeError( + 'The loader.loadMany() function must be called with Array ' + + `but got: ${(keys: any)}.`, + ); + } + // Support ArrayLike by using only minimal property access + const loadPromises = []; + for (let i = 0; i < keys.length; i++) { + loadPromises.push(this.load(keys[i]).catch(error => error)); + } + return Promise.all(loadPromises); + } + + /** + * Clears the value at `key` from the cache, if it exists. Returns itself for + * method chaining. + */ + clear(key: K): this { + const cacheMap = this._cacheMap; + if (cacheMap) { + const cacheKey = this._cacheKeyFn(key); + cacheMap.delete(cacheKey); + } + return this; + } + + /** + * Clears the entire cache. To be used when some event results in unknown + * invalidations across this particular `DataLoader`. Returns itself for + * method chaining. + */ + clearAll(): this { + const cacheMap = this._cacheMap; + if (cacheMap) { + cacheMap.clear(); + } + return this; + } + + /** + * Adds the provided key and value to the cache. If the key already + * exists, no change is made. Returns itself for method chaining. + * + * To prime the cache with an error at a key, provide an Error instance. + */ + prime(key: K, value: V | Promise | Error): this { + const cacheMap = this._cacheMap; + if (cacheMap) { + const cacheKey = this._cacheKeyFn(key); + + // Only add the key if it does not already exist. + if (cacheMap.get(cacheKey) === undefined) { + // Cache a rejected promise if the value is an Error, in order to match + // the behavior of load(key). + let promise; + if (value instanceof Error) { + promise = Promise.reject(value); + // Since this is a case where an Error is intentionally being primed + // for a given key, we want to disable unhandled promise rejection. + promise.catch(() => {}); + } else { + promise = Promise.resolve(value); + } + cacheMap.set(cacheKey, promise); + } + } + return this; + } + + /** + * The name given to this `DataLoader` instance. Useful for APM tools. + * + * Is `null` if not set in the constructor. + */ + name: string | null; +} + +// Private: Enqueue a Job to be executed after all "PromiseJobs" Jobs. +// +// ES6 JavaScript uses the concepts Job and JobQueue to schedule work to occur +// after the current execution context has completed: +// http://www.ecma-international.org/ecma-262/6.0/#sec-jobs-and-job-queues +// +// Node.js uses the `process.nextTick` mechanism to implement the concept of a +// Job, maintaining a global FIFO JobQueue for all Jobs, which is flushed after +// the current call stack ends. +// +// When calling `then` on a Promise, it enqueues a Job on a specific +// "PromiseJobs" JobQueue which is flushed in Node as a single Job on the +// global JobQueue. +// +// DataLoader batches all loads which occur in a single frame of execution, but +// should include in the batch all loads which occur during the flushing of the +// "PromiseJobs" JobQueue after that same execution frame. +// +// In order to avoid the DataLoader dispatch Job occuring before "PromiseJobs", +// A Promise Job is created with the sole purpose of enqueuing a global Job, +// ensuring that it always occurs after "PromiseJobs" ends. +// +// Node.js's job queue is unique. Browsers do not have an equivalent mechanism +// for enqueuing a job to be performed after promise microtasks and before the +// next macrotask. For browser environments, a macrotask is used (via +// setImmediate or setTimeout) at a potential performance penalty. +const enqueuePostPromiseJob = + typeof process === 'object' && typeof process.nextTick === 'function' + ? function (fn) { + if (!resolvedPromise) { + resolvedPromise = Promise.resolve(); + } + resolvedPromise.then(() => { + process.nextTick(fn); + }); + } + : typeof setImmediate === 'function' + ? function (fn) { + setImmediate(fn); + } + : function (fn) { + setTimeout(fn); + }; + +// Private: cached resolved Promise instance +let resolvedPromise; + +// Private: Describes a batch of requests +type Batch = { + hasDispatched: boolean, + keys: Array, + callbacks: Array<{ + resolve: (value: V) => void, + reject: (error: Error) => void, + }>, + cacheHits?: Array<() => void>, +}; + +// Private: Either returns the current batch, or creates and schedules a +// dispatch of a new batch for the given loader. +function getCurrentBatch(loader: DataLoader): Batch { + // If there is an existing batch which has not yet dispatched and is within + // the limit of the batch size, then return it. + const existingBatch = loader._batch; + if ( + existingBatch !== null && + !existingBatch.hasDispatched && + existingBatch.keys.length < loader._maxBatchSize + ) { + return existingBatch; + } + + // Otherwise, create a new batch for this loader. + const newBatch = { hasDispatched: false, keys: [], callbacks: [] }; + + // Store it on the loader so it may be reused. + loader._batch = newBatch; + + // Then schedule a task to dispatch this batch of requests. + loader._batchScheduleFn(() => { + dispatchBatch(loader, newBatch); + }); + + return newBatch; +} + +function dispatchBatch( + loader: DataLoader, + batch: Batch, +) { + // Mark this batch as having been dispatched. + batch.hasDispatched = true; + + // If there's nothing to load, resolve any cache hits and return early. + if (batch.keys.length === 0) { + resolveCacheHits(batch); + return; + } + + // Call the provided batchLoadFn for this loader with the batch's keys and + // with the loader as the `this` context. + let batchPromise; + try { + batchPromise = loader._batchLoadFn(batch.keys); + } catch (e) { + return failedDispatch( + loader, + batch, + new TypeError( + 'DataLoader must be constructed with a function which accepts ' + + 'Array and returns Promise>, but the function ' + + `errored synchronously: ${String(e)}.`, + ), + ); + } + + // Assert the expected response from batchLoadFn + if (!batchPromise || typeof batchPromise.then !== 'function') { + return failedDispatch( + loader, + batch, + new TypeError( + 'DataLoader must be constructed with a function which accepts ' + + 'Array and returns Promise>, but the function did ' + + `not return a Promise: ${String(batchPromise)}.`, + ), + ); + } + + // Await the resolution of the call to batchLoadFn. + batchPromise + .then(values => { + // Assert the expected resolution from batchLoadFn. + if (!isArrayLike(values)) { + throw new TypeError( + 'DataLoader must be constructed with a function which accepts ' + + 'Array and returns Promise>, but the function did ' + + `not return a Promise of an Array: ${String(values)}.`, + ); + } + if (values.length !== batch.keys.length) { + throw new TypeError( + 'DataLoader must be constructed with a function which accepts ' + + 'Array and returns Promise>, but the function did ' + + 'not return a Promise of an Array of the same length as the Array ' + + 'of keys.' + + `\n\nKeys:\n${String(batch.keys)}` + + `\n\nValues:\n${String(values)}`, + ); + } + + // Resolve all cache hits in the same micro-task as freshly loaded values. + resolveCacheHits(batch); + + // Step through values, resolving or rejecting each Promise in the batch. + for (let i = 0; i < batch.callbacks.length; i++) { + const value = values[i]; + if (value instanceof Error) { + batch.callbacks[i].reject(value); + } else { + batch.callbacks[i].resolve(value); + } + } + }) + .catch(error => { + failedDispatch(loader, batch, error); + }); +} + +// Private: do not cache individual loads if the entire batch dispatch fails, +// but still reject each request so they do not hang. +function failedDispatch( + loader: DataLoader, + batch: Batch, + error: Error, +) { + // Cache hits are resolved, even though the batch failed. + resolveCacheHits(batch); + for (let i = 0; i < batch.keys.length; i++) { + loader.clear(batch.keys[i]); + batch.callbacks[i].reject(error); + } +} + +// Private: Resolves the Promises for any cache hits in this batch. +function resolveCacheHits(batch: Batch) { + if (batch.cacheHits) { + for (let i = 0; i < batch.cacheHits.length; i++) { + batch.cacheHits[i](); + } + } +} + +// Private: given the DataLoader's options, produce a valid max batch size. +function getValidMaxBatchSize(options: ?Options): number { + const shouldBatch = !options || options.batch !== false; + if (!shouldBatch) { + return 1; + } + const maxBatchSize = options && options.maxBatchSize; + if (maxBatchSize === undefined) { + return Infinity; + } + if (typeof maxBatchSize !== 'number' || maxBatchSize < 1) { + throw new TypeError( + `maxBatchSize must be a positive number: ${(maxBatchSize: any)}`, + ); + } + return maxBatchSize; +} + +// Private +function getValidBatchScheduleFn( + options: ?Options, +): (() => void) => void { + const batchScheduleFn = options && options.batchScheduleFn; + if (batchScheduleFn === undefined) { + return enqueuePostPromiseJob; + } + if (typeof batchScheduleFn !== 'function') { + throw new TypeError( + `batchScheduleFn must be a function: ${(batchScheduleFn: any)}`, + ); + } + return batchScheduleFn; +} + +// Private: given the DataLoader's options, produce a cache key function. +function getValidCacheKeyFn(options: ?Options): K => C { + const cacheKeyFn = options && options.cacheKeyFn; + if (cacheKeyFn === undefined) { + return (key => key: any); + } + if (typeof cacheKeyFn !== 'function') { + throw new TypeError(`cacheKeyFn must be a function: ${(cacheKeyFn: any)}`); + } + return cacheKeyFn; +} + +// Private: given the DataLoader's options, produce a CacheMap to be used. +function getValidCacheMap( + options: ?Options, +): CacheMap> | null { + const shouldCache = !options || options.cache !== false; + if (!shouldCache) { + return null; + } + const cacheMap = options && options.cacheMap; + if (cacheMap === undefined) { + return new Map(); + } + if (cacheMap !== null) { + const cacheFunctions = ['get', 'set', 'delete', 'clear']; + const missingFunctions = cacheFunctions.filter( + fnName => cacheMap && typeof cacheMap[fnName] !== 'function', + ); + if (missingFunctions.length !== 0) { + throw new TypeError( + 'Custom cacheMap missing methods: ' + missingFunctions.join(', '), + ); + } + } + return cacheMap; +} + +function getValidName(options: ?Options): string | null { + if (options && options.name) { + return options.name; + } + + return null; +} + +// Private +function isArrayLike(x: mixed): boolean { + return ( + typeof x === 'object' && + x !== null && + typeof x.length === 'number' && + (x.length === 0 || + (x.length > 0 && Object.prototype.hasOwnProperty.call(x, x.length - 1))) + ); +} + +module.exports = DataLoader; diff --git a/package.json b/package.json index bbb80be..4e93467 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dataloader", - "version": "2.2.2", + "version": "2.2.3", "description": "A data loading utility to reduce requests to a backend via batching and caching.", "contributors": [ "Lee Byron (http://leebyron.com/)",