refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View 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:"
}
}

View 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>;
}

View 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);
}

View 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;
}

View 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';
}
}

View 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,
});
}

View 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));
}

View 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;
};
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}