refactor progress
This commit is contained in:
21
packages/kv_client/package.json
Normal file
21
packages/kv_client/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@fluxer/kv_client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/errors": "workspace:*",
|
||||
"ioredis": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
105
packages/kv_client/src/IKVProvider.tsx
Normal file
105
packages/kv_client/src/IKVProvider.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface IKVPipeline {
|
||||
get(key: string): this;
|
||||
set(key: string, value: string): this;
|
||||
setex(key: string, ttlSeconds: number, value: string): this;
|
||||
del(key: string): this;
|
||||
expire(key: string, ttlSeconds: number): this;
|
||||
sadd(key: string, ...members: Array<string>): this;
|
||||
srem(key: string, ...members: Array<string>): this;
|
||||
zadd(key: string, score: number, value: string): this;
|
||||
zrem(key: string, ...members: Array<string>): this;
|
||||
mset(...args: Array<string>): this;
|
||||
exec(): Promise<Array<[Error | null, unknown]>>;
|
||||
}
|
||||
|
||||
export interface IKVSubscription {
|
||||
connect(): Promise<void>;
|
||||
on(event: 'message', callback: (channel: string, message: string) => void): void;
|
||||
on(event: 'error', callback: (error: Error) => void): void;
|
||||
subscribe(...channels: Array<string>): Promise<void>;
|
||||
unsubscribe(...channels: Array<string>): Promise<void>;
|
||||
quit(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
removeAllListeners(event?: 'message' | 'error'): void;
|
||||
}
|
||||
|
||||
export interface IKVProvider {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, ...args: Array<string | number>): Promise<string | null>;
|
||||
setex(key: string, ttlSeconds: number, value: string): Promise<void>;
|
||||
setnx(key: string, value: string, ttlSeconds?: number): Promise<boolean>;
|
||||
mget(...keys: Array<string>): Promise<Array<string | null>>;
|
||||
mset(...args: Array<string>): Promise<void>;
|
||||
del(...keys: Array<string>): Promise<number>;
|
||||
exists(key: string): Promise<number>;
|
||||
expire(key: string, ttlSeconds: number): Promise<number>;
|
||||
ttl(key: string): Promise<number>;
|
||||
incr(key: string): Promise<number>;
|
||||
getex(key: string, ttlSeconds: number): Promise<string | null>;
|
||||
getdel(key: string): Promise<string | null>;
|
||||
|
||||
sadd(key: string, ...members: Array<string>): Promise<number>;
|
||||
srem(key: string, ...members: Array<string>): Promise<number>;
|
||||
smembers(key: string): Promise<Array<string>>;
|
||||
sismember(key: string, member: string): Promise<number>;
|
||||
scard(key: string): Promise<number>;
|
||||
spop(key: string, count?: number): Promise<Array<string>>;
|
||||
|
||||
zadd(key: string, ...scoreMembers: Array<number | string>): Promise<number>;
|
||||
zrem(key: string, ...members: Array<string>): Promise<number>;
|
||||
zcard(key: string): Promise<number>;
|
||||
zrangebyscore(
|
||||
key: string,
|
||||
min: string | number,
|
||||
max: string | number,
|
||||
...args: Array<string | number>
|
||||
): Promise<Array<string>>;
|
||||
|
||||
rpush(key: string, ...values: Array<string>): Promise<number>;
|
||||
lpop(key: string, count?: number): Promise<Array<string>>;
|
||||
llen(key: string): Promise<number>;
|
||||
|
||||
hset(key: string, field: string, value: string): Promise<number>;
|
||||
hdel(key: string, ...fields: Array<string>): Promise<number>;
|
||||
hget(key: string, field: string): Promise<string | null>;
|
||||
hgetall(key: string): Promise<Record<string, string>>;
|
||||
|
||||
publish(channel: string, message: string): Promise<number>;
|
||||
duplicate(): IKVSubscription;
|
||||
|
||||
releaseLock(key: string, token: string): Promise<boolean>;
|
||||
renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean>;
|
||||
tryConsumeTokens(
|
||||
key: string,
|
||||
requested: number,
|
||||
maxTokens: number,
|
||||
refillRate: number,
|
||||
refillIntervalMs: number,
|
||||
): Promise<number>;
|
||||
scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void>;
|
||||
removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean>;
|
||||
scan(pattern: string, count: number): Promise<Array<string>>;
|
||||
|
||||
pipeline(): IKVPipeline;
|
||||
multi(): IKVPipeline;
|
||||
health(): Promise<boolean>;
|
||||
}
|
||||
517
packages/kv_client/src/KVClient.tsx
Normal file
517
packages/kv_client/src/KVClient.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IKVPipeline, IKVProvider, IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import {type IKVLogger, type KVClientConfig, resolveKVClientConfig} from '@fluxer/kv_client/src/KVClientConfig';
|
||||
import {KVClientError, KVClientErrorCode} from '@fluxer/kv_client/src/KVClientError';
|
||||
import {
|
||||
createStringEntriesFromPairs,
|
||||
createZSetMembersFromScorePairs,
|
||||
normalizeScoreBound,
|
||||
parseRangeByScoreArguments,
|
||||
parseSetArguments,
|
||||
} from '@fluxer/kv_client/src/KVCommandArguments';
|
||||
import {KVPipeline} from '@fluxer/kv_client/src/KVPipeline';
|
||||
import {KVSubscription} from '@fluxer/kv_client/src/KVSubscription';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const RELEASE_LOCK_SCRIPT = `
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
const RENEW_SNOWFLAKE_SCRIPT = `
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
const TRY_CONSUME_TOKENS_SCRIPT = `
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local requested = tonumber(ARGV[2])
|
||||
local maxTokens = tonumber(ARGV[3])
|
||||
local refillRate = tonumber(ARGV[4])
|
||||
local refillIntervalMs = tonumber(ARGV[5])
|
||||
|
||||
local data = redis.call('GET', key)
|
||||
local tokens = maxTokens
|
||||
local lastRefill = now
|
||||
|
||||
if data then
|
||||
local ok, bucket = pcall(cjson.decode, data)
|
||||
if ok and bucket then
|
||||
tokens = tonumber(bucket.tokens) or maxTokens
|
||||
lastRefill = tonumber(bucket.lastRefill) or now
|
||||
end
|
||||
end
|
||||
|
||||
local elapsed = now - lastRefill
|
||||
if elapsed >= refillIntervalMs then
|
||||
local intervals = math.floor(elapsed / refillIntervalMs)
|
||||
local tokensToAdd = intervals * refillRate
|
||||
if tokensToAdd > 0 then
|
||||
tokens = math.min(maxTokens, tokens + tokensToAdd)
|
||||
lastRefill = now
|
||||
end
|
||||
end
|
||||
|
||||
local consumed = 0
|
||||
if tokens >= requested then
|
||||
consumed = requested
|
||||
tokens = tokens - requested
|
||||
elseif tokens > 0 then
|
||||
consumed = tokens
|
||||
tokens = 0
|
||||
end
|
||||
|
||||
redis.call('SET', key, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
|
||||
return consumed
|
||||
`;
|
||||
|
||||
const SCHEDULE_BULK_DELETION_SCRIPT = `
|
||||
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
|
||||
redis.call('SET', KEYS[2], ARGV[2])
|
||||
return 1
|
||||
`;
|
||||
|
||||
const REMOVE_BULK_DELETION_SCRIPT = `
|
||||
local value = redis.call('GET', KEYS[2])
|
||||
if not value then
|
||||
return 0
|
||||
end
|
||||
redis.call('ZREM', KEYS[1], value)
|
||||
redis.call('DEL', KEYS[2])
|
||||
return 1
|
||||
`;
|
||||
|
||||
export class KVClient implements IKVProvider {
|
||||
private readonly client: Redis;
|
||||
private readonly logger: IKVLogger;
|
||||
private readonly url: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(config: KVClientConfig | string) {
|
||||
const resolvedConfig = resolveKVClientConfig(config);
|
||||
this.url = resolvedConfig.url;
|
||||
this.timeoutMs = resolvedConfig.timeoutMs;
|
||||
this.logger = resolvedConfig.logger;
|
||||
this.client = new Redis(this.url, {
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
});
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
return (await this.execute('health', async () => this.client.ping())) === 'PONG';
|
||||
} catch (error) {
|
||||
this.logger.debug({url: this.url, error}, 'KV health check failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return await this.execute('get', async () => this.client.get(key));
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ...args: Array<string | number>): Promise<string | null> {
|
||||
const options = parseSetArguments(args);
|
||||
if (options.useNx) {
|
||||
if (options.ttlSeconds !== undefined) {
|
||||
const ttlSeconds = options.ttlSeconds;
|
||||
return await this.execute('set', async () => {
|
||||
const result = await this.client.call('SET', key, value, 'EX', ttlSeconds, 'NX');
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
}
|
||||
return await this.execute('set', async () => {
|
||||
const result = await this.client.call('SET', key, value, 'NX');
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.ttlSeconds !== undefined) {
|
||||
const ttlSeconds = options.ttlSeconds;
|
||||
return await this.execute('set', async () => {
|
||||
const result = await this.client.call('SET', key, value, 'EX', ttlSeconds);
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
}
|
||||
|
||||
return await this.execute('set', async () => this.client.set(key, value));
|
||||
}
|
||||
|
||||
async setex(key: string, ttlSeconds: number, value: string): Promise<void> {
|
||||
await this.execute('setex', async () => {
|
||||
await this.client.setex(key, ttlSeconds, value);
|
||||
});
|
||||
}
|
||||
|
||||
async setnx(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
|
||||
if (ttlSeconds !== undefined) {
|
||||
const ttlSecondsValue = ttlSeconds;
|
||||
const result = await this.execute('setnx', async () => {
|
||||
const commandResult = await this.client.call('SET', key, value, 'EX', ttlSecondsValue, 'NX');
|
||||
return normalizeStringOrNull(commandResult);
|
||||
});
|
||||
return result === 'OK';
|
||||
}
|
||||
return (await this.execute('setnx', async () => this.client.setnx(key, value))) === 1;
|
||||
}
|
||||
|
||||
async mget(...keys: Array<string>): Promise<Array<string | null>> {
|
||||
return await this.execute('mget', async () => this.client.mget(...keys));
|
||||
}
|
||||
|
||||
async mset(...args: Array<string>): Promise<void> {
|
||||
const entries = createStringEntriesFromPairs(args);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairs = entries.flatMap((entry) => [entry.key, entry.value]);
|
||||
await this.execute('mset', async () => {
|
||||
await this.client.mset(...pairs);
|
||||
});
|
||||
}
|
||||
|
||||
async del(...keys: Array<string>): Promise<number> {
|
||||
if (keys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await this.execute('del', async () => this.client.del(...keys));
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<number> {
|
||||
return await this.execute('exists', async () => this.client.exists(key));
|
||||
}
|
||||
|
||||
async expire(key: string, ttlSeconds: number): Promise<number> {
|
||||
return await this.execute('expire', async () => this.client.expire(key, ttlSeconds));
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return await this.execute('ttl', async () => this.client.ttl(key));
|
||||
}
|
||||
|
||||
async incr(key: string): Promise<number> {
|
||||
return await this.execute('incr', async () => this.client.incr(key));
|
||||
}
|
||||
|
||||
async getex(key: string, ttlSeconds: number): Promise<string | null> {
|
||||
return await this.execute('getex', async () => {
|
||||
const result = await this.client.call('GETEX', key, 'EX', ttlSeconds);
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
}
|
||||
|
||||
async getdel(key: string): Promise<string | null> {
|
||||
return await this.execute('getdel', async () => {
|
||||
const result = await this.client.call('GETDEL', key);
|
||||
return normalizeStringOrNull(result);
|
||||
});
|
||||
}
|
||||
|
||||
async sadd(key: string, ...members: Array<string>): Promise<number> {
|
||||
if (members.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await this.execute('sadd', async () => this.client.sadd(key, ...members));
|
||||
}
|
||||
|
||||
async srem(key: string, ...members: Array<string>): Promise<number> {
|
||||
if (members.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await this.execute('srem', async () => this.client.srem(key, ...members));
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<Array<string>> {
|
||||
return await this.execute('smembers', async () => this.client.smembers(key));
|
||||
}
|
||||
|
||||
async sismember(key: string, member: string): Promise<number> {
|
||||
return await this.execute('sismember', async () => this.client.sismember(key, member));
|
||||
}
|
||||
|
||||
async scard(key: string): Promise<number> {
|
||||
return await this.execute('scard', async () => this.client.scard(key));
|
||||
}
|
||||
|
||||
async spop(key: string, count: number = 1): Promise<Array<string>> {
|
||||
if (count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.execute('spop', async () => {
|
||||
const result = await this.client.spop(key, count);
|
||||
if (result === null) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(result) ? result : [result];
|
||||
});
|
||||
}
|
||||
|
||||
async zadd(key: string, ...scoreMembers: Array<number | string>): Promise<number> {
|
||||
if (scoreMembers.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const members = createZSetMembersFromScorePairs(scoreMembers);
|
||||
const args = members.flatMap((member) => [member.score, member.value]);
|
||||
return await this.execute('zadd', async () => this.client.zadd(key, ...args));
|
||||
}
|
||||
|
||||
async zrem(key: string, ...members: Array<string>): Promise<number> {
|
||||
if (members.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await this.execute('zrem', async () => this.client.zrem(key, ...members));
|
||||
}
|
||||
|
||||
async zcard(key: string): Promise<number> {
|
||||
return await this.execute('zcard', async () => this.client.zcard(key));
|
||||
}
|
||||
|
||||
async zrangebyscore(
|
||||
key: string,
|
||||
min: string | number,
|
||||
max: string | number,
|
||||
...args: Array<string | number>
|
||||
): Promise<Array<string>> {
|
||||
const options = parseRangeByScoreArguments(args);
|
||||
const minBound = normalizeScoreBound(min);
|
||||
const maxBound = normalizeScoreBound(max);
|
||||
|
||||
if (options.limit === undefined) {
|
||||
return await this.execute('zrangebyscore', async () => this.client.zrangebyscore(key, minBound, maxBound));
|
||||
}
|
||||
|
||||
const {offset, count} = options.limit;
|
||||
return await this.execute('zrangebyscore', async () =>
|
||||
this.client.zrangebyscore(key, minBound, maxBound, 'LIMIT', offset, count),
|
||||
);
|
||||
}
|
||||
|
||||
async rpush(key: string, ...values: Array<string>): Promise<number> {
|
||||
if (values.length === 0) {
|
||||
return await this.llen(key);
|
||||
}
|
||||
return await this.execute('rpush', async () => this.client.rpush(key, ...values));
|
||||
}
|
||||
|
||||
async lpop(key: string, count?: number): Promise<Array<string>> {
|
||||
if (count !== undefined && count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.execute('lpop', async () => {
|
||||
if (count !== undefined) {
|
||||
const result = await this.client.call('LPOP', key, count);
|
||||
if (result === null) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
return result.map((entry) => String(entry));
|
||||
}
|
||||
return [String(result)];
|
||||
}
|
||||
|
||||
const single = await this.client.lpop(key);
|
||||
return single === null ? [] : [single];
|
||||
});
|
||||
}
|
||||
|
||||
async llen(key: string): Promise<number> {
|
||||
return await this.execute('llen', async () => this.client.llen(key));
|
||||
}
|
||||
|
||||
async hset(key: string, field: string, value: string): Promise<number> {
|
||||
return await this.execute('hset', async () => this.client.hset(key, field, value));
|
||||
}
|
||||
|
||||
async hdel(key: string, ...fields: Array<string>): Promise<number> {
|
||||
if (fields.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await this.execute('hdel', async () => this.client.hdel(key, ...fields));
|
||||
}
|
||||
|
||||
async hget(key: string, field: string): Promise<string | null> {
|
||||
return await this.execute('hget', async () => this.client.hget(key, field));
|
||||
}
|
||||
|
||||
async hgetall(key: string): Promise<Record<string, string>> {
|
||||
return await this.execute('hgetall', async () => this.client.hgetall(key));
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<number> {
|
||||
return await this.execute('publish', async () => this.client.publish(channel, message));
|
||||
}
|
||||
|
||||
duplicate(): IKVSubscription {
|
||||
return new KVSubscription({
|
||||
url: this.url,
|
||||
timeoutMs: this.timeoutMs,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const result = await this.execute('releaseLock', async () => this.client.eval(RELEASE_LOCK_SCRIPT, 1, key, token));
|
||||
return Number(result) === 1;
|
||||
}
|
||||
|
||||
async renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean> {
|
||||
const result = await this.execute('renewSnowflakeNode', async () =>
|
||||
this.client.eval(RENEW_SNOWFLAKE_SCRIPT, 1, key, instanceId, ttlSeconds),
|
||||
);
|
||||
return Number(result) === 1;
|
||||
}
|
||||
|
||||
async tryConsumeTokens(
|
||||
key: string,
|
||||
requested: number,
|
||||
maxTokens: number,
|
||||
refillRate: number,
|
||||
refillIntervalMs: number,
|
||||
): Promise<number> {
|
||||
const now = Date.now();
|
||||
const result = await this.execute('tryConsumeTokens', async () =>
|
||||
this.client.eval(TRY_CONSUME_TOKENS_SCRIPT, 1, key, now, requested, maxTokens, refillRate, refillIntervalMs),
|
||||
);
|
||||
return Number(result);
|
||||
}
|
||||
|
||||
async scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void> {
|
||||
await this.execute('scheduleBulkDeletion', async () => {
|
||||
await this.client.eval(SCHEDULE_BULK_DELETION_SCRIPT, 2, queueKey, secondaryKey, score, value);
|
||||
});
|
||||
}
|
||||
|
||||
async removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean> {
|
||||
const result = await this.execute('removeBulkDeletion', async () =>
|
||||
this.client.eval(REMOVE_BULK_DELETION_SCRIPT, 2, queueKey, secondaryKey),
|
||||
);
|
||||
return Number(result) === 1;
|
||||
}
|
||||
|
||||
async scan(pattern: string, count: number): Promise<Array<string>> {
|
||||
return await this.execute('scan', async () => {
|
||||
const limit = Math.max(1, Math.floor(count));
|
||||
let cursor = '0';
|
||||
const keys: Array<string> = [];
|
||||
|
||||
do {
|
||||
const [nextCursor, batch] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', limit);
|
||||
cursor = nextCursor;
|
||||
keys.push(...batch);
|
||||
if (keys.length >= limit) {
|
||||
break;
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return keys.slice(0, limit);
|
||||
});
|
||||
}
|
||||
|
||||
pipeline(): IKVPipeline {
|
||||
return new KVPipeline({
|
||||
createCommander: () => this.client.pipeline(),
|
||||
normalizeError: (command, error) => this.normalizeError(command, error),
|
||||
mode: 'pipeline',
|
||||
});
|
||||
}
|
||||
|
||||
multi(): IKVPipeline {
|
||||
return new KVPipeline({
|
||||
createCommander: () => this.client.multi(),
|
||||
normalizeError: (command, error) => this.normalizeError(command, error),
|
||||
mode: 'multi',
|
||||
});
|
||||
}
|
||||
|
||||
private async execute<T>(command: string, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
throw this.normalizeError(command, error);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeError(command: string, error: unknown): KVClientError {
|
||||
if (error instanceof KVClientError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (isTimeoutError(error)) {
|
||||
return new KVClientError({
|
||||
code: KVClientErrorCode.TIMEOUT,
|
||||
message: `KV request timed out: ${command}`,
|
||||
});
|
||||
}
|
||||
|
||||
return new KVClientError({
|
||||
code: KVClientErrorCode.REQUEST_FAILED,
|
||||
message: `KV request failed (${command}): ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createRetryStrategy(): (times: number) => number {
|
||||
return (times: number) => {
|
||||
const backoffMs = Math.min(times * 100, 2000);
|
||||
return backoffMs;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringOrNull(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorWithCode = error as Error & {code?: string};
|
||||
if (errorWithCode.code === 'ETIMEDOUT' || errorWithCode.code === 'ESOCKETTIMEDOUT') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes('timed out') || message.includes('timeout');
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
66
packages/kv_client/src/KVClientConfig.tsx
Normal file
66
packages/kv_client/src/KVClientConfig.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {DEFAULT_KV_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
|
||||
|
||||
export interface IKVLogger {
|
||||
debug(obj: object, msg?: string): void;
|
||||
error(obj: object, msg?: string): void;
|
||||
}
|
||||
|
||||
export interface KVClientConfig {
|
||||
url: string;
|
||||
timeoutMs?: number;
|
||||
logger?: IKVLogger;
|
||||
}
|
||||
|
||||
export interface ResolvedKVClientConfig {
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
logger: IKVLogger;
|
||||
}
|
||||
|
||||
const noopLogger: IKVLogger = {
|
||||
debug() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
export function resolveKVClientConfig(config: KVClientConfig | string): ResolvedKVClientConfig {
|
||||
if (typeof config === 'string') {
|
||||
return {
|
||||
url: normalizeUrl(config),
|
||||
timeoutMs: DEFAULT_KV_TIMEOUT_MS,
|
||||
logger: noopLogger,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: normalizeUrl(config.url),
|
||||
timeoutMs: config.timeoutMs ?? DEFAULT_KV_TIMEOUT_MS,
|
||||
logger: config.logger ?? noopLogger,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error('KV client URL must not be empty');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
45
packages/kv_client/src/KVClientError.tsx
Normal file
45
packages/kv_client/src/KVClientError.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {FluxerError, type FluxerErrorStatus} from '@fluxer/errors/src/FluxerError';
|
||||
|
||||
export const KVClientErrorCode = {
|
||||
INVALID_ARGUMENT: 'KV_CLIENT_INVALID_ARGUMENT',
|
||||
INVALID_RESPONSE: 'KV_CLIENT_INVALID_RESPONSE',
|
||||
REQUEST_FAILED: 'KV_CLIENT_REQUEST_FAILED',
|
||||
TIMEOUT: 'KV_CLIENT_TIMEOUT',
|
||||
} as const;
|
||||
|
||||
interface KVClientErrorInit {
|
||||
code: string;
|
||||
message: string;
|
||||
status?: FluxerErrorStatus;
|
||||
}
|
||||
|
||||
export class KVClientError extends FluxerError {
|
||||
constructor(init: KVClientErrorInit) {
|
||||
super({
|
||||
code: init.code,
|
||||
message: init.message,
|
||||
status: init.status ?? HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
this.name = 'KVClientError';
|
||||
}
|
||||
}
|
||||
186
packages/kv_client/src/KVCommandArguments.tsx
Normal file
186
packages/kv_client/src/KVCommandArguments.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {KVClientError, KVClientErrorCode} from '@fluxer/kv_client/src/KVClientError';
|
||||
|
||||
export interface KVSetOptions {
|
||||
ttlSeconds?: number;
|
||||
useNx: boolean;
|
||||
}
|
||||
|
||||
export interface KVRangeByScoreOptions {
|
||||
limit?: {
|
||||
offset: number;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSetArguments(args: Array<string | number>): KVSetOptions {
|
||||
let ttlSeconds: number | undefined;
|
||||
let useNx = false;
|
||||
let index = 0;
|
||||
|
||||
while (index < args.length) {
|
||||
const current = args[index];
|
||||
const flag = typeof current === 'string' ? current.toUpperCase() : null;
|
||||
|
||||
if (flag === 'EX') {
|
||||
if (ttlSeconds !== undefined) {
|
||||
throw createInvalidArgumentError('set supports EX only once');
|
||||
}
|
||||
|
||||
ttlSeconds = parseNumberArgument(args[index + 1], 'EX value');
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (flag === 'NX') {
|
||||
if (useNx) {
|
||||
throw createInvalidArgumentError('set supports NX only once');
|
||||
}
|
||||
|
||||
useNx = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw createInvalidArgumentError(`set received unsupported argument: ${String(current)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ttlSeconds,
|
||||
useNx,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStringEntriesFromPairs(args: Array<string>): Array<{key: string; value: string}> {
|
||||
if (args.length % 2 !== 0) {
|
||||
throw createInvalidArgumentError('mset requires key/value pairs');
|
||||
}
|
||||
|
||||
const entries: Array<{key: string; value: string}> = [];
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
entries.push({
|
||||
key: args[index],
|
||||
value: args[index + 1],
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function createZSetMembersFromScorePairs(
|
||||
scoreMembers: Array<number | string>,
|
||||
): Array<{score: number; value: string}> {
|
||||
if (scoreMembers.length % 2 !== 0) {
|
||||
throw createInvalidArgumentError('zadd requires score/member pairs');
|
||||
}
|
||||
|
||||
const members: Array<{score: number; value: string}> = [];
|
||||
for (let index = 0; index < scoreMembers.length; index += 2) {
|
||||
const rawScore = scoreMembers[index];
|
||||
const rawMember = scoreMembers[index + 1];
|
||||
|
||||
if (typeof rawMember !== 'string') {
|
||||
throw createInvalidArgumentError('zadd member must be a string');
|
||||
}
|
||||
|
||||
members.push({
|
||||
score: parseFiniteNumber(rawScore, 'zadd score'),
|
||||
value: rawMember,
|
||||
});
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
export function normalizeScoreBound(score: string | number): string | number {
|
||||
if (score === '-inf' || score === '+inf') {
|
||||
return score;
|
||||
}
|
||||
|
||||
return parseNumberArgument(score, 'score');
|
||||
}
|
||||
|
||||
export function parseRangeByScoreArguments(args: Array<string | number>): KVRangeByScoreOptions {
|
||||
let index = 0;
|
||||
let limit: {offset: number; count: number} | undefined;
|
||||
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
const keyword = typeof token === 'string' ? token.toUpperCase() : null;
|
||||
|
||||
if (keyword === 'LIMIT') {
|
||||
if (limit) {
|
||||
throw createInvalidArgumentError('zrangebyscore supports LIMIT only once');
|
||||
}
|
||||
|
||||
const offset = parseNonNegativeInteger(args[index + 1], 'LIMIT offset');
|
||||
const count = parseNonNegativeInteger(args[index + 2], 'LIMIT count');
|
||||
limit = {offset, count};
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw createInvalidArgumentError(`zrangebyscore received unsupported argument: ${String(token)}`);
|
||||
}
|
||||
|
||||
return {limit};
|
||||
}
|
||||
|
||||
function parseNumberArgument(value: string | number | undefined, label: string): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
throw createInvalidArgumentError(`${label} must be a finite number`);
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value: string | number, label: string): number {
|
||||
const parsed = parseNumberArgument(value, label);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw createInvalidArgumentError(`${label} must be finite`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: string | number | undefined, label: string): number {
|
||||
const parsed = parseNumberArgument(value, label);
|
||||
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw createInvalidArgumentError(`${label} must be a non-negative integer`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function createInvalidArgumentError(message: string): KVClientError {
|
||||
return new KVClientError({
|
||||
code: KVClientErrorCode.INVALID_ARGUMENT,
|
||||
message,
|
||||
});
|
||||
}
|
||||
127
packages/kv_client/src/KVPipeline.tsx
Normal file
127
packages/kv_client/src/KVPipeline.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IKVPipeline} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import {createStringEntriesFromPairs} from '@fluxer/kv_client/src/KVCommandArguments';
|
||||
import type {ChainableCommander} from 'ioredis';
|
||||
|
||||
type PipelineExecResult = [Error | null, unknown];
|
||||
|
||||
interface KVPipelineOptions {
|
||||
createCommander: () => ChainableCommander;
|
||||
normalizeError: (command: string, error: unknown) => Error;
|
||||
mode: 'pipeline' | 'multi';
|
||||
}
|
||||
|
||||
export class KVPipeline implements IKVPipeline {
|
||||
private readonly createCommander: () => ChainableCommander;
|
||||
private readonly normalizeError: (command: string, error: unknown) => Error;
|
||||
private readonly mode: 'pipeline' | 'multi';
|
||||
private commander: ChainableCommander;
|
||||
|
||||
constructor(options: KVPipelineOptions) {
|
||||
this.createCommander = options.createCommander;
|
||||
this.normalizeError = options.normalizeError;
|
||||
this.mode = options.mode;
|
||||
this.commander = options.createCommander();
|
||||
}
|
||||
|
||||
get(key: string): this {
|
||||
this.commander.get(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
set(key: string, value: string): this {
|
||||
this.commander.set(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
setex(key: string, ttlSeconds: number, value: string): this {
|
||||
this.commander.setex(key, ttlSeconds, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
del(key: string): this {
|
||||
this.commander.del(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
expire(key: string, ttlSeconds: number): this {
|
||||
this.commander.expire(key, ttlSeconds);
|
||||
return this;
|
||||
}
|
||||
|
||||
sadd(key: string, ...members: Array<string>): this {
|
||||
this.commander.sadd(key, ...members);
|
||||
return this;
|
||||
}
|
||||
|
||||
srem(key: string, ...members: Array<string>): this {
|
||||
this.commander.srem(key, ...members);
|
||||
return this;
|
||||
}
|
||||
|
||||
zadd(key: string, score: number, value: string): this {
|
||||
this.commander.zadd(key, score, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
zrem(key: string, ...members: Array<string>): this {
|
||||
this.commander.zrem(key, ...members);
|
||||
return this;
|
||||
}
|
||||
|
||||
mset(...args: Array<string>): this {
|
||||
const entries = createStringEntriesFromPairs(args);
|
||||
if (entries.length === 0) {
|
||||
return this;
|
||||
}
|
||||
const pairs = entries.flatMap((entry) => [entry.key, entry.value]);
|
||||
this.commander.mset(...pairs);
|
||||
return this;
|
||||
}
|
||||
|
||||
async exec(): Promise<Array<PipelineExecResult>> {
|
||||
const command = `${this.mode}.exec`;
|
||||
try {
|
||||
const rawResults = (await this.commander.exec()) as Array<PipelineExecResult> | null;
|
||||
this.commander = this.createCommander();
|
||||
|
||||
if (rawResults === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawResults.map((result: PipelineExecResult) => {
|
||||
const [error, value] = result;
|
||||
return [error ? normalizePipelineError(error) : null, value] as PipelineExecResult;
|
||||
});
|
||||
} catch (error) {
|
||||
this.commander = this.createCommander();
|
||||
throw this.normalizeError(command, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePipelineError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return new Error(String(error));
|
||||
}
|
||||
154
packages/kv_client/src/KVSubscription.tsx
Normal file
154
packages/kv_client/src/KVSubscription.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import type {IKVLogger} from '@fluxer/kv_client/src/KVClientConfig';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
interface KVSubscriptionConfig {
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
logger: IKVLogger;
|
||||
}
|
||||
|
||||
export class KVSubscription implements IKVSubscription {
|
||||
private readonly url: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly logger: IKVLogger;
|
||||
private readonly channels: Set<string> = new Set();
|
||||
private readonly messageCallbacks: Set<(channel: string, message: string) => void> = new Set();
|
||||
private readonly errorCallbacks: Set<(error: Error) => void> = new Set();
|
||||
private client: Redis | null = null;
|
||||
|
||||
constructor(config: KVSubscriptionConfig) {
|
||||
this.url = config.url;
|
||||
this.timeoutMs = config.timeoutMs;
|
||||
this.logger = config.logger;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.client !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Redis(this.url, {
|
||||
autoResubscribe: true,
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
});
|
||||
|
||||
client.on('message', (channel: string, message: string) => {
|
||||
for (const callback of this.messageCallbacks) {
|
||||
callback(channel, message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (error: Error) => {
|
||||
this.logger.error({error}, 'KV subscription error');
|
||||
for (const callback of this.errorCallbacks) {
|
||||
callback(error);
|
||||
}
|
||||
});
|
||||
|
||||
this.client = client;
|
||||
|
||||
if (this.channels.size > 0) {
|
||||
await this.client.subscribe(...Array.from(this.channels));
|
||||
}
|
||||
}
|
||||
|
||||
on(event: 'message', callback: (channel: string, message: string) => void): void;
|
||||
on(event: 'error', callback: (error: Error) => void): void;
|
||||
on(
|
||||
event: 'message' | 'error',
|
||||
callback: ((channel: string, message: string) => void) | ((error: Error) => void),
|
||||
): void {
|
||||
if (event === 'message') {
|
||||
this.messageCallbacks.add(callback as (channel: string, message: string) => void);
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorCallbacks.add(callback as (error: Error) => void);
|
||||
}
|
||||
|
||||
async subscribe(...channels: Array<string>): Promise<void> {
|
||||
const newChannels = channels.filter((channel) => {
|
||||
if (this.channels.has(channel)) {
|
||||
return false;
|
||||
}
|
||||
this.channels.add(channel);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newChannels.length === 0 || this.client === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.client.subscribe(...newChannels);
|
||||
}
|
||||
|
||||
async unsubscribe(...channels: Array<string>): Promise<void> {
|
||||
const removedChannels = channels.filter((channel) => this.channels.delete(channel));
|
||||
|
||||
if (removedChannels.length === 0 || this.client === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.client.unsubscribe(...removedChannels);
|
||||
}
|
||||
|
||||
async quit(): Promise<void> {
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
if (client === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.quit();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
if (client === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.disconnect(false);
|
||||
}
|
||||
|
||||
removeAllListeners(event?: 'message' | 'error'): void {
|
||||
if (!event || event === 'message') {
|
||||
this.messageCallbacks.clear();
|
||||
}
|
||||
|
||||
if (!event || event === 'error') {
|
||||
this.errorCallbacks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRetryStrategy(): (times: number) => number {
|
||||
return (times: number) => {
|
||||
const backoffMs = Math.min(times * 100, 2000);
|
||||
return backoffMs;
|
||||
};
|
||||
}
|
||||
5
packages/kv_client/tsconfig.json
Normal file
5
packages/kv_client/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user