|
| 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 | +} |
0 commit comments