@keyv/redis
Version:
Redis storage adapter for Keyv
513 lines (512 loc) • 16.5 kB
JavaScript
// src/index.ts
import EventEmitter from "events";
import {
createClient,
createCluster
} from "@redis/client";
import { Keyv } from "keyv";
import calculateSlot from "cluster-key-slot";
import {
createClient as createClient2,
createCluster as createCluster2
} from "@redis/client";
import {
Keyv as Keyv2
} from "keyv";
var KeyvRedis = class extends EventEmitter {
_client = createClient();
_namespace;
_keyPrefixSeparator = "::";
_clearBatchSize = 1e3;
_useUnlink = true;
_noNamespaceAffectsAll = false;
/**
* KeyvRedis constructor.
* @param {string | RedisClientOptions | RedisClientType} [connect] How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client.
* @param {KeyvRedisOptions} [options] Options for the adapter such as namespace, keyPrefixSeparator, and clearBatchSize.
*/
constructor(connect, options) {
super();
if (connect) {
if (typeof connect === "string") {
this._client = createClient({ url: connect });
} else if (connect.connect !== void 0) {
this._client = this.isClientCluster(connect) ? connect : connect;
} else if (connect instanceof Object) {
this._client = connect.rootNodes === void 0 ? createClient(connect) : createCluster(connect);
}
}
this.setOptions(options);
this.initClient();
}
/**
* Get the Redis client.
*/
get client() {
return this._client;
}
/**
* Set the Redis client.
*/
set client(value) {
this._client = value;
this.initClient();
}
/**
* Get the options for the adapter.
*/
get opts() {
let url = "redis://localhost:6379";
if (this._client.options) {
const redisUrl = this._client.options?.url;
if (redisUrl) {
url = redisUrl;
}
}
const results = {
namespace: this._namespace,
keyPrefixSeparator: this._keyPrefixSeparator,
clearBatchSize: this._clearBatchSize,
noNamespaceAffectsAll: this._noNamespaceAffectsAll,
dialect: "redis",
url
};
return results;
}
/**
* Set the options for the adapter.
*/
set opts(options) {
this.setOptions(options);
}
/**
* Get the namespace for the adapter. If undefined, it will not use a namespace including keyPrefixing.
* @default undefined
*/
get namespace() {
return this._namespace;
}
/**
* Set the namespace for the adapter. If undefined, it will not use a namespace including keyPrefixing.
*/
set namespace(value) {
this._namespace = value;
}
/**
* Get the separator between the namespace and key.
* @default '::'
*/
get keyPrefixSeparator() {
return this._keyPrefixSeparator;
}
/**
* Set the separator between the namespace and key.
*/
set keyPrefixSeparator(value) {
this._keyPrefixSeparator = value;
}
/**
* Get the number of keys to delete in a single batch.
* @default 1000
*/
get clearBatchSize() {
return this._clearBatchSize;
}
/**
* Set the number of keys to delete in a single batch.
*/
set clearBatchSize(value) {
if (value > 0) {
this._clearBatchSize = value;
} else {
this.emit("error", "clearBatchSize must be greater than 0");
}
}
/**
* Get if Unlink is used instead of Del for clearing keys. This is more performant but may not be supported by all Redis versions.
* @default true
*/
get useUnlink() {
return this._useUnlink;
}
/**
* Set if Unlink is used instead of Del for clearing keys. This is more performant but may not be supported by all Redis versions.
*/
set useUnlink(value) {
this._useUnlink = value;
}
/**
* Get if no namespace affects all keys.
* Whether to allow clearing all keys when no namespace is set.
* If set to true and no namespace is set, iterate() will return all keys.
* @default false
*/
get noNamespaceAffectsAll() {
return this._noNamespaceAffectsAll;
}
/**
* Set if not namespace affects all keys.
*/
set noNamespaceAffectsAll(value) {
this._noNamespaceAffectsAll = value;
}
/**
* Get the Redis URL used to connect to the server. This is used to get a connected client.
*/
async getClient() {
try {
if (!this._client.isOpen) {
await this._client.connect();
}
} catch (error) {
this.emit("error", error);
}
return this._client;
}
/**
* Set a key value pair in the store. TTL is in milliseconds.
* @param {string} key - the key to set
* @param {string} value - the value to set
* @param {number} [ttl] - the time to live in milliseconds
*/
async set(key, value, ttl) {
const client = await this.getClient();
key = this.createKeyPrefix(key, this._namespace);
if (ttl) {
await client.set(key, value, { PX: ttl });
} else {
await client.set(key, value);
}
}
/**
* Will set many key value pairs in the store. TTL is in milliseconds. This will be done as a single transaction.
* @param {KeyvEntry[]} entries - the key value pairs to set with optional ttl
*/
async setMany(entries) {
const client = await this.getClient();
const multi = client.multi();
for (const { key, value, ttl } of entries) {
const prefixedKey = this.createKeyPrefix(key, this._namespace);
if (ttl) {
multi.set(prefixedKey, value, { PX: ttl });
} else {
multi.set(prefixedKey, value);
}
}
await multi.exec();
}
/**
* Check if a key exists in the store.
* @param {string} key - the key to check
* @returns {Promise<boolean>} - true if the key exists, false if not
*/
async has(key) {
const client = await this.getClient();
key = this.createKeyPrefix(key, this._namespace);
const exists = await client.exists(key);
return exists === 1;
}
/**
* Check if many keys exist in the store. This will be done as a single transaction.
* @param {Array<string>} keys - the keys to check
* @returns {Promise<Array<boolean>>} - array of booleans for each key if it exists
*/
async hasMany(keys) {
const client = await this.getClient();
const multi = client.multi();
for (const key of keys) {
const prefixedKey = this.createKeyPrefix(key, this._namespace);
multi.exists(prefixedKey);
}
const results = await multi.exec();
return results.map((result) => result === 1);
}
/**
* Get a value from the store. If the key does not exist, it will return undefined.
* @param {string} key - the key to get
* @returns {Promise<string | undefined>} - the value or undefined if the key does not exist
*/
async get(key) {
const client = await this.getClient();
key = this.createKeyPrefix(key, this._namespace);
const value = await client.get(key);
if (value === null) {
return void 0;
}
return value;
}
/**
* Get many values from the store. If a key does not exist, it will return undefined.
* @param {Array<string>} keys - the keys to get
* @returns {Promise<Array<string | undefined>>} - array of values or undefined if the key does not exist
*/
async getMany(keys) {
if (keys.length === 0) {
return [];
}
keys = keys.map((key) => this.createKeyPrefix(key, this._namespace));
const values = await this.mget(keys);
return values;
}
/**
* Delete a key from the store.
* @param {string} key - the key to delete
* @returns {Promise<boolean>} - true if the key was deleted, false if not
*/
async delete(key) {
const client = await this.getClient();
key = this.createKeyPrefix(key, this._namespace);
let deleted = 0;
deleted = await (this._useUnlink ? client.unlink(key) : client.del(key));
return deleted > 0;
}
/**
* Delete many keys from the store. This will be done as a single transaction.
* @param {Array<string>} keys - the keys to delete
* @returns {Promise<boolean>} - true if any key was deleted, false if not
*/
async deleteMany(keys) {
let result = false;
const client = await this.getClient();
const multi = client.multi();
for (const key of keys) {
const prefixedKey = this.createKeyPrefix(key, this._namespace);
if (this._useUnlink) {
multi.unlink(prefixedKey);
} else {
multi.del(prefixedKey);
}
}
const results = await multi.exec();
for (const deleted of results) {
if (typeof deleted === "number" && deleted > 0) {
result = true;
}
}
return result;
}
/**
* Disconnect from the Redis server.
* @returns {Promise<void>}
* @param {boolean} [force] - it will send a quit command if false, otherwise it will send a disconnect command to forcefully disconnect.
* @see {@link https://github.com/redis/node-redis/tree/master/packages/redis#disconnecting}
*/
async disconnect(force) {
if (this._client.isOpen) {
await (force ? this._client.disconnect() : this._client.quit());
}
}
/**
* Helper function to create a key with a namespace.
* @param {string} key - the key to prefix
* @param {string} namespace - the namespace to prefix the key with
* @returns {string} - the key with the namespace such as 'namespace::key'
*/
createKeyPrefix(key, namespace) {
if (namespace) {
return `${namespace}${this._keyPrefixSeparator}${key}`;
}
return key;
}
/**
* Helper function to get a key without the namespace.
* @param {string} key - the key to remove the namespace from
* @param {string} namespace - the namespace to remove from the key
* @returns {string} - the key without the namespace such as 'key'
*/
getKeyWithoutPrefix(key, namespace) {
if (namespace) {
return key.replace(`${namespace}${this._keyPrefixSeparator}`, "");
}
return key;
}
/**
* Is the client a cluster.
* @returns {boolean} - true if the client is a cluster, false if not
*/
isCluster() {
return this.isClientCluster(this._client);
}
/**
* Get the master nodes in the cluster. If not a cluster, it will return the single client.
*
* @returns {Promise<RedisClientType[]>} - array of master nodes
*/
async getMasterNodes() {
if (this.isCluster()) {
const cluster = await this.getClient();
return Promise.all(cluster.masters.map(async (main) => cluster.nodeClient(main)));
}
return [await this.getClient()];
}
/**
* Get an async iterator for the keys and values in the store. If a namespace is provided, it will only iterate over keys with that namespace.
* @param {string} [namespace] - the namespace to iterate over
* @returns {AsyncGenerator<[string, T | undefined], void, unknown>} - async iterator with key value pairs
*/
async *iterator(namespace) {
const clients = await this.getMasterNodes();
for (const client of clients) {
const match = namespace ? `${namespace}${this._keyPrefixSeparator}*` : "*";
let cursor = "0";
do {
const result = await client.scan(Number.parseInt(cursor, 10), { MATCH: match, TYPE: "string" });
cursor = result.cursor.toString();
let { keys } = result;
if (!namespace && !this._noNamespaceAffectsAll) {
keys = keys.filter((key) => !key.includes(this._keyPrefixSeparator));
}
if (keys.length > 0) {
const values = await this.mget(keys);
for (const i of keys.keys()) {
const key = this.getKeyWithoutPrefix(keys[i], namespace);
const value = values[i];
yield [key, value];
}
}
} while (cursor !== "0");
}
}
/**
* Clear all keys in the store.
* IMPORTANT: this can cause performance issues if there are a large number of keys in the store and worse with clusters. Use with caution as not recommended for production.
* If a namespace is not set it will clear all keys with no prefix.
* If a namespace is set it will clear all keys with that namespace.
* @returns {Promise<void>}
*/
async clear() {
try {
const clients = await this.getMasterNodes();
await Promise.all(clients.map(async (client) => {
if (!this._namespace && this._noNamespaceAffectsAll) {
await client.flushDb();
return;
}
let cursor = "0";
const batchSize = this._clearBatchSize;
const match = this._namespace ? `${this._namespace}${this._keyPrefixSeparator}*` : "*";
const deletePromises = [];
do {
const result = await client.scan(Number.parseInt(cursor, 10), { MATCH: match, COUNT: batchSize, TYPE: "string" });
cursor = result.cursor.toString();
let { keys } = result;
if (keys.length === 0) {
continue;
}
if (!this._namespace) {
keys = keys.filter((key) => !key.includes(this._keyPrefixSeparator));
}
deletePromises.push(this.clearWithClusterSupport(keys));
} while (cursor !== "0");
await Promise.all(deletePromises);
}));
} catch (error) {
this.emit("error", error);
}
}
/**
* Get many keys. If the instance is a cluster, it will do multiple MGET calls
* by separating the keys by slot to solve the CROSS-SLOT restriction.
*/
async mget(keys) {
const slotMap = this.getSlotMap(keys);
const valueMap = /* @__PURE__ */ new Map();
await Promise.all(Array.from(slotMap.entries(), async ([slot, keys2]) => {
const client = await this.getSlotMaster(slot);
const values = await client.mGet(keys2);
for (const [index, value] of values.entries()) {
valueMap.set(keys2[index], value ?? void 0);
}
}));
return keys.map((key) => valueMap.get(key));
}
/**
* Clear all keys in the store with a specific namespace. If the instance is a cluster, it will clear all keys
* by separating the keys by slot to solve the CROSS-SLOT restriction.
*/
async clearWithClusterSupport(keys) {
if (keys.length > 0) {
const slotMap = this.getSlotMap(keys);
await Promise.all(Array.from(slotMap.entries(), async ([slot, keys2]) => {
const client = await this.getSlotMaster(slot);
return this._useUnlink ? client.unlink(keys2) : client.del(keys2);
}));
}
}
/**
* Returns the master node client for a given slot or the instance's client if it's not a cluster.
*/
async getSlotMaster(slot) {
const connection = await this.getClient();
if (this.isCluster()) {
const cluster = connection;
const mainNode = cluster.slots[slot].master;
return cluster.nodeClient(mainNode);
}
return connection;
}
/**
* Group keys by their slot.
*
* @param {string[]} keys - the keys to group
* @returns {Map<number, string[]>} - map of slot to keys
*/
getSlotMap(keys) {
const slotMap = /* @__PURE__ */ new Map();
if (this.isCluster()) {
for (const key of keys) {
const slot = calculateSlot(key);
const slotKeys = slotMap.get(slot) ?? [];
slotKeys.push(key);
slotMap.set(slot, slotKeys);
}
} else {
slotMap.set(0, keys);
}
return slotMap;
}
isClientCluster(client) {
if (client.options === void 0 && client.scan === void 0) {
return true;
}
return false;
}
setOptions(options) {
if (!options) {
return;
}
if (options.namespace) {
this._namespace = options.namespace;
}
if (options.keyPrefixSeparator !== void 0) {
this._keyPrefixSeparator = options.keyPrefixSeparator;
}
if (options.clearBatchSize !== void 0 && options.clearBatchSize > 0) {
this._clearBatchSize = options.clearBatchSize;
}
if (options.useUnlink !== void 0) {
this._useUnlink = options.useUnlink;
}
if (options.noNamespaceAffectsAll !== void 0) {
this._noNamespaceAffectsAll = options.noNamespaceAffectsAll;
}
}
initClient() {
this._client.on("error", (error) => {
this.emit("error", error);
});
}
};
function createKeyv(connect, options) {
connect ??= "redis://localhost:6379";
const adapter = new KeyvRedis(connect, options);
const keyv = new Keyv(adapter, { namespace: options?.namespace, useKeyPrefix: false });
return keyv;
}
export {
Keyv2 as Keyv,
createClient2 as createClient,
createCluster2 as createCluster,
createKeyv,
KeyvRedis as default
};