UNPKG

syntropylog

Version:

An instance manager with observability for Node.js applications

713 lines 24.3 kB
/** * A mock implementation of `IBeaconRedisTransaction` for testing. * It queues commands and executes them sequentially against the main `BeaconRedisMock` instance * when `exec()` is called, simulating a Redis MULTI/EXEC block. * @internal */ class BeaconRedisMockTransaction { mockRedis; logger; commands = []; /** * Constructs a mock transaction. * @param {BeaconRedisMock} mockRedis - The parent `BeaconRedisMock` instance to execute commands against. * @param {ILogger} logger - The logger instance for debugging. */ constructor(mockRedis, logger) { this.mockRedis = mockRedis; this.logger = logger; this.logger.debug(`[BeaconRedisMockTransaction] Initiated.`); } /** * Queues a command to be executed later. * @private * @param {() => Promise<any>} command - A function that, when called, executes a mock Redis command. * @returns {this} The transaction instance for chaining. */ _queue(command) { this.commands.push(command); return this; } // --- Full Implementation of the Transaction Interface --- /** @inheritdoc */ get(key) { return this._queue(() => this.mockRedis.get(key)); } /** @inheritdoc */ set(key, value, ttlSeconds) { return this._queue(() => this.mockRedis.set(key, value, ttlSeconds)); } /** @inheritdoc */ del(key) { return this._queue(() => this.mockRedis.del(key)); } /** @inheritdoc */ exists(keys) { return this._queue(() => this.mockRedis.exists(keys)); } /** @inheritdoc */ expire(key, seconds) { return this._queue(() => this.mockRedis.expire(key, seconds)); } /** @inheritdoc */ ttl(key) { return this._queue(() => this.mockRedis.ttl(key)); } /** @inheritdoc */ incr(key) { return this._queue(() => this.mockRedis.incr(key)); } /** @inheritdoc */ decr(key) { return this._queue(() => this.mockRedis.decr(key)); } /** @inheritdoc */ incrBy(key, increment) { return this._queue(() => this.mockRedis.incrBy(key, increment)); } /** @inheritdoc */ decrBy(key, decrement) { return this._queue(() => this.mockRedis.decrBy(key, decrement)); } /** @inheritdoc */ hGet(key, field) { return this._queue(() => this.mockRedis.hGet(key, field)); } /** @inheritdoc */ hSet(key, fieldOrFields, value) { return this._queue(() => this.mockRedis.hSet(key, fieldOrFields, value)); } /** @inheritdoc */ hGetAll(key) { return this._queue(() => this.mockRedis.hGetAll(key)); } /** @inheritdoc */ hDel(key, fields) { return this._queue(() => this.mockRedis.hDel(key, fields)); } /** @inheritdoc */ hExists(key, field) { return this._queue(() => this.mockRedis.hExists(key, field)); } /** @inheritdoc */ hIncrBy(key, field, increment) { return this._queue(() => this.mockRedis.hIncrBy(key, field, increment)); } /** @inheritdoc */ lPush(key, elements) { return this._queue(() => this.mockRedis.lPush(key, elements)); } /** @inheritdoc */ rPush(key, elements) { return this._queue(() => this.mockRedis.rPush(key, elements)); } /** @inheritdoc */ lPop(key) { return this._queue(() => this.mockRedis.lPop(key)); } /** @inheritdoc */ rPop(key) { return this._queue(() => this.mockRedis.rPop(key)); } /** @inheritdoc */ lRange(key, start, stop) { return this._queue(() => this.mockRedis.lRange(key, start, stop)); } /** @inheritdoc */ lLen(key) { return this._queue(() => this.mockRedis.lLen(key)); } /** @inheritdoc */ lTrim(key, start, stop) { return this._queue(() => this.mockRedis.lTrim(key, start, stop)); } /** @inheritdoc */ sAdd(key, members) { return this._queue(() => this.mockRedis.sAdd(key, members)); } /** @inheritdoc */ sMembers(key) { return this._queue(() => this.mockRedis.sMembers(key)); } /** @inheritdoc */ sIsMember(key, member) { return this._queue(() => this.mockRedis.sIsMember(key, member)); } /** @inheritdoc */ sRem(key, members) { return this._queue(() => this.mockRedis.sRem(key, members)); } /** @inheritdoc */ sCard(key) { return this._queue(() => this.mockRedis.sCard(key)); } /** @inheritdoc */ zAdd(key, scoreOrMembers, member) { return this._queue(() => this.mockRedis.zAdd(key, scoreOrMembers, member)); } /** @inheritdoc */ zRange(key, min, max, options) { return this._queue(() => this.mockRedis.zRange(key, min, max, options)); } /** @inheritdoc */ zRangeWithScores(key, min, max, options) { return this._queue(() => this.mockRedis.zRangeWithScores(key, min, max, options)); } /** @inheritdoc */ zRem(key, members) { return this._queue(() => this.mockRedis.zRem(key, members)); } /** @inheritdoc */ zCard(key) { return this._queue(() => this.mockRedis.zCard(key)); } /** @inheritdoc */ zScore(key, member) { return this._queue(() => this.mockRedis.zScore(key, member)); } /** @inheritdoc */ ping(message) { return this._queue(() => this.mockRedis.ping(message)); } /** @inheritdoc */ info(section) { return this._queue(() => this.mockRedis.info(section)); } eval(script, keys, args) { // EVAL can be part of a transaction return this._queue(() => this.mockRedis.eval(script, keys, args)); } /** @inheritdoc */ async exec() { const results = []; for (const cmd of this.commands) { results.push(await cmd()); } this.commands = []; return results; } /** @inheritdoc */ async discard() { this.commands = []; return; } } /** * A full in-memory mock of a Redis client, implementing the `IBeaconRedis` interface. * This class is designed for testing purposes, providing a predictable and fast * alternative to a real Redis server. It supports most common commands and data types. * @implements {IBeaconRedis} */ export class BeaconRedisMock { /** The internal in-memory data store. */ store = new Map(); logger; instanceName; /** * Constructs a new BeaconRedisMock instance. * @param {string} [instanceName='default_mock'] - A name for this mock instance. * @param {ILogger} [parentLogger] - An optional parent logger to create a child logger from. */ constructor(instanceName = 'default_mock', parentLogger) { this.instanceName = instanceName; if (parentLogger) { this.logger = parentLogger.child({ component: `BeaconRedisMock[${this.instanceName}]`, }); } else { // If no logger is provided, create a lightweight, no-op mock logger // to avoid pulling in the entire logging stack. this.logger = { level: 'info', // Add missing level property debug: async () => { }, info: async () => { }, warn: async () => { }, error: async () => { }, fatal: async () => { }, trace: async () => { }, child: () => this.logger, withSource: () => this.logger, setLevel: () => { }, withRetention: () => this.logger, withTransactionId: () => this.logger, }; } } /** * Retrieves an entry from the store if it exists and has not expired. * Also validates that the entry is of the expected type. * @private * @param {string} key - The key of the entry to retrieve. * @param {StoreEntry['type']} [expectedType] - The expected data type for the key. * @returns {StoreEntry | null} The store entry, or null if not found or expired. * @throws {Error} WRONGTYPE error if the key holds a value of the wrong type. */ _getValidEntry(key, expectedType) { const entry = this.store.get(key); if (!entry) return null; if (entry.expiresAt && Date.now() >= entry.expiresAt) { this.store.delete(key); return null; } if (expectedType && entry.type !== expectedType) { throw new Error(`WRONGTYPE Operation against a key holding the wrong kind of value`); } return entry; } /** * Serializes a value to a string for storage, similar to how Redis stores data. * @private * @param {any} value - The value to serialize. * @returns {string} A string representation of the value. */ _serialize(value) { return typeof value === 'string' ? value : JSON.stringify(value); } // --- Key Commands --- /** @inheritdoc */ async get(key) { const entry = this._getValidEntry(key, 'string'); return entry ? entry.value : null; } /** @inheritdoc */ async set(key, value, ttlSeconds) { const entry = { value: this._serialize(value), type: 'string', expiresAt: null, }; if (ttlSeconds) entry.expiresAt = Date.now() + ttlSeconds * 1000; this.store.set(key, entry); return 'OK'; } /** @inheritdoc */ async del(keys) { const keysToDelete = Array.isArray(keys) ? keys : [keys]; let count = 0; for (const key of keysToDelete) { if (this.store.delete(key)) count++; } return count; } /** @inheritdoc */ async exists(keys) { const keysToCheck = Array.isArray(keys) ? keys : [keys]; let count = 0; for (const key of keysToCheck) { if (this._getValidEntry(key)) count++; } return count; } /** @inheritdoc */ async expire(key, seconds) { const entry = this.store.get(key); if (entry) { entry.expiresAt = Date.now() + seconds * 1000; return true; } return false; } /** @inheritdoc */ async ttl(key) { const entry = this._getValidEntry(key); if (!entry) return -2; if (entry.expiresAt === null) return -1; return Math.round((entry.expiresAt - Date.now()) / 1000); } // --- Numeric Commands --- /** @inheritdoc */ async incr(key) { return this.incrBy(key, 1); } /** @inheritdoc */ async decr(key) { return this.decrBy(key, 1); } /** @inheritdoc */ async incrBy(key, increment) { const entry = this._getValidEntry(key, 'string'); const currentValue = entry ? parseInt(entry.value, 10) : 0; if (isNaN(currentValue)) throw new Error('ERR value is not an integer or out of range'); const newValue = currentValue + increment; this.store.set(key, { value: newValue.toString(), type: 'string', expiresAt: entry?.expiresAt || null, }); return newValue; } /** @inheritdoc */ async decrBy(key, decrement) { return this.incrBy(key, -decrement); } // --- Hash Commands --- /** @inheritdoc */ async hGet(key, field) { const entry = this._getValidEntry(key, 'hash'); return entry ? (entry.value[field] ?? null) : null; } /** @inheritdoc */ async hSet(key, fieldOrFields, value) { let entry = this._getValidEntry(key, 'hash'); if (!entry) { entry = { value: {}, type: 'hash', expiresAt: null }; this.store.set(key, entry); } const hash = entry.value; let addedCount = 0; if (typeof fieldOrFields === 'string') { if (!Object.prototype.hasOwnProperty.call(hash, fieldOrFields)) addedCount++; hash[fieldOrFields] = this._serialize(value); } else { for (const field in fieldOrFields) { if (!Object.prototype.hasOwnProperty.call(hash, field)) addedCount++; hash[field] = this._serialize(fieldOrFields[field]); } } return addedCount; } /** @inheritdoc */ async hGetAll(key) { const entry = this._getValidEntry(key, 'hash'); return entry ? { ...entry.value } : {}; } /** @inheritdoc */ async hDel(key, fields) { const entry = this._getValidEntry(key, 'hash'); if (!entry) return 0; const hash = entry.value; const fieldsToDelete = Array.isArray(fields) ? fields : [fields]; let deletedCount = 0; for (const field of fieldsToDelete) { if (Object.prototype.hasOwnProperty.call(hash, field)) { delete hash[field]; deletedCount++; } } return deletedCount; } /** @inheritdoc */ async hExists(key, field) { const entry = this._getValidEntry(key, 'hash'); return !!entry && Object.prototype.hasOwnProperty.call(entry.value, field); } /** @inheritdoc */ async hIncrBy(key, field, increment) { const entry = this._getValidEntry(key, 'hash'); const hash = entry ? entry.value : {}; const currentValue = parseInt(hash[field] || '0', 10); if (isNaN(currentValue)) throw new Error('ERR hash value is not an integer'); const newValue = currentValue + increment; hash[field] = newValue.toString(); if (!entry) this.store.set(key, { value: hash, type: 'hash', expiresAt: null }); return newValue; } // --- List Commands --- /** @inheritdoc */ async lPush(key, elements) { let entry = this._getValidEntry(key, 'list'); if (!entry) { entry = { value: [], type: 'list', expiresAt: null }; this.store.set(key, entry); } const list = entry.value; const valuesToPush = (Array.isArray(elements) ? elements : [elements]).map(this._serialize); return list.unshift(...valuesToPush.reverse()); // Reverse to match Redis LPUSH behavior for multiple args } /** @inheritdoc */ async rPush(key, elements) { let entry = this._getValidEntry(key, 'list'); if (!entry) { entry = { value: [], type: 'list', expiresAt: null }; this.store.set(key, entry); } const list = entry.value; const values = (Array.isArray(elements) ? elements : [elements]).map(this._serialize); return list.push(...values); } /** @inheritdoc */ async lPop(key) { const entry = this._getValidEntry(key, 'list'); return entry ? (entry.value.shift() ?? null) : null; } /** @inheritdoc */ async rPop(key) { const entry = this._getValidEntry(key, 'list'); return entry ? (entry.value.pop() ?? null) : null; } /** @inheritdoc */ async lRange(key, start, stop) { const entry = this._getValidEntry(key, 'list'); if (!entry) return []; const list = entry.value; const realStop = stop < 0 ? list.length + stop : stop; return list.slice(start, realStop + 1); } /** @inheritdoc */ async lLen(key) { const entry = this._getValidEntry(key, 'list'); return entry ? entry.value.length : 0; } /** @inheritdoc */ async lTrim(key, start, stop) { const entry = this._getValidEntry(key, 'list'); if (entry) { const list = entry.value; const realStop = stop < 0 ? list.length + stop : stop; entry.value = list.slice(start, realStop + 1); } return 'OK'; } // --- Set Commands --- /** @inheritdoc */ async sAdd(key, members) { let entry = this._getValidEntry(key, 'set'); if (!entry) { entry = { value: new Set(), type: 'set', expiresAt: null }; this.store.set(key, entry); } const set = entry.value; const values = (Array.isArray(members) ? members : [members]).map(this._serialize); let addedCount = 0; for (const val of values) { if (!set.has(val)) { set.add(val); addedCount++; } } return addedCount; } /** @inheritdoc */ async sMembers(key) { const entry = this._getValidEntry(key, 'set'); return entry ? Array.from(entry.value) : []; } /** @inheritdoc */ async sIsMember(key, member) { const entry = this._getValidEntry(key, 'set'); return !!entry && entry.value.has(this._serialize(member)); } /** @inheritdoc */ async sRem(key, members) { const entry = this._getValidEntry(key, 'set'); if (!entry) return 0; const set = entry.value; const values = (Array.isArray(members) ? members : [members]).map(this._serialize); let removedCount = 0; for (const val of values) { if (set.delete(val)) removedCount++; } return removedCount; } /** @inheritdoc */ async sCard(key) { const entry = this._getValidEntry(key, 'set'); return entry ? entry.value.size : 0; } /** @inheritdoc */ async zAdd(key, scoreOrMembers, member) { let entry = this._getValidEntry(key, 'zset'); if (!entry) { entry = { value: [], type: 'zset', expiresAt: null }; this.store.set(key, entry); } const zset = entry.value; const membersToAdd = Array.isArray(scoreOrMembers) ? scoreOrMembers : [{ score: scoreOrMembers, value: member }]; let addedCount = 0; for (const m of membersToAdd) { const val = this._serialize(m.value); const existingIndex = zset.findIndex((e) => e.value === val); if (existingIndex > -1) { zset[existingIndex].score = m.score; } else { zset.push({ value: val, score: m.score }); addedCount++; } } zset.sort((a, b) => a.score - b.score); return addedCount; } /** @inheritdoc */ async zRange(key, min, max, options) { const entry = this._getValidEntry(key, 'zset'); if (!entry) return []; const zset = [...entry.value]; if (options?.REV) zset.reverse(); // Simplified: does not support BYSCORE or BYLEX const start = Number(min); const end = Number(max) === -1 ? zset.length : Number(max) + 1; return zset.slice(start, end).map((m) => m.value); } /** @inheritdoc */ async zRangeWithScores(key, min, max, options) { const entry = this._getValidEntry(key, 'zset'); if (!entry) return []; const zset = [...entry.value]; if (options?.REV) { zset.reverse(); } const start = Number(min); const end = Number(max) === -1 ? zset.length : Number(max) + 1; return zset.slice(start, end); } /** @inheritdoc */ async zRem(key, members) { const entry = this._getValidEntry(key, 'zset'); if (!entry) return 0; const zset = entry.value; const valuesToRemove = new Set((Array.isArray(members) ? members : [members]).map(this._serialize)); let removedCount = 0; entry.value = zset.filter((m) => { if (valuesToRemove.has(m.value)) { removedCount++; return false; } return true; }); return removedCount; } /** @inheritdoc */ async zCard(key) { const entry = this._getValidEntry(key, 'zset'); return entry ? entry.value.length : 0; } /** @inheritdoc */ async zScore(key, member) { const entry = this._getValidEntry(key, 'zset'); if (!entry) return null; const zset = entry.value; const found = zset.find((m) => m.value === this._serialize(member)); return found ? found.score : null; } // --- Pub/Sub, Scripting, and Server Commands --- pubSubListeners = new Map(); /** * Simulates the SUBSCRIBE command for testing Pub/Sub. * @param {string | string[]} channels - The channel or channels to subscribe to. * @param {(message: string, channel: string) => void} listener - The callback to execute on message receipt. * @returns {Promise<void>} */ async subscribe(channels, listener) { const channelArr = Array.isArray(channels) ? channels : [channels]; for (const channel of channelArr) { if (!this.pubSubListeners.has(channel)) this.pubSubListeners.set(channel, []); this.pubSubListeners.get(channel).push(listener); } } /** * Simulates the UNSUBSCRIBE command. * @param {string | string[]} [channels] - The channel or channels to unsubscribe from. If omitted, unsubscribes from all. * @returns {Promise<void>} */ async unsubscribe(channels) { const channelArr = channels ? Array.isArray(channels) ? channels : [channels] : Array.from(this.pubSubListeners.keys()); for (const channel of channelArr) { this.pubSubListeners.delete(channel); } } /** * Simulates the PUBLISH command for testing purposes. * @param {string} channel - The channel to publish the message to. * @param {string} message - The message to publish. * @returns {Promise<number>} A promise that resolves with the number of clients that received the message. */ async publish(channel, message) { const listeners = this.pubSubListeners.get(channel) || []; listeners.forEach((listener) => listener(message, channel)); return listeners.length; // Return number of subscribers } /** * Simulates the EVAL command. This is not implemented and will throw an error. * @throws {Error} */ async eval(_script, _keys, _args) { throw new Error('EVAL command not implemented in mock.'); } /** @inheritdoc */ async ping(message) { return message || 'PONG'; } /** @inheritdoc */ async info(section) { return `${section ? section + ':' : ''} }# Mock Server\r\nversion:1.0.0`; } // --- Orchestration Methods --- /** @inheritdoc */ multi() { return new BeaconRedisMockTransaction(this, this.logger); } // --- Lifecycle and Management Methods --- /** * Checks if the mock client is "healthy". For the mock, this always returns true. * @returns {Promise<boolean>} A promise that resolves to true. */ async isHealthy() { return true; } /** A no-op connect method to satisfy the interface. */ async connect() { /* no-op */ } /** A no-op quit method to satisfy the interface. */ async quit() { /* no-op */ } /** * Returns the mock instance itself, as it acts as the native client for testing. * @returns {this} The mock instance. */ getNativeClient() { return this; } /** * Gets the configured name of this mock Redis instance. * @returns {string} The instance name. */ getInstanceName() { return this.instanceName; } /** * A mock implementation of `updateConfig` for testing purposes. * It logs the call and does nothing else, satisfying the `IBeaconRedis` interface. * @param {Partial<any>} newConfig - The configuration object. */ updateConfig(newConfig) { this.logger.debug('[BeaconRedisMock] updateConfig called', { newConfig: JSON.stringify(newConfig), }); } } //# sourceMappingURL=BeaconRedisMock.js.map