Skip to content

Commit 99c6781

Browse files
nkaradzhovclaude
andcommitted
feat(multi-db): WIP MultiDbClient wrapper sketch
Sketch of a multi-database client wrapper managing a homogeneous array of underlying clients (standalone/pool/cluster/sentinel) behind one drop-in client surface. Type mechanics only — failover/health/routing stubbed. - @redis/client: dedicated factories (createMultiDbClient/Pool/Cluster/ Sentinel) returning { client, controller }; client typed exactly as the base client (true drop-in), controller holds multi-db-only admin surface. Command forwarding via prototype-walk (no runtime Proxy on hot path). - redis meta-package: createMultiDbClient wrapper injecting default Stack modules, mirroring createClient. - playground.ts for manual poking. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ea1edd commit 99c6781

4 files changed

Lines changed: 326 additions & 0 deletions

File tree

packages/client/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,13 @@ export {
6060
type CommandReplyEvent,
6161
type PoolConnectionWaitEvent,
6262
} from './lib/client/tracing';
63+
64+
export {
65+
createMultiDbClient,
66+
createMultiDbClientPool,
67+
createMultiDbCluster,
68+
createMultiDbSentinel,
69+
MultiDbController,
70+
type MultiDbResult,
71+
type AnyRedisClientType
72+
} from './lib/multi-db';
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { EventEmitter } from 'node:events';
2+
import RedisClient, { RedisClientType, RedisClientOptions } from '../client';
3+
import { RedisClientPool, RedisClientPoolType, RedisPoolOptions } from '../client/pool';
4+
import RedisCluster, { RedisClusterType, RedisClusterOptions } from '../cluster';
5+
import RedisSentinel from '../sentinel';
6+
import { RedisSentinelType, RedisSentinelOptions } from '../sentinel/types';
7+
import { RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping } from '../RESP/types';
8+
9+
/**
10+
* SKETCH — type mechanics only. No failover / health / routing logic yet.
11+
*
12+
* Each factory returns `{ client, controller }`:
13+
*
14+
* - `client` — typed EXACTLY as the underlying client (`RedisClientType`,
15+
* `RedisClusterType`, ...). A true drop-in: any code/type that
16+
* expects the base client accepts it unchanged. Its command
17+
* methods forward to the active DB; its `connect`/`close`/
18+
* `destroy`/`quit` are intercepted to fan out across all DBs.
19+
* - `controller`— the multi-db-only surface (topology, weights, active-DB
20+
* selection, failover events). Kept OFF `client` so `client`
21+
* stays exactly the base type.
22+
*/
23+
24+
/* -------------------------------------------------------------------------- */
25+
/* Types */
26+
/* -------------------------------------------------------------------------- */
27+
28+
/** Every client shape the multi-db layer can wrap. */
29+
export type AnyRedisClientType =
30+
| RedisClientType<any, any, any, any, any>
31+
| RedisClientPoolType<any, any, any, any, any>
32+
| RedisClusterType<any, any, any, any, any>
33+
| RedisSentinelType<any, any, any, any, any>;
34+
35+
/** Lifecycle members the multi-db layer intercepts (fan-out) rather than forwarding to one DB. */
36+
const INTERCEPTED = new Set<PropertyKey>(['connect', 'close', 'destroy', 'quit']);
37+
38+
export interface MultiDbResult<C extends AnyRedisClientType> {
39+
/** drop-in: exactly the base client type */
40+
client: C;
41+
/** multi-db admin surface */
42+
controller: MultiDbController<C>;
43+
}
44+
45+
interface DatabaseConfig<OPTIONS> {
46+
options: OPTIONS;
47+
/** highest healthy weight = active DB */
48+
weight?: number;
49+
}
50+
51+
interface PoolDatabaseConfig<OPTIONS> extends DatabaseConfig<OPTIONS> {
52+
poolOptions?: Partial<RedisPoolOptions>;
53+
}
54+
55+
/* -------------------------------------------------------------------------- */
56+
/* Manager — owns the underlying clients + active selection */
57+
/* -------------------------------------------------------------------------- */
58+
59+
class MultiDbManager<C extends AnyRedisClientType> {
60+
readonly clients: ReadonlyArray<C>;
61+
active: C;
62+
63+
constructor(clients: Array<C>) {
64+
this.clients = clients;
65+
this.active = clients[0]; // failover selection stubbed
66+
}
67+
68+
async connect(): Promise<void> {
69+
await Promise.all(this.clients.map(c => c.connect()));
70+
}
71+
72+
async close(): Promise<void> {
73+
await Promise.all(this.clients.map(c => c.close()));
74+
}
75+
76+
destroy(): void {
77+
for (const c of this.clients) c.destroy();
78+
}
79+
80+
async quit(): Promise<void> {
81+
// no per-DB quit fan-out subtleties for the sketch; treat like close
82+
await this.close();
83+
}
84+
}
85+
86+
/* -------------------------------------------------------------------------- */
87+
/* client — the drop-in surface (typed exactly as C) */
88+
/* -------------------------------------------------------------------------- */
89+
90+
/**
91+
* Lifecycle base. `connect`/`close`/`destroy`/`quit` are real methods (fan-out),
92+
* NOT forwarded to one DB. Everything else is patched on by `attachForwarders`.
93+
*/
94+
class MultiDbClientBase<C extends AnyRedisClientType> {
95+
/** @internal read by the forwarders patched below */
96+
readonly _mgr: MultiDbManager<C>;
97+
98+
constructor(mgr: MultiDbManager<C>) {
99+
this._mgr = mgr;
100+
}
101+
102+
connect() {
103+
return this._mgr.connect().then(() => this);
104+
}
105+
106+
close() {
107+
return this._mgr.close();
108+
}
109+
110+
destroy() {
111+
this._mgr.destroy();
112+
}
113+
114+
quit() {
115+
return this._mgr.quit();
116+
}
117+
}
118+
119+
/**
120+
* Patch command methods + module/function namespaces onto `target`, forwarding
121+
* each to the ACTIVE DB. Same shape as `attachConfig` — real (own) properties,
122+
* no runtime trap — but discovered by walking a representative built client's
123+
* prototype chain instead of a command registry (kind-agnostic; avoids
124+
* importing each kind's registry + private executor). Runs ONCE at construction;
125+
* the closures read `mgr.active` at CALL time, so the method SET is fixed
126+
* (homogeneous DBs) while the TARGET tracks failover.
127+
*/
128+
function attachForwarders<C extends AnyRedisClientType>(
129+
target: MultiDbClientBase<C>,
130+
mgr: MultiDbManager<C>
131+
): void {
132+
const skip = new Set<PropertyKey>([...INTERCEPTED, 'constructor']);
133+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic patching
134+
const dst = target as any;
135+
136+
for (let proto = Object.getPrototypeOf(mgr.active); proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
137+
for (const name of Object.getOwnPropertyNames(proto)) {
138+
if (skip.has(name)) continue;
139+
skip.add(name); // most-derived wins; don't reattach shadowed base members
140+
141+
const desc = Object.getOwnPropertyDescriptor(proto, name)!;
142+
if (desc.get) {
143+
// namespace (`json`, `ts`) or computed prop (`isOpen`) → read from active
144+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic
145+
Object.defineProperty(dst, name, { get: () => (mgr.active as any)[name], enumerable: false });
146+
} else if (typeof desc.value === 'function') {
147+
// command / script method → call active's own method (this = active)
148+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic
149+
dst[name] = (...args: Array<unknown>) => (mgr.active as any)[name](...args);
150+
}
151+
}
152+
}
153+
}
154+
155+
function makeClient<C extends AnyRedisClientType>(mgr: MultiDbManager<C>): C {
156+
const client = new MultiDbClientBase(mgr);
157+
attachForwarders(client, mgr);
158+
return client as unknown as C;
159+
}
160+
161+
/* -------------------------------------------------------------------------- */
162+
/* controller — multi-db-only surface (all stubbed) */
163+
/* -------------------------------------------------------------------------- */
164+
165+
export class MultiDbController<C extends AnyRedisClientType> extends EventEmitter {
166+
#mgr: MultiDbManager<C>;
167+
168+
/** @internal */
169+
constructor(mgr: MultiDbManager<C>) {
170+
super();
171+
this.#mgr = mgr;
172+
}
173+
174+
/** the DB currently receiving commands */
175+
getActiveDatabase(): C {
176+
return this.#mgr.active;
177+
}
178+
179+
/** all managed DBs, in config order */
180+
getDatabases(): ReadonlyArray<C> {
181+
return this.#mgr.clients;
182+
}
183+
184+
/** force the active DB (TODO: validate index, emit 'failover') */
185+
setActiveDatabase(index: number): void {
186+
this.#mgr.active = this.#mgr.clients[index];
187+
}
188+
189+
// TODO: setWeight, addDatabase, removeDatabase, health inspection,
190+
// 'failover' / 'database-down' events.
191+
}
192+
193+
/* -------------------------------------------------------------------------- */
194+
/* Dedicated factories */
195+
/* -------------------------------------------------------------------------- */
196+
197+
function assemble<C extends AnyRedisClientType>(clients: Array<C>): MultiDbResult<C> {
198+
const mgr = new MultiDbManager(clients);
199+
return { client: makeClient(mgr), controller: new MultiDbController(mgr) };
200+
}
201+
202+
export function createMultiDbClient<
203+
M extends RedisModules = {},
204+
F extends RedisFunctions = {},
205+
S extends RedisScripts = {},
206+
RESP extends RespVersions = 3,
207+
T extends TypeMapping = {}
208+
>(options: {
209+
databases: Array<DatabaseConfig<RedisClientOptions<M, F, S, RESP, T>>>;
210+
}): MultiDbResult<RedisClientType<M, F, S, RESP, T>> {
211+
return assemble(options.databases.map(db => RedisClient.create(db.options)));
212+
}
213+
214+
export function createMultiDbClientPool<
215+
M extends RedisModules = {},
216+
F extends RedisFunctions = {},
217+
S extends RedisScripts = {},
218+
RESP extends RespVersions = 3,
219+
T extends TypeMapping = {}
220+
>(options: {
221+
databases: Array<PoolDatabaseConfig<RedisClientOptions<M, F, S, RESP, T>>>;
222+
}): MultiDbResult<RedisClientPoolType<M, F, S, RESP, T>> {
223+
return assemble(options.databases.map(db => RedisClientPool.create(db.options, db.poolOptions)));
224+
}
225+
226+
export function createMultiDbCluster<
227+
M extends RedisModules = {},
228+
F extends RedisFunctions = {},
229+
S extends RedisScripts = {},
230+
RESP extends RespVersions = 3,
231+
T extends TypeMapping = {}
232+
>(options: {
233+
databases: Array<DatabaseConfig<RedisClusterOptions<M, F, S, RESP, T>>>;
234+
}): MultiDbResult<RedisClusterType<M, F, S, RESP, T>> {
235+
return assemble(options.databases.map(db => RedisCluster.create(db.options)));
236+
}
237+
238+
export function createMultiDbSentinel<
239+
M extends RedisModules = {},
240+
F extends RedisFunctions = {},
241+
S extends RedisScripts = {},
242+
RESP extends RespVersions = 3,
243+
T extends TypeMapping = {}
244+
>(options: {
245+
databases: Array<DatabaseConfig<RedisSentinelOptions<M, F, S, RESP, T>>>;
246+
}): MultiDbResult<RedisSentinelType<M, F, S, RESP, T>> {
247+
return assemble(options.databases.map(db => RedisSentinel.create(db.options)));
248+
}

packages/redis/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
createClientPool as genericCreateClientPool,
1717
RedisClientPoolType as GenericRedisClientPoolType,
1818
RedisPoolOptions,
19+
createMultiDbClient as genericCreateMultiDbClient,
20+
MultiDbResult,
1921
} from '@redis/client';
2022
import RedisBloomModules from '@redis/bloom';
2123
import RedisJSON from '@redis/json';
@@ -64,6 +66,37 @@ export function createClient<
6466
}) as RedisClientType<M, F, S, RESP, TYPE_MAPPING>;
6567
}
6668

69+
/**
70+
* Multi-database client with the Redis Stack default modules pre-registered
71+
* (mirrors {@link createClient}). Returns `{ client, controller }`; `client`
72+
* is a drop-in {@link RedisClientType}.
73+
*/
74+
export function createMultiDbClient<
75+
M extends RedisModules = {},
76+
F extends RedisFunctions = {},
77+
S extends RedisScripts = {},
78+
RESP extends RespVersions = 3,
79+
TYPE_MAPPING extends TypeMapping = {}
80+
>(options: {
81+
databases: Array<{
82+
options: RedisClientOptions<M, F, S, RESP, TYPE_MAPPING>;
83+
weight?: number;
84+
}>;
85+
}): MultiDbResult<RedisClientType<M, F, S, RESP, TYPE_MAPPING>> {
86+
return genericCreateMultiDbClient({
87+
databases: options.databases.map(db => ({
88+
...db,
89+
options: {
90+
...db.options,
91+
modules: {
92+
...modules,
93+
...(db.options?.modules as M)
94+
}
95+
}
96+
}))
97+
}) as unknown as MultiDbResult<RedisClientType<M, F, S, RESP, TYPE_MAPPING>>;
98+
}
99+
67100
export function createClientPool<
68101
M extends RedisModules = {},
69102
F extends RedisFunctions = {},

packages/redis/playground.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Simple playground for MultiDbClient (meta package — default modules included).
2+
// Run:
3+
// npx tsx packages/redis/playground.ts
4+
// Needs a Redis on 127.0.0.1:3000 (Redis 8+ ships json/ft/ts builtin).
5+
6+
import { createMultiDbClient } from './index';
7+
8+
async function main() {
9+
// two logical DBs on the same server so routing is easy to see
10+
const { client, controller } = createMultiDbClient({
11+
databases: [
12+
{ options: { url: 'redis://127.0.0.1:3000/0' }, weight: 100 },
13+
{ options: { url: 'redis://127.0.0.1:3000/1' }, weight: 50 }
14+
]
15+
});
16+
17+
await client.connect();
18+
19+
// plain commands — go to the active DB (db0)
20+
await client.set('hello', 'world');
21+
console.log('db0 hello =', await client.get('hello'));
22+
23+
// default modules autocomplete: client.json / client.ft / client.ts
24+
await client.json.set('doc', '$', { a: 1 });
25+
console.log('db0 json.get =', JSON.stringify(await client.json.get('doc')));
26+
27+
// switch active DB (stand-in for failover)
28+
controller.setActiveDatabase(1);
29+
console.log('db1 hello =', await client.get('hello')); // null — different DB
30+
31+
controller.setActiveDatabase(0);
32+
await client.close();
33+
}
34+
35+
main();

0 commit comments

Comments
 (0)