1542 lines
54 KiB
JavaScript
1542 lines
54 KiB
JavaScript
/**
|
|
* @module LRUCache
|
|
*/
|
|
const perf = typeof performance === 'object' &&
|
|
performance &&
|
|
typeof performance.now === 'function'
|
|
? performance
|
|
: Date;
|
|
const warned = new Set();
|
|
/* c8 ignore start */
|
|
const PROCESS = (typeof process === 'object' && !!process ? process : {});
|
|
/* c8 ignore start */
|
|
const emitWarning = (msg, type, code, fn) => {
|
|
typeof PROCESS.emitWarning === 'function'
|
|
? PROCESS.emitWarning(msg, type, code, fn)
|
|
: console.error(`[${code}] ${type}: ${msg}`);
|
|
};
|
|
let AC = globalThis.AbortController;
|
|
let AS = globalThis.AbortSignal;
|
|
/* c8 ignore start */
|
|
if (typeof AC === 'undefined') {
|
|
//@ts-ignore
|
|
AS = class AbortSignal {
|
|
onabort;
|
|
_onabort = [];
|
|
reason;
|
|
aborted = false;
|
|
addEventListener(_, fn) {
|
|
this._onabort.push(fn);
|
|
}
|
|
};
|
|
//@ts-ignore
|
|
AC = class AbortController {
|
|
constructor() {
|
|
warnACPolyfill();
|
|
}
|
|
signal = new AS();
|
|
abort(reason) {
|
|
if (this.signal.aborted)
|
|
return;
|
|
//@ts-ignore
|
|
this.signal.reason = reason;
|
|
//@ts-ignore
|
|
this.signal.aborted = true;
|
|
//@ts-ignore
|
|
for (const fn of this.signal._onabort) {
|
|
fn(reason);
|
|
}
|
|
this.signal.onabort?.(reason);
|
|
}
|
|
};
|
|
let printACPolyfillWarning = PROCESS.env?.LRU_CACHE_IGNORE_AC_WARNING !== '1';
|
|
const warnACPolyfill = () => {
|
|
if (!printACPolyfillWarning)
|
|
return;
|
|
printACPolyfillWarning = false;
|
|
emitWarning('AbortController is not defined. If using lru-cache in ' +
|
|
'node 14, load an AbortController polyfill from the ' +
|
|
'`node-abort-controller` package. A minimal polyfill is ' +
|
|
'provided for use by LRUCache.fetch(), but it should not be ' +
|
|
'relied upon in other contexts (eg, passing it to other APIs that ' +
|
|
'use AbortController/AbortSignal might have undesirable effects). ' +
|
|
'You may disable this with LRU_CACHE_IGNORE_AC_WARNING=1 in the env.', 'NO_ABORT_CONTROLLER', 'ENOTSUP', warnACPolyfill);
|
|
};
|
|
}
|
|
/* c8 ignore stop */
|
|
const shouldWarn = (code) => !warned.has(code);
|
|
const TYPE = Symbol('type');
|
|
const isPosInt = (n) => n && n === Math.floor(n) && n > 0 && isFinite(n);
|
|
/* c8 ignore start */
|
|
// This is a little bit ridiculous, tbh.
|
|
// The maximum array length is 2^32-1 or thereabouts on most JS impls.
|
|
// And well before that point, you're caching the entire world, I mean,
|
|
// that's ~32GB of just integers for the next/prev links, plus whatever
|
|
// else to hold that many keys and values. Just filling the memory with
|
|
// zeroes at init time is brutal when you get that big.
|
|
// But why not be complete?
|
|
// Maybe in the future, these limits will have expanded.
|
|
const getUintArray = (max) => !isPosInt(max)
|
|
? null
|
|
: max <= Math.pow(2, 8)
|
|
? Uint8Array
|
|
: max <= Math.pow(2, 16)
|
|
? Uint16Array
|
|
: max <= Math.pow(2, 32)
|
|
? Uint32Array
|
|
: max <= Number.MAX_SAFE_INTEGER
|
|
? ZeroArray
|
|
: null;
|
|
/* c8 ignore stop */
|
|
class ZeroArray extends Array {
|
|
constructor(size) {
|
|
super(size);
|
|
this.fill(0);
|
|
}
|
|
}
|
|
class Stack {
|
|
heap;
|
|
length;
|
|
// private constructor
|
|
static #constructing = false;
|
|
static create(max) {
|
|
const HeapCls = getUintArray(max);
|
|
if (!HeapCls)
|
|
return [];
|
|
Stack.#constructing = true;
|
|
const s = new Stack(max, HeapCls);
|
|
Stack.#constructing = false;
|
|
return s;
|
|
}
|
|
constructor(max, HeapCls) {
|
|
/* c8 ignore start */
|
|
if (!Stack.#constructing) {
|
|
throw new TypeError('instantiate Stack using Stack.create(n)');
|
|
}
|
|
/* c8 ignore stop */
|
|
this.heap = new HeapCls(max);
|
|
this.length = 0;
|
|
}
|
|
push(n) {
|
|
this.heap[this.length++] = n;
|
|
}
|
|
pop() {
|
|
return this.heap[--this.length];
|
|
}
|
|
}
|
|
/**
|
|
* Default export, the thing you're using this module to get.
|
|
*
|
|
* The `K` and `V` types define the key and value types, respectively. The
|
|
* optional `FC` type defines the type of the `context` object passed to
|
|
* `cache.fetch()` and `cache.memo()`.
|
|
*
|
|
* Keys and values **must not** be `null` or `undefined`.
|
|
*
|
|
* All properties from the options object (with the exception of `max`,
|
|
* `maxSize`, `fetchMethod`, `memoMethod`, `dispose` and `disposeAfter`) are
|
|
* added as normal public members. (The listed options are read-only getters.)
|
|
*
|
|
* Changing any of these will alter the defaults for subsequent method calls.
|
|
*/
|
|
export class LRUCache {
|
|
// options that cannot be changed without disaster
|
|
#max;
|
|
#maxSize;
|
|
#dispose;
|
|
#disposeAfter;
|
|
#fetchMethod;
|
|
#memoMethod;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.ttl}
|
|
*/
|
|
ttl;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.ttlResolution}
|
|
*/
|
|
ttlResolution;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.ttlAutopurge}
|
|
*/
|
|
ttlAutopurge;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.updateAgeOnGet}
|
|
*/
|
|
updateAgeOnGet;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.updateAgeOnHas}
|
|
*/
|
|
updateAgeOnHas;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.allowStale}
|
|
*/
|
|
allowStale;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.noDisposeOnSet}
|
|
*/
|
|
noDisposeOnSet;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.noUpdateTTL}
|
|
*/
|
|
noUpdateTTL;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.maxEntrySize}
|
|
*/
|
|
maxEntrySize;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.sizeCalculation}
|
|
*/
|
|
sizeCalculation;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.noDeleteOnFetchRejection}
|
|
*/
|
|
noDeleteOnFetchRejection;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.noDeleteOnStaleGet}
|
|
*/
|
|
noDeleteOnStaleGet;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.allowStaleOnFetchAbort}
|
|
*/
|
|
allowStaleOnFetchAbort;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.allowStaleOnFetchRejection}
|
|
*/
|
|
allowStaleOnFetchRejection;
|
|
/**
|
|
* {@link LRUCache.OptionsBase.ignoreFetchAbort}
|
|
*/
|
|
ignoreFetchAbort;
|
|
// computed properties
|
|
#size;
|
|
#calculatedSize;
|
|
#keyMap;
|
|
#keyList;
|
|
#valList;
|
|
#next;
|
|
#prev;
|
|
#head;
|
|
#tail;
|
|
#free;
|
|
#disposed;
|
|
#sizes;
|
|
#starts;
|
|
#ttls;
|
|
#hasDispose;
|
|
#hasFetchMethod;
|
|
#hasDisposeAfter;
|
|
/**
|
|
* Do not call this method unless you need to inspect the
|
|
* inner workings of the cache. If anything returned by this
|
|
* object is modified in any way, strange breakage may occur.
|
|
*
|
|
* These fields are private for a reason!
|
|
*
|
|
* @internal
|
|
*/
|
|
static unsafeExposeInternals(c) {
|
|
return {
|
|
// properties
|
|
starts: c.#starts,
|
|
ttls: c.#ttls,
|
|
sizes: c.#sizes,
|
|
keyMap: c.#keyMap,
|
|
keyList: c.#keyList,
|
|
valList: c.#valList,
|
|
next: c.#next,
|
|
prev: c.#prev,
|
|
get head() {
|
|
return c.#head;
|
|
},
|
|
get tail() {
|
|
return c.#tail;
|
|
},
|
|
free: c.#free,
|
|
// methods
|
|
isBackgroundFetch: (p) => c.#isBackgroundFetch(p),
|
|
backgroundFetch: (k, index, options, context) => c.#backgroundFetch(k, index, options, context),
|
|
moveToTail: (index) => c.#moveToTail(index),
|
|
indexes: (options) => c.#indexes(options),
|
|
rindexes: (options) => c.#rindexes(options),
|
|
isStale: (index) => c.#isStale(index),
|
|
};
|
|
}
|
|
// Protected read-only members
|
|
/**
|
|
* {@link LRUCache.OptionsBase.max} (read-only)
|
|
*/
|
|
get max() {
|
|
return this.#max;
|
|
}
|
|
/**
|
|
* {@link LRUCache.OptionsBase.maxSize} (read-only)
|
|
*/
|
|
get maxSize() {
|
|
return this.#maxSize;
|
|
}
|
|
/**
|
|
* The total computed size of items in the cache (read-only)
|
|
*/
|
|
get calculatedSize() {
|
|
return this.#calculatedSize;
|
|
}
|
|
/**
|
|
* The number of items stored in the cache (read-only)
|
|
*/
|
|
get size() {
|
|
return this.#size;
|
|
}
|
|
/**
|
|
* {@link LRUCache.OptionsBase.fetchMethod} (read-only)
|
|
*/
|
|
get fetchMethod() {
|
|
return this.#fetchMethod;
|
|
}
|
|
get memoMethod() {
|
|
return this.#memoMethod;
|
|
}
|
|
/**
|
|
* {@link LRUCache.OptionsBase.dispose} (read-only)
|
|
*/
|
|
get dispose() {
|
|
return this.#dispose;
|
|
}
|
|
/**
|
|
* {@link LRUCache.OptionsBase.disposeAfter} (read-only)
|
|
*/
|
|
get disposeAfter() {
|
|
return this.#disposeAfter;
|
|
}
|
|
constructor(options) {
|
|
const { max = 0, ttl, ttlResolution = 1, ttlAutopurge, updateAgeOnGet, updateAgeOnHas, allowStale, dispose, disposeAfter, noDisposeOnSet, noUpdateTTL, maxSize = 0, maxEntrySize = 0, sizeCalculation, fetchMethod, memoMethod, noDeleteOnFetchRejection, noDeleteOnStaleGet, allowStaleOnFetchRejection, allowStaleOnFetchAbort, ignoreFetchAbort, } = options;
|
|
if (max !== 0 && !isPosInt(max)) {
|
|
throw new TypeError('max option must be a nonnegative integer');
|
|
}
|
|
const UintArray = max ? getUintArray(max) : Array;
|
|
if (!UintArray) {
|
|
throw new Error('invalid max value: ' + max);
|
|
}
|
|
this.#max = max;
|
|
this.#maxSize = maxSize;
|
|
this.maxEntrySize = maxEntrySize || this.#maxSize;
|
|
this.sizeCalculation = sizeCalculation;
|
|
if (this.sizeCalculation) {
|
|
if (!this.#maxSize && !this.maxEntrySize) {
|
|
throw new TypeError('cannot set sizeCalculation without setting maxSize or maxEntrySize');
|
|
}
|
|
if (typeof this.sizeCalculation !== 'function') {
|
|
throw new TypeError('sizeCalculation set to non-function');
|
|
}
|
|
}
|
|
if (memoMethod !== undefined &&
|
|
typeof memoMethod !== 'function') {
|
|
throw new TypeError('memoMethod must be a function if defined');
|
|
}
|
|
this.#memoMethod = memoMethod;
|
|
if (fetchMethod !== undefined &&
|
|
typeof fetchMethod !== 'function') {
|
|
throw new TypeError('fetchMethod must be a function if specified');
|
|
}
|
|
this.#fetchMethod = fetchMethod;
|
|
this.#hasFetchMethod = !!fetchMethod;
|
|
this.#keyMap = new Map();
|
|
this.#keyList = new Array(max).fill(undefined);
|
|
this.#valList = new Array(max).fill(undefined);
|
|
this.#next = new UintArray(max);
|
|
this.#prev = new UintArray(max);
|
|
this.#head = 0;
|
|
this.#tail = 0;
|
|
this.#free = Stack.create(max);
|
|
this.#size = 0;
|
|
this.#calculatedSize = 0;
|
|
if (typeof dispose === 'function') {
|
|
this.#dispose = dispose;
|
|
}
|
|
if (typeof disposeAfter === 'function') {
|
|
this.#disposeAfter = disposeAfter;
|
|
this.#disposed = [];
|
|
}
|
|
else {
|
|
this.#disposeAfter = undefined;
|
|
this.#disposed = undefined;
|
|
}
|
|
this.#hasDispose = !!this.#dispose;
|
|
this.#hasDisposeAfter = !!this.#disposeAfter;
|
|
this.noDisposeOnSet = !!noDisposeOnSet;
|
|
this.noUpdateTTL = !!noUpdateTTL;
|
|
this.noDeleteOnFetchRejection = !!noDeleteOnFetchRejection;
|
|
this.allowStaleOnFetchRejection = !!allowStaleOnFetchRejection;
|
|
this.allowStaleOnFetchAbort = !!allowStaleOnFetchAbort;
|
|
this.ignoreFetchAbort = !!ignoreFetchAbort;
|
|
// NB: maxEntrySize is set to maxSize if it's set
|
|
if (this.maxEntrySize !== 0) {
|
|
if (this.#maxSize !== 0) {
|
|
if (!isPosInt(this.#maxSize)) {
|
|
throw new TypeError('maxSize must be a positive integer if specified');
|
|
}
|
|
}
|
|
if (!isPosInt(this.maxEntrySize)) {
|
|
throw new TypeError('maxEntrySize must be a positive integer if specified');
|
|
}
|
|
this.#initializeSizeTracking();
|
|
}
|
|
this.allowStale = !!allowStale;
|
|
this.noDeleteOnStaleGet = !!noDeleteOnStaleGet;
|
|
this.updateAgeOnGet = !!updateAgeOnGet;
|
|
this.updateAgeOnHas = !!updateAgeOnHas;
|
|
this.ttlResolution =
|
|
isPosInt(ttlResolution) || ttlResolution === 0
|
|
? ttlResolution
|
|
: 1;
|
|
this.ttlAutopurge = !!ttlAutopurge;
|
|
this.ttl = ttl || 0;
|
|
if (this.ttl) {
|
|
if (!isPosInt(this.ttl)) {
|
|
throw new TypeError('ttl must be a positive integer if specified');
|
|
}
|
|
this.#initializeTTLTracking();
|
|
}
|
|
// do not allow completely unbounded caches
|
|
if (this.#max === 0 && this.ttl === 0 && this.#maxSize === 0) {
|
|
throw new TypeError('At least one of max, maxSize, or ttl is required');
|
|
}
|
|
if (!this.ttlAutopurge && !this.#max && !this.#maxSize) {
|
|
const code = 'LRU_CACHE_UNBOUNDED';
|
|
if (shouldWarn(code)) {
|
|
warned.add(code);
|
|
const msg = 'TTL caching without ttlAutopurge, max, or maxSize can ' +
|
|
'result in unbounded memory consumption.';
|
|
emitWarning(msg, 'UnboundedCacheWarning', code, LRUCache);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Return the number of ms left in the item's TTL. If item is not in cache,
|
|
* returns `0`. Returns `Infinity` if item is in cache without a defined TTL.
|
|
*/
|
|
getRemainingTTL(key) {
|
|
return this.#keyMap.has(key) ? Infinity : 0;
|
|
}
|
|
#initializeTTLTracking() {
|
|
const ttls = new ZeroArray(this.#max);
|
|
const starts = new ZeroArray(this.#max);
|
|
this.#ttls = ttls;
|
|
this.#starts = starts;
|
|
this.#setItemTTL = (index, ttl, start = perf.now()) => {
|
|
starts[index] = ttl !== 0 ? start : 0;
|
|
ttls[index] = ttl;
|
|
if (ttl !== 0 && this.ttlAutopurge) {
|
|
const t = setTimeout(() => {
|
|
if (this.#isStale(index)) {
|
|
this.#delete(this.#keyList[index], 'expire');
|
|
}
|
|
}, ttl + 1);
|
|
// unref() not supported on all platforms
|
|
/* c8 ignore start */
|
|
if (t.unref) {
|
|
t.unref();
|
|
}
|
|
/* c8 ignore stop */
|
|
}
|
|
};
|
|
this.#updateItemAge = index => {
|
|
starts[index] = ttls[index] !== 0 ? perf.now() : 0;
|
|
};
|
|
this.#statusTTL = (status, index) => {
|
|
if (ttls[index]) {
|
|
const ttl = ttls[index];
|
|
const start = starts[index];
|
|
/* c8 ignore next */
|
|
if (!ttl || !start)
|
|
return;
|
|
status.ttl = ttl;
|
|
status.start = start;
|
|
status.now = cachedNow || getNow();
|
|
const age = status.now - start;
|
|
status.remainingTTL = ttl - age;
|
|
}
|
|
};
|
|
// debounce calls to perf.now() to 1s so we're not hitting
|
|
// that costly call repeatedly.
|
|
let cachedNow = 0;
|
|
const getNow = () => {
|
|
const n = perf.now();
|
|
if (this.ttlResolution > 0) {
|
|
cachedNow = n;
|
|
const t = setTimeout(() => (cachedNow = 0), this.ttlResolution);
|
|
// not available on all platforms
|
|
/* c8 ignore start */
|
|
if (t.unref) {
|
|
t.unref();
|
|
}
|
|
/* c8 ignore stop */
|
|
}
|
|
return n;
|
|
};
|
|
this.getRemainingTTL = key => {
|
|
const index = this.#keyMap.get(key);
|
|
if (index === undefined) {
|
|
return 0;
|
|
}
|
|
const ttl = ttls[index];
|
|
const start = starts[index];
|
|
if (!ttl || !start) {
|
|
return Infinity;
|
|
}
|
|
const age = (cachedNow || getNow()) - start;
|
|
return ttl - age;
|
|
};
|
|
this.#isStale = index => {
|
|
const s = starts[index];
|
|
const t = ttls[index];
|
|
return !!t && !!s && (cachedNow || getNow()) - s > t;
|
|
};
|
|
}
|
|
// conditionally set private methods related to TTL
|
|
#updateItemAge = () => { };
|
|
#statusTTL = () => { };
|
|
#setItemTTL = () => { };
|
|
/* c8 ignore stop */
|
|
#isStale = () => false;
|
|
#initializeSizeTracking() {
|
|
const sizes = new ZeroArray(this.#max);
|
|
this.#calculatedSize = 0;
|
|
this.#sizes = sizes;
|
|
this.#removeItemSize = index => {
|
|
this.#calculatedSize -= sizes[index];
|
|
sizes[index] = 0;
|
|
};
|
|
this.#requireSize = (k, v, size, sizeCalculation) => {
|
|
// provisionally accept background fetches.
|
|
// actual value size will be checked when they return.
|
|
if (this.#isBackgroundFetch(v)) {
|
|
return 0;
|
|
}
|
|
if (!isPosInt(size)) {
|
|
if (sizeCalculation) {
|
|
if (typeof sizeCalculation !== 'function') {
|
|
throw new TypeError('sizeCalculation must be a function');
|
|
}
|
|
size = sizeCalculation(v, k);
|
|
if (!isPosInt(size)) {
|
|
throw new TypeError('sizeCalculation return invalid (expect positive integer)');
|
|
}
|
|
}
|
|
else {
|
|
throw new TypeError('invalid size value (must be positive integer). ' +
|
|
'When maxSize or maxEntrySize is used, sizeCalculation ' +
|
|
'or size must be set.');
|
|
}
|
|
}
|
|
return size;
|
|
};
|
|
this.#addItemSize = (index, size, status) => {
|
|
sizes[index] = size;
|
|
if (this.#maxSize) {
|
|
const maxSize = this.#maxSize - sizes[index];
|
|
while (this.#calculatedSize > maxSize) {
|
|
this.#evict(true);
|
|
}
|
|
}
|
|
this.#calculatedSize += sizes[index];
|
|
if (status) {
|
|
status.entrySize = size;
|
|
status.totalCalculatedSize = this.#calculatedSize;
|
|
}
|
|
};
|
|
}
|
|
#removeItemSize = _i => { };
|
|
#addItemSize = (_i, _s, _st) => { };
|
|
#requireSize = (_k, _v, size, sizeCalculation) => {
|
|
if (size || sizeCalculation) {
|
|
throw new TypeError('cannot set size without setting maxSize or maxEntrySize on cache');
|
|
}
|
|
return 0;
|
|
};
|
|
*#indexes({ allowStale = this.allowStale } = {}) {
|
|
if (this.#size) {
|
|
for (let i = this.#tail; true;) {
|
|
if (!this.#isValidIndex(i)) {
|
|
break;
|
|
}
|
|
if (allowStale || !this.#isStale(i)) {
|
|
yield i;
|
|
}
|
|
if (i === this.#head) {
|
|
break;
|
|
}
|
|
else {
|
|
i = this.#prev[i];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
*#rindexes({ allowStale = this.allowStale } = {}) {
|
|
if (this.#size) {
|
|
for (let i = this.#head; true;) {
|
|
if (!this.#isValidIndex(i)) {
|
|
break;
|
|
}
|
|
if (allowStale || !this.#isStale(i)) {
|
|
yield i;
|
|
}
|
|
if (i === this.#tail) {
|
|
break;
|
|
}
|
|
else {
|
|
i = this.#next[i];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#isValidIndex(index) {
|
|
return (index !== undefined &&
|
|
this.#keyMap.get(this.#keyList[index]) === index);
|
|
}
|
|
/**
|
|
* Return a generator yielding `[key, value]` pairs,
|
|
* in order from most recently used to least recently used.
|
|
*/
|
|
*entries() {
|
|
for (const i of this.#indexes()) {
|
|
if (this.#valList[i] !== undefined &&
|
|
this.#keyList[i] !== undefined &&
|
|
!this.#isBackgroundFetch(this.#valList[i])) {
|
|
yield [this.#keyList[i], this.#valList[i]];
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Inverse order version of {@link LRUCache.entries}
|
|
*
|
|
* Return a generator yielding `[key, value]` pairs,
|
|
* in order from least recently used to most recently used.
|
|
*/
|
|
*rentries() {
|
|
for (const i of this.#rindexes()) {
|
|
if (this.#valList[i] !== undefined &&
|
|
this.#keyList[i] !== undefined &&
|
|
!this.#isBackgroundFetch(this.#valList[i])) {
|
|
yield [this.#keyList[i], this.#valList[i]];
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Return a generator yielding the keys in the cache,
|
|
* in order from most recently used to least recently used.
|
|
*/
|
|
*keys() {
|
|
for (const i of this.#indexes()) {
|
|
const k = this.#keyList[i];
|
|
if (k !== undefined &&
|
|
!this.#isBackgroundFetch(this.#valList[i])) {
|
|
yield k;
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Inverse order version of {@link LRUCache.keys}
|
|
*
|
|
* Return a generator yielding the keys in the cache,
|
|
* in order from least recently used to most recently used.
|
|
*/
|
|
*rkeys() {
|
|
for (const i of this.#rindexes()) {
|
|
const k = this.#keyList[i];
|
|
if (k !== undefined &&
|
|
!this.#isBackgroundFetch(this.#valList[i])) {
|
|
yield k;
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Return a generator yielding the values in the cache,
|
|
* in order from most recently used to least recently used.
|
|
*/
|
|
*values() {
|
|
for (const i of this.#indexes()) {
|
|
const v = this.#valList[i];
|
|
if (v !== undefined &&
|
|
!this.#isBackgroundFetch(this.#valList[i])) {
|
|
yield this.#valList[i];
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Inverse order version of {@link LRUCache.values}
|
|
*
|
|
* Return a generator yielding the values in the cache,
|
|
* in order from least recently used to most recently used.
|
|
*/
|
|
*rvalues() {
|
|
for (const i of this.#rindexes()) {
|
|
const v = this.#valList[i];
|
|
if (v !== undefined &&
|
|
!this.#isBackgroundFetch(this.#valList[i])) {
|
|
yield this.#valList[i];
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Iterating over the cache itself yields the same results as
|
|
* {@link LRUCache.entries}
|
|
*/
|
|
[Symbol.iterator]() {
|
|
return this.entries();
|
|
}
|
|
/**
|
|
* A String value that is used in the creation of the default string
|
|
* description of an object. Called by the built-in method
|
|
* `Object.prototype.toString`.
|
|
*/
|
|
[Symbol.toStringTag] = 'LRUCache';
|
|
/**
|
|
* Find a value for which the supplied fn method returns a truthy value,
|
|
* similar to `Array.find()`. fn is called as `fn(value, key, cache)`.
|
|
*/
|
|
find(fn, getOptions = {}) {
|
|
for (const i of this.#indexes()) {
|
|
const v = this.#valList[i];
|
|
const value = this.#isBackgroundFetch(v)
|
|
? v.__staleWhileFetching
|
|
: v;
|
|
if (value === undefined)
|
|
continue;
|
|
if (fn(value, this.#keyList[i], this)) {
|
|
return this.get(this.#keyList[i], getOptions);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Call the supplied function on each item in the cache, in order from most
|
|
* recently used to least recently used.
|
|
*
|
|
* `fn` is called as `fn(value, key, cache)`.
|
|
*
|
|
* If `thisp` is provided, function will be called in the `this`-context of
|
|
* the provided object, or the cache if no `thisp` object is provided.
|
|
*
|
|
* Does not update age or recenty of use, or iterate over stale values.
|
|
*/
|
|
forEach(fn, thisp = this) {
|
|
for (const i of this.#indexes()) {
|
|
const v = this.#valList[i];
|
|
const value = this.#isBackgroundFetch(v)
|
|
? v.__staleWhileFetching
|
|
: v;
|
|
if (value === undefined)
|
|
continue;
|
|
fn.call(thisp, value, this.#keyList[i], this);
|
|
}
|
|
}
|
|
/**
|
|
* The same as {@link LRUCache.forEach} but items are iterated over in
|
|
* reverse order. (ie, less recently used items are iterated over first.)
|
|
*/
|
|
rforEach(fn, thisp = this) {
|
|
for (const i of this.#rindexes()) {
|
|
const v = this.#valList[i];
|
|
const value = this.#isBackgroundFetch(v)
|
|
? v.__staleWhileFetching
|
|
: v;
|
|
if (value === undefined)
|
|
continue;
|
|
fn.call(thisp, value, this.#keyList[i], this);
|
|
}
|
|
}
|
|
/**
|
|
* Delete any stale entries. Returns true if anything was removed,
|
|
* false otherwise.
|
|
*/
|
|
purgeStale() {
|
|
let deleted = false;
|
|
for (const i of this.#rindexes({ allowStale: true })) {
|
|
if (this.#isStale(i)) {
|
|
this.#delete(this.#keyList[i], 'expire');
|
|
deleted = true;
|
|
}
|
|
}
|
|
return deleted;
|
|
}
|
|
/**
|
|
* Get the extended info about a given entry, to get its value, size, and
|
|
* TTL info simultaneously. Returns `undefined` if the key is not present.
|
|
*
|
|
* Unlike {@link LRUCache#dump}, which is designed to be portable and survive
|
|
* serialization, the `start` value is always the current timestamp, and the
|
|
* `ttl` is a calculated remaining time to live (negative if expired).
|
|
*
|
|
* Always returns stale values, if their info is found in the cache, so be
|
|
* sure to check for expirations (ie, a negative {@link LRUCache.Entry#ttl})
|
|
* if relevant.
|
|
*/
|
|
info(key) {
|
|
const i = this.#keyMap.get(key);
|
|
if (i === undefined)
|
|
return undefined;
|
|
const v = this.#valList[i];
|
|
const value = this.#isBackgroundFetch(v)
|
|
? v.__staleWhileFetching
|
|
: v;
|
|
if (value === undefined)
|
|
return undefined;
|
|
const entry = { value };
|
|
if (this.#ttls && this.#starts) {
|
|
const ttl = this.#ttls[i];
|
|
const start = this.#starts[i];
|
|
if (ttl && start) {
|
|
const remain = ttl - (perf.now() - start);
|
|
entry.ttl = remain;
|
|
entry.start = Date.now();
|
|
}
|
|
}
|
|
if (this.#sizes) {
|
|
entry.size = this.#sizes[i];
|
|
}
|
|
return entry;
|
|
}
|
|
/**
|
|
* Return an array of [key, {@link LRUCache.Entry}] tuples which can be
|
|
* passed to {@link LRLUCache#load}.
|
|
*
|
|
* The `start` fields are calculated relative to a portable `Date.now()`
|
|
* timestamp, even if `performance.now()` is available.
|
|
*
|
|
* Stale entries are always included in the `dump`, even if
|
|
* {@link LRUCache.OptionsBase.allowStale} is false.
|
|
*
|
|
* Note: this returns an actual array, not a generator, so it can be more
|
|
* easily passed around.
|
|
*/
|
|
dump() {
|
|
const arr = [];
|
|
for (const i of this.#indexes({ allowStale: true })) {
|
|
const key = this.#keyList[i];
|
|
const v = this.#valList[i];
|
|
const value = this.#isBackgroundFetch(v)
|
|
? v.__staleWhileFetching
|
|
: v;
|
|
if (value === undefined || key === undefined)
|
|
continue;
|
|
const entry = { value };
|
|
if (this.#ttls && this.#starts) {
|
|
entry.ttl = this.#ttls[i];
|
|
// always dump the start relative to a portable timestamp
|
|
// it's ok for this to be a bit slow, it's a rare operation.
|
|
const age = perf.now() - this.#starts[i];
|
|
entry.start = Math.floor(Date.now() - age);
|
|
}
|
|
if (this.#sizes) {
|
|
entry.size = this.#sizes[i];
|
|
}
|
|
arr.unshift([key, entry]);
|
|
}
|
|
return arr;
|
|
}
|
|
/**
|
|
* Reset the cache and load in the items in entries in the order listed.
|
|
*
|
|
* The shape of the resulting cache may be different if the same options are
|
|
* not used in both caches.
|
|
*
|
|
* The `start` fields are assumed to be calculated relative to a portable
|
|
* `Date.now()` timestamp, even if `performance.now()` is available.
|
|
*/
|
|
load(arr) {
|
|
this.clear();
|
|
for (const [key, entry] of arr) {
|
|
if (entry.start) {
|
|
// entry.start is a portable timestamp, but we may be using
|
|
// node's performance.now(), so calculate the offset, so that
|
|
// we get the intended remaining TTL, no matter how long it's
|
|
// been on ice.
|
|
//
|
|
// it's ok for this to be a bit slow, it's a rare operation.
|
|
const age = Date.now() - entry.start;
|
|
entry.start = perf.now() - age;
|
|
}
|
|
this.set(key, entry.value, entry);
|
|
}
|
|
}
|
|
/**
|
|
* Add a value to the cache.
|
|
*
|
|
* Note: if `undefined` is specified as a value, this is an alias for
|
|
* {@link LRUCache#delete}
|
|
*
|
|
* Fields on the {@link LRUCache.SetOptions} options param will override
|
|
* their corresponding values in the constructor options for the scope
|
|
* of this single `set()` operation.
|
|
*
|
|
* If `start` is provided, then that will set the effective start
|
|
* time for the TTL calculation. Note that this must be a previous
|
|
* value of `performance.now()` if supported, or a previous value of
|
|
* `Date.now()` if not.
|
|
*
|
|
* Options object may also include `size`, which will prevent
|
|
* calling the `sizeCalculation` function and just use the specified
|
|
* number if it is a positive integer, and `noDisposeOnSet` which
|
|
* will prevent calling a `dispose` function in the case of
|
|
* overwrites.
|
|
*
|
|
* If the `size` (or return value of `sizeCalculation`) for a given
|
|
* entry is greater than `maxEntrySize`, then the item will not be
|
|
* added to the cache.
|
|
*
|
|
* Will update the recency of the entry.
|
|
*
|
|
* If the value is `undefined`, then this is an alias for
|
|
* `cache.delete(key)`. `undefined` is never stored in the cache.
|
|
*/
|
|
set(k, v, setOptions = {}) {
|
|
if (v === undefined) {
|
|
this.delete(k);
|
|
return this;
|
|
}
|
|
const { ttl = this.ttl, start, noDisposeOnSet = this.noDisposeOnSet, sizeCalculation = this.sizeCalculation, status, } = setOptions;
|
|
let { noUpdateTTL = this.noUpdateTTL } = setOptions;
|
|
const size = this.#requireSize(k, v, setOptions.size || 0, sizeCalculation);
|
|
// if the item doesn't fit, don't do anything
|
|
// NB: maxEntrySize set to maxSize by default
|
|
if (this.maxEntrySize && size > this.maxEntrySize) {
|
|
if (status) {
|
|
status.set = 'miss';
|
|
status.maxEntrySizeExceeded = true;
|
|
}
|
|
// have to delete, in case something is there already.
|
|
this.#delete(k, 'set');
|
|
return this;
|
|
}
|
|
let index = this.#size === 0 ? undefined : this.#keyMap.get(k);
|
|
if (index === undefined) {
|
|
// addition
|
|
index = (this.#size === 0
|
|
? this.#tail
|
|
: this.#free.length !== 0
|
|
? this.#free.pop()
|
|
: this.#size === this.#max
|
|
? this.#evict(false)
|
|
: this.#size);
|
|
this.#keyList[index] = k;
|
|
this.#valList[index] = v;
|
|
this.#keyMap.set(k, index);
|
|
this.#next[this.#tail] = index;
|
|
this.#prev[index] = this.#tail;
|
|
this.#tail = index;
|
|
this.#size++;
|
|
this.#addItemSize(index, size, status);
|
|
if (status)
|
|
status.set = 'add';
|
|
noUpdateTTL = false;
|
|
}
|
|
else {
|
|
// update
|
|
this.#moveToTail(index);
|
|
const oldVal = this.#valList[index];
|
|
if (v !== oldVal) {
|
|
if (this.#hasFetchMethod && this.#isBackgroundFetch(oldVal)) {
|
|
oldVal.__abortController.abort(new Error('replaced'));
|
|
const { __staleWhileFetching: s } = oldVal;
|
|
if (s !== undefined && !noDisposeOnSet) {
|
|
if (this.#hasDispose) {
|
|
this.#dispose?.(s, k, 'set');
|
|
}
|
|
if (this.#hasDisposeAfter) {
|
|
this.#disposed?.push([s, k, 'set']);
|
|
}
|
|
}
|
|
}
|
|
else if (!noDisposeOnSet) {
|
|
if (this.#hasDispose) {
|
|
this.#dispose?.(oldVal, k, 'set');
|
|
}
|
|
if (this.#hasDisposeAfter) {
|
|
this.#disposed?.push([oldVal, k, 'set']);
|
|
}
|
|
}
|
|
this.#removeItemSize(index);
|
|
this.#addItemSize(index, size, status);
|
|
this.#valList[index] = v;
|
|
if (status) {
|
|
status.set = 'replace';
|
|
const oldValue = oldVal && this.#isBackgroundFetch(oldVal)
|
|
? oldVal.__staleWhileFetching
|
|
: oldVal;
|
|
if (oldValue !== undefined)
|
|
status.oldValue = oldValue;
|
|
}
|
|
}
|
|
else if (status) {
|
|
status.set = 'update';
|
|
}
|
|
}
|
|
if (ttl !== 0 && !this.#ttls) {
|
|
this.#initializeTTLTracking();
|
|
}
|
|
if (this.#ttls) {
|
|
if (!noUpdateTTL) {
|
|
this.#setItemTTL(index, ttl, start);
|
|
}
|
|
if (status)
|
|
this.#statusTTL(status, index);
|
|
}
|
|
if (!noDisposeOnSet && this.#hasDisposeAfter && this.#disposed) {
|
|
const dt = this.#disposed;
|
|
let task;
|
|
while ((task = dt?.shift())) {
|
|
this.#disposeAfter?.(...task);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
/**
|
|
* Evict the least recently used item, returning its value or
|
|
* `undefined` if cache is empty.
|
|
*/
|
|
pop() {
|
|
try {
|
|
while (this.#size) {
|
|
const val = this.#valList[this.#head];
|
|
this.#evict(true);
|
|
if (this.#isBackgroundFetch(val)) {
|
|
if (val.__staleWhileFetching) {
|
|
return val.__staleWhileFetching;
|
|
}
|
|
}
|
|
else if (val !== undefined) {
|
|
return val;
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
if (this.#hasDisposeAfter && this.#disposed) {
|
|
const dt = this.#disposed;
|
|
let task;
|
|
while ((task = dt?.shift())) {
|
|
this.#disposeAfter?.(...task);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#evict(free) {
|
|
const head = this.#head;
|
|
const k = this.#keyList[head];
|
|
const v = this.#valList[head];
|
|
if (this.#hasFetchMethod && this.#isBackgroundFetch(v)) {
|
|
v.__abortController.abort(new Error('evicted'));
|
|
}
|
|
else if (this.#hasDispose || this.#hasDisposeAfter) {
|
|
if (this.#hasDispose) {
|
|
this.#dispose?.(v, k, 'evict');
|
|
}
|
|
if (this.#hasDisposeAfter) {
|
|
this.#disposed?.push([v, k, 'evict']);
|
|
}
|
|
}
|
|
this.#removeItemSize(head);
|
|
// if we aren't about to use the index, then null these out
|
|
if (free) {
|
|
this.#keyList[head] = undefined;
|
|
this.#valList[head] = undefined;
|
|
this.#free.push(head);
|
|
}
|
|
if (this.#size === 1) {
|
|
this.#head = this.#tail = 0;
|
|
this.#free.length = 0;
|
|
}
|
|
else {
|
|
this.#head = this.#next[head];
|
|
}
|
|
this.#keyMap.delete(k);
|
|
this.#size--;
|
|
return head;
|
|
}
|
|
/**
|
|
* Check if a key is in the cache, without updating the recency of use.
|
|
* Will return false if the item is stale, even though it is technically
|
|
* in the cache.
|
|
*
|
|
* Check if a key is in the cache, without updating the recency of
|
|
* use. Age is updated if {@link LRUCache.OptionsBase.updateAgeOnHas} is set
|
|
* to `true` in either the options or the constructor.
|
|
*
|
|
* Will return `false` if the item is stale, even though it is technically in
|
|
* the cache. The difference can be determined (if it matters) by using a
|
|
* `status` argument, and inspecting the `has` field.
|
|
*
|
|
* Will not update item age unless
|
|
* {@link LRUCache.OptionsBase.updateAgeOnHas} is set.
|
|
*/
|
|
has(k, hasOptions = {}) {
|
|
const { updateAgeOnHas = this.updateAgeOnHas, status } = hasOptions;
|
|
const index = this.#keyMap.get(k);
|
|
if (index !== undefined) {
|
|
const v = this.#valList[index];
|
|
if (this.#isBackgroundFetch(v) &&
|
|
v.__staleWhileFetching === undefined) {
|
|
return false;
|
|
}
|
|
if (!this.#isStale(index)) {
|
|
if (updateAgeOnHas) {
|
|
this.#updateItemAge(index);
|
|
}
|
|
if (status) {
|
|
status.has = 'hit';
|
|
this.#statusTTL(status, index);
|
|
}
|
|
return true;
|
|
}
|
|
else if (status) {
|
|
status.has = 'stale';
|
|
this.#statusTTL(status, index);
|
|
}
|
|
}
|
|
else if (status) {
|
|
status.has = 'miss';
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Like {@link LRUCache#get} but doesn't update recency or delete stale
|
|
* items.
|
|
*
|
|
* Returns `undefined` if the item is stale, unless
|
|
* {@link LRUCache.OptionsBase.allowStale} is set.
|
|
*/
|
|
peek(k, peekOptions = {}) {
|
|
const { allowStale = this.allowStale } = peekOptions;
|
|
const index = this.#keyMap.get(k);
|
|
if (index === undefined ||
|
|
(!allowStale && this.#isStale(index))) {
|
|
return;
|
|
}
|
|
const v = this.#valList[index];
|
|
// either stale and allowed, or forcing a refresh of non-stale value
|
|
return this.#isBackgroundFetch(v) ? v.__staleWhileFetching : v;
|
|
}
|
|
#backgroundFetch(k, index, options, context) {
|
|
const v = index === undefined ? undefined : this.#valList[index];
|
|
if (this.#isBackgroundFetch(v)) {
|
|
return v;
|
|
}
|
|
const ac = new AC();
|
|
const { signal } = options;
|
|
// when/if our AC signals, then stop listening to theirs.
|
|
signal?.addEventListener('abort', () => ac.abort(signal.reason), {
|
|
signal: ac.signal,
|
|
});
|
|
const fetchOpts = {
|
|
signal: ac.signal,
|
|
options,
|
|
context,
|
|
};
|
|
const cb = (v, updateCache = false) => {
|
|
const { aborted } = ac.signal;
|
|
const ignoreAbort = options.ignoreFetchAbort && v !== undefined;
|
|
if (options.status) {
|
|
if (aborted && !updateCache) {
|
|
options.status.fetchAborted = true;
|
|
options.status.fetchError = ac.signal.reason;
|
|
if (ignoreAbort)
|
|
options.status.fetchAbortIgnored = true;
|
|
}
|
|
else {
|
|
options.status.fetchResolved = true;
|
|
}
|
|
}
|
|
if (aborted && !ignoreAbort && !updateCache) {
|
|
return fetchFail(ac.signal.reason);
|
|
}
|
|
// either we didn't abort, and are still here, or we did, and ignored
|
|
const bf = p;
|
|
if (this.#valList[index] === p) {
|
|
if (v === undefined) {
|
|
if (bf.__staleWhileFetching) {
|
|
this.#valList[index] = bf.__staleWhileFetching;
|
|
}
|
|
else {
|
|
this.#delete(k, 'fetch');
|
|
}
|
|
}
|
|
else {
|
|
if (options.status)
|
|
options.status.fetchUpdated = true;
|
|
this.set(k, v, fetchOpts.options);
|
|
}
|
|
}
|
|
return v;
|
|
};
|
|
const eb = (er) => {
|
|
if (options.status) {
|
|
options.status.fetchRejected = true;
|
|
options.status.fetchError = er;
|
|
}
|
|
return fetchFail(er);
|
|
};
|
|
const fetchFail = (er) => {
|
|
const { aborted } = ac.signal;
|
|
const allowStaleAborted = aborted && options.allowStaleOnFetchAbort;
|
|
const allowStale = allowStaleAborted || options.allowStaleOnFetchRejection;
|
|
const noDelete = allowStale || options.noDeleteOnFetchRejection;
|
|
const bf = p;
|
|
if (this.#valList[index] === p) {
|
|
// if we allow stale on fetch rejections, then we need to ensure that
|
|
// the stale value is not removed from the cache when the fetch fails.
|
|
const del = !noDelete || bf.__staleWhileFetching === undefined;
|
|
if (del) {
|
|
this.#delete(k, 'fetch');
|
|
}
|
|
else if (!allowStaleAborted) {
|
|
// still replace the *promise* with the stale value,
|
|
// since we are done with the promise at this point.
|
|
// leave it untouched if we're still waiting for an
|
|
// aborted background fetch that hasn't yet returned.
|
|
this.#valList[index] = bf.__staleWhileFetching;
|
|
}
|
|
}
|
|
if (allowStale) {
|
|
if (options.status && bf.__staleWhileFetching !== undefined) {
|
|
options.status.returnedStale = true;
|
|
}
|
|
return bf.__staleWhileFetching;
|
|
}
|
|
else if (bf.__returned === bf) {
|
|
throw er;
|
|
}
|
|
};
|
|
const pcall = (res, rej) => {
|
|
const fmp = this.#fetchMethod?.(k, v, fetchOpts);
|
|
if (fmp && fmp instanceof Promise) {
|
|
fmp.then(v => res(v === undefined ? undefined : v), rej);
|
|
}
|
|
// ignored, we go until we finish, regardless.
|
|
// defer check until we are actually aborting,
|
|
// so fetchMethod can override.
|
|
ac.signal.addEventListener('abort', () => {
|
|
if (!options.ignoreFetchAbort ||
|
|
options.allowStaleOnFetchAbort) {
|
|
res(undefined);
|
|
// when it eventually resolves, update the cache.
|
|
if (options.allowStaleOnFetchAbort) {
|
|
res = v => cb(v, true);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
if (options.status)
|
|
options.status.fetchDispatched = true;
|
|
const p = new Promise(pcall).then(cb, eb);
|
|
const bf = Object.assign(p, {
|
|
__abortController: ac,
|
|
__staleWhileFetching: v,
|
|
__returned: undefined,
|
|
});
|
|
if (index === undefined) {
|
|
// internal, don't expose status.
|
|
this.set(k, bf, { ...fetchOpts.options, status: undefined });
|
|
index = this.#keyMap.get(k);
|
|
}
|
|
else {
|
|
this.#valList[index] = bf;
|
|
}
|
|
return bf;
|
|
}
|
|
#isBackgroundFetch(p) {
|
|
if (!this.#hasFetchMethod)
|
|
return false;
|
|
const b = p;
|
|
return (!!b &&
|
|
b instanceof Promise &&
|
|
b.hasOwnProperty('__staleWhileFetching') &&
|
|
b.__abortController instanceof AC);
|
|
}
|
|
async fetch(k, fetchOptions = {}) {
|
|
const {
|
|
// get options
|
|
allowStale = this.allowStale, updateAgeOnGet = this.updateAgeOnGet, noDeleteOnStaleGet = this.noDeleteOnStaleGet,
|
|
// set options
|
|
ttl = this.ttl, noDisposeOnSet = this.noDisposeOnSet, size = 0, sizeCalculation = this.sizeCalculation, noUpdateTTL = this.noUpdateTTL,
|
|
// fetch exclusive options
|
|
noDeleteOnFetchRejection = this.noDeleteOnFetchRejection, allowStaleOnFetchRejection = this.allowStaleOnFetchRejection, ignoreFetchAbort = this.ignoreFetchAbort, allowStaleOnFetchAbort = this.allowStaleOnFetchAbort, context, forceRefresh = false, status, signal, } = fetchOptions;
|
|
if (!this.#hasFetchMethod) {
|
|
if (status)
|
|
status.fetch = 'get';
|
|
return this.get(k, {
|
|
allowStale,
|
|
updateAgeOnGet,
|
|
noDeleteOnStaleGet,
|
|
status,
|
|
});
|
|
}
|
|
const options = {
|
|
allowStale,
|
|
updateAgeOnGet,
|
|
noDeleteOnStaleGet,
|
|
ttl,
|
|
noDisposeOnSet,
|
|
size,
|
|
sizeCalculation,
|
|
noUpdateTTL,
|
|
noDeleteOnFetchRejection,
|
|
allowStaleOnFetchRejection,
|
|
allowStaleOnFetchAbort,
|
|
ignoreFetchAbort,
|
|
status,
|
|
signal,
|
|
};
|
|
let index = this.#keyMap.get(k);
|
|
if (index === undefined) {
|
|
if (status)
|
|
status.fetch = 'miss';
|
|
const p = this.#backgroundFetch(k, index, options, context);
|
|
return (p.__returned = p);
|
|
}
|
|
else {
|
|
// in cache, maybe already fetching
|
|
const v = this.#valList[index];
|
|
if (this.#isBackgroundFetch(v)) {
|
|
const stale = allowStale && v.__staleWhileFetching !== undefined;
|
|
if (status) {
|
|
status.fetch = 'inflight';
|
|
if (stale)
|
|
status.returnedStale = true;
|
|
}
|
|
return stale ? v.__staleWhileFetching : (v.__returned = v);
|
|
}
|
|
// if we force a refresh, that means do NOT serve the cached value,
|
|
// unless we are already in the process of refreshing the cache.
|
|
const isStale = this.#isStale(index);
|
|
if (!forceRefresh && !isStale) {
|
|
if (status)
|
|
status.fetch = 'hit';
|
|
this.#moveToTail(index);
|
|
if (updateAgeOnGet) {
|
|
this.#updateItemAge(index);
|
|
}
|
|
if (status)
|
|
this.#statusTTL(status, index);
|
|
return v;
|
|
}
|
|
// ok, it is stale or a forced refresh, and not already fetching.
|
|
// refresh the cache.
|
|
const p = this.#backgroundFetch(k, index, options, context);
|
|
const hasStale = p.__staleWhileFetching !== undefined;
|
|
const staleVal = hasStale && allowStale;
|
|
if (status) {
|
|
status.fetch = isStale ? 'stale' : 'refresh';
|
|
if (staleVal && isStale)
|
|
status.returnedStale = true;
|
|
}
|
|
return staleVal ? p.__staleWhileFetching : (p.__returned = p);
|
|
}
|
|
}
|
|
async forceFetch(k, fetchOptions = {}) {
|
|
const v = await this.fetch(k, fetchOptions);
|
|
if (v === undefined)
|
|
throw new Error('fetch() returned undefined');
|
|
return v;
|
|
}
|
|
memo(k, memoOptions = {}) {
|
|
const memoMethod = this.#memoMethod;
|
|
if (!memoMethod) {
|
|
throw new Error('no memoMethod provided to constructor');
|
|
}
|
|
const { context, forceRefresh, ...options } = memoOptions;
|
|
const v = this.get(k, options);
|
|
if (!forceRefresh && v !== undefined)
|
|
return v;
|
|
const vv = memoMethod(k, v, {
|
|
options,
|
|
context,
|
|
});
|
|
this.set(k, vv, options);
|
|
return vv;
|
|
}
|
|
/**
|
|
* Return a value from the cache. Will update the recency of the cache
|
|
* entry found.
|
|
*
|
|
* If the key is not found, get() will return `undefined`.
|
|
*/
|
|
get(k, getOptions = {}) {
|
|
const { allowStale = this.allowStale, updateAgeOnGet = this.updateAgeOnGet, noDeleteOnStaleGet = this.noDeleteOnStaleGet, status, } = getOptions;
|
|
const index = this.#keyMap.get(k);
|
|
if (index !== undefined) {
|
|
const value = this.#valList[index];
|
|
const fetching = this.#isBackgroundFetch(value);
|
|
if (status)
|
|
this.#statusTTL(status, index);
|
|
if (this.#isStale(index)) {
|
|
if (status)
|
|
status.get = 'stale';
|
|
// delete only if not an in-flight background fetch
|
|
if (!fetching) {
|
|
if (!noDeleteOnStaleGet) {
|
|
this.#delete(k, 'expire');
|
|
}
|
|
if (status && allowStale)
|
|
status.returnedStale = true;
|
|
return allowStale ? value : undefined;
|
|
}
|
|
else {
|
|
if (status &&
|
|
allowStale &&
|
|
value.__staleWhileFetching !== undefined) {
|
|
status.returnedStale = true;
|
|
}
|
|
return allowStale ? value.__staleWhileFetching : undefined;
|
|
}
|
|
}
|
|
else {
|
|
if (status)
|
|
status.get = 'hit';
|
|
// if we're currently fetching it, we don't actually have it yet
|
|
// it's not stale, which means this isn't a staleWhileRefetching.
|
|
// If it's not stale, and fetching, AND has a __staleWhileFetching
|
|
// value, then that means the user fetched with {forceRefresh:true},
|
|
// so it's safe to return that value.
|
|
if (fetching) {
|
|
return value.__staleWhileFetching;
|
|
}
|
|
this.#moveToTail(index);
|
|
if (updateAgeOnGet) {
|
|
this.#updateItemAge(index);
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
else if (status) {
|
|
status.get = 'miss';
|
|
}
|
|
}
|
|
#connect(p, n) {
|
|
this.#prev[n] = p;
|
|
this.#next[p] = n;
|
|
}
|
|
#moveToTail(index) {
|
|
// if tail already, nothing to do
|
|
// if head, move head to next[index]
|
|
// else
|
|
// move next[prev[index]] to next[index] (head has no prev)
|
|
// move prev[next[index]] to prev[index]
|
|
// prev[index] = tail
|
|
// next[tail] = index
|
|
// tail = index
|
|
if (index !== this.#tail) {
|
|
if (index === this.#head) {
|
|
this.#head = this.#next[index];
|
|
}
|
|
else {
|
|
this.#connect(this.#prev[index], this.#next[index]);
|
|
}
|
|
this.#connect(this.#tail, index);
|
|
this.#tail = index;
|
|
}
|
|
}
|
|
/**
|
|
* Deletes a key out of the cache.
|
|
*
|
|
* Returns true if the key was deleted, false otherwise.
|
|
*/
|
|
delete(k) {
|
|
return this.#delete(k, 'delete');
|
|
}
|
|
#delete(k, reason) {
|
|
let deleted = false;
|
|
if (this.#size !== 0) {
|
|
const index = this.#keyMap.get(k);
|
|
if (index !== undefined) {
|
|
deleted = true;
|
|
if (this.#size === 1) {
|
|
this.#clear(reason);
|
|
}
|
|
else {
|
|
this.#removeItemSize(index);
|
|
const v = this.#valList[index];
|
|
if (this.#isBackgroundFetch(v)) {
|
|
v.__abortController.abort(new Error('deleted'));
|
|
}
|
|
else if (this.#hasDispose || this.#hasDisposeAfter) {
|
|
if (this.#hasDispose) {
|
|
this.#dispose?.(v, k, reason);
|
|
}
|
|
if (this.#hasDisposeAfter) {
|
|
this.#disposed?.push([v, k, reason]);
|
|
}
|
|
}
|
|
this.#keyMap.delete(k);
|
|
this.#keyList[index] = undefined;
|
|
this.#valList[index] = undefined;
|
|
if (index === this.#tail) {
|
|
this.#tail = this.#prev[index];
|
|
}
|
|
else if (index === this.#head) {
|
|
this.#head = this.#next[index];
|
|
}
|
|
else {
|
|
const pi = this.#prev[index];
|
|
this.#next[pi] = this.#next[index];
|
|
const ni = this.#next[index];
|
|
this.#prev[ni] = this.#prev[index];
|
|
}
|
|
this.#size--;
|
|
this.#free.push(index);
|
|
}
|
|
}
|
|
}
|
|
if (this.#hasDisposeAfter && this.#disposed?.length) {
|
|
const dt = this.#disposed;
|
|
let task;
|
|
while ((task = dt?.shift())) {
|
|
this.#disposeAfter?.(...task);
|
|
}
|
|
}
|
|
return deleted;
|
|
}
|
|
/**
|
|
* Clear the cache entirely, throwing away all values.
|
|
*/
|
|
clear() {
|
|
return this.#clear('delete');
|
|
}
|
|
#clear(reason) {
|
|
for (const index of this.#rindexes({ allowStale: true })) {
|
|
const v = this.#valList[index];
|
|
if (this.#isBackgroundFetch(v)) {
|
|
v.__abortController.abort(new Error('deleted'));
|
|
}
|
|
else {
|
|
const k = this.#keyList[index];
|
|
if (this.#hasDispose) {
|
|
this.#dispose?.(v, k, reason);
|
|
}
|
|
if (this.#hasDisposeAfter) {
|
|
this.#disposed?.push([v, k, reason]);
|
|
}
|
|
}
|
|
}
|
|
this.#keyMap.clear();
|
|
this.#valList.fill(undefined);
|
|
this.#keyList.fill(undefined);
|
|
if (this.#ttls && this.#starts) {
|
|
this.#ttls.fill(0);
|
|
this.#starts.fill(0);
|
|
}
|
|
if (this.#sizes) {
|
|
this.#sizes.fill(0);
|
|
}
|
|
this.#head = 0;
|
|
this.#tail = 0;
|
|
this.#free.length = 0;
|
|
this.#calculatedSize = 0;
|
|
this.#size = 0;
|
|
if (this.#hasDisposeAfter && this.#disposed) {
|
|
const dt = this.#disposed;
|
|
let task;
|
|
while ((task = dt?.shift())) {
|
|
this.#disposeAfter?.(...task);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=index.js.map
|