syntropylog
Version:
An instance manager with observability for Node.js applications
713 lines • 24.3 kB
JavaScript
/**
* 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