@nasriya/cachify
Version:
A lightweight, extensible in-memory caching library for storing anything, with built-in TTL and customizable cache types.
820 lines (819 loc) • 42 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const atomix_1 = __importDefault(require("@nasriya/atomix"));
const cron_1 = __importDefault(require("@nasriya/cron"));
const kvs_record_1 = __importDefault(require("./kvs.record"));
const KVsCacheConfig_1 = __importDefault(require("../../configs/managers/kvs/KVsCacheConfig"));
const helpers_1 = __importDefault(require("../helpers"));
const consts_1 = __importDefault(require("../../consts/consts"));
const utils_1 = __importDefault(require("../../../utils/utils"));
const hasOwnProp = atomix_1.default.dataTypes.record.hasOwnProperty;
class KVsCacheManager {
#_records = new Map();
#_defaultEngines = ['memory'];
#_enginesProxy;
#_persistenceProxy;
#_events;
#_configs;
#_queue = new atomix_1.default.tools.TasksQueue({ autoRun: true });
#_jobs = { clearIdleItems: undefined };
#_flags = {
blocking: { clearing: false, backingUp: false, restoring: false }
};
constructor(assets) {
this.#_enginesProxy = assets.enginesProxy;
this.#_persistenceProxy = assets.persistenceProxy;
this.#_events = assets.eventsManager;
// Prepare the manager's configs
{
const onCacheStatusChange = (cache, status) => {
if (cache === 'idle') {
if (status === 'enabled') {
this.#_jobs.clearIdleItems.start();
}
else {
this.#_jobs.clearIdleItems.stop();
}
}
};
this.#_configs = new KVsCacheConfig_1.default(onCacheStatusChange);
}
// Prepare the scheduled task to clean up idle items
{
this.#_jobs.clearIdleItems = cron_1.default.schedule(cron_1.default.time.every(5).minutes(), this.#_helpers.cacheManagement.idle.createCleanHandler(), {
runOnInit: false,
name: `kvs_clean_idle_items_${atomix_1.default.utils.generateRandom(8)}`
});
}
// Listen on events to update stats
{
this.#_events.on('remove', async (event) => {
const scopeMap = this.#_helpers.records.getScopeMap(event.item.scope);
const record = scopeMap.get(event.item.key);
if (record) {
this.#_memoryManager.handle.remove(record);
await this.#_enginesProxy.remove(record);
}
scopeMap.delete(event.item.key);
}, { type: 'beforeAll' });
this.#_events.on('bulkRemove', async (event) => {
for (const item of event.items) {
const scopeMap = this.#_helpers.records.getScopeMap(item.scope);
const record = scopeMap.get(item.key);
if (record) {
this.#_memoryManager.handle.remove(record);
await this.#_enginesProxy.remove(record);
}
scopeMap.delete(item.key);
}
}, { type: 'beforeAll' });
this.#_events.on('update', event => {
this.#_stats.counts.update++;
}, { type: 'beforeAll' });
this.#_events.on('read', event => {
this.#_stats.counts.read++;
}, { type: 'beforeAll' });
this.#_events.on('touch', event => {
this.#_stats.counts.touch++;
}, { type: 'beforeAll' });
this.#_events.on('create', event => {
const scopeMap = this.#_helpers.records.getScopeMap(event.item.scope);
const record = scopeMap.get(event.item.key);
this.#_memoryManager.handle.create(record);
}, { type: 'beforeAll' });
}
}
#_memoryManager = {
data: {
sortingHandler: (a, b) => {
const aStats = a.stats;
const bStats = b.stats;
const aScore = aStats.counts.touch + aStats.counts.read + aStats.counts.hit;
const bScore = bStats.counts.touch + bStats.counts.read + bStats.counts.hit;
const aAccessTime = aStats.dates.lastAccess ?? 0;
const bAccessTime = bStats.dates.lastAccess ?? 0;
// Prefer older and less-used entries
if (aScore === bScore) {
return aAccessTime - bAccessTime;
}
return aScore - bScore;
}
},
helpers: {
getRecordSize: (key, value) => {
const keyLength = Buffer.byteLength(key);
const valueLength = helpers_1.default.estimateValueSize(value);
return keyLength + valueLength;
},
applyDelta: async (delta) => {
this.#_stats.sizeInMemory = Math.max(this.#_stats.sizeInMemory + delta, 0);
const sizeToFree = () => Math.max(this.#_stats.sizeInMemory - this.#_configs.maxTotalSize, 0);
if (sizeToFree() === 0) {
return;
}
const taskId = `free_memory`;
const isInQueue = this.#_queue.hasTask(taskId);
if (isInQueue) {
return;
}
const task = {
id: taskId,
type: 'free_memory',
priority: 2,
action: async () => {
// Get all the records accross all scopes
const allRecords = [];
for (const [_, scopeMap] of this.#_records) {
allRecords.push(...Array.from(scopeMap.values()));
}
// Sort the records
allRecords.sort(this.#_memoryManager.data.sortingHandler);
// Attempting to free memory
for (const record of allRecords) {
if (sizeToFree() === 0) {
break;
}
if (!this.#_records.get(record.scope).has(record.key)) {
continue;
}
await this.#_events.emit.remove(record, { reason: 'memory.limit' });
}
}
};
this.#_queue.addTask(task);
}
},
handle: {
update: async (record, newValue) => {
if (!record.engines.includes('memory')) {
return;
}
const res = await this.#_enginesProxy.read(record);
const oldSize = this.#_memoryManager.helpers.getRecordSize(record.key, res.value);
const newSize = this.#_memoryManager.helpers.getRecordSize(record.key, newValue);
const delta = newSize - oldSize;
await this.#_memoryManager.helpers.applyDelta(delta);
},
remove: async (record) => {
if (!record.engines.includes('memory')) {
return;
}
await this.#_memoryManager.helpers.applyDelta(-record.stats.size);
},
create: async (record) => {
if (!record.engines.includes('memory')) {
return;
}
const res = await this.#_enginesProxy.read(record);
const size = this.#_memoryManager.helpers.getRecordSize(record.key, res.value);
await this.#_memoryManager.helpers.applyDelta(size);
}
}
};
#_helpers = {
records: {
getScopeMap: (scope) => {
return helpers_1.default.records.getScopeMap(scope, this.#_records);
}
},
cacheManagement: {
idle: {
createCleanHandler: () => {
return helpers_1.default.cacheManagement.idle.createCleanHandler(this.#_records, this.#_configs.idle, this.#_events);
}
},
eviction: {
scheduleEvictionCheck: atomix_1.default.utils.debounce(() => {
if (this.#_configs.eviction.enabled === false || this.#_queue.hasTask('eviction_check')) {
return;
}
const task = {
id: 'eviction_check',
type: 'eviction_check',
action: async () => {
await helpers_1.default.cacheManagement.eviction.evictIfEnabled({
records: this.#_records,
policy: this.#_configs.eviction,
eventsManager: this.#_events,
getSize: () => this.size
});
}
};
this.#_queue.addTask(task);
}, 100)
}
},
checkIfClearing: (operation, key) => {
if (this.#_flags.blocking.clearing) {
throw new Error(`Cannot ${operation} (${key}) while clearing`);
}
},
createRemovePromise: async (key, scope = 'global') => {
const scopeMap = this.#_helpers.records.getScopeMap(scope);
const record = scopeMap.get(key);
if (!record) {
return false;
}
await this.#_events.emit.remove(record, { reason: 'manual' });
return true;
},
startBlockingProcess: (process) => {
for (const [key, value] of Object.entries(this.#_flags.blocking)) {
if (value && key !== process) {
throw new Error(`Cannot start ${process} while ${key}`);
}
}
this.#_flags.blocking[process] = true;
},
setMethod: {
defaultConfigs: (cacheConfig) => {
const configs = {
preload: false,
scope: 'global',
storeIn: [],
ttl: {
value: cacheConfig.ttl.enabled ? cacheConfig.ttl.value : 0,
onExpire: cacheConfig.ttl.onExpire,
sliding: cacheConfig.ttl.sliding
}
};
return atomix_1.default.dataTypes.object.smartClone(configs);
},
validate: {
miniHelpers: {
normalOptions: (options) => {
const configs = this.#_helpers.setMethod.defaultConfigs(this.#_configs);
if (hasOwnProp(options, 'scope')) {
if (!atomix_1.default.valueIs.string(options.scope)) {
throw new TypeError(`The "scope" property of the "options" object (when provided) must be a string, but instead got ${typeof options.scope}`);
}
if (options.scope.length === 0) {
throw new RangeError(`The "scope" property of the "options" object (when provided) must be a non-empty string`);
}
configs.scope = options.scope;
}
if (hasOwnProp(options, 'ttl')) {
const ttl = options.ttl;
const isRecord = atomix_1.default.valueIs.record(ttl);
const isNumber = atomix_1.default.valueIs.number(ttl);
if (!(isNumber || isRecord)) {
throw new TypeError(`The "ttl" property of the "options" object (when provided) must be a number or a record, but instead got ${typeof ttl}`);
}
if (isNumber) {
configs.ttl.value = ttl;
}
if (isRecord) {
if (hasOwnProp(ttl, 'value')) {
if (!atomix_1.default.valueIs.number(ttl.value)) {
throw new TypeError(`The "value" property of the "ttl" object (when provided) must be a number, but instead got ${typeof ttl.value}`);
}
if (!atomix_1.default.valueIs.integer(ttl.value)) {
throw new TypeError(`The "value" property of the "ttl" object (when provided) must be an integer, but instead got ${ttl.value}`);
}
configs.ttl.value = ttl.value;
}
if (hasOwnProp(ttl, 'sliding')) {
if (typeof ttl.sliding !== 'boolean') {
throw new TypeError(`The "sliding" property of the "ttl" object (when provided) must be a boolean, but instead got ${typeof ttl.sliding}`);
}
configs.ttl.sliding = ttl.sliding;
}
if (hasOwnProp(ttl, 'onExpire')) {
if (typeof ttl.onExpire !== 'function') {
throw new TypeError(`The "onExpire" property of the "ttl" object (when provided) must be a function, but instead got ${typeof ttl.onExpire}`);
}
configs.ttl.onExpire = ttl.onExpire;
}
}
}
if (hasOwnProp(options, 'storeIn')) {
const isString = atomix_1.default.valueIs.string(options.storeIn);
const isArray = atomix_1.default.valueIs.array(options.storeIn);
if (!(isString || isArray)) {
throw new TypeError(`The "storeIn" property of the "options" object (when provided) must be a string or an array of strings, but instead got ${typeof options.storeIn}`);
}
const enginesInput = [];
if (isString) {
enginesInput.push(options.storeIn);
}
else {
enginesInput.push(...options.storeIn);
}
for (const engine of enginesInput) {
if (!atomix_1.default.valueIs.string(engine)) {
throw new TypeError(`The "storeIn" property of the "options" object (when provided) must be a string or an array of strings, but instead got ${typeof engine}`);
}
if (engine.length === 0) {
throw new RangeError(`The "storeIn" property of the "options" object (when provided) must be a non-empty string or an array of non-empty strings`);
}
if (!this.#_enginesProxy.engines.hasEngine(engine)) {
throw new Error(`The "storeIn" property of the "options" object (when provided) must be a string or an array of strings, but the engine "${engine}" is not defined.`);
}
}
configs.storeIn = enginesInput;
}
return configs;
},
preloadOptions: {
warmup: (options, normalConfigs) => {
const { preload: _, ...rest } = normalConfigs;
const configs = {
initiator: 'warmup',
preload: true,
...rest,
};
return configs;
},
restore: (options, normalConfigs) => {
const { preload: _, ...rest } = normalConfigs;
for (const key in rest) {
if (!hasOwnProp(rest, key)) {
throw new SyntaxError(`The preload restore options object must have a "${key}" property`);
}
}
const configs = {
initiator: 'restore',
preload: true,
...rest,
stats: {
size: 0,
dates: {
created: 0,
expireAt: undefined,
lastAccess: undefined,
lastUpdate: undefined
},
counts: {
read: 0,
update: 0,
touch: 0,
hit: 0,
miss: 0
}
}
};
const assertPositiveInteger = utils_1.default.assert.type.positiveInteger;
if (hasOwnProp(options, 'stats')) {
if (!atomix_1.default.valueIs.record(options.stats)) {
throw new TypeError(`The "stats" property of the "options" object (when provided) must be an object, but instead got ${typeof options.stats}`);
}
if (hasOwnProp(options.stats, 'dates')) {
const dates = options.stats.dates;
if (!atomix_1.default.valueIs.record(dates)) {
throw new TypeError(`The "dates" property of the "stats" object (when provided) must be an object, but instead got ${typeof dates}`);
}
if (hasOwnProp(dates, 'created')) {
assertPositiveInteger(dates.created, 'created', 'stats.dates');
configs.stats.dates.created = dates.created;
}
else {
throw new SyntaxError(`The "created" property of the "dates" property of the "stats" object (when provided) is required when "preload" is true`);
}
if (hasOwnProp(dates, 'lastAccess')) {
if (dates.lastAccess !== undefined) {
assertPositiveInteger(dates.lastAccess, 'lastAccess', 'stats.dates');
}
configs.stats.dates.lastAccess = dates.lastAccess;
}
if (hasOwnProp(dates, 'lastUpdate')) {
if (dates.lastUpdate !== undefined) {
assertPositiveInteger(dates.lastUpdate, 'lastUpdate', 'stats.dates');
}
configs.stats.dates.lastUpdate = dates.lastUpdate;
}
if (hasOwnProp(dates, 'expireAt')) {
if (dates.expireAt !== undefined) {
assertPositiveInteger(dates.expireAt, 'expireAt', 'stats.dates');
}
configs.stats.dates.expireAt = dates.expireAt;
}
}
else {
throw new SyntaxError(`The "dates" property of the "stats" object (when provided) is required when "preload" is true`);
}
if (hasOwnProp(options.stats, 'counts')) {
const counts = options.stats.counts;
if (!atomix_1.default.valueIs.record(counts)) {
throw new TypeError(`The "counts" property of the "stats" object (when provided) must be an object, but instead got ${typeof counts}`);
}
if (hasOwnProp(counts, 'read')) {
assertPositiveInteger(counts.read, 'read', 'stats.counts');
configs.stats.counts.read = counts.read;
}
else {
throw new SyntaxError(`The "read" property of the "counts" property of the "stats" object (when provided) is required when "preload" is true`);
}
if (hasOwnProp(counts, 'update')) {
assertPositiveInteger(counts.update, 'update', 'stats.counts');
configs.stats.counts.update = counts.update;
}
else {
throw new SyntaxError(`The "update" property of the "counts" property of the "stats" object (when provided) is required when "preload" is true`);
}
if (hasOwnProp(counts, 'touch')) {
assertPositiveInteger(counts.touch, 'touch', 'stats.counts');
configs.stats.counts.touch = counts.touch;
}
else {
throw new SyntaxError(`The "touch" property of the "counts" property of the "stats" object (when provided) is required when "preload" is true`);
}
if (hasOwnProp(counts, 'hit')) {
assertPositiveInteger(counts.hit, 'hit', 'stats.counts');
configs.stats.counts.hit = counts.hit;
}
else {
throw new SyntaxError(`The "hit" property of the "counts" property of the "stats" object (when provided) is required when "preload" is true`);
}
if (hasOwnProp(counts, 'miss')) {
assertPositiveInteger(counts.miss, 'miss', 'stats.counts');
configs.stats.counts.miss = counts.miss;
}
else {
throw new SyntaxError(`The "miss" property of the "counts" property of the "stats" object (when provided) is required when "preload" is true`);
}
}
else {
throw new SyntaxError(`The "counts" property of the "stats" object (when provided) is required when "preload" is true`);
}
}
else {
throw new SyntaxError(`The "stats" property of the "options" object (when provided) must be an object, but instead got ${typeof options.stats}`);
}
return configs;
},
any: (options, normalConfigs) => {
const { preload: _, ttl, ...rest } = normalConfigs;
const initiator = options.initiator;
switch (initiator) {
case 'restore':
return this.#_helpers.setMethod.validate.miniHelpers.preloadOptions.restore(options, normalConfigs);
case 'warmup':
return this.#_helpers.setMethod.validate.miniHelpers.preloadOptions.warmup(options, normalConfigs);
default:
throw new SyntaxError(`The "initiator" property of the "options" object (when provided) must be either ${consts_1.default.CACHE_PRELOAD_INITIATORS.map(i => `"${i}"`).join(', ')} when "preload" is true, but instead got "${initiator}"`);
}
}
}
},
options: (options) => {
if (options === undefined) {
return this.#_helpers.setMethod.defaultConfigs(this.#_configs);
}
if (!atomix_1.default.valueIs.record(options)) {
throw new TypeError(`The "options" parameter (when provided) must be an object, but instead got ${typeof options}`);
}
let preload = false;
if (hasOwnProp(options, 'preload')) {
if (typeof options.preload !== 'boolean') {
throw new TypeError(`The "preload" property of the "options" object (when provided) must be a boolean, but instead got ${typeof options.preload}`);
}
preload = options.preload;
if (options.preload) {
if (hasOwnProp(options, 'initiator')) {
const initiator = options.initiator;
if (!atomix_1.default.valueIs.string(initiator)) {
throw new TypeError(`The "initiator" property of the "options" object (when provided) must be a string, but instead got ${typeof initiator}`);
}
if (!consts_1.default.CACHE_PRELOAD_INITIATORS.includes(initiator)) {
throw new RangeError(`The "initiator" property of the "options" object (when provided) must be one of the following values: ${consts_1.default.CACHE_PRELOAD_INITIATORS.join(', ')}, but instead got "${initiator}".`);
}
}
else {
throw new SyntaxError(`The "initiator" property of the "options" object (when provided) is required when "preload" is true`);
}
}
}
const validate = this.#_helpers.setMethod.validate;
const normalConfigs = validate.miniHelpers.normalOptions(options);
return preload ? validate.miniHelpers.preloadOptions.any(options, normalConfigs) : normalConfigs;
}
}
}
};
#_stats = {
sizeInMemory: 0,
counts: {
read: 0,
update: 0,
touch: 0
}
};
/**
* Sets the default engines to use when no engines are provided to a {@link set} method.
*
* - If no engines are provided, the default engines are used.
* - If the engines argument is undefined, the default engines are reset to include only the `'memory'` engine.
* - If the engines argument is an empty array, a RangeError is thrown.
* - If the engines argument contains any engines that do not exist, a RangeError is thrown.
*
* @param {string | string[] | undefined} engines The engines to set as the default engines.
* @since v1.0.0
*/
set defaultEngines(engines) {
if (engines === undefined) {
this.#_defaultEngines.length = 0;
this.#_defaultEngines.push('memory');
return;
}
const defaults = atomix_1.default.valueIs.arrayOfStrings(engines) ? engines : atomix_1.default.valueIs.string(engines) ? [engines] : undefined;
if (defaults === undefined) {
throw new TypeError(`The "engines" parameter (when provided) must be a string or an array of strings, but instead got ${typeof engines}`);
}
if (defaults.length === 0) {
throw new RangeError(`The "engines" parameter (when provided) must contain at least one engine, but instead got an empty array`);
}
for (const engine of defaults) {
if (!this.#_enginesProxy.engines.hasEngine(engine)) {
throw new RangeError(`The "engines" parameter (when provided) must contain only existing engines, but instead got "${engine}"`);
}
}
this.#_defaultEngines.length = 0;
this.#_defaultEngines.push(...defaults);
}
/**
* @returns The list of default engines.
* @since v1.0.0
*/
get defaultEngines() {
return this.#_defaultEngines;
}
/**
* Sets a key-value pair in the cache with an optional TTL.
* If the key already exists, its value and TTL are updated.
* If the key does not exist and the cache has reached its limit,
* the least recently used record is removed.
*
* @param {string} key The key to set in the cache.
* @param {any} value The value to associate with the given key.
* @param {KVSetOptions} [options] The options to use for setting the key-value pair.
* The following options are available:
* - `scope`: The scope of the record to set. Defaults to 'global'.
* - `preload`: Whether or not to preload the record when it is created. Defaults to false.
* - `ttl`: The time-to-live (TTL) settings for the record. Defaults to the cache's default TTL settings.
* The `ttl` option can be a number (the TTL in milliseconds) or an object with the following properties:
* - `value`: The TTL in milliseconds.
* - `onExpire`: The function to call when the record expires.
* The function will be called with the record as its argument.
* @since v1.0.0
*/
async set(key, value, options) {
this.#_helpers.checkIfClearing('set', key);
try {
const configs = this.#_helpers.setMethod.validate.options(options);
this.#_helpers.cacheManagement.eviction.scheduleEvictionCheck().catch(err => {
if (err?.message !== 'Debounced function cancelled') {
throw err;
}
});
const scopeMap = this.#_helpers.records.getScopeMap(configs.scope);
if (scopeMap.has(key)) {
const record = scopeMap.get(key);
this.#_memoryManager.handle.update(record, value);
return record.update(value);
}
if (configs.storeIn.length === 0) {
configs.storeIn.push(...this.#_defaultEngines);
}
const record = new kvs_record_1.default(key, configs, this.#_enginesProxy, this.#_events);
scopeMap.set(key, record);
await record._init(value, configs.preload);
}
catch (error) {
if (error instanceof TypeError) {
error.message = `Unable to create a (key:value) pair record: ${error.message}`;
}
throw error;
}
}
/**
* Retrieves the value associated with the given key from the cache within the specified scope.
* If the key is not found, the method returns undefined.
* Emits a 'read' event with the reason 'hit' upon successful retrieval.
*
* @param {string} key - The key of the record to be retrieved.
* @param {string} [scope='global'] - The scope from which to retrieve the record.
* @returns {Promise<unknown>} The value associated with the given key, or undefined if no record exists.
* @since v1.0.0
*/
async get(key, scope = 'global') {
this.#_helpers.checkIfClearing('get', key);
const scopeMap = this.#_helpers.records.getScopeMap(scope);
const record = scopeMap.get(key);
if (!record) {
return undefined;
}
const value = await record.read();
await this.#_events.emit.read(record); // Emit the 'read' event
return value;
}
/**
* Removes a key-value pair from the cache within the specified scope.
* Emits a 'remove' event with the reason 'manual' upon successful removal.
*
* @param {string} key - The key of the record to be removed.
* @param {string} [scope='global'] - The scope from which to remove the record.
* @returns {boolean} True if the record was found and removed, false otherwise.
* @since v1.0.0
*/
async remove(key, scope = 'global') {
this.#_helpers.checkIfClearing('remove', key);
return this.#_helpers.createRemovePromise(key, scope);
}
/**
* Checks if a key exists within the specified scope in the cache.
*
* @param {string} key - The key to check for existence.
* @param {string} [scope='global'] - The scope in which to check for the key.
* @returns {boolean} True if the key exists in the specified scope, false otherwise.
* @since v1.0.0
*/
has(key, scope = 'global') {
this.#_helpers.checkIfClearing('has', key);
const scopeMap = this.#_helpers.records.getScopeMap(scope);
return scopeMap.has(key);
}
/**
* Retrieves the total number of records stored in the cache across all scopes.
* @returns {number} The total number of records in the cache.
* @since v1.0.0
*/
get size() {
let size = 0;
this.#_records.forEach(scopeMap => size += scopeMap.size);
return size;
}
/**
* Retrieves the statistics counts for cache operations.
* The statistics include the counts of read, update, and touch events.
* @returns An object containing the counts of read, update, and touch events.
* @since v1.0.0
*/
get stats() {
const cloned = atomix_1.default.dataTypes.object.smartClone(this.#_stats);
return atomix_1.default.dataTypes.record.deepFreeze(cloned);
}
/**
* Retrieves the configuration object for the cache.
* This object contains the configuration for the time-to-live (TTL) and least-recently-used (LRU) policies.
* @returns The configuration object for the cache.
* @since v1.0.0
*/
get configs() { return this.#_configs; }
/**
* Clears the cache for the specified scope or for all scopes if no scope is provided.
* Emits a 'clear' event for each record before removing it.
*
* @param {string} [scope] - The scope for which to clear the cache. If not provided, clears all scopes.
* @throws {TypeError} If the provided scope is not a string.
* @throws {RangeError} If the provided scope is an empty string.
* @since v1.0.0
*/
async clear(scope) {
if (this.#_flags.blocking.clearing) {
return;
}
try {
this.#_helpers.startBlockingProcess('clearing');
const recordsMap = (() => {
if (scope !== undefined) {
if (!atomix_1.default.valueIs.string(scope)) {
throw new TypeError(`The provided scope (${scope}) is not a string.`);
}
if (!atomix_1.default.valueIs.notEmptyString(scope)) {
throw new RangeError(`The provided scope (${scope}) cannot be empty.`);
}
const scopeMap = this.#_helpers.records.getScopeMap(scope);
const mainMap = new Map();
mainMap.set(scope, scopeMap);
return mainMap;
}
else {
return this.#_records;
}
})();
const iterator = helpers_1.default.records.createIterator(recordsMap);
const records = [];
const stats = Object.seal({ read: 0, update: 0, touch: 0 });
const remove = async () => {
if (records.length === 0) {
return;
}
await this.#_events.emit.bulkRemove(records, { reason: 'manual' });
this.#_stats.counts.read -= stats.read;
this.#_stats.counts.update -= stats.update;
this.#_stats.counts.touch -= stats.touch;
records.length = stats.read = stats.update = stats.touch = 0;
};
for (const { record } of iterator) {
stats.read += record.stats.counts.read;
stats.update += record.stats.counts.update;
stats.touch += record.stats.counts.touch;
records.push(record);
if (records.length === 1_000) {
await remove();
}
}
await remove();
if (this.size === 0) {
this.#_helpers.cacheManagement.eviction.scheduleEvictionCheck.cancel();
this.#_events.dispose();
}
}
catch (err) {
if (err instanceof Error) {
err.message = `Unable to clear the ${this}: ${err.message}`;
}
throw err;
}
finally {
this.#_flags.blocking.clearing = false;
}
}
/**
* Exports all cached records to the specified persistent storage service.
*
* This method performs a full backup of the current cache. If a backup
* operation is already in progress, this call is skipped silently.
*
* @template S - The target storage service type.
* @param to - The identifier of the storage service to back up to.
* @param args - Additional arguments to pass to the storage service's `backup` method.
* @returns A promise that resolves once the backup completes.
*
* @throws If the target storage service is unsupported or not implemented.
* @example
* // Dump to an S3 bucket
* await cachify.kvs.backup('s3', 'backups/data-2025-07-27');
*
* @example
* await cachify.kvs.backup('local', './backup/records-2025-07-27');
* @since v1.0.0
*/
async backup(to, ...args) {
if (this.#_flags.blocking.backingUp) {
return;
}
try {
this.#_helpers.startBlockingProcess('backingUp');
await this.#_persistenceProxy.backup({ source: 'kvs', content: this.#_records }, to, ...args);
}
catch (error) {
if (error instanceof Error) {
error.message = `Failed to backup ${this} to ${to}: ${error.message}`;
}
throw error;
}
finally {
this.#_flags.blocking.backingUp = false;
}
}
/**
* Restores records from a specified persistent storage service.
*
* This method performs a full restore of the cache from the specified storage service.
* If a restore operation is already in progress, this call is skipped silently.
*
* @template S - The target storage service type.
* @param from - The identifier of the storage service to restore from.
* @param args - Additional arguments to pass to the storage service's `restore` method.
* @returns A promise that resolves once the restore completes.
*
* @throws If the target storage service is unsupported or not implemented.
* @example
* // Restore from an S3 bucket
* await cachify.kvs.restore('s3', 'backups/data-2025-07-27');
*
* @example
* await cachify.kvs.restore('local', './backup/records-2025-07-27');
* @since v1.0.0
*/
async restore(from, ...args) {
if (this.#_flags.blocking.restoring) {
return;
}
try {
this.#_helpers.startBlockingProcess('restoring');
await this.#_persistenceProxy.restore('kvs', from, ...args);
}
catch (error) {
if (error instanceof Error) {
error.message = `Failed to restore ${this} from ${from}: ${error.message}`;
}
throw error;
}
finally {
this.#_flags.blocking.restoring = false;
}
}
/**
* Returns a string representation of the object.
*
* @returns {string} The string representation of the object.
* @since v1.0.0
*/
toString() { return 'KVsCacheManager'; }
}
exports.default = KVsCacheManager;