@redfx/ioredis 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Walker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @redfx/ioredis
2
+
3
+ [ioredis](https://github.com/redis/ioredis) driver for [@redfx/core](https://www.npmjs.com/package/@redfx/core).
4
+ It implements the `send`-level port over `ioredis.sendCommand`, with a dedicated pub/sub connection
5
+ and an optional connection pool.
6
+
7
+ ```ts
8
+ import { Redis } from "@redfx/core"
9
+ import { IoRedis } from "@redfx/ioredis"
10
+ import { Config } from "effect"
11
+
12
+ const RedisLive = IoRedis.layerConfig(Config.string("REDIS_URL"))
13
+ // IoRedis.layer({ url, options, commandTimeout }) single connection
14
+ // IoRedis.layerPooled({ url, size: 10 }) pooled commands, dedicated pub/sub
15
+ ```
16
+
17
+ Peer dependencies: `effect`, `ioredis`, `@redfx/core`.
18
+
19
+ ## License
20
+
21
+ MIT
@@ -0,0 +1,26 @@
1
+ import { ConnectionError, type Redis } from "@redfx/core";
2
+ import { type Config, type ConfigError, type Duration, Layer } from "effect";
3
+ import { type ClusterNode, type ClusterOptions, type RedisOptions } from "ioredis";
4
+ export interface ClientConfig {
5
+ readonly url?: string;
6
+ readonly options?: RedisOptions;
7
+ /** Effect-level per-command deadline; on expiry the command fails with `TimeoutError`. */
8
+ readonly commandTimeout?: Duration.DurationInput;
9
+ }
10
+ export interface ClusterConfig {
11
+ readonly nodes: ReadonlyArray<ClusterNode>;
12
+ readonly options?: ClusterOptions;
13
+ readonly commandTimeout?: Duration.DurationInput;
14
+ }
15
+ export declare namespace IoRedis {
16
+ const layer: (config?: ClientConfig) => Layer.Layer<Redis, ConnectionError>;
17
+ const layerConfig: (url: Config.Config<string>, config?: Omit<ClientConfig, "url">) => Layer.Layer<Redis, ConnectionError | ConfigError.ConfigError>;
18
+ /** Pools `size` command connections; pub/sub still uses a dedicated connection. */
19
+ const layerPooled: (config: ClientConfig & {
20
+ readonly size: number;
21
+ }) => Layer.Layer<Redis, ConnectionError>;
22
+ /** Connects to a Redis Cluster (the client pools per-node internally, so no pooled variant).
23
+ * Cross-slot multi-key commands need a shared hash tag `{…}` or they fail with `CROSSSLOT`. */
24
+ const layerCluster: (config: ClusterConfig) => Layer.Layer<Redis, ConnectionError>;
25
+ }
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,eAAe,EAKf,KAAK,KAAK,EAIX,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,KAAK,MAAM,EACX,KAAK,WAAW,EAChB,KAAK,QAAQ,EAEb,KAAK,EAGN,MAAM,QAAQ,CAAC;AAChB,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,cAAc,EAGnB,KAAK,YAAY,EAClB,MAAM,SAAS,CAAC;AAKjB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,CAAC;IAChC,0FAA0F;IAC1F,QAAQ,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,aAAa,CAAC;CAClD;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,WAAW,CAAC,CAAC;IAC3C,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,aAAa,CAAC;CAClD;AAuLD,yBAAiB,OAAO,CAAC;IAChB,MAAM,KAAK,GAAI,SAAQ,YAAiB,KAAG,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,CAGhF,CAAC;IAEE,MAAM,WAAW,GACtB,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAC1B,SAAS,IAAI,CAAC,YAAY,EAAE,KAAK,CAAC,KACjC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,WAAW,CAAC,WAAW,CACyB,CAAC;IAEzF,mFAAmF;IAC5E,MAAM,WAAW,GACtB,QAAQ,YAAY,GAAG;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAC/C,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,CAWpC,CAAC;IAEF;oGACgG;IACzF,MAAM,YAAY,GAAI,QAAQ,aAAa,KAAG,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,CAGnF,CAAC;CACN"}
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ import { CommandError, ConnectionError, layerConnection, pooledConnection, } from "@redfx/core";
2
+ import { Effect, Layer, Stream, } from "effect";
3
+ import { Cluster, Command, Redis as IORedisClient, } from "ioredis";
4
+ const DEFAULTS = {
5
+ lazyConnect: true,
6
+ maxRetriesPerRequest: 3,
7
+ retryStrategy: (times) => (times > 5 ? null : Math.min(times * 100, 2000)),
8
+ keepAlive: 30_000, // surface a silently-dead peer so a blocking read fails instead of hanging
9
+ };
10
+ // The consumer Stream owns reconnection (Stream.retry), so disable ioredis's own recovery: a dropped
11
+ // XREAD must fail fast with a ConnectionError, not be silently reconnected and resent under us.
12
+ const DEDICATED_OVERRIDES = {
13
+ retryStrategy: () => null,
14
+ autoResendUnfulfilledCommands: false,
15
+ enableOfflineQueue: false,
16
+ maxRetriesPerRequest: 0,
17
+ };
18
+ const CLUSTER_DEFAULTS = {
19
+ lazyConnect: true,
20
+ redisOptions: { maxRetriesPerRequest: 3, keepAlive: 30_000 },
21
+ };
22
+ const CONNECTION_CODES = new Set([
23
+ "ECONNREFUSED",
24
+ "ECONNRESET",
25
+ "EPIPE",
26
+ "ETIMEDOUT",
27
+ "ENOTFOUND",
28
+ "EAI_AGAIN",
29
+ "ERR_REDIS_CONNECTION_CLOSED",
30
+ ]);
31
+ const mapError = (command, cause) => {
32
+ const err = cause;
33
+ const message = err.message ?? String(cause);
34
+ if (
35
+ // MaxRetriesPerRequestError extends AbortError; both mean "aborted because the connection is gone".
36
+ err.name === "MaxRetriesPerRequestError" ||
37
+ err.name === "AbortError" ||
38
+ (err.code !== undefined && CONNECTION_CODES.has(err.code)) ||
39
+ /connection is closed|stream isn't writeable|connection is already closed|connection ended|reached the max retries|command aborted|failed to refresh slots cache|none of (the )?startup nodes/i.test(message)) {
40
+ return new ConnectionError({ message, cause });
41
+ }
42
+ return new CommandError({ message, command, code: err.code ?? message.split(" ")[0], cause });
43
+ };
44
+ const toArg = (a) => typeof a === "string" ? a : Buffer.from(a);
45
+ const closeQuietly = (client) => Effect.promise(() => client
46
+ .quit()
47
+ .then(() => undefined)
48
+ .catch(() => void client.disconnect()));
49
+ const makeClient = (config) => Effect.acquireRelease(Effect.tryPromise({
50
+ try: async () => {
51
+ const client = config.url
52
+ ? new IORedisClient(config.url, { ...DEFAULTS, ...config.options })
53
+ : new IORedisClient({ ...DEFAULTS, ...config.options });
54
+ // Errors surface as command/connect rejections; a listener just prevents Node's unhandled-'error' crash.
55
+ client.on("error", () => { });
56
+ await client.connect();
57
+ return client;
58
+ },
59
+ catch: (cause) => new ConnectionError({ message: "ioredis: failed to connect", cause }),
60
+ }), closeQuietly);
61
+ const makeDedicatedClient = (config) => makeClient({ ...config, options: { ...config.options, ...DEDICATED_OVERRIDES } });
62
+ const makeClusterClient = (config) => Effect.acquireRelease(Effect.tryPromise({
63
+ try: async () => {
64
+ const client = new Cluster([...config.nodes], { ...CLUSTER_DEFAULTS, ...config.options });
65
+ client.on("error", () => { });
66
+ await client.connect();
67
+ return client;
68
+ },
69
+ catch: (cause) => new ConnectionError({ message: "ioredis cluster: failed to connect", cause }),
70
+ }), closeQuietly);
71
+ const send = (client) => (command) => Effect.tryPromise({
72
+ try: () => client.sendCommand(new Command(command.name, command.args.map(toArg), { replyEncoding: "utf8" })),
73
+ catch: (cause) => mapError(command.name, cause),
74
+ });
75
+ const pipeline = (client) => (commands) => Effect.tryPromise({
76
+ // ioredis' batch form dispatches by JS method name (lowercase), unlike `sendCommand`.
77
+ try: () => client.pipeline(commands.map((c) => [c.name.toLowerCase(), ...c.args.map(toArg)])).exec(),
78
+ catch: (cause) => mapError("PIPELINE", cause),
79
+ }).pipe(Effect.flatMap((results) => {
80
+ if (results === null)
81
+ return Effect.succeed([]);
82
+ const failed = results.find(([err]) => err != null);
83
+ if (failed?.[0])
84
+ return Effect.fail(mapError("PIPELINE", failed[0]));
85
+ return Effect.succeed(results.map(([, value]) => value));
86
+ }));
87
+ const subscribeStream = (acquire) => (channels) => Stream.asyncScoped((emit) => Effect.gen(function* () {
88
+ const sub = yield* acquire;
89
+ sub.on("message", (channel, message) => emit.single({ channel, message }));
90
+ sub.on("error", (cause) => emit.fail(new ConnectionError({ message: "ioredis: subscriber error", cause })));
91
+ yield* Effect.tryPromise({
92
+ try: () => sub.subscribe(...channels),
93
+ catch: (cause) => mapError("SUBSCRIBE", cause),
94
+ });
95
+ }));
96
+ const dedicatedStream = (acquire) => (f) => Stream.unwrapScoped(Effect.gen(function* () {
97
+ const client = yield* acquire;
98
+ // Force the socket down first (LIFO) so a blocking read aborts at once, not after quit() waits it out.
99
+ yield* Effect.addFinalizer(() => Effect.sync(() => client.disconnect()));
100
+ return f({
101
+ send: send(client),
102
+ pipeline: pipeline(client),
103
+ subscribe: subscribeStream(acquire),
104
+ dedicated: dedicatedStream(acquire),
105
+ close: closeQuietly(client),
106
+ });
107
+ }));
108
+ const makeConnection = (acquire,
109
+ // Defaults to `acquire` (cluster reuses its own client); single-node layers pass a fail-fast one.
110
+ dedicatedAcquire = acquire) => Effect.gen(function* () {
111
+ const client = yield* acquire;
112
+ return {
113
+ send: send(client),
114
+ pipeline: pipeline(client),
115
+ subscribe: subscribeStream(acquire),
116
+ dedicated: dedicatedStream(dedicatedAcquire),
117
+ close: closeQuietly(client),
118
+ };
119
+ });
120
+ export var IoRedis;
121
+ (function (IoRedis) {
122
+ IoRedis.layer = (config = {}) => layerConnection(makeConnection(makeClient(config), makeDedicatedClient(config)), {
123
+ commandTimeout: config.commandTimeout,
124
+ });
125
+ IoRedis.layerConfig = (url, config) => Layer.unwrapEffect(Effect.map(url, (resolved) => IoRedis.layer({ ...config, url: resolved })));
126
+ /** Pools `size` command connections; pub/sub still uses a dedicated connection. */
127
+ IoRedis.layerPooled = (config) => {
128
+ const acquire = makeClient(config);
129
+ return layerConnection(pooledConnection(makeConnection(acquire), config.size, subscribeStream(acquire), dedicatedStream(makeDedicatedClient(config))), { commandTimeout: config.commandTimeout });
130
+ };
131
+ /** Connects to a Redis Cluster (the client pools per-node internally, so no pooled variant).
132
+ * Cross-slot multi-key commands need a shared hash tag `{…}` or they fail with `CROSSSLOT`. */
133
+ IoRedis.layerCluster = (config) => layerConnection(makeConnection(makeClusterClient(config)), {
134
+ commandTimeout: config.commandTimeout,
135
+ });
136
+ })(IoRedis || (IoRedis = {}));
137
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@redfx/ioredis",
3
+ "version": "1.0.0",
4
+ "description": "ioredis driver adapter for redfx: the send-level port over ioredis.sendCommand, with pooling and pub/sub.",
5
+ "license": "MIT",
6
+ "author": "Alex Walker",
7
+ "homepage": "https://github.com/al3xanderwalker/redfx",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/al3xanderwalker/redfx.git",
11
+ "directory": "packages/ioredis"
12
+ },
13
+ "keywords": [
14
+ "effect",
15
+ "redis",
16
+ "ioredis",
17
+ "effect-ts"
18
+ ],
19
+ "type": "module",
20
+ "sideEffects": false,
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "bun": "./src/index.ts",
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "peerDependencies": {
36
+ "effect": "^3.21.0",
37
+ "ioredis": "^5.0.0",
38
+ "@redfx/core": "^1.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "effect": "^3.21.3",
42
+ "ioredis": "^5.11.0",
43
+ "typescript": "^5.9.3",
44
+ "@redfx/core": "1.0.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.json",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit"
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,262 @@
1
+ import {
2
+ CommandError,
3
+ ConnectionError,
4
+ type ConnectionService,
5
+ layerConnection,
6
+ type PushMessage,
7
+ pooledConnection,
8
+ type Redis,
9
+ type RedisCommand,
10
+ type RedisError,
11
+ type RespValue,
12
+ } from "@redfx/core";
13
+ import {
14
+ type Config,
15
+ type ConfigError,
16
+ type Duration,
17
+ Effect,
18
+ Layer,
19
+ type Scope,
20
+ Stream,
21
+ } from "effect";
22
+ import {
23
+ Cluster,
24
+ type ClusterNode,
25
+ type ClusterOptions,
26
+ Command,
27
+ Redis as IORedisClient,
28
+ type RedisOptions,
29
+ } from "ioredis";
30
+
31
+ // Redis and Cluster share the surface the port drives, so both run the same connection logic.
32
+ type RedisLike = IORedisClient | Cluster;
33
+
34
+ export interface ClientConfig {
35
+ readonly url?: string;
36
+ readonly options?: RedisOptions;
37
+ /** Effect-level per-command deadline; on expiry the command fails with `TimeoutError`. */
38
+ readonly commandTimeout?: Duration.DurationInput;
39
+ }
40
+
41
+ export interface ClusterConfig {
42
+ readonly nodes: ReadonlyArray<ClusterNode>;
43
+ readonly options?: ClusterOptions;
44
+ readonly commandTimeout?: Duration.DurationInput;
45
+ }
46
+
47
+ const DEFAULTS: RedisOptions = {
48
+ lazyConnect: true,
49
+ maxRetriesPerRequest: 3,
50
+ retryStrategy: (times) => (times > 5 ? null : Math.min(times * 100, 2000)),
51
+ keepAlive: 30_000, // surface a silently-dead peer so a blocking read fails instead of hanging
52
+ };
53
+
54
+ // The consumer Stream owns reconnection (Stream.retry), so disable ioredis's own recovery: a dropped
55
+ // XREAD must fail fast with a ConnectionError, not be silently reconnected and resent under us.
56
+ const DEDICATED_OVERRIDES: RedisOptions = {
57
+ retryStrategy: () => null,
58
+ autoResendUnfulfilledCommands: false,
59
+ enableOfflineQueue: false,
60
+ maxRetriesPerRequest: 0,
61
+ };
62
+
63
+ const CLUSTER_DEFAULTS: ClusterOptions = {
64
+ lazyConnect: true,
65
+ redisOptions: { maxRetriesPerRequest: 3, keepAlive: 30_000 },
66
+ };
67
+
68
+ const CONNECTION_CODES = new Set([
69
+ "ECONNREFUSED",
70
+ "ECONNRESET",
71
+ "EPIPE",
72
+ "ETIMEDOUT",
73
+ "ENOTFOUND",
74
+ "EAI_AGAIN",
75
+ "ERR_REDIS_CONNECTION_CLOSED",
76
+ ]);
77
+
78
+ const mapError = (command: string, cause: unknown): RedisError => {
79
+ const err = cause as { code?: string; message?: string; name?: string };
80
+ const message = err.message ?? String(cause);
81
+ if (
82
+ // MaxRetriesPerRequestError extends AbortError; both mean "aborted because the connection is gone".
83
+ err.name === "MaxRetriesPerRequestError" ||
84
+ err.name === "AbortError" ||
85
+ (err.code !== undefined && CONNECTION_CODES.has(err.code)) ||
86
+ /connection is closed|stream isn't writeable|connection is already closed|connection ended|reached the max retries|command aborted|failed to refresh slots cache|none of (the )?startup nodes/i.test(
87
+ message,
88
+ )
89
+ ) {
90
+ return new ConnectionError({ message, cause });
91
+ }
92
+ return new CommandError({ message, command, code: err.code ?? message.split(" ")[0], cause });
93
+ };
94
+
95
+ const toArg = (a: string | Uint8Array): string | Buffer =>
96
+ typeof a === "string" ? a : Buffer.from(a);
97
+
98
+ const closeQuietly = (client: RedisLike): Effect.Effect<void> =>
99
+ Effect.promise(() =>
100
+ client
101
+ .quit()
102
+ .then(() => undefined)
103
+ .catch(() => void client.disconnect()),
104
+ );
105
+
106
+ const makeClient = (
107
+ config: ClientConfig,
108
+ ): Effect.Effect<IORedisClient, ConnectionError, Scope.Scope> =>
109
+ Effect.acquireRelease(
110
+ Effect.tryPromise({
111
+ try: async () => {
112
+ const client = config.url
113
+ ? new IORedisClient(config.url, { ...DEFAULTS, ...config.options })
114
+ : new IORedisClient({ ...DEFAULTS, ...config.options });
115
+ // Errors surface as command/connect rejections; a listener just prevents Node's unhandled-'error' crash.
116
+ client.on("error", () => {});
117
+ await client.connect();
118
+ return client;
119
+ },
120
+ catch: (cause) => new ConnectionError({ message: "ioredis: failed to connect", cause }),
121
+ }),
122
+ closeQuietly,
123
+ );
124
+
125
+ const makeDedicatedClient = (
126
+ config: ClientConfig,
127
+ ): Effect.Effect<IORedisClient, ConnectionError, Scope.Scope> =>
128
+ makeClient({ ...config, options: { ...config.options, ...DEDICATED_OVERRIDES } });
129
+
130
+ const makeClusterClient = (
131
+ config: ClusterConfig,
132
+ ): Effect.Effect<Cluster, ConnectionError, Scope.Scope> =>
133
+ Effect.acquireRelease(
134
+ Effect.tryPromise({
135
+ try: async () => {
136
+ const client = new Cluster([...config.nodes], { ...CLUSTER_DEFAULTS, ...config.options });
137
+ client.on("error", () => {});
138
+ await client.connect();
139
+ return client;
140
+ },
141
+ catch: (cause) =>
142
+ new ConnectionError({ message: "ioredis cluster: failed to connect", cause }),
143
+ }),
144
+ closeQuietly,
145
+ );
146
+
147
+ const send =
148
+ (client: RedisLike) =>
149
+ (command: RedisCommand): Effect.Effect<RespValue, RedisError> =>
150
+ Effect.tryPromise({
151
+ try: () =>
152
+ client.sendCommand(
153
+ new Command(command.name, command.args.map(toArg), { replyEncoding: "utf8" }),
154
+ ) as Promise<RespValue>,
155
+ catch: (cause) => mapError(command.name, cause),
156
+ });
157
+
158
+ const pipeline =
159
+ (client: RedisLike) =>
160
+ (commands: ReadonlyArray<RedisCommand>): Effect.Effect<ReadonlyArray<RespValue>, RedisError> =>
161
+ Effect.tryPromise({
162
+ // ioredis' batch form dispatches by JS method name (lowercase), unlike `sendCommand`.
163
+ try: () =>
164
+ client.pipeline(commands.map((c) => [c.name.toLowerCase(), ...c.args.map(toArg)])).exec(),
165
+ catch: (cause) => mapError("PIPELINE", cause),
166
+ }).pipe(
167
+ Effect.flatMap((results) => {
168
+ if (results === null) return Effect.succeed<ReadonlyArray<RespValue>>([]);
169
+ const failed = results.find(([err]) => err != null);
170
+ if (failed?.[0]) return Effect.fail(mapError("PIPELINE", failed[0]));
171
+ return Effect.succeed(results.map(([, value]) => value as RespValue));
172
+ }),
173
+ );
174
+
175
+ const subscribeStream =
176
+ (acquire: Effect.Effect<RedisLike, ConnectionError, Scope.Scope>) =>
177
+ (channels: ReadonlyArray<string>): Stream.Stream<PushMessage, RedisError> =>
178
+ Stream.asyncScoped<PushMessage, RedisError>((emit) =>
179
+ Effect.gen(function* () {
180
+ const sub = yield* acquire;
181
+ sub.on("message", (channel: string, message: string) => emit.single({ channel, message }));
182
+ sub.on("error", (cause) =>
183
+ emit.fail(new ConnectionError({ message: "ioredis: subscriber error", cause })),
184
+ );
185
+ yield* Effect.tryPromise({
186
+ try: () => sub.subscribe(...channels),
187
+ catch: (cause) => mapError("SUBSCRIBE", cause),
188
+ });
189
+ }),
190
+ );
191
+
192
+ const dedicatedStream =
193
+ (
194
+ acquire: Effect.Effect<RedisLike, ConnectionError, Scope.Scope>,
195
+ ): ConnectionService["dedicated"] =>
196
+ (f) =>
197
+ Stream.unwrapScoped(
198
+ Effect.gen(function* () {
199
+ const client = yield* acquire;
200
+ // Force the socket down first (LIFO) so a blocking read aborts at once, not after quit() waits it out.
201
+ yield* Effect.addFinalizer(() => Effect.sync(() => client.disconnect()));
202
+ return f({
203
+ send: send(client),
204
+ pipeline: pipeline(client),
205
+ subscribe: subscribeStream(acquire),
206
+ dedicated: dedicatedStream(acquire),
207
+ close: closeQuietly(client),
208
+ });
209
+ }),
210
+ );
211
+
212
+ const makeConnection = (
213
+ acquire: Effect.Effect<RedisLike, ConnectionError, Scope.Scope>,
214
+ // Defaults to `acquire` (cluster reuses its own client); single-node layers pass a fail-fast one.
215
+ dedicatedAcquire: Effect.Effect<RedisLike, ConnectionError, Scope.Scope> = acquire,
216
+ ): Effect.Effect<ConnectionService, ConnectionError, Scope.Scope> =>
217
+ Effect.gen(function* () {
218
+ const client = yield* acquire;
219
+ return {
220
+ send: send(client),
221
+ pipeline: pipeline(client),
222
+ subscribe: subscribeStream(acquire),
223
+ dedicated: dedicatedStream(dedicatedAcquire),
224
+ close: closeQuietly(client),
225
+ } satisfies ConnectionService;
226
+ });
227
+
228
+ export namespace IoRedis {
229
+ export const layer = (config: ClientConfig = {}): Layer.Layer<Redis, ConnectionError> =>
230
+ layerConnection(makeConnection(makeClient(config), makeDedicatedClient(config)), {
231
+ commandTimeout: config.commandTimeout,
232
+ });
233
+
234
+ export const layerConfig = (
235
+ url: Config.Config<string>,
236
+ config?: Omit<ClientConfig, "url">,
237
+ ): Layer.Layer<Redis, ConnectionError | ConfigError.ConfigError> =>
238
+ Layer.unwrapEffect(Effect.map(url, (resolved) => layer({ ...config, url: resolved })));
239
+
240
+ /** Pools `size` command connections; pub/sub still uses a dedicated connection. */
241
+ export const layerPooled = (
242
+ config: ClientConfig & { readonly size: number },
243
+ ): Layer.Layer<Redis, ConnectionError> => {
244
+ const acquire = makeClient(config);
245
+ return layerConnection(
246
+ pooledConnection(
247
+ makeConnection(acquire),
248
+ config.size,
249
+ subscribeStream(acquire),
250
+ dedicatedStream(makeDedicatedClient(config)),
251
+ ),
252
+ { commandTimeout: config.commandTimeout },
253
+ );
254
+ };
255
+
256
+ /** Connects to a Redis Cluster (the client pools per-node internally, so no pooled variant).
257
+ * Cross-slot multi-key commands need a shared hash tag `{…}` or they fail with `CROSSSLOT`. */
258
+ export const layerCluster = (config: ClusterConfig): Layer.Layer<Redis, ConnectionError> =>
259
+ layerConnection(makeConnection(makeClusterClient(config)), {
260
+ commandTimeout: config.commandTimeout,
261
+ });
262
+ }