@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/dist/query.js ADDED
@@ -0,0 +1,653 @@
1
+ // Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+ /** Maximum page size the server will accept. */
4
+ const MAX_PAGE_SIZE = 2000;
5
+ // ---------------------------------------------------------------------------
6
+ // Lazy<T>
7
+ // ---------------------------------------------------------------------------
8
+ /**
9
+ * Base class for lazy, async-iterable collections.
10
+ *
11
+ * Subclasses only need to implement `[Symbol.asyncIterator]`. All of the
12
+ * terminal helpers (`toArray`, `count`, `first`, `isEmpty`, `forEach`) and
13
+ * the lazy transforms (`map`, `filter`) are provided and work against the
14
+ * iterator.
15
+ *
16
+ * `map` and `filter` return a fresh `Lazy<U>` wrapper so that callers can
17
+ * continue chaining helpers (e.g. `query.map(...).filter(...).toArray()`)
18
+ * without having to reach for `[Symbol.asyncIterator]()` or `Array.fromAsync`
19
+ * manually.
20
+ */
21
+ export class Lazy {
22
+ /**
23
+ * Collect all items into an array.
24
+ *
25
+ * Delegates to the built-in `Array.fromAsync()` helper.
26
+ */
27
+ async toArray() {
28
+ return Array.fromAsync(this);
29
+ }
30
+ /**
31
+ * Count all items.
32
+ *
33
+ * Iterates lazily, holding only one item at a time in memory. The
34
+ * underlying iterator is responsible for doing this efficiently — for
35
+ * paginated queries this means `ceil(N / pageSize)` HTTP requests.
36
+ */
37
+ async count() {
38
+ let n = 0;
39
+ for await (const _ of this)
40
+ n++;
41
+ return n;
42
+ }
43
+ /** Return the first item, or `undefined` if the collection is empty. */
44
+ async first() {
45
+ for await (const item of this)
46
+ return item;
47
+ return undefined;
48
+ }
49
+ /** Return `true` if there are no items. */
50
+ async isEmpty() {
51
+ return (await this.first()) === undefined;
52
+ }
53
+ /** Invoke `fn` for each item, awaiting async results sequentially. */
54
+ async forEach(fn) {
55
+ for await (const item of this) {
56
+ await fn(item);
57
+ }
58
+ }
59
+ /** Lazily transform each item. Returns a new `Lazy`. */
60
+ map(fn) {
61
+ return new MappedLazy(this, fn);
62
+ }
63
+ /** Lazily keep only items for which `fn` returns a truthy value. */
64
+ filter(fn) {
65
+ return new FilteredLazy(this, fn);
66
+ }
67
+ }
68
+ /** @internal */
69
+ class MappedLazy extends Lazy {
70
+ source;
71
+ fn;
72
+ constructor(source, fn) {
73
+ super();
74
+ this.source = source;
75
+ this.fn = fn;
76
+ }
77
+ async *[Symbol.asyncIterator]() {
78
+ for await (const item of this.source) {
79
+ yield await this.fn(item);
80
+ }
81
+ }
82
+ }
83
+ /** @internal */
84
+ class FilteredLazy extends Lazy {
85
+ source;
86
+ fn;
87
+ constructor(source, fn) {
88
+ super();
89
+ this.source = source;
90
+ this.fn = fn;
91
+ }
92
+ async *[Symbol.asyncIterator]() {
93
+ for await (const item of this.source) {
94
+ if (await this.fn(item))
95
+ yield item;
96
+ }
97
+ }
98
+ }
99
+ /**
100
+ * Lazy, paginating query builder for jobs.
101
+ *
102
+ * Created by `client.jobs()`. Each builder method returns a new instance
103
+ * (immutable). Iteration is lazy — no HTTP requests are made until the
104
+ * iterator is consumed or a terminal method is called.
105
+ *
106
+ * Implements `Symbol.asyncIterator`, so a `JobQuery` can be used directly
107
+ * in `for await...of` loops, or handed to `Array.fromAsync()` (with an
108
+ * optional mapper). To use the Node 22+ async iterator helpers
109
+ * (`.map()`, `.filter()`, etc.), obtain the iterator explicitly with
110
+ * `[Symbol.asyncIterator]()` first — those helpers live on
111
+ * `AsyncIterator.prototype`, not on async iterables.
112
+ *
113
+ * @example Iterating, mapping, and collecting
114
+ * ```ts
115
+ * // Plain loop
116
+ * for await (const job of client.jobs().byQueue("emails")) {
117
+ * console.log(job.id);
118
+ * }
119
+ *
120
+ * // Array.fromAsync with a mapper
121
+ * const ids = await Array.fromAsync(
122
+ * client.jobs().byQueue("emails"),
123
+ * (job) => job.id,
124
+ * );
125
+ *
126
+ * // Iterator helpers via [Symbol.asyncIterator]()
127
+ * const urgent = await client.jobs()
128
+ * .byQueue("emails")
129
+ * [Symbol.asyncIterator]()
130
+ * .filter((job) => (job.payload as any).urgent)
131
+ * .toArray();
132
+ * ```
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * // Count ready jobs on a queue (paginates server-side; O(1) memory)
137
+ * const n = await client.jobs()
138
+ * .byQueue("emails")
139
+ * .byStatus("ready")
140
+ * .count();
141
+ *
142
+ * // Move all jobs from one queue to another
143
+ * await client.jobs().byQueue("old").updateAll({ queue: "new" });
144
+ *
145
+ * // Delete dead jobs matching a payload subset
146
+ * await client.jobs()
147
+ * .byStatus("dead")
148
+ * .withPayloadSubset({ userId: 42 })
149
+ * .deleteAll();
150
+ *
151
+ * // Iterate in batches
152
+ * for await (const page of client.jobs().inPagesOf(100).pages()) {
153
+ * for (const job of page.jobs) console.log(job.id);
154
+ * }
155
+ * ```
156
+ */
157
+ export class JobQuery extends Lazy {
158
+ client;
159
+ _id;
160
+ _queue;
161
+ _type;
162
+ _status;
163
+ _jqFilter;
164
+ _order;
165
+ _limit;
166
+ _pageSize;
167
+ /** @internal */
168
+ constructor(client, options = {}) {
169
+ super();
170
+ this.client = client;
171
+ this._id = options.id;
172
+ this._queue = options.queue;
173
+ this._type = options.type;
174
+ this._status = options.status;
175
+ this._jqFilter = options.jqFilter;
176
+ this._order = options.order;
177
+ this._limit = options.limit;
178
+ this._pageSize = options.pageSize;
179
+ }
180
+ // --- Filter builders ---
181
+ /** Filter by job ID. Replaces any existing ID filter. */
182
+ byId(id) {
183
+ return this.rebuild({ id });
184
+ }
185
+ /** Add an ID to the existing ID filter (unions with existing). */
186
+ addId(id) {
187
+ return this.rebuild({ id: concat(this._id, id) });
188
+ }
189
+ /** Filter by queue name. Replaces any existing queue filter. */
190
+ byQueue(queue) {
191
+ return this.rebuild({ queue });
192
+ }
193
+ /** Add a queue to the existing queue filter. */
194
+ addQueue(queue) {
195
+ return this.rebuild({ queue: concat(this._queue, queue) });
196
+ }
197
+ /** Filter by job type. Replaces any existing type filter. */
198
+ byType(type) {
199
+ return this.rebuild({ type });
200
+ }
201
+ /** Add a type to the existing type filter. */
202
+ addType(type) {
203
+ return this.rebuild({ type: concat(this._type, type) });
204
+ }
205
+ /** Filter by status. Replaces any existing status filter. */
206
+ byStatus(status) {
207
+ return this.rebuild({ status });
208
+ }
209
+ /** Add a status to the existing status filter. */
210
+ addStatus(status) {
211
+ return this.rebuild({ status: concat(this._status, status) });
212
+ }
213
+ /** Replace the jq payload filter expression. */
214
+ byJqFilter(jqFilter) {
215
+ return this.rebuild({ jqFilter });
216
+ }
217
+ /**
218
+ * Add a jq payload filter. Combines with any existing filter via `and`.
219
+ */
220
+ addJqFilter(jqFilter) {
221
+ return this.rebuild({
222
+ jqFilter: concat(this._jqFilter, `(${jqFilter})`).join(" and "),
223
+ });
224
+ }
225
+ /**
226
+ * Constrain results by an exact payload match.
227
+ *
228
+ * Layers onto any existing jq filter via `and`. The emitted jq
229
+ * expression is `. == <JSON>`, so matching is structural and
230
+ * key-order independent (jq's object equality sorts keys).
231
+ *
232
+ * This is syntactic sugar around `addJqFilter()` so it can be combined with
233
+ * other jq filter methods.
234
+ */
235
+ withPayload(payload) {
236
+ return this.addJqFilter(`. == ${JSON.stringify(payload)}`);
237
+ }
238
+ /**
239
+ * Constrain results by a payload subset.
240
+ *
241
+ * Layers onto any existing jq filter via `and`. The emitted jq
242
+ * expression depends on the shape of `payload`:
243
+ *
244
+ * - **Objects**: `. | contains(<JSON>)` — deep subset check; every key in
245
+ * `payload` must exist in the job payload with a contained value.
246
+ * - **Arrays**: `.[0:N] == <JSON>` — prefix match against the first `N`
247
+ * elements of the job payload.
248
+ * - **Scalars** (number, string, boolean, null): `. == <JSON>` — equality.
249
+ *
250
+ * This is syntactic sugar around `addJqFilter()` so it can be combined with
251
+ * other jq filter methods.
252
+ */
253
+ withPayloadSubset(payload) {
254
+ return this.addJqFilter(payloadSubsetFilter(payload));
255
+ }
256
+ // --- Ordering, limiting, paging ---
257
+ /** Set the sort order. */
258
+ order(direction) {
259
+ return this.rebuild({ order: direction });
260
+ }
261
+ /** Reverse the sort order. Defaults to "desc" if no order was set. */
262
+ reverseOrder() {
263
+ return this.rebuild({ order: this._order === "desc" ? "asc" : "desc" });
264
+ }
265
+ /**
266
+ * Set the maximum total number of jobs to return across all pages.
267
+ *
268
+ * Also caps page fetches and limits the number of jobs touched by
269
+ * `updateAll` / `deleteAll`.
270
+ */
271
+ limit(n) {
272
+ return this.rebuild({ limit: n });
273
+ }
274
+ /**
275
+ * Set the page size for pagination.
276
+ *
277
+ * Affects how many jobs are fetched per HTTP request during iteration,
278
+ * and also causes `updateAll` / `deleteAll` to batch per page using
279
+ * ID-scoped bulk operations.
280
+ */
281
+ inPagesOf(n) {
282
+ return this.rebuild({ pageSize: n });
283
+ }
284
+ // --- Terminal methods ---
285
+ /**
286
+ * Return the first matching job, or `undefined` if none.
287
+ *
288
+ * Optimised override: pushes `limit=1` to the server instead of
289
+ * iterating a full default page.
290
+ */
291
+ async first() {
292
+ for await (const job of this.limit(1)) {
293
+ return job;
294
+ }
295
+ return undefined;
296
+ }
297
+ /**
298
+ * Return the last matching job, or `undefined` if none.
299
+ *
300
+ * Optimised: reverses the order and fetches a single job.
301
+ */
302
+ async last() {
303
+ return this.reverseOrder().first();
304
+ }
305
+ /**
306
+ * Update all matching jobs.
307
+ *
308
+ * Behaviour depends on whether `limit` or `pageSize` is set:
309
+ *
310
+ * - **Unbounded**: a single bulk update request is sent with the query
311
+ * filters as the scope.
312
+ * - **Bounded**: pages are iterated and a separate bulk update is issued
313
+ * per page, scoped to both the query filters and the IDs on that page.
314
+ * This is safe against races with newly enqueued jobs and lets callers
315
+ * throttle large update operations via `inPagesOf`.
316
+ *
317
+ * Returns the total number of updated jobs.
318
+ */
319
+ async updateAll(apply) {
320
+ const where = this.toWhere();
321
+ if (this._limit == null && this._pageSize == null) {
322
+ return this.client.updateAllJobs({ where, apply });
323
+ }
324
+ let remaining = this._limit;
325
+ let updated = 0;
326
+ for await (const page of this.pages()) {
327
+ let ids = page.jobs.map((j) => j.id);
328
+ if (remaining != null) {
329
+ if (remaining <= 0)
330
+ break;
331
+ if (ids.length > remaining)
332
+ ids = ids.slice(0, remaining);
333
+ }
334
+ if (ids.length === 0)
335
+ continue;
336
+ updated += await this.client.updateAllJobs({
337
+ where: { ...where, id: ids },
338
+ apply,
339
+ });
340
+ if (remaining != null) {
341
+ remaining -= ids.length;
342
+ if (remaining <= 0)
343
+ break;
344
+ }
345
+ }
346
+ return updated;
347
+ }
348
+ /**
349
+ * Update the first matching job.
350
+ *
351
+ * Returns 1 if a job was updated, 0 if no jobs matched.
352
+ */
353
+ async updateOne(apply) {
354
+ return this.limit(1).updateAll(apply);
355
+ }
356
+ /**
357
+ * Delete all matching jobs.
358
+ *
359
+ * Behaviour depends on whether `limit` or `pageSize` is set:
360
+ *
361
+ * - **Unbounded**: a single bulk delete request is sent with the query
362
+ * filters as the scope. A query with no filters will delete *all* jobs
363
+ * on the server — useful in tests, dangerous in production.
364
+ * - **Bounded**: pages are iterated and a separate bulk delete is issued
365
+ * per page, scoped to both the query filters and the IDs on that page.
366
+ *
367
+ * Returns the total number of deleted jobs.
368
+ */
369
+ async deleteAll() {
370
+ const where = this.toWhere();
371
+ if (this._limit == null && this._pageSize == null) {
372
+ return this.client.deleteAllJobs({ where });
373
+ }
374
+ let remaining = this._limit;
375
+ let deleted = 0;
376
+ for await (const page of this.pages()) {
377
+ let ids = page.jobs.map((j) => j.id);
378
+ if (remaining != null) {
379
+ if (remaining <= 0)
380
+ break;
381
+ if (ids.length > remaining)
382
+ ids = ids.slice(0, remaining);
383
+ }
384
+ if (ids.length === 0)
385
+ continue;
386
+ deleted += await this.client.deleteAllJobs({
387
+ where: { ...where, id: ids },
388
+ });
389
+ if (remaining != null) {
390
+ remaining -= ids.length;
391
+ if (remaining <= 0)
392
+ break;
393
+ }
394
+ }
395
+ return deleted;
396
+ }
397
+ /**
398
+ * Delete the first matching job.
399
+ *
400
+ * Returns 1 if a job was deleted, 0 if no jobs matched.
401
+ */
402
+ async deleteOne() {
403
+ return this.limit(1).deleteAll();
404
+ }
405
+ // --- Iteration ---
406
+ /**
407
+ * Async iterator over individual jobs.
408
+ *
409
+ * Lazily paginates through results, respecting `limit` if set. Enables
410
+ * `for await...of` loops and is also consumable by `Array.fromAsync()`.
411
+ */
412
+ async *[Symbol.asyncIterator]() {
413
+ let remaining = this._limit;
414
+ for await (const page of this.pages()) {
415
+ for (const job of page.jobs) {
416
+ if (remaining != null) {
417
+ if (remaining <= 0)
418
+ return;
419
+ remaining--;
420
+ }
421
+ yield job;
422
+ }
423
+ if (remaining != null && remaining <= 0)
424
+ return;
425
+ }
426
+ }
427
+ /**
428
+ * Async iterator over pages of jobs.
429
+ *
430
+ * Each yielded value is a `JobPage`. Useful when you want to process jobs
431
+ * in batches. Terminates once the query's `limit` has been reached (does
432
+ * not truncate the final page).
433
+ */
434
+ async *pages() {
435
+ const effectivePageSize = clampPageSize(this._pageSize, this._limit);
436
+ let remaining = this._limit;
437
+ let page = await this.client.listJobs({
438
+ id: this._id,
439
+ queue: this._queue,
440
+ type: this._type,
441
+ status: this._status,
442
+ filter: this._jqFilter,
443
+ order: this._order,
444
+ limit: effectivePageSize,
445
+ });
446
+ while (page) {
447
+ yield page;
448
+ if (remaining != null) {
449
+ remaining -= page.jobs.length;
450
+ if (remaining <= 0)
451
+ return;
452
+ }
453
+ page = await page.nextPage();
454
+ }
455
+ }
456
+ // --- Internal helpers ---
457
+ toWhere() {
458
+ return {
459
+ id: this._id,
460
+ queue: this._queue,
461
+ type: this._type,
462
+ status: this._status,
463
+ filter: this._jqFilter,
464
+ };
465
+ }
466
+ rebuild(overrides) {
467
+ return new JobQuery(this.client, {
468
+ id: this._id,
469
+ queue: this._queue,
470
+ type: this._type,
471
+ status: this._status,
472
+ jqFilter: this._jqFilter,
473
+ order: this._order,
474
+ limit: this._limit,
475
+ pageSize: this._pageSize,
476
+ ...overrides,
477
+ });
478
+ }
479
+ }
480
+ /**
481
+ * Lazy async iterator over error records with a chainable builder API.
482
+ *
483
+ * Created by `Job.errors()`. Each builder method returns a new instance
484
+ * (immutable). Iteration is lazy — no HTTP requests are made until the
485
+ * iterator is consumed.
486
+ *
487
+ * @example
488
+ * ```ts
489
+ * // Iterate all errors
490
+ * for await (const error of job.errors()) {
491
+ * console.log(`Attempt ${error.attempt}: ${error.message}`);
492
+ * }
493
+ *
494
+ * // With builder options
495
+ * const recent = await job.errors()
496
+ * .order("desc")
497
+ * .limit(5)
498
+ * .toArray();
499
+ * ```
500
+ */
501
+ export class ErrorQuery extends Lazy {
502
+ client;
503
+ jobId;
504
+ _order;
505
+ _limit;
506
+ _pageSize;
507
+ /** @internal */
508
+ constructor(client, jobId, options) {
509
+ super();
510
+ this.client = client;
511
+ this.jobId = jobId;
512
+ this._order = options?.order;
513
+ this._limit = options?.limit;
514
+ this._pageSize = options?.pageSize;
515
+ }
516
+ /** Set the sort order. Returns a new query. */
517
+ order(direction) {
518
+ return this.rebuild({ order: direction });
519
+ }
520
+ /**
521
+ * Set the maximum total number of errors to return across all pages.
522
+ *
523
+ * Also used to optimise the page size — if the limit is smaller than
524
+ * the page size, only the needed amount is fetched.
525
+ */
526
+ limit(n) {
527
+ return this.rebuild({ limit: n });
528
+ }
529
+ /** Set the page size for pagination. Returns a new query. */
530
+ inPagesOf(n) {
531
+ return this.rebuild({ pageSize: n });
532
+ }
533
+ /** Reverse the sort order. Returns a new query. */
534
+ reverseOrder() {
535
+ return this.rebuild({ order: this._order === "desc" ? "asc" : "desc" });
536
+ }
537
+ /**
538
+ * Return the first error, or `undefined` if none.
539
+ *
540
+ * Optimised override: pushes `limit=1` to the server instead of
541
+ * iterating a full default page.
542
+ */
543
+ async first() {
544
+ for await (const error of this.limit(1)) {
545
+ return error;
546
+ }
547
+ return undefined;
548
+ }
549
+ /**
550
+ * Return the last error, or `undefined` if none.
551
+ *
552
+ * Optimised: reverses the order and fetches a single error.
553
+ */
554
+ async last() {
555
+ return this.reverseOrder().first();
556
+ }
557
+ /**
558
+ * Async iterator over individual error records.
559
+ *
560
+ * Lazily paginates through results, respecting `limit` if set.
561
+ * Enables `for await...of` loops and is consumable by `Array.fromAsync()`.
562
+ */
563
+ async *[Symbol.asyncIterator]() {
564
+ let remaining = this._limit;
565
+ const effectivePageSize = clampPageSize(this._pageSize, this._limit);
566
+ let page = await this.client.listErrors(this.jobId, {
567
+ order: this._order,
568
+ limit: effectivePageSize,
569
+ });
570
+ while (true) {
571
+ for (const error of page) {
572
+ if (remaining != null) {
573
+ if (remaining <= 0)
574
+ return;
575
+ remaining--;
576
+ }
577
+ yield error;
578
+ }
579
+ if (!page.hasNext)
580
+ return;
581
+ if (remaining != null && remaining <= 0)
582
+ return;
583
+ page = (await page.nextPage());
584
+ }
585
+ }
586
+ /**
587
+ * Async iterator over pages of error records.
588
+ *
589
+ * Each yielded value is an `ErrorPage`. Useful when you want to
590
+ * process errors in batches.
591
+ */
592
+ async *pages() {
593
+ let remaining = this._limit;
594
+ const effectivePageSize = clampPageSize(this._pageSize, this._limit);
595
+ let page = await this.client.listErrors(this.jobId, {
596
+ order: this._order,
597
+ limit: effectivePageSize,
598
+ });
599
+ while (page) {
600
+ yield page;
601
+ if (remaining != null) {
602
+ remaining -= page.errors.length;
603
+ if (remaining <= 0)
604
+ return;
605
+ }
606
+ page = await page.nextPage();
607
+ }
608
+ }
609
+ rebuild(overrides) {
610
+ return new ErrorQuery(this.client, this.jobId, {
611
+ order: this._order,
612
+ limit: this._limit,
613
+ pageSize: this._pageSize,
614
+ ...overrides,
615
+ });
616
+ }
617
+ }
618
+ // ---------------------------------------------------------------------------
619
+ // Module-local helpers
620
+ // ---------------------------------------------------------------------------
621
+ /**
622
+ * Compute the effective per-request page size.
623
+ *
624
+ * Takes the smallest of the caller-provided `pageSize`, the overall
625
+ * `limit`, and `MAX_PAGE_SIZE`. Returns `undefined` when none are set,
626
+ * which lets the server apply its own default.
627
+ */
628
+ function clampPageSize(pageSize, limit) {
629
+ const candidates = [pageSize, limit, MAX_PAGE_SIZE].filter((v) => v != null);
630
+ if (candidates.length === 0)
631
+ return undefined;
632
+ return Math.min(...candidates);
633
+ }
634
+ /** Combine two scalar-or-array values into a single flattened array. */
635
+ function concat(existing, next) {
636
+ return [].concat(existing ?? []).concat(next ?? []);
637
+ }
638
+ /**
639
+ * Build a jq subset filter for a payload value.
640
+ *
641
+ * - Object → `. | contains(<JSON>)`
642
+ * - Array → `.[0:N] == <JSON>` (prefix match)
643
+ * - Scalar → `. == <JSON>`
644
+ */
645
+ function payloadSubsetFilter(payload) {
646
+ if (payload === null || typeof payload !== "object") {
647
+ return `. == ${JSON.stringify(payload)}`;
648
+ }
649
+ if (Array.isArray(payload)) {
650
+ return `.[0:${payload.length}] == ${JSON.stringify(payload)}`;
651
+ }
652
+ return `. | contains(${JSON.stringify(payload)})`;
653
+ }