Review funkt

This commit is contained in:
2026-08-09 21:56:00 +00:00
parent f522ae9953
commit fe9dfa4519
11366 changed files with 2335077 additions and 97 deletions

View File

@@ -0,0 +1,768 @@
import { __asyncValues } from "tslib";
import { createHash } from 'crypto';
import { EventEmitter } from 'events';
import { Readable } from 'stream';
import { ConnectionClosedError } from './errors/connection-closed-error';
function normalizeScriptArgs(args) {
return args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
}
function isConnectionClosedError(err) {
return ((err === null || err === void 0 ? void 0 : err.message) === 'Disconnects client' ||
(err === null || err === void 0 ? void 0 : err.message) === 'The client is closed' ||
(err === null || err === void 0 ? void 0 : err.message) === 'Connection is closed.');
}
export function createNodeRedisClient(client) {
return new NodeRedisAdapter(client);
}
/**
* Full wrapper (not augmentation) because node-redis's API is structurally
* different from ioredis and cannot be patched in-place.
*/
class NodeRedisAdapter extends EventEmitter {
/**
* Expose connection status using the vocabulary that
* {@link RedisConnection.waitUntilReady} expects:
* 'wait' → not yet connected, call connect()
* 'ready' → usable
* 'end' → permanently closed
*/
get status() {
if (this.statusOverride) {
return this.statusOverride;
}
if (this.raw.isReady) {
return 'ready';
}
if (this.raw.isOpen) {
return 'connect';
}
// Distinguish "never connected" from "disconnected after use"
return this.hasConnected ? 'end' : 'wait';
}
set status(val) {
// Allow RedisConnection to forcibly set 'end'
if (val === 'end') {
this.destroying = true;
if (this.raw.isOpen) {
try {
this.raw.quit().catch(() => { });
}
catch (_a) {
// already closed
}
}
}
this.statusOverride = val;
}
get options() {
var _a;
return (_a = this.raw.options) !== null && _a !== void 0 ? _a : {};
}
set options(val) {
// no-op – callers sometimes assign
}
constructor(raw) {
super();
this.raw = raw;
this.scripts = new Map();
this.hasConnected = false;
this.destroying = false;
this.isCluster = false; // TODO: cluster support
// Track first connection so status can distinguish 'wait' vs 'end'.
// When a connectionName is set (via duplicate()), delay the 'ready'
// event until CLIENT SETNAME completes so that callers waiting for
// 'ready' (e.g. RedisConnection.waitUntilReady) see the name already
// applied.
raw.on('ready', () => {
this.hasConnected = true;
if (this.connectionName) {
this.raw.clientSetName(this.connectionName).then(() => this.emit('ready'), () => this.emit('ready'));
}
else {
this.emit('ready');
}
});
raw.on('error', (err) => {
// Suppress the expected DisconnectsClientError that node-redis emits
// when destroy() is called intentionally (e.g. during close/disconnect).
if (this.destroying && isConnectionClosedError(err)) {
return;
}
this.emit('error', err);
});
raw.on('end', () => this.emit('close'));
raw.on('reconnecting', () => this.emit('reconnecting'));
// Auto-connect eagerly, like ioredis does in its constructor.
// This ensures commands can be issued immediately without an
// explicit connect() call. The promise is stored so that
// connect() is idempotent and callers can still await it.
if (!raw.isOpen) {
this.connectPromise = raw.connect().then(() => {
this.connectPromise = undefined;
}, (err) => {
this.connectPromise = undefined;
// Don't throw — errors surface via the 'error' event.
});
}
}
// ---------------------------------------------------------------
// Connection lifecycle
// ---------------------------------------------------------------
async connect() {
if (this.connectPromise) {
return this.connectPromise;
}
if (!this.raw.isOpen) {
this.connectPromise = this.raw.connect().then(() => {
this.connectPromise = undefined;
}, (err) => {
this.connectPromise = undefined;
throw err;
});
return this.connectPromise;
}
if (!this.raw.isReady) {
await new Promise((resolve, reject) => {
const onReady = () => {
cleanup();
resolve();
};
const onError = (err) => {
cleanup();
reject(err);
};
const onEnd = () => {
cleanup();
reject(new Error('Connection ended before ready event'));
};
const cleanup = () => {
this.off('ready', onReady);
this.off('error', onError);
this.off('end', onEnd);
};
this.once('ready', onReady);
this.once('error', onError);
this.once('end', onEnd);
});
}
}
disconnect(reconnect = false) {
this.destroying = true;
if (!reconnect) {
this.statusOverride = 'end';
}
try {
if (this.raw.isOpen) {
// Use destroy() for immediate teardown. This interrupts any pending
// blocking commands (e.g. BZPOPMIN) without waiting for them to
// complete. The resulting "Disconnects client" rejections are handled
// by BullMQ's isNotConnectionError() checks.
this.raw.destroy();
}
}
catch (_a) {
// Swallow errors from already-closed connections
}
this.emit('close');
if (reconnect) {
this.statusOverride = undefined;
this.emit('reconnecting');
this.connect()
.catch(err => {
if (!isConnectionClosedError(err)) {
this.emit('error', err);
}
})
.finally(() => {
this.destroying = false;
});
}
else {
// Emit both 'close' and 'end' so that all listeners are unblocked.
// RedisConnection.close() listens for 'close', RedisConnection.disconnect() listens for 'end'.
this.emit('end');
}
}
async quit() {
if (this.destroying || this.statusOverride === 'end') {
setImmediate(() => {
this.emit('end');
this.emit('close');
});
return 'OK';
}
this.destroying = true;
try {
if (this.raw.isOpen) {
try {
await this.raw.quit();
}
catch (_a) {
// Swallow errors from already-closing connections
}
}
}
catch (_b) {
// Swallow errors from already-closing connections
}
this.statusOverride = 'end';
// Emit on next tick so callers can register listeners after await quit()
setImmediate(() => {
this.emit('end');
this.emit('close');
});
return 'OK';
}
duplicate(...args) {
const dup = this.raw.duplicate();
const adapter = new NodeRedisAdapter(dup);
// Copy registered scripts to the duplicate
for (const [name, script] of this.scripts) {
adapter.scripts.set(name, script);
adapter[name] = (...args) => adapter.runCommand(name, args);
}
// Handle connectionName option (ioredis calls CLIENT SETNAME automatically).
// Setting connectionName BEFORE auto-connect resolves ensures the
// constructor's 'ready' handler applies CLIENT SETNAME before emitting
// 'ready', so callers (like RedisConnection.waitUntilReady) see the name
// already set when the connection is reported as ready.
const opts = args[0];
if (opts && typeof opts === 'object' && opts.connectionName) {
adapter.connectionName = opts.connectionName;
}
return adapter;
}
// ---------------------------------------------------------------
// Lua script engine
// ---------------------------------------------------------------
defineCommand(name, definition) {
const sha = createHash('sha1').update(definition.lua).digest('hex');
this.scripts.set(name, {
sha,
lua: definition.lua,
numberOfKeys: definition.numberOfKeys,
});
// Mimic ioredis behavior: add a callable property on the instance
// so that `(client as any)[name]` is defined (used by ScriptLoader cache check)
this[name] = (...args) => this.runCommand(name, args);
// Pre-load the script into Redis so that EVALSHA in transactions works
// immediately. This mirrors what ioredis does under the hood.
this.raw.scriptLoad(definition.lua).catch(() => {
// Ignore errors here – runCommand has NOSCRIPT fallback for non-tx path
});
}
async runCommand(name, args) {
var _a, _b;
const script = this.scripts.get(name);
if (!script) {
throw new Error(`BullMQ: unknown command "${name}"`);
}
const commandArgs = normalizeScriptArgs(args);
const { sha, lua, numberOfKeys } = script;
const keys = commandArgs.slice(0, numberOfKeys).map(String);
// Preserve Buffer arguments (e.g. msgpack data) – only stringify non-Buffers.
// Convert undefined/null to '' to match ioredis behavior (keeps arg positions).
const argv = commandArgs.slice(numberOfKeys).map((a) => {
if (Buffer.isBuffer(a)) {
return a;
}
if (a === undefined || a === null) {
return '';
}
return String(a);
});
try {
return await this.raw.evalSha(sha, { keys, arguments: argv });
}
catch (err) {
if (this.destroying && isConnectionClosedError(err)) {
return null;
}
if (isConnectionClosedError(err)) {
throw new ConnectionClosedError(err.message, err);
}
// NOSCRIPT – fall back to EVAL which also caches the script
if ((_b = (_a = err === null || err === void 0 ? void 0 : err.message) === null || _a === void 0 ? void 0 : _a.includes) === null || _b === void 0 ? void 0 : _b.call(_a, 'NOSCRIPT')) {
try {
return await this.raw.eval(lua, { keys, arguments: argv });
}
catch (evalErr) {
if (this.destroying && isConnectionClosedError(evalErr)) {
return null;
}
if (isConnectionClosedError(evalErr)) {
throw new ConnectionClosedError(evalErr.message, evalErr);
}
throw evalErr;
}
}
throw err;
}
}
// ---------------------------------------------------------------
// Pipeline / Transaction
// ---------------------------------------------------------------
multi() {
return new NodeRedisTransaction(this.raw.multi(), this.scripts);
}
pipeline() {
// node-redis doesn't have a separate pipeline concept;
// use multi() which behaves similarly for batching.
return this.multi();
}
// ---------------------------------------------------------------
// Hash commands
// ---------------------------------------------------------------
async hgetall(key) {
const result = await this.raw.hGetAll(key);
return result !== null && result !== void 0 ? result : {};
}
async hget(key, field) {
var _a;
return (_a = (await this.raw.hGet(key, field))) !== null && _a !== void 0 ? _a : null;
}
async hmget(key, ...fields) {
const result = await this.raw.hmGet(key, fields);
return result.map((v) => v !== null && v !== void 0 ? v : null);
}
async hset(key, dataOrField, ...rest) {
if (typeof dataOrField === 'object') {
// Record-based call: hset(key, { field: value, ... })
return await this.raw.hSet(key, dataOrField);
}
// Varargs call (ioredis compat): hset(key, field, value, field, value, ...)
const record = {};
record[dataOrField] = String(rest[0]);
for (let i = 1; i < rest.length; i += 2) {
record[String(rest[i])] = String(rest[i + 1]);
}
return await this.raw.hSet(key, record);
}
async hdel(key, ...fields) {
return await this.raw.hDel(key, fields);
}
async hexists(key, field) {
const exists = await this.raw.hExists(key, field);
return exists ? 1 : 0;
}
// ---------------------------------------------------------------
// String commands
// ---------------------------------------------------------------
async get(key) {
var _a;
return (_a = (await this.raw.get(key))) !== null && _a !== void 0 ? _a : null;
}
async set(key, value, options) {
const opts = {};
if ((options === null || options === void 0 ? void 0 : options.PX) != null) {
opts.PX = options.PX;
}
else if ((options === null || options === void 0 ? void 0 : options.EX) != null) {
opts.EX = options.EX;
}
return await this.raw.set(key, String(value), opts);
}
async del(...keys) {
if (keys.length === 0) {
return 0;
}
return await this.raw.del(keys);
}
// ---------------------------------------------------------------
// Sorted set commands
// ---------------------------------------------------------------
async zrange(key, start, end, options) {
if (options === null || options === void 0 ? void 0 : options.WITHSCORES) {
// node-redis v5 uses a separate method for WITHSCORES
const items = await this.raw.zRangeWithScores(key, start, end);
// Flatten to [member, score, member, score, …] like ioredis
const flat = [];
for (const item of items) {
flat.push(item.value, String(item.score));
}
return flat;
}
return await this.raw.zRange(key, start, end);
}
async zrevrange(key, start, end, options) {
if (options === null || options === void 0 ? void 0 : options.WITHSCORES) {
const items = await this.raw.zRangeWithScores(key, start, end, {
REV: true,
});
const flat = [];
for (const item of items) {
flat.push(item.value, String(item.score));
}
return flat;
}
return await this.raw.zRange(key, start, end, { REV: true });
}
async zcard(key) {
return await this.raw.zCard(key);
}
async zscore(key, member) {
const score = await this.raw.zScore(key, member);
return score != null ? String(score) : null;
}
// ---------------------------------------------------------------
// List commands
// ---------------------------------------------------------------
async lrange(key, start, end) {
return await this.raw.lRange(key, start, end);
}
async llen(key) {
return await this.raw.lLen(key);
}
async ltrim(key, start, end) {
await this.raw.lTrim(key, start, end);
return 'OK';
}
async lpos(key, value) {
var _a;
return (_a = (await this.raw.lPos(key, value))) !== null && _a !== void 0 ? _a : null;
}
// ---------------------------------------------------------------
// Set commands
// ---------------------------------------------------------------
async smembers(key) {
return await this.raw.sMembers(key);
}
// ---------------------------------------------------------------
// Stream commands
// ---------------------------------------------------------------
async xadd(key, id, fields, options) {
const opts = {};
if ((options === null || options === void 0 ? void 0 : options.MAXLEN) != null) {
opts.TRIM = {
strategy: 'MAXLEN',
threshold: options.MAXLEN,
strategyModifier: options.approximate === false ? undefined : '~',
};
}
// node-redis xAdd rejects numeric field values — stringify all values
const strFields = {};
for (const [k, v] of Object.entries(fields)) {
strFields[k] = String(v);
}
return await this.raw.xAdd(key, id, strFields, opts);
}
async xread(streams, options) {
const opts = {};
if ((options === null || options === void 0 ? void 0 : options.BLOCK) != null) {
opts.BLOCK = options.BLOCK;
}
if ((options === null || options === void 0 ? void 0 : options.COUNT) != null) {
opts.COUNT = options.COUNT;
}
const streamArgs = streams.map(s => ({ key: s.key, id: s.id }));
let result;
try {
result = await this.raw.xRead(streamArgs, opts);
}
catch (err) {
if (this.destroying && isConnectionClosedError(err)) {
return null;
}
if (isConnectionClosedError(err)) {
throw new ConnectionClosedError(err.message, err);
}
throw err;
}
if (!result) {
return null;
}
// Normalize to ioredis format: [[streamName, [[id, [field, value, …]], …]], …]
return result.map((stream) => [
stream.name,
stream.messages.map((msg) => [
msg.id,
Object.entries(msg.message).flat(),
]),
]);
}
async xtrim(key, strategy, threshold, options) {
const strategyModifier = (options === null || options === void 0 ? void 0 : options.approximate) === false ? undefined : '~';
return await this.raw.xTrim(key, strategy, threshold, {
strategyModifier,
});
}
// ---------------------------------------------------------------
// Blocking commands
// ---------------------------------------------------------------
async bzpopmin(key, timeout) {
let result;
try {
result = await this.raw.bzPopMin(key, timeout);
}
catch (err) {
if (this.destroying && isConnectionClosedError(err)) {
return null;
}
if (isConnectionClosedError(err)) {
throw new ConnectionClosedError(err.message, err);
}
throw err;
}
if (!result) {
return null;
}
return [result.key, result.value, String(result.score)];
}
// ---------------------------------------------------------------
// Server / admin commands
// ---------------------------------------------------------------
async info() {
return await this.raw.info();
}
async clientSetName(name) {
return await this.raw.clientSetName(name);
}
async clientList() {
return await this.raw.sendCommand(['CLIENT', 'LIST']);
}
// ---------------------------------------------------------------
// Key scanning
// ---------------------------------------------------------------
async scan(cursor, options) {
const opts = {};
if (options === null || options === void 0 ? void 0 : options.MATCH) {
opts.MATCH = options.MATCH;
}
if (options === null || options === void 0 ? void 0 : options.COUNT) {
opts.COUNT = options.COUNT;
}
const result = await this.raw.scan(String(cursor), opts);
return [String(result.cursor), result.keys];
}
scanStream(options) {
const raw = this.raw;
const connectPromise = this.connectPromise;
const scanOpts = {};
if (options.match) {
scanOpts.MATCH = options.match;
}
if (options.count) {
scanOpts.COUNT = options.count;
}
const readable = new Readable({
objectMode: true,
async read() {
var _a, e_1, _b, _c;
try {
if (connectPromise) {
await connectPromise;
}
try {
for (var _d = true, _e = __asyncValues(raw.scanIterator(scanOpts)), _f; _f = await _e.next(), _a = _f.done, !_a; _d = true) {
_c = _f.value;
_d = false;
const keys = _c;
if (!readable.push(Array.isArray(keys) ? keys : [keys])) {
return; // backpressure
}
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (!_d && !_a && (_b = _e.return)) await _b.call(_e);
}
finally { if (e_1) throw e_1.error; }
}
readable.push(null); // EOF
}
catch (err) {
readable.destroy(err);
}
},
});
return readable;
}
// ---------------------------------------------------------------
// Extra Redis commands (not part of IRedisClient but used by tests
// and occasionally by user code that accesses the raw client).
// ---------------------------------------------------------------
async keys(pattern) {
return await this.raw.keys(pattern);
}
async exists(...keys) {
if (keys.length === 0) {
return 0;
}
return await this.raw.exists(keys);
}
async zadd(key, ...args) {
// ioredis: zadd(key, score, member, score, member, ...)
const members = [];
for (let i = 0; i < args.length; i += 2) {
members.push({ score: Number(args[i]), value: String(args[i + 1]) });
}
return await this.raw.zAdd(key, members);
}
async zrem(key, ...members) {
return await this.raw.zRem(key, members);
}
async xlen(key) {
return await this.raw.xLen(key);
}
async xrevrange(key, end, start, ...rest) {
const opts = {};
// ioredis: xrevrange(key, end, start, 'COUNT', n)
if (rest[0] === 'COUNT') {
opts.COUNT = Number(rest[1]);
}
const result = await this.raw.xRevRange(key, end, start, opts);
// Normalize to ioredis format: [[id, [field, value, …]], …]
return result.map((msg) => [
msg.id,
Object.entries(msg.message).flat(),
]);
}
async sadd(key, ...members) {
return await this.raw.sAdd(key, members.map(String));
}
async scard(key) {
return await this.raw.sCard(key);
}
async lpush(key, ...values) {
return await this.raw.lPush(key, values);
}
async rpop(key) {
return await this.raw.rPop(key);
}
async incr(key) {
return await this.raw.incr(key);
}
async incrby(key, increment) {
return await this.raw.incrBy(key, increment);
}
async flushall() {
return await this.raw.flushAll();
}
}
// ---------------------------------------------------------------------------
// Transaction / Pipeline wrapper
// ---------------------------------------------------------------------------
class NodeRedisTransaction {
constructor(raw, scripts) {
this.raw = raw;
this.scripts = scripts;
this.transformers = [];
}
addIdentityTransformer() {
this.transformers.push((v) => v);
}
hgetall(key) {
this.raw.hGetAll(key);
this.addIdentityTransformer();
return this;
}
hset(key, data) {
this.raw.hSet(key, data);
this.addIdentityTransformer();
return this;
}
hscan(key, cursor, options) {
const opts = {};
if ((options === null || options === void 0 ? void 0 : options.COUNT) != null) {
opts.COUNT = options.COUNT;
}
this.raw.hScan(key, String(cursor), opts);
// Transform node-redis { cursor, entries: [{field, value}] }
// to ioredis [cursor, [field, value, field, value, ...]]
this.transformers.push((val) => {
if (!val) {
return ['0', []];
}
const flat = [];
for (const entry of val.entries || []) {
flat.push(entry.field, entry.value);
}
return [String(val.cursor), flat];
});
return this;
}
smembers(key) {
this.raw.sMembers(key);
this.addIdentityTransformer();
return this;
}
sscan(key, cursor, options) {
const opts = {};
if ((options === null || options === void 0 ? void 0 : options.COUNT) != null) {
opts.COUNT = options.COUNT;
}
this.raw.sScan(key, String(cursor), opts);
// Transform node-redis { cursor, members: [...] }
// to ioredis [cursor, [member, member, ...]]
this.transformers.push((val) => {
if (!val) {
return ['0', []];
}
return [String(val.cursor), val.members || []];
});
return this;
}
zrange(key, start, end) {
this.raw.zRange(key, start, end);
this.addIdentityTransformer();
return this;
}
lrange(key, start, end) {
this.raw.lRange(key, start, end);
this.addIdentityTransformer();
return this;
}
llen(key) {
this.raw.lLen(key);
this.addIdentityTransformer();
return this;
}
del(...keys) {
if (keys.length > 0) {
this.raw.del(keys);
this.addIdentityTransformer();
}
return this;
}
runCommand(name, args) {
const script = this.scripts.get(name);
if (!script) {
throw new Error(`BullMQ: unknown command "${name}" in transaction`);
}
const commandArgs = normalizeScriptArgs(args);
const { sha, lua, numberOfKeys } = script;
const cmdKeys = commandArgs.slice(0, numberOfKeys).map(String);
const argv = commandArgs.slice(numberOfKeys).map((a) => {
if (Buffer.isBuffer(a)) {
return a;
}
if (a === undefined || a === null) {
return '';
}
return String(a);
});
// Use evalSha in pipeline; NOSCRIPT handling happens at exec()-time
// node-redis multi supports evalSha
this.raw.evalSha(sha, { keys: cmdKeys, arguments: argv });
this.addIdentityTransformer();
return this;
}
async exec() {
const results = await this.raw.exec();
if (!results) {
return null;
}
// node-redis multi.exec() returns values directly (or throws on error).
// Normalize to ioredis format: [Error | null, value][]
// Apply per-command transformers for format differences (hscan, sscan).
return results.map((result, i) => {
if (result instanceof Error) {
return [result, null];
}
const transformer = this.transformers[i];
const value = transformer ? transformer(result) : result;
return [null, value];
});
}
}