@zizq-labs/zizq 0.3.1 → 0.4.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/README.md CHANGED
@@ -7,10 +7,12 @@ API.
7
7
  This is the official Zizq client library for Node.js, written in TypeScript.
8
8
 
9
9
  [![CI](https://github.com/zizq-labs/zizq-node/actions/workflows/ci.yml/badge.svg)](https://github.com/zizq-labs/zizq-node/actions/workflows/ci.yml)
10
+ [![npm version](https://img.shields.io/npm/v/@zizq-labs/zizq.svg)](https://www.npmjs.com/package/@zizq-labs/zizq)
10
11
 
11
12
  ## Features
12
13
 
13
14
  * Concurrent async-based worker
15
+ * Plain handler functions, or composable Job Functions with attached defaults
14
16
  * Enqueue and process jobs from one language to another
15
17
  * Arbitrary named queues
16
18
  * Granular job priorities
@@ -29,7 +31,7 @@ This is the official Zizq client library for Node.js, written in TypeScript.
29
31
 
30
32
  Install it with your package manager of choice:
31
33
 
32
- ``` shell
34
+ ```shell
33
35
  npm install @zizq-labs/zizq
34
36
  ```
35
37
 
@@ -39,29 +41,123 @@ Or:
39
41
  yarn add @zizq-labs/zizq
40
42
  ```
41
43
 
42
- ## Example
44
+ Node **22.19 or newer** is required. Client and server share version
45
+ numbers — keep the client's major/minor at or below the server's.
43
46
 
44
- > [!TIP]
45
- > There is also a composable function-based convenience wrapper available. Read
46
- > the documentation on
47
- > [Handler Functions](https://zizq.io/docs/clients/node/handlers.html) for more
48
- > info
47
+ ## Configuration
49
48
 
50
- Enqueueing a job.
49
+ A `Client` instance is the configuration — there is no global object. The
50
+ defaults talk to a server at `http://localhost:7890`, which is fine for local
51
+ development. For anything else, pass the URL (and TLS, if needed) when
52
+ constructing the client:
51
53
 
52
54
  ```ts
55
+ import fs from "node:fs";
53
56
  import { Client } from "@zizq-labs/zizq";
54
57
 
55
- const client = new Client({ url: "http://localhost:7890" });
58
+ const client = new Client({
59
+ url: "https://zizq.your.network:7890",
60
+ tls: {
61
+ ca: fs.readFileSync("/path/to/server-ca-cert.pem"),
62
+ },
63
+ });
64
+ ```
56
65
 
57
- await client.enqueue({
66
+ For mutual TLS, add `cert` and `key` to the `tls` object (both PEM-encoded
67
+ strings or `Buffer`s).
68
+
69
+ > [!CAUTION]
70
+ > If your server is exposed directly to the internet, it should require
71
+ > mutual TLS — otherwise anybody can talk to it.
72
+
73
+ ## Usage
74
+
75
+ > [!TIP]
76
+ > This README is an overview. The
77
+ > [full documentation](https://zizq.io/docs/clients/node/) covers each
78
+ > feature in depth — handler patterns, job querying, unique jobs, and more.
79
+
80
+ ### Enqueuing jobs
81
+
82
+ The simplest enqueue takes a `type`, `queue`, and `payload`. The Zizq server
83
+ returns the created job, with its `id`, `status`, `readyAt` and other
84
+ metadata:
85
+
86
+ ```ts
87
+ const job = await client.enqueue({
58
88
  type: "send_email",
59
89
  queue: "emails",
60
- payload: { to: "user@example.com" },
90
+ payload: { userId: 42, template: "welcome" },
91
+ });
92
+ job.id; // "03fu0wm75gxgmfyfplwvazhex"
93
+ ```
94
+
95
+ Per-call options override server defaults — set `priority`, `readyAt` (to
96
+ schedule a job in the future), `retryLimit`, `backoff`, or `retention`
97
+ inline. `client.enqueueBulk(inputs)` submits many jobs atomically in a
98
+ single HTTP request, across queues and types:
99
+
100
+ ```ts
101
+ await client.enqueueBulk([
102
+ { type: "send_email", queue: "emails", payload: { userId: 1 } },
103
+ { type: "send_email", queue: "emails", payload: { userId: 2 } },
104
+ ]);
105
+ ```
106
+
107
+ ### Defining handlers
108
+
109
+ A handler is an `async` function that accepts a `job` and either resolves
110
+ (the worker acks it as successful) or throws (the worker reports a failure
111
+ and the server retries per the backoff policy). The simplest version is a
112
+ `switch` on `job.type`:
113
+
114
+ ```ts
115
+ async function handler(job) {
116
+ switch (job.type) {
117
+ case "send_email":
118
+ return sendEmail(job.payload);
119
+ case "generate_report":
120
+ return generateReport(job.payload);
121
+ default:
122
+ throw new Error(`unexpected job type: ${job.type}`);
123
+ }
124
+ }
125
+ ```
126
+
127
+ For a more composable style, the client ships `buildHandler` — pass in your
128
+ job functions and you get back a handler that dispatches on the function
129
+ name. Each function can also carry `zizqOptions` with its own defaults
130
+ (queue, priority, backoff, retention, uniqueness), so enqueuing later only
131
+ needs the payload:
132
+
133
+ ```ts
134
+ import { buildHandler } from "@zizq-labs/zizq";
135
+
136
+ async function sendEmail(payload, job) {
137
+ // ...
138
+ }
139
+ sendEmail.zizqOptions = { queue: "emails", priority: 100 };
140
+
141
+ async function generateReport(payload) {
142
+ // ...
143
+ }
144
+ generateReport.zizqOptions = { queue: "reports" };
145
+
146
+ const handler = buildHandler([sendEmail, generateReport]);
147
+
148
+ // Job functions can be enqueued directly — the queue and other defaults
149
+ // come from the function's zizqOptions.
150
+ await client.enqueue({
151
+ type: sendEmail,
152
+ payload: { userId: 42, template: "welcome" },
61
153
  });
62
154
  ```
63
155
 
64
- A very basic worker with a custom handler.
156
+ ### Running a worker
157
+
158
+ A `Worker` streams jobs from the server, dispatches them through your
159
+ handler with bounded concurrency, batches acks, and reconnects on transient
160
+ failures. `worker.run()` blocks until the worker stops:
65
161
 
66
162
  ```ts
67
163
  import { Client, Worker } from "@zizq-labs/zizq";
@@ -71,16 +167,8 @@ const client = new Client({ url: "http://localhost:7890" });
71
167
  const worker = new Worker({
72
168
  client,
73
169
  concurrency: 25,
74
- queues: ["emails"],
75
- handler: async (job) => {
76
- switch (job.type) {
77
- case "send_email":
78
- console.log(`sending email using payload ${JSON.stringify(job.payload)}`);
79
- break;
80
- default:
81
- throw new Error(`unknown job type: ${job.type}`);
82
- }
83
- },
170
+ queues: ["emails", "payments"],
171
+ handler,
84
172
  });
85
173
 
86
174
  process.on("SIGINT", () => worker.stop());
@@ -89,6 +177,48 @@ process.on("SIGTERM", () => worker.stop());
89
177
  await worker.run();
90
178
  ```
91
179
 
180
+ `worker.stop()` waits for in-flight handlers to settle and flushes pending
181
+ acks before returning. To put a deadline on shutdown, schedule
182
+ `worker.kill()` after a timeout — any handlers still running continue to
183
+ completion (Node can't cancel promises), but their acks are not flushed, so
184
+ the server re-dispatches those jobs to another worker. No jobs are lost.
185
+
186
+ ### Recurring jobs (cron)
187
+
188
+ Define a cron schedule somewhere in your application startup.
189
+ Registrations are idempotent — every process can safely call the same
190
+ `register()`, and Zizq keeps the server-side schedule in sync by adding,
191
+ replacing, and removing entries as the definition changes. Cron requires
192
+ a Pro license on the server.
193
+
194
+ ```ts
195
+ import { sendDailyDigest } from "./handlers";
196
+
197
+ await client.cron("maintenance").register({
198
+ timezone: "Europe/London",
199
+ entries: [
200
+ {
201
+ name: "refresh_warehouse",
202
+ expression: "*/15 * * * *",
203
+ type: "refresh_warehouse",
204
+ queue: "data_warehouse",
205
+ payload: { incremental: true },
206
+ },
207
+ {
208
+ name: "daily_digest",
209
+ expression: "0 9 * * *",
210
+ type: sendDailyDigest, // Job Functions are accepted directly
211
+ payload: {},
212
+ },
213
+ ],
214
+ });
215
+ ```
216
+
217
+ Once defined, schedules can be inspected and managed via
218
+ `client.cron("maintenance")` — `get()` to read the current state,
219
+ `pause()`/`resume()` at the schedule or per-entry level, and `delete()`
220
+ to remove a schedule entirely.
221
+
92
222
  ## Resources
93
223
 
94
224
  * [Node.js Client Docs](https://zizq.io/docs/clients/node/)
@@ -103,3 +233,7 @@ If you need help using Zizq,
103
233
  [create an issue](https://github.com/zizq-labs/zizq-node/issues) on the
104
234
  [zizq-node](https://github.com/zizq-labs/zizq-node) repo. Feedback is very
105
235
  welcome.
236
+
237
+ ## License
238
+
239
+ MIT — see [LICENSE](LICENSE).
package/dist/client.d.ts CHANGED
@@ -82,6 +82,35 @@ export interface ClientOptions {
82
82
  format?: Format;
83
83
  /** TLS configuration for HTTPS connections. */
84
84
  tls?: TlsOptions;
85
+ /**
86
+ * Connect timeout in milliseconds — deadline for the TCP/TLS
87
+ * handshake. Applies to every pool.
88
+ *
89
+ * Default: 10000 (10s).
90
+ */
91
+ connectTimeout?: number;
92
+ /**
93
+ * Per-read timeout in milliseconds for API traffic (enqueue,
94
+ * queries, mutations — anything that isn't the long-lived
95
+ * `take()` stream). Bounds the time between consecutive bytes
96
+ * received from the server.
97
+ *
98
+ * Default: 30000 (30s).
99
+ */
100
+ readTimeout?: number;
101
+ /**
102
+ * Per-read timeout in milliseconds for the long-lived
103
+ * `take()` stream consumed by {@link Worker}. The server's
104
+ * heartbeats (default ~3s) reset the clock on each frame, so this
105
+ * only fires when the connection has actually gone silent.
106
+ * Triggers a Worker reconnect via the standard error path.
107
+ *
108
+ * Should be comfortably above the server's heartbeat interval to
109
+ * avoid false-positive disconnects.
110
+ *
111
+ * Default: 30000 (30s).
112
+ */
113
+ streamIdleTimeout?: number;
85
114
  /** @internal For testing — override the HTTP dispatcher. */
86
115
  dispatcher?: Dispatcher;
87
116
  }
@@ -280,6 +309,27 @@ export declare class Client {
280
309
  * ```
281
310
  */
282
311
  deleteAllJobs(options?: DeleteAllJobsOptions): Promise<number>;
312
+ /**
313
+ * Wipe *every* cron group and *every* job on the server.
314
+ *
315
+ * Equivalent to calling {@link deleteAllCrons} followed by
316
+ * {@link deleteAllJobs} (no filter), but in a single request. Useful
317
+ * primarily as a setup/teardown step in tests where you want a
318
+ * known-empty server between scenarios.
319
+ *
320
+ * **Destructive.** No filters, no escape hatch, no confirmation —
321
+ * the server-side operation simply returns once everything is gone.
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * await client.reset();
326
+ * ```
327
+ */
328
+ reset(): Promise<void>;
329
+ /**
330
+ * Alias for {@link reset}.
331
+ */
332
+ eraseAllData(): Promise<void>;
283
333
  /**
284
334
  * Count jobs matching the given filters.
285
335
  *
@@ -493,6 +543,17 @@ export declare class Client {
493
543
  * @throws {NotFoundError} If the cron group is not found.
494
544
  */
495
545
  deleteCronGroup(name: string): Promise<void>;
546
+ /**
547
+ * Delete every cron group on the server in a single call.
548
+ *
549
+ * Returns the number of cron groups removed.
550
+ *
551
+ * **Destructive.** This deletes *every cron group on the server*. For
552
+ * granular deletes, use {@link deleteCronGroup} with a specific name.
553
+ *
554
+ * Requires a Pro license on the server.
555
+ */
556
+ deleteAllCrons(): Promise<number>;
496
557
  /**
497
558
  * Fetch a single cron entry within a group.
498
559
  *
package/dist/client.js CHANGED
@@ -151,29 +151,54 @@ export class Client {
151
151
  this.contentType = CONTENT_TYPES[this.format];
152
152
  this.accept = CONTENT_TYPES[this.format];
153
153
  this.streamAccept = STREAM_ACCEPT[this.format];
154
+ const connectTimeout = options.connectTimeout ?? 10_000;
155
+ const readTimeout = options.readTimeout ?? 30_000;
156
+ const streamIdleTimeout = options.streamIdleTimeout ?? 30_000;
154
157
  if (options.dispatcher) {
155
158
  // Testing: use the same dispatcher for both.
156
159
  this.http = options.dispatcher;
157
160
  this.streamHttp = options.dispatcher;
158
161
  }
159
162
  else {
160
- const connectOpts = options.tls ? {
161
- ca: options.tls.ca,
162
- cert: options.tls.cert,
163
- key: options.tls.key,
164
- } : undefined;
163
+ const connectOpts = {
164
+ ...(options.tls ? {
165
+ ca: options.tls.ca,
166
+ cert: options.tls.cert,
167
+ key: options.tls.key,
168
+ } : {}),
169
+ timeout: connectTimeout,
170
+ };
165
171
  // HTTP/2 for request/response traffic (multiplexed acks, enqueues).
172
+ // `bodyTimeout` is per-chunk inactivity — reset on each byte —
173
+ // so it acts as the read timeout; `headersTimeout` caps
174
+ // "server accepted the request but didn't respond" cases.
166
175
  this.http = new Pool(this.url, {
167
176
  allowH2: true,
168
177
  connect: connectOpts,
178
+ headersTimeout: readTimeout,
179
+ bodyTimeout: readTimeout,
169
180
  });
170
181
  // HTTP/1.1 for the long-lived take stream. HTTP/2 adds framing
171
182
  // overhead and flow control with no multiplexing benefit on a
172
183
  // single long-lived response, resulting in measurably lower
173
184
  // throughput compared to HTTP/1.1 chunked transfer.
185
+ //
186
+ // `bodyTimeout` here uses `streamIdleTimeout` rather than the
187
+ // normal `readTimeout`: server heartbeats reset it on each frame,
188
+ // so this only fires when the connection has actually gone
189
+ // silent. The Worker's reconnect path handles the resulting
190
+ // BodyTimeoutError.
191
+ //
192
+ // `headersTimeout` stays on `readTimeout`: the response headers
193
+ // are a one-shot request/response pair like any other API call,
194
+ // only the body streams. Falling through to undici's 5-minute
195
+ // default would leave a server stuck pre-headers blocking the
196
+ // worker far longer than the user-configured timeout would suggest.
174
197
  this.streamHttp = new Pool(this.url, {
175
198
  allowH2: false,
176
199
  connect: connectOpts,
200
+ headersTimeout: readTimeout,
201
+ bodyTimeout: streamIdleTimeout,
177
202
  });
178
203
  }
179
204
  }
@@ -342,6 +367,34 @@ export class Client {
342
367
  const data = await this.handleResponse(await this.request("DELETE", path));
343
368
  return data.deleted;
344
369
  }
370
+ /**
371
+ * Wipe *every* cron group and *every* job on the server.
372
+ *
373
+ * Equivalent to calling {@link deleteAllCrons} followed by
374
+ * {@link deleteAllJobs} (no filter), but in a single request. Useful
375
+ * primarily as a setup/teardown step in tests where you want a
376
+ * known-empty server between scenarios.
377
+ *
378
+ * **Destructive.** No filters, no escape hatch, no confirmation —
379
+ * the server-side operation simply returns once everything is gone.
380
+ *
381
+ * @example
382
+ * ```ts
383
+ * await client.reset();
384
+ * ```
385
+ */
386
+ async reset() {
387
+ const res = await this.request("POST", "/reset");
388
+ if (res.statusCode !== 204) {
389
+ await this.throwOnError(res);
390
+ }
391
+ }
392
+ /**
393
+ * Alias for {@link reset}.
394
+ */
395
+ async eraseAllData() {
396
+ return this.reset();
397
+ }
345
398
  /**
346
399
  * Count jobs matching the given filters.
347
400
  *
@@ -645,6 +698,20 @@ export class Client {
645
698
  await this.throwOnError(res);
646
699
  }
647
700
  }
701
+ /**
702
+ * Delete every cron group on the server in a single call.
703
+ *
704
+ * Returns the number of cron groups removed.
705
+ *
706
+ * **Destructive.** This deletes *every cron group on the server*. For
707
+ * granular deletes, use {@link deleteCronGroup} with a specific name.
708
+ *
709
+ * Requires a Pro license on the server.
710
+ */
711
+ async deleteAllCrons() {
712
+ const data = await this.handleResponse(await this.request("DELETE", "/crons"));
713
+ return data.deleted;
714
+ }
648
715
  /**
649
716
  * Fetch a single cron entry within a group.
650
717
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zizq-labs/zizq",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Node.js client for the Zizq job queue server",
5
5
  "homepage": "https://zizq.io",
6
6
  "repository": {