queasy 0.2.0 → 0.3.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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * cascade-b handler — terminal handler, dispatches no further jobs.
3
+ * Subject to all chaos behaviors.
4
+ */
5
+
6
+ import { BroadcastChannel } from 'node:worker_threads';
7
+ import { createClient } from 'redis';
8
+ import { PermanentError } from '../../src/index.js';
9
+ import { pickChaos } from '../shared/chaos.js';
10
+ import { emitEvent } from '../shared/stream.js';
11
+
12
+ const eventRedis = createClient();
13
+ await eventRedis.connect();
14
+
15
+ const crashChannel = new BroadcastChannel('fuzz-crash');
16
+
17
+ /**
18
+ * @param {any} data
19
+ * @param {import('../../src/types.js').Job} job
20
+ */
21
+ export async function handle(_data, job) {
22
+ const startedAt = Date.now();
23
+ await emitEvent(eventRedis, {
24
+ type: 'start',
25
+ queue: '{fuzz}:cascade-b',
26
+ id: job.id,
27
+ pid: String(process.pid),
28
+ runAt: String(job.runAt),
29
+ startedAt: String(startedAt),
30
+ });
31
+
32
+ const chaos = pickChaos();
33
+ await emitEvent(eventRedis, {
34
+ type: 'chaos',
35
+ queue: '{fuzz}:cascade-b',
36
+ id: job.id,
37
+ chaos,
38
+ });
39
+
40
+ if (chaos === 'crash') {
41
+ crashChannel.postMessage({ type: 'crash' });
42
+ // Stall so the process exits before we return
43
+ await new Promise(() => {});
44
+ }
45
+
46
+ if (chaos === 'stall') {
47
+ await new Promise(() => {});
48
+ }
49
+
50
+ if (chaos === 'spin') {
51
+ const end = Date.now() + 10_000;
52
+ while (Date.now() < end) {
53
+ /* busy wait */
54
+ }
55
+ }
56
+
57
+ if (chaos === 'permanent') {
58
+ throw new PermanentError('cascade-b: permanent chaos');
59
+ }
60
+
61
+ if (chaos === 'retriable') {
62
+ throw new Error('cascade-b: retriable chaos');
63
+ }
64
+
65
+ // Normal completion
66
+ await emitEvent(eventRedis, {
67
+ type: 'finish',
68
+ queue: '{fuzz}:cascade-b',
69
+ id: job.id,
70
+ finishedAt: String(Date.now()),
71
+ });
72
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Fail handler — invoked for every job that exhausts retries/stalls on any queue.
3
+ *
4
+ * Received data: [originalJobId, originalData, errorObject]
5
+ *
6
+ * For periodic jobs (id starts with 'fuzz-periodic-'), re-dispatches the job
7
+ * so that periodic jobs keep running indefinitely even after permanent failure.
8
+ * For cascade jobs, simply records the failure.
9
+ */
10
+
11
+ import { createClient } from 'redis';
12
+ import { Client } from '../../src/index.js';
13
+ import { emitEvent } from '../shared/stream.js';
14
+
15
+ const redis = createClient();
16
+ const eventRedis = createClient();
17
+
18
+ await redis.connect();
19
+ await eventRedis.connect();
20
+
21
+ // Dispatch-only queasy client (await ready to avoid Function not found race)
22
+ const client = await new Promise((resolve) => new Client(redis, 0, resolve));
23
+
24
+ // Queue references (keys already include braces)
25
+ const periodicQueue = client.queue('{fuzz}:periodic', true);
26
+
27
+ /**
28
+ * @param {[string, any, {message: string}]} data
29
+ * @param {import('../../src/types.js').Job} job
30
+ */
31
+ export async function handle(data, job) {
32
+ const [originalId, originalData, error] = data;
33
+
34
+ await emitEvent(eventRedis, {
35
+ type: 'fail',
36
+ queue: 'fail',
37
+ id: originalId,
38
+ failJobId: job.id,
39
+ error: error?.message ?? String(error),
40
+ });
41
+
42
+ // Re-dispatch periodic jobs so they continue indefinitely.
43
+ if (originalId.startsWith('fuzz-periodic-')) {
44
+ const delay = 1000 + Math.random() * 4000;
45
+ await periodicQueue.dispatch(originalData, {
46
+ id: originalId,
47
+ runAt: Date.now() + delay,
48
+ updateRunAt: true,
49
+ updateData: true,
50
+ });
51
+ }
52
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * periodic handler — re-queues itself with the same job ID so it fires indefinitely.
3
+ * Also dispatches cascade-a jobs on normal completion.
4
+ * Subject to all chaos behaviors.
5
+ */
6
+
7
+ import { BroadcastChannel } from 'node:worker_threads';
8
+ import { createClient } from 'redis';
9
+ import { Client, PermanentError } from '../../src/index.js';
10
+ import { pickChaos } from '../shared/chaos.js';
11
+ import { emitEvent } from '../shared/stream.js';
12
+
13
+ const redis = createClient();
14
+ const eventRedis = createClient();
15
+
16
+ await redis.connect();
17
+ await eventRedis.connect();
18
+
19
+ // Dispatch-only queasy client (await ready to avoid Function not found race)
20
+ const client = await new Promise((resolve) => new Client(redis, 0, resolve));
21
+ const periodicQueue = client.queue('{fuzz}:periodic', true);
22
+ const cascadeAQueue = client.queue('{fuzz}:cascade-a', true);
23
+
24
+ const crashChannel = new BroadcastChannel('fuzz-crash');
25
+
26
+ /**
27
+ * @param {any} data
28
+ * @param {import('../../src/types.js').Job} job
29
+ */
30
+ export async function handle(data, job) {
31
+ const startedAt = Date.now();
32
+ await emitEvent(eventRedis, {
33
+ type: 'start',
34
+ queue: '{fuzz}:periodic',
35
+ id: job.id,
36
+ pid: String(process.pid),
37
+ runAt: String(job.runAt),
38
+ startedAt: String(startedAt),
39
+ });
40
+
41
+ const chaos = pickChaos();
42
+ await emitEvent(eventRedis, {
43
+ type: 'chaos',
44
+ queue: '{fuzz}:periodic',
45
+ id: job.id,
46
+ chaos,
47
+ });
48
+
49
+ if (chaos === 'crash') {
50
+ crashChannel.postMessage({ type: 'crash' });
51
+ await new Promise(() => {});
52
+ }
53
+
54
+ if (chaos === 'stall') {
55
+ await new Promise(() => {});
56
+ }
57
+
58
+ if (chaos === 'spin') {
59
+ const end = Date.now() + 10_000;
60
+ while (Date.now() < end) {
61
+ /* busy wait */
62
+ }
63
+ }
64
+
65
+ if (chaos === 'permanent') {
66
+ throw new PermanentError('periodic: permanent chaos');
67
+ }
68
+
69
+ if (chaos === 'retriable') {
70
+ throw new Error('periodic: retriable chaos');
71
+ }
72
+
73
+ // Normal completion: dispatch a cascade-a job and re-queue self
74
+ const cascadeRunAt = Date.now() + Math.random() * 2000;
75
+ const selfDelay = 1000 + Math.random() * 4000;
76
+
77
+ const [cascadeId] = await Promise.all([
78
+ cascadeAQueue.dispatch({ from: job.id }, { runAt: cascadeRunAt }),
79
+ periodicQueue.dispatch(data, {
80
+ id: job.id,
81
+ runAt: Date.now() + selfDelay,
82
+ updateRunAt: true,
83
+ }),
84
+ ]);
85
+
86
+ await emitEvent(eventRedis, {
87
+ type: 'finish',
88
+ queue: '{fuzz}:periodic',
89
+ id: job.id,
90
+ finishedAt: String(Date.now()),
91
+ dispatched: cascadeId,
92
+ });
93
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Fuzz test child process entry point.
3
+ *
4
+ * Each child process creates one queasy Client (with worker threads) and
5
+ * calls listen() on all three queues. Handlers run inside queasy's internal
6
+ * worker threads and communicate crash signals via BroadcastChannel.
7
+ */
8
+
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { BroadcastChannel } from 'node:worker_threads';
12
+ import { Client } from '../src/index.js';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+
16
+ // ── Configuration ─────────────────────────────────────────────────────────────
17
+
18
+ const WORKER_THREADS = 2; // worker threads per child process
19
+
20
+ const BASE_OPTIONS = {
21
+ maxRetries: 3,
22
+ maxStalls: 2,
23
+ minBackoff: 200,
24
+ maxBackoff: 2000,
25
+ timeout: 3000,
26
+ size: 10,
27
+ };
28
+
29
+ const FAIL_RETRY_OPTIONS = {
30
+ maxRetries: 5,
31
+ minBackoff: 200,
32
+ };
33
+
34
+ // ── Handler paths ──────────────────────────────────────────────────────────────
35
+
36
+ const failHandlerPath = join(__dirname, 'handlers', 'fail-handler.js');
37
+ const periodicPath = join(__dirname, 'handlers', 'periodic.js');
38
+ const cascadeAPath = join(__dirname, 'handlers', 'cascade-a.js');
39
+ const cascadeBPath = join(__dirname, 'handlers', 'cascade-b.js');
40
+
41
+ // ── Crash signal ───────────────────────────────────────────────────────────────
42
+
43
+ // Handlers running in worker threads post to this channel to trigger a crash.
44
+ const crashChannel = new BroadcastChannel('fuzz-crash');
45
+ crashChannel.onmessage = () => {
46
+ process.exit(1);
47
+ };
48
+
49
+ // ── Redis + queasy client ──────────────────────────────────────────────────────
50
+
51
+ const client = await new Promise((resolve) => new Client({}, WORKER_THREADS, resolve));
52
+
53
+ client.on('disconnected', (reason) => {
54
+ console.error(`[process ${process.pid}] Client disconnected: ${reason}`);
55
+ process.exit(1);
56
+ });
57
+
58
+ // Forward client lifecycle events to the orchestrator via IPC.
59
+ // The orchestrator uses these to authoritatively track active jobs.
60
+ client.on('dequeue', (queue, job) => {
61
+ process.send({ type: 'dequeue', queue, jobId: job.id, runAt: job.runAt });
62
+ });
63
+ client.on('finish', (queue, jobId) => {
64
+ process.send({ type: 'finish', queue, jobId });
65
+ });
66
+ client.on('retry', (queue, jobId) => {
67
+ process.send({ type: 'retry', queue, jobId });
68
+ });
69
+ client.on('fail', (queue, jobId) => {
70
+ process.send({ type: 'fail', queue, jobId });
71
+ });
72
+
73
+ // ── Queue setup ────────────────────────────────────────────────────────────────
74
+
75
+ const periodicQueue = client.queue('{fuzz}:periodic', true);
76
+ const cascadeAQueue = client.queue('{fuzz}:cascade-a', true);
77
+ const cascadeBQueue = client.queue('{fuzz}:cascade-b', true);
78
+
79
+ await Promise.all([
80
+ periodicQueue.listen(periodicPath, {
81
+ ...BASE_OPTIONS,
82
+ priority: 300,
83
+ failHandler: failHandlerPath,
84
+ failRetryOptions: FAIL_RETRY_OPTIONS,
85
+ }),
86
+ cascadeAQueue.listen(cascadeAPath, {
87
+ ...BASE_OPTIONS,
88
+ priority: 200,
89
+ failHandler: failHandlerPath,
90
+ failRetryOptions: FAIL_RETRY_OPTIONS,
91
+ }),
92
+ cascadeBQueue.listen(cascadeBPath, {
93
+ ...BASE_OPTIONS,
94
+ priority: 100,
95
+ failHandler: failHandlerPath,
96
+ failRetryOptions: FAIL_RETRY_OPTIONS,
97
+ }),
98
+ ]);
99
+
100
+ console.log(`[process ${process.pid}] Ready`);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Weighted random chaos behavior picker.
3
+ * All handlers apply the same set of chaos behaviors.
4
+ */
5
+
6
+ const BEHAVIORS = [
7
+ { action: 'normal', weight: 65 },
8
+ { action: 'retriable', weight: 15 },
9
+ { action: 'permanent', weight: 5 },
10
+ { action: 'stall', weight: 10 },
11
+ { action: 'spin', weight: 3 },
12
+ { action: 'crash', weight: 2 },
13
+ ];
14
+
15
+ const TOTAL_WEIGHT = BEHAVIORS.reduce((sum, b) => sum + b.weight, 0);
16
+
17
+ /**
18
+ * Pick a chaos action based on weighted probability.
19
+ * @returns {'normal' | 'retriable' | 'permanent' | 'stall' | 'spin' | 'crash'}
20
+ */
21
+ export function pickChaos() {
22
+ let r = Math.random() * TOTAL_WEIGHT;
23
+ for (const { action, weight } of BEHAVIORS) {
24
+ r -= weight;
25
+ if (r <= 0) return /** @type {any} */ (action);
26
+ }
27
+ return 'normal';
28
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Redis stream helpers for the fuzz test event log.
3
+ * All events are written to the 'fuzz:events' stream.
4
+ */
5
+
6
+ export const STREAM_KEY = 'fuzz:events';
7
+
8
+ /**
9
+ * Emit a structured event to the fuzz:events stream.
10
+ * @param {import('redis').RedisClientType} redis
11
+ * @param {Record<string, string>} fields
12
+ */
13
+ export async function emitEvent(redis, fields) {
14
+ try {
15
+ await redis.xAdd(STREAM_KEY, '*', fields);
16
+ } catch {
17
+ // Never let event emission crash a handler
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Async generator that yields parsed event objects from the fuzz:events stream.
23
+ * Blocks for up to 1 second waiting for new events, then yields control back.
24
+ * @param {import('redis').RedisClientType} redis
25
+ * @param {string} [lastId='0'] - Stream ID to start reading from ('0' = from beginning, '$' = new only)
26
+ * @yields {Record<string, string>}
27
+ */
28
+ export async function* readEvents(redis, lastId = '0') {
29
+ let id = lastId;
30
+ while (true) {
31
+ const results = await redis.xRead({ key: STREAM_KEY, id }, { BLOCK: 1000, COUNT: 100 });
32
+ if (!results) continue;
33
+ for (const { messages } of results) {
34
+ for (const { id: msgId, message } of messages) {
35
+ id = msgId;
36
+ yield message;
37
+ }
38
+ }
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "queasy",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A simple Redis-backed queue library for Node.js",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -29,13 +29,12 @@
29
29
  ],
30
30
  "author": "",
31
31
  "license": "ISC",
32
- "peerDependencies": {
32
+ "dependencies": {
33
33
  "redis": "^5.10.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@biomejs/biome": "^2.3.14",
37
37
  "@types/node": "^25.2.0",
38
- "redis": "^5.10.0",
39
38
  "typescript": "^5.9.3"
40
39
  }
41
40
  }
@@ -0,0 +1,279 @@
1
+ # Plan: Client constructs its own Redis connection
2
+
3
+ ## Goal
4
+
5
+ Change the `Client` constructor so that instead of accepting a pre-built node-redis
6
+ connection as its first argument, it accepts an options object and constructs the
7
+ connection itself (calling either `createClient` or `createCluster` from the `redis`
8
+ package). This gives Queasy full control over connection lifecycle.
9
+
10
+ ---
11
+
12
+ ## 1. Options object design
13
+
14
+ The first argument changes from `RedisClient` to a `RedisOptions` object:
15
+
16
+ ```ts
17
+ import type { RedisClientOptions, RedisClusterOptions } from 'redis';
18
+
19
+ type SingleNodeOptions = Pick<RedisClientOptions, 'url' | 'socket' | 'username' | 'password' | 'database'>;
20
+
21
+ type RedisOptions =
22
+ | SingleNodeOptions
23
+ | {
24
+ rootNodes: SingleNodeOptions[];
25
+ defaults?: Partial<SingleNodeOptions>;
26
+ nodeAddressMap?: RedisClusterOptions['nodeAddressMap'];
27
+ }
28
+ ```
29
+
30
+ `RedisClientOptions` and `RedisClusterOptions` are both exported from the top-level
31
+ `redis` package and can be imported directly. `RedisClusterClientOptions` (the type
32
+ node-redis uses for `rootNodes` elements and `defaults`) is **not** exported at the
33
+ top level, so we define the cluster form inline, reusing `SingleNodeOptions` to
34
+ constrain both `rootNodes` elements and `defaults` to the same permitted fields as
35
+ the single-node case. `nodeAddressMap` is taken via an index type from
36
+ `RedisClusterOptions` directly to avoid depending on the unexported `NodeAddressMap`
37
+ type.
38
+
39
+ The dispatch rule: **if `options.rootNodes` exists, use `createCluster`; otherwise
40
+ use `createClient`.**
41
+
42
+ - If `options.rootNodes` is present: call `createCluster(options)`, passing the
43
+ object through directly. The caller may also provide `defaults` (shared auth/TLS
44
+ for all nodes) and `nodeAddressMap` — all standard `createCluster` options,
45
+ named consistently with the node-redis API.
46
+ - Otherwise: call `createClient(options)`, passing the object through directly.
47
+ - The constructed connection is stored as `this.redis`.
48
+ - `connect()` is called internally in the constructor.
49
+ - `close()` calls `this.redis.destroy()` to disconnect.
50
+
51
+ ### Constructor signature
52
+
53
+ All three arguments default so that `new Client()` works (connects to `localhost:6379`):
54
+
55
+ ```js
56
+ constructor(options = {}, workerCount = os.cpus().length, callback = undefined)
57
+ ```
58
+
59
+ ---
60
+
61
+ ## 2. Changes to `src/client.js`
62
+
63
+ 1. Add imports:
64
+ ```js
65
+ import { createClient, createCluster } from 'redis';
66
+ ```
67
+
68
+ 2. Add a helper at module level:
69
+ ```js
70
+ function buildRedisConnection(options) {
71
+ if (options.rootNodes) {
72
+ return createCluster(options);
73
+ }
74
+ return createClient(options);
75
+ }
76
+ ```
77
+
78
+ 3. Update constructor:
79
+ - Parameter `redis` → `options` (with JSDoc type `RedisOptions`)
80
+ - Replace `this.redis = redis;` with:
81
+ ```js
82
+ this.redis = buildRedisConnection(options);
83
+ ```
84
+ - Replace the bare `installLuaFunctions(this.redis).then(...)` call with:
85
+ ```js
86
+ this.redis.connect()
87
+ .then(() => installLuaFunctions(this.redis))
88
+ .then((disconnect) => { ... })
89
+ .catch((err) => {
90
+ this.disconnected = true;
91
+ this.emit('disconnected', err.message);
92
+ });
93
+ ```
94
+ - Apply defaults to all parameters:
95
+ ```js
96
+ constructor(options = {}, workerCount = os.cpus().length, callback)
97
+ ```
98
+ Add `import os from 'node:os';` (check if already imported; if so, reuse).
99
+
100
+ 4. Update `close()`:
101
+ - After `this.disconnected = true;`, add:
102
+ ```js
103
+ await this.redis.destroy().catch(() => {});
104
+ ```
105
+ `destroy()` is the documented disconnect method for both `RedisClientType`
106
+ and `RedisClusterType` in node-redis v5.
107
+
108
+ 5. Update JSDoc typedef:
109
+ ```js
110
+ /** @typedef {import('./types').RedisOptions} RedisOptions */
111
+ ```
112
+ Point to `types.ts` rather than duplicating the type inline in JS, since the
113
+ full `Pick<>` construction is cleaner to express in TypeScript. Remove the old
114
+ `RedisClient` typedef.
115
+
116
+ ---
117
+
118
+ ## 3. Changes to `src/types.ts`
119
+
120
+ Import the relevant node-redis types and define `RedisOptions` using `Pick<>` and
121
+ an inline cluster shape:
122
+
123
+ ```ts
124
+ import type { RedisClientOptions, RedisClusterOptions } from 'redis';
125
+
126
+ type SingleNodeOptions = Pick<RedisClientOptions, 'url' | 'socket' | 'username' | 'password' | 'database'>;
127
+
128
+ export type RedisOptions =
129
+ | SingleNodeOptions
130
+ | {
131
+ rootNodes: SingleNodeOptions[];
132
+ defaults?: Partial<SingleNodeOptions>;
133
+ nodeAddressMap?: RedisClusterOptions['nodeAddressMap'];
134
+ }
135
+ ```
136
+
137
+ `SingleNodeOptions` is not exported — it's an internal building block. Only
138
+ `RedisOptions` is exported. Update the `Client` constructor signature to accept
139
+ `RedisOptions` instead of `RedisClientType`.
140
+
141
+ ---
142
+
143
+ ## 4. Changes to `package.json`
144
+
145
+ - Move `redis` from `peerDependencies` + `devDependencies` to **`dependencies`**
146
+ (queasy now owns the connection; callers no longer need to install it themselves).
147
+ - Remove the `peerDependencies` section entirely.
148
+
149
+ Before:
150
+ ```json
151
+ "peerDependencies": { "redis": "^5.10.0" },
152
+ "devDependencies": { "redis": "^5.10.0", ... }
153
+ ```
154
+
155
+ After:
156
+ ```json
157
+ "dependencies": { "redis": "^5.10.0" },
158
+ "devDependencies": { ... }
159
+ ```
160
+
161
+ ---
162
+
163
+ ## 5. Changes to `test/queue.test.js`
164
+
165
+ Currently each `beforeEach` creates and connects a Redis client, passes it to
166
+ `Client`, and each `afterEach` calls `redis.quit()`.
167
+
168
+ Changes:
169
+ - Remove the `createClient` import and the `redis` variable.
170
+ - Change `new Client(redis, 1)` → `new Client({}, 1)`.
171
+ - Remove `await redis.quit()` — `client.close()` now handles disconnection.
172
+ - Any direct Redis inspection calls (`redis.zScore`, `redis.hGetAll`, `redis.keys`,
173
+ `redis.del`) still need a Redis connection. Add a dedicated `redisInspect` client
174
+ used only for test-side assertions:
175
+ ```js
176
+ // beforeEach
177
+ redisInspect = createClient();
178
+ await redisInspect.connect();
179
+ // afterEach
180
+ await redisInspect.quit();
181
+ ```
182
+
183
+ ---
184
+
185
+ ## 6. Changes to `test/client.test.js`
186
+
187
+ Same pattern as `queue.test.js`:
188
+ - Remove `createClient` import and `redis` variable.
189
+ - `new Client(redis, 1)` → `new Client({}, 1)`.
190
+ - Direct Redis calls (`redis.zScore`, `redis.keys`, `redis.del`) move to a
191
+ separate `redisInspect` client.
192
+ - Remove `await redis.quit()` from `afterEach`; `client.close()` handles it.
193
+
194
+ ---
195
+
196
+ ## 7. Changes to `test/redis-functions.test.js`
197
+
198
+ This test file does **not** use the `Client` class — it builds its own `redis`
199
+ connection to test Lua functions directly. **No changes needed.**
200
+
201
+ ---
202
+
203
+ ## 8. Changes to `fuzztest/process.js`
204
+
205
+ Currently:
206
+ ```js
207
+ import { createClient } from 'redis';
208
+ const redis = createClient();
209
+ await redis.connect();
210
+ const client = await new Promise((resolve) => new Client(redis, WORKER_THREADS, resolve));
211
+ ```
212
+
213
+ Change to:
214
+ ```js
215
+ const client = await new Promise((resolve) => new Client({}, WORKER_THREADS, resolve));
216
+ ```
217
+
218
+ Remove the `import { createClient } from 'redis'` line and the three lines that
219
+ create and connect `redis`.
220
+
221
+ ---
222
+
223
+ ## 9. Changes to `Readme.md`
224
+
225
+ Update the `client()` API section:
226
+
227
+ **Before:**
228
+ ```
229
+ ### `client(redisConnection, workerCount)`
230
+ Returns a Queasy client.
231
+ - `redisConnection`: a node-redis connection object.
232
+ - `workerCount`: number; Size of the worker pool. ...
233
+ ```
234
+
235
+ **After:**
236
+ ```
237
+ ### `new Client(options, workerCount)`
238
+ Returns a Queasy client. Queasy creates and manages its own Redis connection internally.
239
+ - `options`: connection options. Two forms are accepted:
240
+ - **Single node** (plain object): passed to node-redis `createClient`. Accepts
241
+ `url`, `socket`, `username`, `password`, and `database`. Defaults to `{}`
242
+ (connects to `localhost:6379`).
243
+ - **Cluster** (object with `rootNodes`): passed to node-redis `createCluster`.
244
+ Accepts `rootNodes` (required — array of per-node connection options, at least
245
+ three recommended), `defaults` (options shared across all nodes, e.g. auth and
246
+ TLS), and `nodeAddressMap` (for address translation in NAT environments).
247
+ - `workerCount`: number; size of the worker pool. Defaults to number of CPUs.
248
+ ```
249
+
250
+ Also update the terminology section to reflect that the client manages its own
251
+ connection rather than accepting one from the caller.
252
+
253
+ ---
254
+
255
+ ## 10. Changes to `CLAUDE.md`
256
+
257
+ In the Architecture section, update the JS layer description:
258
+ - "On construction, it calls `createClient` or `createCluster` (based on whether
259
+ `options.rootNodes` is present), connects, then uploads the Lua script via
260
+ `FUNCTION LOAD REPLACE`."
261
+ - Remove the mention of `WeakSet (initializedClients)` tracking which Redis clients
262
+ have had functions loaded (no longer relevant since the connection is internal and
263
+ only created once per `Client` instance).
264
+
265
+ ---
266
+
267
+ ## Summary of file changes
268
+
269
+ | File | Change |
270
+ |---|---|
271
+ | `src/client.js` | Accept `RedisOptions`; call `createClient`/`createCluster`; connect in constructor; `destroy()` in `close()`; emit `'disconnected'` on connect error; default all args |
272
+ | `src/types.ts` | Add `RedisOptions` type using `Pick<RedisClientOptions, ...> \| Pick<RedisClusterOptions, ...>`; remove `RedisClientType` reference |
273
+ | `package.json` | Move `redis` from `peerDependencies`+`devDependencies` to `dependencies` |
274
+ | `test/queue.test.js` | Remove external `redis` client; pass `{}` to `Client`; add `redisInspect` for assertions |
275
+ | `test/client.test.js` | Same as above |
276
+ | `test/redis-functions.test.js` | No changes |
277
+ | `fuzztest/process.js` | Remove `createClient`/`redis`; pass `{}` to `Client` |
278
+ | `Readme.md` | Update `client()` API docs |
279
+ | `CLAUDE.md` | Update architecture description |