UNPKG

@cachemap/core

Version:
852 lines (683 loc) 24 kB
import { instance } from '@cachemap/controller'; import { MapStore } from '@cachemap/map'; import { type DehydratedMetadata, type Metadata, type Store, type Tag } from '@cachemap/types'; import { ArgsError, GroupedError, PositionError, ValueFormat, constants, dehydrateMetadata, isJsonValue, prepareGetEntry, prepareSetEntry, rehydrateMetadata, sizeOf, } from '@cachemap/utils'; import { Cacheability } from 'cacheability'; import { EventEmitter } from 'eventemitter3'; import { castArray, get, isArray, isFunction, isNumber, isPlainObject, isString, isUndefined } from 'lodash-es'; import { Md5 } from 'ts-md5'; import { type JsonValue } from 'type-fest'; import { DEFAULT_BACKUP_INTERVAL, DEFAULT_MAX_HEAP_SIZE } from '../constants.ts'; import { type CacheHeaders, type ConstructorOptions, type ControllerEvent, type ExportOptions, type ExportResult, type FilterByValue, type ImportOptions, type MethodName, type Reaper, type ReaperInit, type RequestQueue, } from '../types.ts'; export class Core { public events = { ENTRY_DELETED: 'ENTRY_DELETED', }; private static _sortComparator = (a: Metadata, b: Metadata): number => { let index; if (a.accessedCount > b.accessedCount) { index = -1; } else if (a.accessedCount < b.accessedCount) { index = 1; } else if (a.lastAccessed > b.lastAccessed) { index = -1; } else if (a.lastAccessed < b.lastAccessed) { index = 1; } else if (a.lastUpdated > b.lastUpdated) { index = -1; } else if (a.lastUpdated < b.lastUpdated) { index = 1; } else if (a.added > b.added) { index = -1; } else if (a.added < b.added) { index = 1; } else if (a.size < b.size) { index = -1; } else if (a.size > b.size) { index = 1; } else { index = 0; } return index; }; private _handleClearEvent = ({ name, type }: ControllerEvent): void => { if ((isString(name) && name === this._name) || (isString(type) && type === this._type)) { void this._clear(); } }; private _handleStartReaperEvent = ({ name, type }: ControllerEvent): void => { if ((isString(name) && name === this._name) || (isString(type) && type === this._type)) { this._reaper?.start(); } }; private _handleStopReaperEvent = ({ name, type }: ControllerEvent): void => { if ((isString(name) && name === this._name) || (isString(type) && type === this._type)) { this._reaper?.stop(); } }; private _handleStartBackupEvent = ({ name, type }: ControllerEvent): void => { if ((isString(name) && name === this._name) || (isString(type) && type === this._type)) { this._startBackup(); } }; private _handleStopBackupEvent = ({ name, type }: ControllerEvent): void => { if ((isString(name) && name === this._name) || (isString(type) && type === this._type)) { this._stopBackup(); } }; private _backupInterval: number = DEFAULT_BACKUP_INTERVAL; private _backupIntervalID?: NodeJS.Timeout; private _backupStore?: Store; private readonly _disableCacheInvalidation: boolean; private _emitter: EventEmitter = new EventEmitter(); private readonly _encryptionSecret: string | undefined; private _maxHeapSize: number = DEFAULT_MAX_HEAP_SIZE; private _metadata: Metadata[] = []; private readonly _name: string; private _persistedStore = true; private _processing: string[] = []; private _ready = false; private readonly _reaper?: Reaper; private _requestQueue: RequestQueue = []; private readonly _sharedCache: boolean; private _store?: Store; private readonly _type: string; private _usedHeapSize = 0; private readonly _valueFormatting: ValueFormat = ValueFormat.String; constructor(options: ConstructorOptions) { const errors: ArgsError[] = []; if (!isPlainObject(options)) { errors.push(new ArgsError('@cachemap/core expected options to be a plain object.')); } if (!isString(options.name)) { errors.push(new ArgsError('@cachemap/core expected options.name to be a string.')); } if (!isFunction(options.store)) { errors.push(new ArgsError('@cachemap/core expected options.store to be a function.')); } if (!isString(options.type)) { errors.push(new ArgsError('@cachemap/core expected options.type to be a string.')); } if (options.valueFormatting === ValueFormat.Ecrypt && !options.encryptionSecret) { errors.push( new ArgsError('@cachemap/core expected encryptionSecret to be set when valueFormatting is "encrypt"'), ); } if (errors.length > 0) { throw new GroupedError('@cachemap/core constructor argument validation errors.', errors); } const { backupInterval, backupStore, disableCacheInvalidation = false, encryptionSecret, name, reaper, sharedCache = false, sortComparator, startBackup, store: storeInit, type, valueFormatting, } = options; this._disableCacheInvalidation = disableCacheInvalidation; if (valueFormatting) { this._valueFormatting = valueFormatting; } if (isString(encryptionSecret)) { this._encryptionSecret = encryptionSecret; } this._name = name; if (isFunction(reaper)) { this._reaper = this._initializeReaper(reaper); } this._sharedCache = sharedCache; if (isFunction(sortComparator)) { Core._sortComparator = sortComparator; } this._type = type; this._addControllerEventListeners(); void Promise.resolve(storeInit({ name })).then(async store => { this._maxHeapSize = store.maxHeapSize; if (backupStore) { if (store.type === 'map') { throw new ArgsError("@cachemap/core expected store.type not to be 'map' when backupStore is true."); } if (isNumber(backupInterval)) { this._backupInterval = backupInterval; } this._backupStore = store; this._persistedStore = true; this._store = new MapStore({ maxHeapSize: store.maxHeapSize, name }); await this._backupStoreEntriesToStore(); this._ready = true; void this._releaseQueuedRequests(); if (startBackup) { this._startBackup(); } } else { this._persistedStore = store.type !== 'map'; this._store = store; await this._retreiveMetadataFromStore(); this._ready = true; void this._releaseQueuedRequests(); } }); } public async clear(): Promise<void> { return this._clear(); } public async delete(key: string, options: { hashKey?: boolean } = {}): Promise<boolean> { const errors: ArgsError[] = []; if (!isString(key)) { errors.push(new ArgsError('@cachemap/core expected key to be a string.')); } if (!isPlainObject(options)) { errors.push(new ArgsError('@cachemap/core expected options to be a plain object.')); } if (errors.length > 0) { throw new GroupedError('@cachemap/core delete argument validation errors.', errors); } return this._delete(key, options); } get emitter(): EventEmitter { return this._emitter; } public async entries<T>(keys?: string[]): Promise<[string, T][]> { if (keys && !isArray(keys)) { throw new ArgsError('@cachemap/core expected keys to be an array.'); } const entries = await this._entries<T>(keys); return entries.sort(([a], [b]) => { if (a < b) { return -1; } if (a > b) { return 1; } return 0; }); } public async export<T>(options: ExportOptions = {}): Promise<ExportResult<T>> { const errors: ArgsError[] = []; if (!isPlainObject(options)) { errors.push(new ArgsError('@cachemap/core expected options to be an plain object.')); } if (options.keys && !isArray(options.keys)) { errors.push(new ArgsError('@cachemap/core expected options.keys to be an array.')); } if (errors.length > 0) { throw new GroupedError('@cachemap/core export argument validation errors.', errors); } const { entries, metadata } = await this._export<T>(options); return { entries: entries.sort(([a], [b]) => { if (a < b) { return -1; } if (a > b) { return 1; } return 0; }), metadata: metadata.sort((a, b) => { if (a.key < b.key) { return -1; } if (a.key > b.key) { return 1; } return 0; }), }; } public async get<T>(key: string, options: { hashKey?: boolean } = {}): Promise<T | undefined> { const errors: ArgsError[] = []; if (!isString(key)) { errors.push(new ArgsError('@cachemap/core expected key to be a string.')); } if (!isPlainObject(options)) { errors.push(new ArgsError('@cachemap/core expected options to be a plain object.')); } if (errors.length > 0) { throw new GroupedError('@cachemap/core get argument validation errors.', errors); } return this._get<T>(key, options); } public async has( key: string, options: { deleteExpired?: boolean; hashKey?: boolean } = {}, ): Promise<false | Cacheability> { const errors: ArgsError[] = []; if (!isString(key)) { errors.push(new ArgsError('@cachemap/core expected key to be a string.')); } if (!isPlainObject(options)) { errors.push(new ArgsError('@cachemap/core expected opts to be a plain object.')); } if (errors.length > 0) { throw new GroupedError('@cachemap/core has argument validation errors.', errors); } return this._has(key, options); } public async import(options: ImportOptions): Promise<void> { if (!isPlainObject(options)) { throw new ArgsError('@cachemap/core expected options to be a plain object.'); } const { entries, metadata } = options; const errors: ArgsError[] = []; if (!isArray(entries)) { errors.push(new ArgsError('@cachemap/core expected entries to be an array.')); } if (!isArray(metadata)) { errors.push(new ArgsError('@cachemap/core expected metadata to be an array.')); } if (errors.length > 0) { throw new GroupedError('@cachemap/core has argument validation errors.', errors); } return this._import(options); } get metadata(): Metadata[] { return this._metadata; } get name(): string { return this._name; } get reaper(): Reaper | undefined { return this._reaper; } public async set( key: string, value: unknown, options: { cacheHeaders?: CacheHeaders; hashKey?: boolean; tag?: Tag } = {}, ): Promise<void> { const errors: ArgsError[] = []; if (!isString(key)) { errors.push(new ArgsError('@cachemap/core expected key to be a string.')); } if (!isPlainObject(options)) { errors.push(new ArgsError('@cachemap/core expected options to be a plain object.')); } if (!isJsonValue(value)) { errors.push(new ArgsError('@cachemap/core expected value to be JSON serializable.')); } if (errors.length > 0) { throw new GroupedError('@cachemap/core set argument validation errors.', errors); } // typescript not deriving value is JsonValue from above type guard. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return this._set(key, value as JsonValue, options); } public async size(): Promise<number> { return this._size(); } public startBackup(): void { this._startBackup(); } public stopBackup(): void { this._stopBackup(); } get storeType(): string { return this._store?.type ?? 'none'; } get type(): string { return this._type; } get usedHeapSize(): number { return this._usedHeapSize; } private _addControllerEventListeners() { instance.on(constants.CLEAR, this._handleClearEvent); instance.on(constants.START_REAPER, this._handleStartReaperEvent); instance.on(constants.STOP_REAPER, this._handleStopReaperEvent); instance.on(constants.START_BACKUP, this._handleStartBackupEvent); instance.on(constants.STOP_BACKUP, this._handleStopBackupEvent); } private async _addMetadata(key: string, size: number, cacheability: Cacheability, tag?: Tag): Promise<void> { this._metadata.push({ accessedCount: 0, added: Date.now(), cacheability, key, lastAccessed: Date.now(), lastUpdated: Date.now(), size, tags: tag ? [tag] : [], updatedCount: 0, }); this._sortMetadata(); this._updateHeapSize(); return this._backupMetadata(); } private _addRequestToQueue<T>(methodName: MethodName, ...payload: unknown[]) { return new Promise((resolve: (value: T) => void) => { this._requestQueue.push([resolve, methodName, payload]); }); } private async _backupMetadata(): Promise<void> { if (!this._store || !this._persistedStore) { return; } const store = this._backupStore ?? this._store; return store.set( constants.METADATA, // metadata is serializable as JSON. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions prepareSetEntry(dehydrateMetadata(this._metadata) as JsonValue, this._valueFormatting, this._encryptionSecret), ); } private async _backupStoreEntriesToStore(): Promise<void> { if (!(this._backupStore && this._store)) { throw new PositionError( '@cachemap/core expected backupStoreEntriesToStore to be called after setting the backupStore and store.', ); } this._metadata = []; const backupMetadata = await this._backupStore.get(constants.METADATA); if (backupMetadata) { const metadata = prepareGetEntry<DehydratedMetadata[]>( backupMetadata, this._valueFormatting, this._encryptionSecret, ); if (metadata.length > 0) { const keys = metadata.map(entry => entry.key); await this._store.import(await this._backupStore.entries(keys)); this._metadata = rehydrateMetadata(metadata); } } } private _calcReductionChunk(): number | undefined { const reductionSize = Math.round(this._maxHeapSize * 0.2); let chunkSize = 0; let chunk: number | undefined; for (let index = this._metadata.length - 1; index >= 0; index -= 1) { // Based on surrounding code context, this cannot be undefined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion chunkSize += this._metadata[index]!.size; if (chunkSize > reductionSize) { chunk = index; break; } } return chunk; } private async _clear(): Promise<void> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.CLEAR); } await this._store.clear(); this._metadata = []; this._processing = []; this._updateHeapSize(); return this._backupMetadata(); } private async _delete(key: string, options: { hashKey?: boolean } = {}): Promise<boolean> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.DELETE, key, options); } const deleteKey = options.hashKey ? Md5.hashStr(key) : key; const deleted = await this._store.delete(deleteKey); if (!deleted) { return false; } await this._deleteMetadata(deleteKey); return true; } private async _deleteMetadata(key: string): Promise<void> { const index = this._metadata.findIndex(metadata => metadata.key === key); if (index === -1) { return; } this._metadata.splice(index, 1); this._sortMetadata(); this._updateHeapSize(); return this._backupMetadata(); } private async _entries<T>(keys?: string[]): Promise<[string, T][]> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.ENTRIES, keys); } const entryKeys = keys ?? this._metadata.map(metadata => metadata.key); const entries = await this._store.entries(entryKeys); return entries.map(([key, data]) => [key, prepareGetEntry(data, this._valueFormatting, this._encryptionSecret)]); } private async _export<T>({ filterByValue, keys, tag, }: { filterByValue?: FilterByValue | FilterByValue[]; keys?: string[]; tag?: Tag; }): Promise<ExportResult<T>> { let exportKeys: string[] | undefined; let metadata = this._metadata; if (tag) { metadata = this._metadata.filter(meta => meta.tags.includes(tag)); exportKeys = metadata.map(meta => meta.key); } else if (keys) { metadata = this._metadata.filter(meta => keys.includes(meta.key)); exportKeys = keys; } let entries = await this._entries<T>(exportKeys); if (filterByValue) { const castFilterByValue = castArray(filterByValue); entries = entries.filter(([, data]) => castFilterByValue.every(({ comparator, keyChain }) => get(data, keyChain) === comparator), ); metadata = metadata.filter(meta => entries.some(([key]) => key === meta.key)); } return { entries, metadata }; } private async _get<T>(key: string, options: { hashKey?: boolean }): Promise<T | undefined> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.GET, key, options); } const getKey = options.hashKey ? Md5.hashStr(key) : key; const value = await this._store.get(getKey); if (!value) { return; } await this._updateMetadata(getKey); return prepareGetEntry(value, this._valueFormatting, this._encryptionSecret); } private _getCacheability(key: string): Cacheability | undefined { const metadata = this._getMetadataEntry(key); return metadata ? metadata.cacheability : undefined; } private _getMetadataEntry(key: string): Metadata | undefined { return this._metadata.find(metadata => metadata.key === key); } private async _has( key: string, options: { deleteExpired?: boolean; hashKey?: boolean }, ): Promise<false | Cacheability> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.HAS, key, options); } const hasKey = options.hashKey ? Md5.hashStr(key) : key; const exists = await this._store.has(hasKey); if (!exists) { return false; } if (options.deleteExpired && this._hasCacheEntryExpired(hasKey)) { await this.delete(hasKey); return false; } return this._getCacheability(hasKey) ?? false; } private _hasCacheEntryExpired(key: string): boolean { if (this._disableCacheInvalidation) { return false; } const cacheability = this._getCacheability(key); return cacheability ? !cacheability.checkTTL() : false; } private async _import(options: ImportOptions): Promise<void> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.IMPORT, options); } let filtered: Metadata[] = []; if (this._metadata.length > 0) { filtered = this._metadata.filter(metadata => { return !options.metadata.some(optionsMetadata => metadata.key === optionsMetadata.key); }); } const entries = options.entries.map( // typescript is not seeing this as a string tuple. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions ([key, data]) => [key, prepareSetEntry(data, this._valueFormatting, this._encryptionSecret)] as [string, string], ); await this._store.import(entries); this._metadata = rehydrateMetadata([...filtered, ...options.metadata]); this._sortMetadata(); await this._backupMetadata(); this._updateHeapSize(); } private _initializeReaper(reaperInit: ReaperInit): Reaper { return reaperInit({ deleteCallback: async (key: string, tags?: Tag[]) => { this.emitter.emit(this.events.ENTRY_DELETED, { deleted: await this._delete(key), key, tags }); }, metadataCallback: () => this._metadata, }); } private _processed(key: string): void { this._processing = this._processing.filter(value => value !== key); } private _reduceHeapSize(): void { const index = this._calcReductionChunk(); if (!index || !this._reaper) { return; } void this._reaper.cull(this._metadata.slice(index)); } private async _releaseQueuedRequests() { for (const [resolve, methodName, payload] of this._requestQueue) { // @ts-expect-error complicated resolve(await this[methodName](...payload)); } this._requestQueue = []; } private async _retreiveMetadataFromStore(): Promise<void> { if (!this._store || !this._persistedStore) { return; } const metadata = await this._store.get(constants.METADATA); if (metadata) { this._metadata = rehydrateMetadata(prepareGetEntry(metadata, this._valueFormatting, this._encryptionSecret)); } } private async _set( key: string, value: JsonValue, options: { cacheHeaders?: CacheHeaders; hashKey?: boolean; tag?: Tag }, ): Promise<void> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.SET, key, value, options); } const cacheability = new Cacheability({ headers: options.cacheHeaders }); const { cacheControl } = cacheability.metadata; if (cacheControl.noStore || (this._sharedCache && cacheControl.private)) { return; } const setKey = options.hashKey ? Md5.hashStr(key) : key; const processing = this._processing.includes(setKey); if (!processing) { this._processing.push(setKey); } try { const exists = (await this._store.has(setKey)) || processing; const preparedSetValue = prepareSetEntry(value, this._valueFormatting, this._encryptionSecret); await this._store.set(setKey, preparedSetValue); await (exists ? this._updateMetadata(setKey, sizeOf(preparedSetValue), cacheability, options.tag) : this._addMetadata(setKey, sizeOf(preparedSetValue), cacheability, options.tag)); this._processed(setKey); } catch (error) { this._processed(setKey); throw error; } } private async _size(): Promise<number> { if (!this._ready || !this._store) { return this._addRequestToQueue(constants.SIZE); } return this._store.size(); } private _sortMetadata(): void { this._metadata.sort(Core._sortComparator); } private _startBackup(): void { this._backupIntervalID = setInterval(() => { void this._storeEntriesToBackupStore(); }, this._backupInterval); } private _stopBackup(): void { if (this._backupIntervalID) { clearInterval(this._backupIntervalID); } } private async _storeEntriesToBackupStore(): Promise<void> { if (!(this._backupStore && this._store)) { return; } await this._backupStore.clear(); const keys = this._metadata.map(entry => entry.key); void this._backupStore.import(await this._store.entries(keys)); } private _updateHeapSize(): void { this._usedHeapSize = this._metadata.reduce((acc, value) => acc + value.size, 0); if (!this._disableCacheInvalidation && this._usedHeapSize > this._maxHeapSize) { this._reduceHeapSize(); } } private async _updateMetadata(key: string, size?: number, cacheability?: Cacheability, tag?: Tag): Promise<void> { const entry = this._getMetadataEntry(key); if (!entry) { return; } if (size) { entry.size = size; entry.lastUpdated = Date.now(); entry.updatedCount += 1; } else { entry.accessedCount += 1; entry.lastAccessed = Date.now(); } if (cacheability) { entry.cacheability = cacheability; } if (!isUndefined(tag)) { entry.tags.push(tag); } this._sortMetadata(); this._updateHeapSize(); return this._backupMetadata(); } }