@zizq-labs/zizq 0.1.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 +21 -0
- package/README.md +86 -0
- package/dist/client.d.ts +747 -0
- package/dist/client.js +902 -0
- package/dist/enqueue.d.ts +119 -0
- package/dist/enqueue.js +110 -0
- package/dist/handler.d.ts +179 -0
- package/dist/handler.js +45 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/query.d.ts +372 -0
- package/dist/query.js +653 -0
- package/dist/resources.d.ts +281 -0
- package/dist/resources.js +319 -0
- package/dist/unique-key.d.ts +58 -0
- package/dist/unique-key.js +122 -0
- package/dist/worker.d.ts +307 -0
- package/dist/worker.js +396 -0
- package/package.json +34 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
// Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
/**
|
|
4
|
+
* Low-level HTTP client for the Zizq job queue server.
|
|
5
|
+
*
|
|
6
|
+
* @example Basic usage
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { Client } from "@zizq-labs/zizq";
|
|
9
|
+
*
|
|
10
|
+
* const client = new Client({ url: "http://localhost:7890" });
|
|
11
|
+
*
|
|
12
|
+
* // Enqueue a job
|
|
13
|
+
* const job = await client.enqueue({
|
|
14
|
+
* type: "send_email",
|
|
15
|
+
* queue: "emails",
|
|
16
|
+
* payload: { to: "user@example.com", subject: "Hello" },
|
|
17
|
+
* });
|
|
18
|
+
* console.log(job.id); // "03fvqh..."
|
|
19
|
+
*
|
|
20
|
+
* // Take and process jobs (streaming)
|
|
21
|
+
* for await (const job of client.take({ prefetch: 5, queues: ["emails"] })) {
|
|
22
|
+
* try {
|
|
23
|
+
* await processJob(job);
|
|
24
|
+
* await client.reportSuccess(job.id);
|
|
25
|
+
* } catch (err) {
|
|
26
|
+
* await client.reportFailure(job.id, { message: err.message });
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* await client.close();
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @module
|
|
34
|
+
*/
|
|
35
|
+
import { Pool } from "undici";
|
|
36
|
+
import { encode as msgpackEncode, decode as msgpackDecode } from "@msgpack/msgpack";
|
|
37
|
+
import { Job, JobPage, ErrorRecord, ErrorPage } from "./resources.js";
|
|
38
|
+
import { JobQuery } from "./query.js";
|
|
39
|
+
// Re-export resource types so consumers can import from client.ts.
|
|
40
|
+
export { Job, JobPage, ErrorRecord, ErrorPage } from "./resources.js";
|
|
41
|
+
/** Base error class for all Zizq errors. */
|
|
42
|
+
export class ZizqError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "ZizqError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Network-level failure (connection refused, DNS, timeout, etc.).
|
|
50
|
+
*
|
|
51
|
+
* These are always transient and safe to retry.
|
|
52
|
+
*/
|
|
53
|
+
export class ConnectionError extends ZizqError {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "ConnectionError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* HTTP error — the server returned a non-success status code.
|
|
61
|
+
*
|
|
62
|
+
* Carries the HTTP status code and (when available) the parsed response
|
|
63
|
+
* body, which typically contains an `error` field with a human-readable
|
|
64
|
+
* message from the server.
|
|
65
|
+
*/
|
|
66
|
+
export class ResponseError extends ZizqError {
|
|
67
|
+
/** HTTP status code from the server. */
|
|
68
|
+
status;
|
|
69
|
+
/** Parsed response body, if available. */
|
|
70
|
+
body;
|
|
71
|
+
constructor(message, status, body) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "ResponseError";
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.body = body;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 4xx client error — the request was invalid. Not retryable. */
|
|
79
|
+
export class ClientError extends ResponseError {
|
|
80
|
+
constructor(message, status, body) {
|
|
81
|
+
super(message, status, body);
|
|
82
|
+
this.name = "ClientError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** 404 specifically — job not found, etc. */
|
|
86
|
+
export class NotFoundError extends ClientError {
|
|
87
|
+
constructor(message, body) {
|
|
88
|
+
super(message, 404, body);
|
|
89
|
+
this.name = "NotFoundError";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** 5xx server error — something went wrong on the server. Retryable. */
|
|
93
|
+
export class ServerError extends ResponseError {
|
|
94
|
+
constructor(message, status, body) {
|
|
95
|
+
super(message, status, body);
|
|
96
|
+
this.name = "ServerError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Content-type headers for each format. */
|
|
100
|
+
const CONTENT_TYPES = {
|
|
101
|
+
json: "application/json",
|
|
102
|
+
msgpack: "application/msgpack",
|
|
103
|
+
};
|
|
104
|
+
/** Accept headers for the streaming take endpoint. */
|
|
105
|
+
const STREAM_ACCEPT = {
|
|
106
|
+
json: "application/x-ndjson",
|
|
107
|
+
msgpack: "application/vnd.zizq.msgpack-stream",
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Low-level HTTP client for the Zizq job queue server.
|
|
111
|
+
*
|
|
112
|
+
* Maintains a persistent connection (HTTP/2 when available) for efficient
|
|
113
|
+
* request multiplexing. All methods map directly to server API endpoints.
|
|
114
|
+
*
|
|
115
|
+
* Call {@link close} when done to wait for in-flight requests to complete and
|
|
116
|
+
* release the underlying connection.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* const client = new Client({ url: "http://localhost:7890" });
|
|
121
|
+
*
|
|
122
|
+
* const job = await client.enqueue({
|
|
123
|
+
* type: "send_email",
|
|
124
|
+
* queue: "emails",
|
|
125
|
+
* payload: { to: "user@example.com" },
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* await client.close();
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export class Client {
|
|
132
|
+
/** Pool for request/response traffic (enqueue, ack, failure, get). */
|
|
133
|
+
http;
|
|
134
|
+
/** Separate pool for long-lived streaming connections (take). */
|
|
135
|
+
streamHttp;
|
|
136
|
+
/** The base URL of the Zizq server. */
|
|
137
|
+
url;
|
|
138
|
+
/** Serialization format. */
|
|
139
|
+
format;
|
|
140
|
+
/** Content-type for requests. */
|
|
141
|
+
contentType;
|
|
142
|
+
/** Accept header for request/response endpoints. */
|
|
143
|
+
accept;
|
|
144
|
+
/** Accept header for the streaming take endpoint. */
|
|
145
|
+
streamAccept;
|
|
146
|
+
constructor(options) {
|
|
147
|
+
this.url = options.url.replace(/\/+$/, "");
|
|
148
|
+
this.format = options.format ?? "json";
|
|
149
|
+
this.contentType = CONTENT_TYPES[this.format];
|
|
150
|
+
this.accept = CONTENT_TYPES[this.format];
|
|
151
|
+
this.streamAccept = STREAM_ACCEPT[this.format];
|
|
152
|
+
if (options.dispatcher) {
|
|
153
|
+
// Testing: use the same dispatcher for both.
|
|
154
|
+
this.http = options.dispatcher;
|
|
155
|
+
this.streamHttp = options.dispatcher;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
const connectOpts = options.tls ? {
|
|
159
|
+
ca: options.tls.ca,
|
|
160
|
+
cert: options.tls.cert,
|
|
161
|
+
key: options.tls.key,
|
|
162
|
+
} : undefined;
|
|
163
|
+
// HTTP/2 for request/response traffic (multiplexed acks, enqueues).
|
|
164
|
+
this.http = new Pool(this.url, {
|
|
165
|
+
allowH2: true,
|
|
166
|
+
connect: connectOpts,
|
|
167
|
+
});
|
|
168
|
+
// HTTP/1.1 for the long-lived take stream. HTTP/2 adds framing
|
|
169
|
+
// overhead and flow control with no multiplexing benefit on a
|
|
170
|
+
// single long-lived response, resulting in measurably lower
|
|
171
|
+
// throughput compared to HTTP/1.1 chunked transfer.
|
|
172
|
+
this.streamHttp = new Pool(this.url, {
|
|
173
|
+
allowH2: false,
|
|
174
|
+
connect: connectOpts,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Enqueue a single job.
|
|
180
|
+
*
|
|
181
|
+
* @returns The created job, including its server-assigned `id` and `status`.
|
|
182
|
+
* @throws {ZizqError} If the server rejects the request (e.g. invalid queue name).
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* const job = await client.enqueue({
|
|
187
|
+
* type: "send_email",
|
|
188
|
+
* queue: "emails",
|
|
189
|
+
* payload: { to: "user@example.com" },
|
|
190
|
+
* });
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
async enqueue(options) {
|
|
194
|
+
const api = enqueueToApi(options);
|
|
195
|
+
return this.wrapJob(await this.handleResponse(await this.post("/jobs", api)));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Enqueue multiple jobs in a single request.
|
|
199
|
+
*
|
|
200
|
+
* @returns An array of created jobs in the same order as the input.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* const jobs = await client.enqueueBulk([
|
|
205
|
+
* { type: "send_email", queue: "emails", payload: { to: "a@b.com" } },
|
|
206
|
+
* { type: "send_email", queue: "emails", payload: { to: "c@d.com" } },
|
|
207
|
+
* ]);
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
async enqueueBulk(jobs) {
|
|
211
|
+
const api = { jobs: jobs.map(enqueueToApi) };
|
|
212
|
+
const data = await this.handleResponse(await this.post("/jobs/bulk", api));
|
|
213
|
+
return data.jobs.map((j) => this.wrapJob(j));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Acknowledge a job as successfully completed.
|
|
217
|
+
*
|
|
218
|
+
* @param id - The job ID to acknowledge.
|
|
219
|
+
* @throws {ZizqError} If the job is not found or not in-flight.
|
|
220
|
+
*/
|
|
221
|
+
async reportSuccess(id) {
|
|
222
|
+
const res = await this.request("POST", `/jobs/${encodeURIComponent(id)}/success`);
|
|
223
|
+
if (res.statusCode !== 204) {
|
|
224
|
+
await this.throwOnError(res);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Acknowledge multiple jobs as successfully completed in a single request.
|
|
229
|
+
*
|
|
230
|
+
* Jobs that have already been acknowledged or that don't exist are
|
|
231
|
+
* silently ignored (the server returns 422 but the client treats it
|
|
232
|
+
* as success).
|
|
233
|
+
*
|
|
234
|
+
* @param ids - Array of job IDs to acknowledge.
|
|
235
|
+
*/
|
|
236
|
+
async reportSuccessBulk(ids) {
|
|
237
|
+
const res = await this.post("/jobs/success", { ids });
|
|
238
|
+
// 204 = all found, 422 = some not found (still accepted).
|
|
239
|
+
if (res.statusCode !== 204 && res.statusCode !== 422) {
|
|
240
|
+
await this.throwOnError(res);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Report a job as failed.
|
|
245
|
+
*
|
|
246
|
+
* The server will either reschedule the job with backoff or move it to
|
|
247
|
+
* the dead list if the retry limit has been exceeded.
|
|
248
|
+
*
|
|
249
|
+
* @param id - The job ID to report failure for.
|
|
250
|
+
* @param options - Error details (message, stack trace, etc.).
|
|
251
|
+
* @returns The updated job with its new status and attempt count.
|
|
252
|
+
*/
|
|
253
|
+
async reportFailure(id, options) {
|
|
254
|
+
const api = failureToApi(options);
|
|
255
|
+
return this.wrapJob(await this.handleResponse(await this.post(`/jobs/${encodeURIComponent(id)}/failure`, api)));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Fetch a single job by ID.
|
|
259
|
+
*
|
|
260
|
+
* @param id - The job ID to fetch.
|
|
261
|
+
* @returns The full job data including payload.
|
|
262
|
+
* @throws {ZizqError} If the job is not found (404).
|
|
263
|
+
*/
|
|
264
|
+
async getJob(id) {
|
|
265
|
+
return this.wrapJob(await this.handleResponse(await this.request("GET", `/jobs/${encodeURIComponent(id)}`)));
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Delete a single job by ID.
|
|
269
|
+
*
|
|
270
|
+
* @param id - The job ID to delete.
|
|
271
|
+
* @throws {NotFoundError} If the job is not found.
|
|
272
|
+
*/
|
|
273
|
+
async deleteJob(id) {
|
|
274
|
+
const res = await this.request("DELETE", `/jobs/${encodeURIComponent(id)}`);
|
|
275
|
+
if (res.statusCode !== 204) {
|
|
276
|
+
await this.throwOnError(res);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Delete jobs matching the given filters.
|
|
281
|
+
*
|
|
282
|
+
* An empty options object deletes all jobs.
|
|
283
|
+
*
|
|
284
|
+
* @returns The number of deleted jobs.
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* ```ts
|
|
288
|
+
* const count = await client.deleteAllJobs({ queue: "emails", status: "dead" });
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
async deleteAllJobs(options = {}) {
|
|
292
|
+
assertOnlyKeys("deleteAllJobs", options, ["where"]);
|
|
293
|
+
const where = options.where ?? {};
|
|
294
|
+
assertOnlyKeys("deleteAllJobs.where", where, ["id", "status", "queue", "type", "filter"]);
|
|
295
|
+
// Build the multi-value filters first so we can short-circuit if any
|
|
296
|
+
// resolves to an empty string. An empty filter matches nothing — we
|
|
297
|
+
// don't want to pass it as "no filter" and accidentally delete everything.
|
|
298
|
+
const id = where.id != null ? toCommaList(where.id) : undefined;
|
|
299
|
+
const status = where.status != null ? toCommaList(where.status) : undefined;
|
|
300
|
+
const queue = where.queue != null ? toCommaList(where.queue) : undefined;
|
|
301
|
+
const type = where.type != null ? toCommaList(where.type) : undefined;
|
|
302
|
+
if (id === "" || status === "" || queue === "" || type === "")
|
|
303
|
+
return 0;
|
|
304
|
+
const params = new URLSearchParams();
|
|
305
|
+
if (id != null)
|
|
306
|
+
params.set("id", id);
|
|
307
|
+
if (status != null)
|
|
308
|
+
params.set("status", status);
|
|
309
|
+
if (queue != null)
|
|
310
|
+
params.set("queue", queue);
|
|
311
|
+
if (type != null)
|
|
312
|
+
params.set("type", type);
|
|
313
|
+
if (where.filter != null)
|
|
314
|
+
params.set("filter", where.filter);
|
|
315
|
+
const qs = params.toString();
|
|
316
|
+
const path = `/jobs${qs ? "?" + qs : ""}`;
|
|
317
|
+
const data = await this.handleResponse(await this.request("DELETE", path));
|
|
318
|
+
return data.deleted;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Update a single job's mutable fields.
|
|
322
|
+
*
|
|
323
|
+
* Field semantics:
|
|
324
|
+
* - **omitted or `undefined`** — leave unchanged
|
|
325
|
+
* - **`null`** — clear the field (only valid for nullable fields)
|
|
326
|
+
* - **a value** — update to that value
|
|
327
|
+
*
|
|
328
|
+
* @param id - The job ID to update.
|
|
329
|
+
* @param options - Fields to update.
|
|
330
|
+
* @returns The updated job.
|
|
331
|
+
* @throws {NotFoundError} If the job is not found.
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* ```ts
|
|
335
|
+
* // Change priority and clear the retry limit
|
|
336
|
+
* await client.updateJob("job-id", {
|
|
337
|
+
* priority: 100,
|
|
338
|
+
* retryLimit: null,
|
|
339
|
+
* });
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
async updateJob(id, options) {
|
|
343
|
+
const api = updateToApi(options);
|
|
344
|
+
return this.wrapJob(await this.handleResponse(await this.patch(`/jobs/${encodeURIComponent(id)}`, api)));
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Bulk update jobs matching a filter.
|
|
348
|
+
*
|
|
349
|
+
* @returns The number of updated jobs.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```ts
|
|
353
|
+
* const count = await client.updateAllJobs({
|
|
354
|
+
* where: { queue: "emails", status: "ready" },
|
|
355
|
+
* apply: { priority: 1000 },
|
|
356
|
+
* });
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
async updateAllJobs(options) {
|
|
360
|
+
assertOnlyKeys("updateAllJobs", options, ["where", "apply"]);
|
|
361
|
+
const where = options.where ?? {};
|
|
362
|
+
assertOnlyKeys("updateAllJobs.where", where, ["id", "status", "queue", "type", "filter"]);
|
|
363
|
+
// Same empty-filter short-circuit as deleteAllJobs.
|
|
364
|
+
const id = where.id != null ? toCommaList(where.id) : undefined;
|
|
365
|
+
const status = where.status != null ? toCommaList(where.status) : undefined;
|
|
366
|
+
const queue = where.queue != null ? toCommaList(where.queue) : undefined;
|
|
367
|
+
const type = where.type != null ? toCommaList(where.type) : undefined;
|
|
368
|
+
if (id === "" || status === "" || queue === "" || type === "")
|
|
369
|
+
return 0;
|
|
370
|
+
const params = new URLSearchParams();
|
|
371
|
+
if (id != null)
|
|
372
|
+
params.set("id", id);
|
|
373
|
+
if (status != null)
|
|
374
|
+
params.set("status", status);
|
|
375
|
+
if (queue != null)
|
|
376
|
+
params.set("queue", queue);
|
|
377
|
+
if (type != null)
|
|
378
|
+
params.set("type", type);
|
|
379
|
+
if (where.filter != null)
|
|
380
|
+
params.set("filter", where.filter);
|
|
381
|
+
const qs = params.toString();
|
|
382
|
+
const path = `/jobs${qs ? "?" + qs : ""}`;
|
|
383
|
+
const api = updateToApi(options.apply);
|
|
384
|
+
const data = await this.handleResponse(await this.patch(path, api));
|
|
385
|
+
return data.patched;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* List jobs with cursor-based pagination.
|
|
389
|
+
*
|
|
390
|
+
* Returns a single page of jobs. Use `page.nextPage()` and
|
|
391
|
+
* `page.prevPage()` to navigate between pages.
|
|
392
|
+
*
|
|
393
|
+
* @example
|
|
394
|
+
* ```ts
|
|
395
|
+
* const page = await client.listJobs({ queue: ["emails"], limit: 10 });
|
|
396
|
+
* for (const job of page.jobs) {
|
|
397
|
+
* console.log(job.id, job.status);
|
|
398
|
+
* }
|
|
399
|
+
* if (page.hasNext) {
|
|
400
|
+
* const next = await page.nextPage();
|
|
401
|
+
* }
|
|
402
|
+
* ```
|
|
403
|
+
*/
|
|
404
|
+
async listJobs(options = {}) {
|
|
405
|
+
const params = new URLSearchParams();
|
|
406
|
+
if (options.from != null)
|
|
407
|
+
params.set("from", options.from);
|
|
408
|
+
if (options.order != null)
|
|
409
|
+
params.set("order", options.order);
|
|
410
|
+
if (options.limit != null)
|
|
411
|
+
params.set("limit", String(options.limit));
|
|
412
|
+
if (options.status)
|
|
413
|
+
params.set("status", toCommaList(options.status));
|
|
414
|
+
if (options.queue)
|
|
415
|
+
params.set("queue", toCommaList(options.queue));
|
|
416
|
+
if (options.type)
|
|
417
|
+
params.set("type", toCommaList(options.type));
|
|
418
|
+
if (options.id)
|
|
419
|
+
params.set("id", toCommaList(options.id));
|
|
420
|
+
if (options.filter != null)
|
|
421
|
+
params.set("filter", options.filter);
|
|
422
|
+
const qs = params.toString();
|
|
423
|
+
const path = `/jobs${qs ? "?" + qs : ""}`;
|
|
424
|
+
return this.listJobsByPath(path);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Fetch a page of jobs by a raw path (used internally for pagination links).
|
|
428
|
+
*
|
|
429
|
+
* @internal
|
|
430
|
+
*/
|
|
431
|
+
async listJobsByPath(path) {
|
|
432
|
+
const data = await this.handleResponse(await this.request("GET", path));
|
|
433
|
+
const jobs = data.jobs.map((j) => this.wrapJob(j));
|
|
434
|
+
return new JobPage(this, jobs, data.pages);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Health check.
|
|
438
|
+
*
|
|
439
|
+
* @returns The parsed response body, e.g. `{ status: "ok" }`.
|
|
440
|
+
*/
|
|
441
|
+
async health() {
|
|
442
|
+
return await this.handleResponse(await this.request("GET", "/health"));
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Server version.
|
|
446
|
+
*
|
|
447
|
+
* @returns The server's version string.
|
|
448
|
+
*/
|
|
449
|
+
async serverVersion() {
|
|
450
|
+
const data = await this.handleResponse(await this.request("GET", "/version"));
|
|
451
|
+
return data.version;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Start a composable, lazy query over jobs.
|
|
455
|
+
*
|
|
456
|
+
* Returns a {@link JobQuery} that can be chained with filter and
|
|
457
|
+
* ordering methods, then iterated or used with terminal methods like
|
|
458
|
+
* `first()`, `toArray()`, `updateAll()`, and `deleteAll()`.
|
|
459
|
+
*
|
|
460
|
+
* No HTTP request is made until the query is consumed.
|
|
461
|
+
*
|
|
462
|
+
* Accepts an optional {@link JobQueryOptions} to seed the query's initial
|
|
463
|
+
* filter, order, limit, and page size — handy as a shorthand for
|
|
464
|
+
* `client.jobs().byQueue(...).limit(...)` etc.
|
|
465
|
+
*
|
|
466
|
+
* @example
|
|
467
|
+
* ```ts
|
|
468
|
+
* const dead = await client.jobs()
|
|
469
|
+
* .byQueue("emails")
|
|
470
|
+
* .byStatus("dead")
|
|
471
|
+
* .toArray();
|
|
472
|
+
*
|
|
473
|
+
* // Shorthand with seeded options
|
|
474
|
+
* const ready = await client.jobs({ queue: "emails", status: "ready" }).toArray();
|
|
475
|
+
*
|
|
476
|
+
* for await (const job of client.jobs().byStatus("ready")) {
|
|
477
|
+
* console.log(job.id);
|
|
478
|
+
* }
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
jobs(options) {
|
|
482
|
+
return new JobQuery(this, options);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* List all distinct queue names on the server.
|
|
486
|
+
*
|
|
487
|
+
* @returns An array of queue name strings, sorted alphabetically.
|
|
488
|
+
*/
|
|
489
|
+
async queues() {
|
|
490
|
+
const data = await this.handleResponse(await this.request("GET", "/queues"));
|
|
491
|
+
return data.queues;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* List error records for a job with cursor-based pagination.
|
|
495
|
+
*
|
|
496
|
+
* @param id - The job ID to list errors for.
|
|
497
|
+
* @param options - Pagination and ordering options.
|
|
498
|
+
*
|
|
499
|
+
* @example
|
|
500
|
+
* ```ts
|
|
501
|
+
* const page = await client.listErrors("job-id", { order: "desc" });
|
|
502
|
+
* for (const error of page) {
|
|
503
|
+
* console.log(`Attempt ${error.attempt}: ${error.message}`);
|
|
504
|
+
* }
|
|
505
|
+
* ```
|
|
506
|
+
*/
|
|
507
|
+
async listErrors(id, options = {}) {
|
|
508
|
+
const params = new URLSearchParams();
|
|
509
|
+
if (options.from != null)
|
|
510
|
+
params.set("from", String(options.from));
|
|
511
|
+
if (options.order != null)
|
|
512
|
+
params.set("order", options.order);
|
|
513
|
+
if (options.limit != null)
|
|
514
|
+
params.set("limit", String(options.limit));
|
|
515
|
+
const qs = params.toString();
|
|
516
|
+
const path = `/jobs/${encodeURIComponent(id)}/errors${qs ? "?" + qs : ""}`;
|
|
517
|
+
return this.listErrorsByPath(path);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Fetch a page of errors by a raw path (used internally for pagination links).
|
|
521
|
+
*
|
|
522
|
+
* @internal
|
|
523
|
+
*/
|
|
524
|
+
async listErrorsByPath(path) {
|
|
525
|
+
const data = await this.handleResponse(await this.request("GET", path));
|
|
526
|
+
const errors = data.errors.map((e) => this.wrapError(e));
|
|
527
|
+
return new ErrorPage(this, errors, data.pages);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Fetch a single error record for a job by attempt number.
|
|
531
|
+
*
|
|
532
|
+
* @param id - The job ID.
|
|
533
|
+
* @param attempt - The attempt number (1-based).
|
|
534
|
+
* @returns The error record for that attempt.
|
|
535
|
+
* @throws {NotFoundError} If the job or attempt is not found.
|
|
536
|
+
*/
|
|
537
|
+
async getError(id, attempt) {
|
|
538
|
+
const raw = await this.handleResponse(await this.request("GET", `/jobs/${encodeURIComponent(id)}/errors/${encodeURIComponent(String(attempt))}`));
|
|
539
|
+
return this.wrapError(raw);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Connect to the streaming take endpoint and return an async generator
|
|
543
|
+
* of jobs.
|
|
544
|
+
*
|
|
545
|
+
* The returned promise resolves once the HTTP connection is established.
|
|
546
|
+
* The async generator then yields jobs as they arrive. Heartbeats in
|
|
547
|
+
* the stream are silently skipped.
|
|
548
|
+
*
|
|
549
|
+
* The generator completes when the server closes the connection. The
|
|
550
|
+
* caller may also break out of the loop explicitly to end the stream,
|
|
551
|
+
* or provide an AbortSignal to explicitly signal cancellation.
|
|
552
|
+
*
|
|
553
|
+
* @example
|
|
554
|
+
* ```ts
|
|
555
|
+
* for await (const job of await client.take({ prefetch: 5, queues: ["emails"] })) {
|
|
556
|
+
* await processJob(job.payload);
|
|
557
|
+
* await client.reportSuccess(job.id);
|
|
558
|
+
* }
|
|
559
|
+
* ```
|
|
560
|
+
*/
|
|
561
|
+
async take(options = {}) {
|
|
562
|
+
const params = new URLSearchParams();
|
|
563
|
+
if (options.prefetch != null) {
|
|
564
|
+
params.set("prefetch", String(options.prefetch));
|
|
565
|
+
}
|
|
566
|
+
if (options.queues?.length) {
|
|
567
|
+
params.set("queue", options.queues.join(","));
|
|
568
|
+
}
|
|
569
|
+
const qs = params.toString();
|
|
570
|
+
const path = `/jobs/take${qs ? "?" + qs : ""}`;
|
|
571
|
+
const res = await this.streamHttp.request({
|
|
572
|
+
method: "GET",
|
|
573
|
+
path,
|
|
574
|
+
headers: { accept: this.streamAccept },
|
|
575
|
+
signal: options.signal ?? null,
|
|
576
|
+
});
|
|
577
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
578
|
+
await this.throwOnError(res);
|
|
579
|
+
}
|
|
580
|
+
const body = res.body;
|
|
581
|
+
// Use the response content-type to pick the stream parser, not the
|
|
582
|
+
// requested format — the server may respond differently (e.g. 406).
|
|
583
|
+
const contentType = String(res.headers["content-type"] ?? "");
|
|
584
|
+
if (contentType.includes("msgpack")) {
|
|
585
|
+
return this.iterateMsgpackStream(body);
|
|
586
|
+
}
|
|
587
|
+
return this.iterateNdjson(body);
|
|
588
|
+
}
|
|
589
|
+
/** Parse an NDJSON stream, yielding jobs. Empty lines are heartbeats. */
|
|
590
|
+
async *iterateNdjson(body) {
|
|
591
|
+
const decoder = new TextDecoder();
|
|
592
|
+
let buffer = "";
|
|
593
|
+
try {
|
|
594
|
+
for await (const chunk of body) {
|
|
595
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
596
|
+
let newlineIdx;
|
|
597
|
+
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
|
598
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
599
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
600
|
+
if (line.length === 0)
|
|
601
|
+
continue;
|
|
602
|
+
yield this.wrapJob(JSON.parse(line));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
body.destroy();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Parse a length-prefixed MessagePack stream, yielding jobs.
|
|
612
|
+
*
|
|
613
|
+
* Frame format: [4-byte big-endian length][MessagePack payload].
|
|
614
|
+
* A zero-length frame is a heartbeat and is silently skipped.
|
|
615
|
+
*/
|
|
616
|
+
async *iterateMsgpackStream(body) {
|
|
617
|
+
let buffer = Buffer.alloc(0);
|
|
618
|
+
try {
|
|
619
|
+
for await (const chunk of body) {
|
|
620
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
621
|
+
while (buffer.length >= 4) {
|
|
622
|
+
const frameLen = buffer.readUInt32BE(0);
|
|
623
|
+
// Zero-length frame is a heartbeat.
|
|
624
|
+
if (frameLen === 0) {
|
|
625
|
+
buffer = buffer.subarray(4);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
// Wait for the full frame.
|
|
629
|
+
if (buffer.length < 4 + frameLen)
|
|
630
|
+
break;
|
|
631
|
+
const payload = buffer.subarray(4, 4 + frameLen);
|
|
632
|
+
buffer = buffer.subarray(4 + frameLen);
|
|
633
|
+
yield this.wrapJob(msgpackDecode(payload));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
body.destroy();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** Wrap raw API data as a Job instance. */
|
|
642
|
+
wrapJob(raw) {
|
|
643
|
+
return new Job(this, jobFromApi(raw));
|
|
644
|
+
}
|
|
645
|
+
/** Wrap raw API data as an ErrorRecord instance. */
|
|
646
|
+
wrapError(raw) {
|
|
647
|
+
return new ErrorRecord(errorFromApi(raw));
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Gracefully close the underlying HTTP connection.
|
|
651
|
+
*
|
|
652
|
+
* Waits for in-flight requests to complete. If a streaming `take()`
|
|
653
|
+
* connection is open, this will block until it ends — use
|
|
654
|
+
* {@link destroy} for hard immediate shutdown.
|
|
655
|
+
*/
|
|
656
|
+
async close() {
|
|
657
|
+
await Promise.all([this.http.close(), this.streamHttp.close()]);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Forcefully destroy the underlying HTTP connection.
|
|
661
|
+
*
|
|
662
|
+
* Immediately terminates all in-flight requests including any open
|
|
663
|
+
* `take()` stream. Use this when `close()` would block (e.g. after
|
|
664
|
+
* an unclean interruption in the REPL).
|
|
665
|
+
*/
|
|
666
|
+
async destroy() {
|
|
667
|
+
await Promise.all([this.http.destroy(), this.streamHttp.destroy()]);
|
|
668
|
+
}
|
|
669
|
+
async request(method, path, extraHeaders) {
|
|
670
|
+
try {
|
|
671
|
+
return await this.http.request({
|
|
672
|
+
method: method,
|
|
673
|
+
path,
|
|
674
|
+
headers: {
|
|
675
|
+
accept: this.accept,
|
|
676
|
+
...extraHeaders,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
throw toConnectionError(err);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async post(path, body) {
|
|
685
|
+
return this.requestWithBody("POST", path, body);
|
|
686
|
+
}
|
|
687
|
+
async patch(path, body) {
|
|
688
|
+
return this.requestWithBody("PATCH", path, body);
|
|
689
|
+
}
|
|
690
|
+
async requestWithBody(method, path, body) {
|
|
691
|
+
try {
|
|
692
|
+
return await this.http.request({
|
|
693
|
+
method,
|
|
694
|
+
path,
|
|
695
|
+
headers: {
|
|
696
|
+
"content-type": this.contentType,
|
|
697
|
+
accept: this.accept,
|
|
698
|
+
},
|
|
699
|
+
body: this.encode(body),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
catch (err) {
|
|
703
|
+
throw toConnectionError(err);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
/** Encode a value in the configured format. */
|
|
707
|
+
encode(value) {
|
|
708
|
+
if (this.format === "msgpack") {
|
|
709
|
+
return Buffer.from(msgpackEncode(value));
|
|
710
|
+
}
|
|
711
|
+
return JSON.stringify(value);
|
|
712
|
+
}
|
|
713
|
+
async handleResponse(res) {
|
|
714
|
+
if (res.statusCode === 204) {
|
|
715
|
+
// Drain the body to release the connection.
|
|
716
|
+
for await (const _ of res.body) { }
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
const body = await this.readBody(res);
|
|
720
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
721
|
+
throw buildResponseError(res.statusCode, body);
|
|
722
|
+
}
|
|
723
|
+
return body;
|
|
724
|
+
}
|
|
725
|
+
async throwOnError(res) {
|
|
726
|
+
let body;
|
|
727
|
+
try {
|
|
728
|
+
body = await this.readBody(res);
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
body = undefined;
|
|
732
|
+
}
|
|
733
|
+
throw buildResponseError(res.statusCode, body);
|
|
734
|
+
}
|
|
735
|
+
/** Read and decode a response body, using the content-type header to
|
|
736
|
+
* pick the correct decoder rather than assuming the requested format. */
|
|
737
|
+
async readBody(res) {
|
|
738
|
+
const chunks = [];
|
|
739
|
+
for await (const chunk of res.body) {
|
|
740
|
+
chunks.push(chunk);
|
|
741
|
+
}
|
|
742
|
+
const data = Buffer.concat(chunks);
|
|
743
|
+
const contentType = String(res.headers["content-type"] ?? "");
|
|
744
|
+
if (contentType.includes("msgpack")) {
|
|
745
|
+
return msgpackDecode(data);
|
|
746
|
+
}
|
|
747
|
+
return JSON.parse(new TextDecoder().decode(data));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// --- API format translation ---
|
|
751
|
+
//
|
|
752
|
+
// The server uses snake_case keys. The client exposes camelCase. These
|
|
753
|
+
// helpers translate at the boundary. `undefined` values are stripped via
|
|
754
|
+
// delete so they don't appear as keys in the JSON body, while `null`
|
|
755
|
+
// values are preserved (needed for PATCH resets).
|
|
756
|
+
/** Normalize a scalar or array to a comma-separated string. */
|
|
757
|
+
function toCommaList(value) {
|
|
758
|
+
return Array.isArray(value) ? value.join(",") : value;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Throw if `obj` contains keys not in the allowed list.
|
|
762
|
+
*
|
|
763
|
+
* Catches typos and structural mistakes in options objects (e.g. passing
|
|
764
|
+
* `{ status: "ready" }` to `deleteAllJobs` instead of `{ where: { status: "ready" } }`)
|
|
765
|
+
* which would otherwise silently delete or update everything.
|
|
766
|
+
*/
|
|
767
|
+
function assertOnlyKeys(context, obj, allowed) {
|
|
768
|
+
for (const key of Object.keys(obj)) {
|
|
769
|
+
if (!allowed.includes(key)) {
|
|
770
|
+
throw new Error(`${context}: unknown option "${key}". Allowed: ${allowed.join(", ")}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/** Strip keys whose value is `undefined` from an object (in place). */
|
|
775
|
+
function stripUndefined(obj) {
|
|
776
|
+
for (const k in obj)
|
|
777
|
+
if (obj[k] === undefined)
|
|
778
|
+
delete obj[k];
|
|
779
|
+
return obj;
|
|
780
|
+
}
|
|
781
|
+
/** Convert an EnqueueOptions to the server's snake_case API format. */
|
|
782
|
+
function enqueueToApi(opts) {
|
|
783
|
+
return stripUndefined({
|
|
784
|
+
type: opts.type,
|
|
785
|
+
queue: opts.queue,
|
|
786
|
+
payload: opts.payload,
|
|
787
|
+
priority: opts.priority,
|
|
788
|
+
ready_at: opts.readyAt,
|
|
789
|
+
retry_limit: opts.retryLimit,
|
|
790
|
+
backoff: opts.backoff && backoffToApi(opts.backoff),
|
|
791
|
+
retention: opts.retention && retentionToApi(opts.retention),
|
|
792
|
+
unique_key: opts.uniqueKey,
|
|
793
|
+
unique_while: opts.uniqueWhile,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Convert an UpdateJobOptions to the server's snake_case patch format.
|
|
798
|
+
*
|
|
799
|
+
* `undefined` values are stripped (leave field unchanged), `null` values
|
|
800
|
+
* are preserved (clear field on the server). Nested backoff and retention
|
|
801
|
+
* objects are translated when present, passed through as `null` when null.
|
|
802
|
+
*/
|
|
803
|
+
function updateToApi(opts) {
|
|
804
|
+
return stripUndefined({
|
|
805
|
+
queue: opts.queue,
|
|
806
|
+
priority: opts.priority,
|
|
807
|
+
ready_at: opts.readyAt,
|
|
808
|
+
retry_limit: opts.retryLimit,
|
|
809
|
+
backoff: opts.backoff && backoffToApi(opts.backoff),
|
|
810
|
+
retention: opts.retention && retentionToApi(opts.retention),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
/** Convert a FailureOptions to the server's snake_case API format. */
|
|
814
|
+
function failureToApi(opts) {
|
|
815
|
+
return stripUndefined({
|
|
816
|
+
message: opts.message,
|
|
817
|
+
error_type: opts.errorType,
|
|
818
|
+
backtrace: opts.backtrace,
|
|
819
|
+
retry_at: opts.retryAt,
|
|
820
|
+
kill: opts.kill,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/** Convert a BackoffConfig to API format. */
|
|
824
|
+
function backoffToApi(b) {
|
|
825
|
+
return { base_ms: b.baseMs, exponent: b.exponent, jitter_ms: b.jitterMs };
|
|
826
|
+
}
|
|
827
|
+
/** Convert a RetentionConfig to API format. */
|
|
828
|
+
function retentionToApi(r) {
|
|
829
|
+
return stripUndefined({
|
|
830
|
+
completed_ms: r.completedMs,
|
|
831
|
+
dead_ms: r.deadMs,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
/** Convert an API-format job object to a camelCase JobData. */
|
|
835
|
+
function jobFromApi(raw) {
|
|
836
|
+
const r = raw;
|
|
837
|
+
return stripUndefined({
|
|
838
|
+
id: r.id,
|
|
839
|
+
type: r.type,
|
|
840
|
+
queue: r.queue,
|
|
841
|
+
priority: r.priority,
|
|
842
|
+
status: r.status,
|
|
843
|
+
payload: r.payload,
|
|
844
|
+
readyAt: r.ready_at,
|
|
845
|
+
attempts: r.attempts,
|
|
846
|
+
retryLimit: r.retry_limit,
|
|
847
|
+
backoff: r.backoff != null ? backoffFromApi(r.backoff) : undefined,
|
|
848
|
+
dequeuedAt: r.dequeued_at,
|
|
849
|
+
failedAt: r.failed_at,
|
|
850
|
+
completedAt: r.completed_at,
|
|
851
|
+
retention: r.retention != null ? retentionFromApi(r.retention) : undefined,
|
|
852
|
+
purgeAt: r.purge_at,
|
|
853
|
+
uniqueKey: r.unique_key,
|
|
854
|
+
uniqueWhile: r.unique_while,
|
|
855
|
+
duplicate: r.duplicate,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
/** Convert an API-format backoff to camelCase. */
|
|
859
|
+
function backoffFromApi(raw) {
|
|
860
|
+
const r = raw;
|
|
861
|
+
return {
|
|
862
|
+
baseMs: r.base_ms,
|
|
863
|
+
exponent: r.exponent,
|
|
864
|
+
jitterMs: r.jitter_ms,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
/** Convert an API-format retention to camelCase. */
|
|
868
|
+
function retentionFromApi(raw) {
|
|
869
|
+
const r = raw;
|
|
870
|
+
return stripUndefined({
|
|
871
|
+
completedMs: r.completed_ms,
|
|
872
|
+
deadMs: r.dead_ms,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
/** Build the appropriate ResponseError subclass for an HTTP status code. */
|
|
876
|
+
/** Convert an API-format error record to camelCase. */
|
|
877
|
+
function errorFromApi(raw) {
|
|
878
|
+
const r = raw;
|
|
879
|
+
return stripUndefined({
|
|
880
|
+
attempt: r.attempt,
|
|
881
|
+
message: r.message,
|
|
882
|
+
errorType: r.error_type,
|
|
883
|
+
backtrace: r.backtrace,
|
|
884
|
+
dequeuedAt: r.dequeued_at,
|
|
885
|
+
failedAt: r.failed_at,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
function buildResponseError(status, body) {
|
|
889
|
+
const message = body?.error ?? `HTTP ${status}`;
|
|
890
|
+
if (status === 404)
|
|
891
|
+
return new NotFoundError(message, body);
|
|
892
|
+
if (status >= 400 && status < 500)
|
|
893
|
+
return new ClientError(message, status, body);
|
|
894
|
+
if (status >= 500)
|
|
895
|
+
return new ServerError(message, status, body);
|
|
896
|
+
return new ResponseError(message, status, body);
|
|
897
|
+
}
|
|
898
|
+
/** Wrap a low-level error (from undici) as a ConnectionError. */
|
|
899
|
+
function toConnectionError(err) {
|
|
900
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
901
|
+
return new ConnectionError(message);
|
|
902
|
+
}
|