@zizq-labs/zizq 0.4.2 → 0.6.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
@@ -12,7 +12,7 @@ This is the official Zizq client library for Node.js, written in TypeScript.
12
12
  ## Features
13
13
 
14
14
  * Concurrent async-based worker
15
- * Plain handler functions, or composable Job Functions with attached defaults
15
+ * Simple handler functions, or composable `Router`
16
16
  * Enqueue and process jobs from one language to another
17
17
  * Arbitrary named queues
18
18
  * Granular job priorities
@@ -21,7 +21,8 @@ This is the official Zizq client library for Node.js, written in TypeScript.
21
21
  * Configurable job retention policies
22
22
  * Recurring jobs (cron)
23
23
  * Job introspection and management APIs, with support for `jq` query filters
24
- * Unique jobs
24
+ * Unique jobs (de-duplicated)
25
+ * Batched jobs (folded/merged)
25
26
 
26
27
  ## Installation
27
28
 
@@ -106,7 +107,7 @@ await client.enqueueBulk([
106
107
 
107
108
  ### Defining handlers
108
109
 
109
- A handler is an `async` function that accepts a `job` and either resolves
110
+ A handler is any `async` function that accepts a `job` and either resolves
110
111
  (the worker acks it as successful) or throws (the worker reports a failure
111
112
  and the server retries per the backoff policy). The simplest version is a
112
113
  `switch` on `job.type`:
@@ -124,38 +125,9 @@ async function handler(job) {
124
125
  }
125
126
  ```
126
127
 
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" },
153
- });
154
- ```
155
-
156
- For cross-language workflows or when you want explicit `type -> handler`
157
- registration with optional fallback, use `Router`. Routes match by job
158
- type (a string the producer agrees on with the consumer), and an
128
+ For a more composable style, the client ships a `Router` that implements
129
+ handler registration for named job types with optional fallback. Routes match
130
+ by job type (a string the producer agrees on with the consumer), and an
159
131
  optional `fallback` catches anything unmatched:
160
132
 
161
133
  ```ts
@@ -220,8 +192,6 @@ replacing, and removing entries as the definition changes. Cron requires
220
192
  a Pro license on the server.
221
193
 
222
194
  ```ts
223
- import { sendDailyDigest } from "./handlers";
224
-
225
195
  await client.cron("maintenance").register({
226
196
  timezone: "Europe/London",
227
197
  entries: [
@@ -235,7 +205,7 @@ await client.cron("maintenance").register({
235
205
  {
236
206
  name: "daily_digest",
237
207
  expression: "0 9 * * *",
238
- type: sendDailyDigest, // Job Functions are accepted directly
208
+ type: "send_daily_digest",
239
209
  payload: {},
240
210
  },
241
211
  ],
@@ -0,0 +1,64 @@
1
+ import type { EnqueueInput } from "./types.ts";
2
+ /** Options for the third argument of {@link batchConfig}. */
3
+ export interface BatchConfigOptions {
4
+ /**
5
+ * Append `| unique` to the fold expression to deduplicate entries
6
+ * within the batch. `unique` in jq also sorts, so it subsumes
7
+ * `sorted:` when both are set.
8
+ */
9
+ dedup?: boolean;
10
+ /**
11
+ * Append `| sort` to the fold expression to sort entries within
12
+ * the batch. Ignored when `dedup:` is also set.
13
+ */
14
+ sorted?: boolean;
15
+ }
16
+ /**
17
+ * Build a batched-job configuration for a specific target path.
18
+ *
19
+ * Returns the `{key, when, fold}` shape expected by
20
+ * `client.enqueue({batch: ...})`, with `key` as a function that
21
+ * derives a stable batch key from the enqueue input at call time
22
+ * (hashing everything *except* the batch target path).
23
+ *
24
+ * @param limit Maximum combined length of the batched value at
25
+ * `path` before the current batch is sealed and a new one starts.
26
+ * @param path jq path to the batch target within the payload.
27
+ * Defaults to `.` (the whole payload is the batch target, assumed
28
+ * to be an array).
29
+ * @param options `dedup: true` deduplicates via `| unique`;
30
+ * `sorted: true` sorts via `| sort`. `dedup:` subsumes `sorted:`.
31
+ *
32
+ * @example Whole-payload batch (payload is an array)
33
+ * ```ts
34
+ * await client.enqueue({
35
+ * type: "audit.events",
36
+ * queue: "audit",
37
+ * payload: [{ actor: "u1", action: "login" }],
38
+ * batch: batchConfig(1000),
39
+ * });
40
+ * ```
41
+ *
42
+ * @example Batch a specific field
43
+ * ```ts
44
+ * await client.enqueue({
45
+ * type: "push",
46
+ * queue: "push",
47
+ * payload: { deviceIds: [id], platform: "apple" },
48
+ * batch: batchConfig(100, ".deviceIds"),
49
+ * });
50
+ * ```
51
+ *
52
+ * @example Custom key (spread + override)
53
+ * ```ts
54
+ * batch: {
55
+ * ...batchConfig(100, ".deviceIds"),
56
+ * key: (input) => `push:tenant-${input.payload.tenantId}`,
57
+ * }
58
+ * ```
59
+ */
60
+ export declare function batchConfig(limit: number, path?: string, options?: BatchConfigOptions): {
61
+ key: (input: EnqueueInput) => string;
62
+ when: string;
63
+ fold: string;
64
+ };
@@ -0,0 +1,76 @@
1
+ // Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+ /**
4
+ * Helper for building batched-job configuration objects. See
5
+ * {@link batchConfig}.
6
+ *
7
+ * @module
8
+ */
9
+ import { payloadHasher } from "./payload-hasher.js";
10
+ /**
11
+ * Build a batched-job configuration for a specific target path.
12
+ *
13
+ * Returns the `{key, when, fold}` shape expected by
14
+ * `client.enqueue({batch: ...})`, with `key` as a function that
15
+ * derives a stable batch key from the enqueue input at call time
16
+ * (hashing everything *except* the batch target path).
17
+ *
18
+ * @param limit Maximum combined length of the batched value at
19
+ * `path` before the current batch is sealed and a new one starts.
20
+ * @param path jq path to the batch target within the payload.
21
+ * Defaults to `.` (the whole payload is the batch target, assumed
22
+ * to be an array).
23
+ * @param options `dedup: true` deduplicates via `| unique`;
24
+ * `sorted: true` sorts via `| sort`. `dedup:` subsumes `sorted:`.
25
+ *
26
+ * @example Whole-payload batch (payload is an array)
27
+ * ```ts
28
+ * await client.enqueue({
29
+ * type: "audit.events",
30
+ * queue: "audit",
31
+ * payload: [{ actor: "u1", action: "login" }],
32
+ * batch: batchConfig(1000),
33
+ * });
34
+ * ```
35
+ *
36
+ * @example Batch a specific field
37
+ * ```ts
38
+ * await client.enqueue({
39
+ * type: "push",
40
+ * queue: "push",
41
+ * payload: { deviceIds: [id], platform: "apple" },
42
+ * batch: batchConfig(100, ".deviceIds"),
43
+ * });
44
+ * ```
45
+ *
46
+ * @example Custom key (spread + override)
47
+ * ```ts
48
+ * batch: {
49
+ * ...batchConfig(100, ".deviceIds"),
50
+ * key: (input) => `push:tenant-${input.payload.tenantId}`,
51
+ * }
52
+ * ```
53
+ */
54
+ export function batchConfig(limit, path = ".", options = {}) {
55
+ // `payloadHasher` validates `path` via its jq-path parser and
56
+ // throws on invalid syntax at construction time — surfacing typos
57
+ // before any enqueue happens.
58
+ const key = payloadHasher({ except: [path] });
59
+ // Pipe-form access (`$var | <path>`) is the uniform shape that
60
+ // works for every path we accept, including the root `.` case
61
+ // where `$existing<path>` would otherwise be a syntax error.
62
+ const when = `(($existing | ${path}) + ($new | ${path})) | length <= ${limit}`;
63
+ let fold;
64
+ if (options.dedup) {
65
+ fold =
66
+ `$existing | ${path} = ((${path}) + ($new | ${path}) | unique)`;
67
+ }
68
+ else if (options.sorted) {
69
+ fold =
70
+ `$existing | ${path} = ((${path}) + ($new | ${path}) | sort)`;
71
+ }
72
+ else {
73
+ fold = `$existing | ${path} += ($new | ${path})`;
74
+ }
75
+ return { key, when, fold };
76
+ }
package/dist/client.d.ts CHANGED
@@ -34,7 +34,7 @@ import { type Dispatcher } from "undici";
34
34
  import { Job, JobPage, ErrorRecord, ErrorPage, CronGroup, CronEntry } from "./resources.ts";
35
35
  import { JobQuery, type JobQueryOptions } from "./query.ts";
36
36
  import { CronHandle } from "./cron.ts";
37
- export type { JobStatus, SortDirection, UniqueScope, BackoffConfig, RetentionConfig, EnqueueOptions, EnqueueInput, FailureOptions, UpdateJobOptions, Format, TakeOptions, ListJobsOptions, ListErrorsOptions, UpdateAllJobsOptions, JobFilter, DeleteAllJobsOptions, CronEntryInput, ReplaceCronGroupOptions, } from "./types.ts";
37
+ export type { JobStatus, SortDirection, UniqueScope, BackoffConfig, RetentionConfig, EnqueueOptions, EnqueueInput, FailureOptions, UpdateJobOptions, Format, TakeOptions, ListJobsOptions, ListErrorsOptions, UpdateAllJobsOptions, JobFilter, DeleteAllJobsOptions, CronEntryInput, RangeBounds, RangeFilter, ReplaceCronGroupOptions, } from "./types.ts";
38
38
  import type { EnqueueOptions, EnqueueInput, FailureOptions, UpdateJobOptions, Format, TakeOptions, ListJobsOptions, ListErrorsOptions, UpdateAllJobsOptions, JobFilter, DeleteAllJobsOptions, CronEntryInput, ReplaceCronGroupOptions } from "./types.ts";
39
39
  export { Job, JobPage, ErrorRecord, ErrorPage, CronGroup, CronEntry, type JobData, type ErrorRecordData, type CronGroupData, type CronEntryData, } from "./resources.ts";
40
40
  /** Response shape for `GET /health`. */
package/dist/client.js CHANGED
@@ -367,7 +367,7 @@ export class Client {
367
367
  async deleteAllJobs(options = {}) {
368
368
  assertOnlyKeys("deleteAllJobs", options, ["where"]);
369
369
  const where = options.where ?? {};
370
- assertOnlyKeys("deleteAllJobs.where", where, ["id", "status", "queue", "type", "filter"]);
370
+ assertOnlyKeys("deleteAllJobs.where", where, ["id", "status", "queue", "type", "filter", "priority", "readyAt", "attempts"]);
371
371
  // Build the filter params, then short-circuit if any array filter resolved
372
372
  // to empty — an empty filter matches nothing, and we don't want to
373
373
  // accidentally delete everything.
@@ -417,7 +417,7 @@ export class Client {
417
417
  * ```
418
418
  */
419
419
  async countJobs(where = {}) {
420
- assertOnlyKeys("countJobs", where, ["id", "status", "queue", "type", "filter"]);
420
+ assertOnlyKeys("countJobs", where, ["id", "status", "queue", "type", "filter", "priority", "readyAt", "attempts"]);
421
421
  const params = buildJobFilter(where);
422
422
  if (isEmptyFilter(params))
423
423
  return 0;
@@ -468,7 +468,7 @@ export class Client {
468
468
  async updateAllJobs(options) {
469
469
  assertOnlyKeys("updateAllJobs", options, ["where", "apply"]);
470
470
  const where = options.where ?? {};
471
- assertOnlyKeys("updateAllJobs.where", where, ["id", "status", "queue", "type", "filter"]);
471
+ assertOnlyKeys("updateAllJobs.where", where, ["id", "status", "queue", "type", "filter", "priority", "readyAt", "attempts"]);
472
472
  // Same empty-filter short-circuit as deleteAllJobs.
473
473
  const params = buildJobFilter(where);
474
474
  if (isEmptyFilter(params))
@@ -1059,6 +1059,42 @@ function isEmptyFilter(params) {
1059
1059
  params.get("queue") === "" ||
1060
1060
  params.get("type") === "");
1061
1061
  }
1062
+ /**
1063
+ * Encode a {@link RangeFilter} value into the server's query syntax.
1064
+ *
1065
+ * The four accepted shapes:
1066
+ *
1067
+ * - `number` (e.g. `50`) -> `"50"`
1068
+ * - `{ min, max }` -> `"min..max"`
1069
+ * - `{ min }` -> `"min.."`
1070
+ * - `{ max }` -> `"..max"`
1071
+ *
1072
+ * Returns `undefined` for `null`/`undefined` so the caller can skip
1073
+ * setting the param. Throws `TypeError` for any other shape (e.g. arrays,
1074
+ * strings, objects with unknown keys).
1075
+ */
1076
+ function encodeRange(value) {
1077
+ if (value == null)
1078
+ return undefined;
1079
+ if (typeof value === "number") {
1080
+ if (!Number.isFinite(value)) {
1081
+ throw new TypeError(`range filter value must be a finite number, got ${value}`);
1082
+ }
1083
+ return String(value);
1084
+ }
1085
+ if (typeof value !== "object" || Array.isArray(value)) {
1086
+ throw new TypeError(`range filter must be a number or { min?, max? } object, got ${typeof value}`);
1087
+ }
1088
+ const { min, max } = value;
1089
+ for (const [key, bound] of [["min", min], ["max", max]]) {
1090
+ if (bound != null && (typeof bound !== "number" || !Number.isFinite(bound))) {
1091
+ throw new TypeError(`range filter "${key}" must be a finite number, got ${typeof bound}`);
1092
+ }
1093
+ }
1094
+ const lo = min != null ? String(min) : "";
1095
+ const hi = max != null ? String(max) : "";
1096
+ return `${lo}..${hi}`;
1097
+ }
1062
1098
  /**
1063
1099
  * Populate a {@link URLSearchParams} with the standard job filter fields.
1064
1100
  */
@@ -1073,6 +1109,15 @@ function buildJobFilter(where, params = new URLSearchParams()) {
1073
1109
  params.set("type", toCommaList(where.type));
1074
1110
  if (where.filter != null)
1075
1111
  params.set("filter", where.filter);
1112
+ const priority = encodeRange(where.priority);
1113
+ if (priority != null)
1114
+ params.set("priority", priority);
1115
+ const readyAt = encodeRange(where.readyAt);
1116
+ if (readyAt != null)
1117
+ params.set("ready_at", readyAt);
1118
+ const attempts = encodeRange(where.attempts);
1119
+ if (attempts != null)
1120
+ params.set("attempts", attempts);
1076
1121
  return params;
1077
1122
  }
1078
1123
  /**
@@ -1109,6 +1154,7 @@ function enqueueToApi(opts) {
1109
1154
  retention: opts.retention && retentionToApi(opts.retention),
1110
1155
  unique_key: opts.uniqueKey,
1111
1156
  unique_while: opts.uniqueWhile,
1157
+ batch: opts.batch,
1112
1158
  });
1113
1159
  }
1114
1160
  /**
@@ -1171,6 +1217,8 @@ function jobFromApi(raw) {
1171
1217
  uniqueKey: r.unique_key,
1172
1218
  uniqueWhile: r.unique_while,
1173
1219
  duplicate: r.duplicate,
1220
+ folded: r.folded,
1221
+ batch: r.batch,
1174
1222
  });
1175
1223
  }
1176
1224
  /** Convert an API-format backoff to camelCase. */
@@ -1263,6 +1311,7 @@ function cronJobToApi(job) {
1263
1311
  retention: job.retention && retentionToApi(job.retention),
1264
1312
  unique_key: job.uniqueKey,
1265
1313
  unique_while: job.uniqueWhile,
1314
+ batch: job.batch,
1266
1315
  });
1267
1316
  }
1268
1317
  /** Convert a CronEntryInput to the server's API format. */
package/dist/enqueue.js CHANGED
@@ -37,6 +37,16 @@ function computeUniqueKey(jobFn, payload) {
37
37
  return uniqueKey(jobFn, payload);
38
38
  return uniqueKey;
39
39
  }
40
+ // Resolve a value that may be either a literal or a function derived
41
+ // from the enqueue input. Used for both `uniqueKey` and `batch.key`
42
+ // on `EnqueueInput` so `payloadHasher(...)` and other userland
43
+ // factories can produce a function that receives the input at call
44
+ // time.
45
+ function resolveKey(value, input) {
46
+ if (value === undefined)
47
+ return undefined;
48
+ return typeof value === "function" ? value(input) : value;
49
+ }
40
50
  /**
41
51
  * Resolve an EnqueueInput into a low-level EnqueueOptions.
42
52
  *
@@ -54,6 +64,7 @@ export function resolveInput(input) {
54
64
  if (!jobType) {
55
65
  throw new Error("Job function must have a name or zizqOptions.type");
56
66
  }
67
+ warnJobFunctionDeprecated(input.type, jobType);
57
68
  }
58
69
  else {
59
70
  jobType = input.type;
@@ -62,11 +73,18 @@ export function resolveInput(input) {
62
73
  if (!queue) {
63
74
  throw new Error(`No queue specified for job type "${jobType}"`);
64
75
  }
65
- const uniqueKey = input.uniqueKey ??
76
+ const uniqueKey = resolveKey(input.uniqueKey, input) ??
66
77
  (typeof input.type === "function"
67
78
  ? computeUniqueKey(input.type, input.payload)
68
79
  : undefined);
69
80
  const uniqueWhile = input.uniqueWhile ?? defaults?.uniqueWhile;
81
+ const batch = input.batch
82
+ ? {
83
+ key: resolveKey(input.batch.key, input),
84
+ when: input.batch.when,
85
+ fold: input.batch.fold,
86
+ }
87
+ : undefined;
70
88
  const opts = {
71
89
  type: jobType,
72
90
  queue,
@@ -78,6 +96,7 @@ export function resolveInput(input) {
78
96
  retention: input.retention,
79
97
  uniqueKey,
80
98
  uniqueWhile: uniqueKey ? uniqueWhile : undefined,
99
+ batch,
81
100
  };
82
101
  // Apply the transform hook (if any). Transforms can change the options based
83
102
  // on the payload or other factors, and can mutate `opts` in place, or return
@@ -88,3 +107,18 @@ export function resolveInput(input) {
88
107
  }
89
108
  return opts;
90
109
  }
110
+ // --- Deprecation warnings ---
111
+ // One warning per unique job function, ever. WeakSet so job functions
112
+ // can still be GC'd even after we've warned about them.
113
+ const warnedFunctions = new WeakSet();
114
+ function warnJobFunctionDeprecated(fn, jobType) {
115
+ if (warnedFunctions.has(fn))
116
+ return;
117
+ warnedFunctions.add(fn);
118
+ process.emitWarning("Job functions are deprecated and will removed in v1.0. " +
119
+ `Migrate ${jobType} to ` +
120
+ `client.enqueue({type: '${jobType}', queue: '...', payload: ...}).`, {
121
+ type: "DeprecationWarning",
122
+ code: "ZIZQ_JOB_FUNCTIONS_DEPRECATED",
123
+ });
124
+ }
package/dist/handler.js CHANGED
@@ -25,6 +25,7 @@ import { Router } from "./router.js";
25
25
  * functions resolve to the same type.
26
26
  */
27
27
  export function buildHandler(jobs) {
28
+ warnBuildHandlerDeprecated();
28
29
  const seen = new Set();
29
30
  const router = new Router();
30
31
  for (const fn of jobs) {
@@ -40,3 +41,16 @@ export function buildHandler(jobs) {
40
41
  }
41
42
  return router.build();
42
43
  }
44
+ // --- Deprecation warnings ---
45
+ let warnedBuildHandler = false;
46
+ function warnBuildHandlerDeprecated() {
47
+ if (warnedBuildHandler)
48
+ return;
49
+ warnedBuildHandler = true;
50
+ process.emitWarning("buildHandler and job functions are deprecated and will be removed in " +
51
+ "v1.0. Use Router directly (client.enqueue({type: '...', ...}) + a " +
52
+ "Router-based worker) instead.", {
53
+ type: "DeprecationWarning",
54
+ code: "ZIZQ_JOB_FUNCTIONS_DEPRECATED",
55
+ });
56
+ }
package/dist/index.d.ts CHANGED
@@ -14,3 +14,7 @@ export { enqueue, enqueueBulk } from "./enqueue.ts";
14
14
  export { CronHandle, CronEntryHandle } from "./cron.ts";
15
15
  export type { CronEntryDefinition, RegisterCronOptions } from "./cron.ts";
16
16
  export { uniqueKey } from "./unique-key.ts";
17
+ export { payloadHasher } from "./payload-hasher.ts";
18
+ export type { PayloadHasherOptions } from "./payload-hasher.ts";
19
+ export { batchConfig } from "./batch-config.ts";
20
+ export type { BatchConfigOptions } from "./batch-config.ts";
package/dist/index.js CHANGED
@@ -8,3 +8,5 @@ export { Lazy, ErrorQuery, JobQuery } from "./query.js";
8
8
  export { enqueue, enqueueBulk } from "./enqueue.js";
9
9
  export { CronHandle, CronEntryHandle } from "./cron.js";
10
10
  export { uniqueKey } from "./unique-key.js";
11
+ export { payloadHasher } from "./payload-hasher.js";
12
+ export { batchConfig } from "./batch-config.js";
@@ -0,0 +1,50 @@
1
+ import type { EnqueueInput } from "./types.ts";
2
+ /** Options for {@link payloadHasher}. */
3
+ export interface PayloadHasherOptions {
4
+ /**
5
+ * jq paths whose values participate in the hash. When multiple paths
6
+ * are supplied their sub-values are hashed together, keyed by path
7
+ * so ordering doesn't matter. Defaults to `['.']` (whole payload).
8
+ *
9
+ * Accepts a single path or an array. Cannot be combined with `except`.
10
+ */
11
+ only?: string | string[];
12
+ /**
13
+ * jq paths whose values are excluded from the hash. The payload is
14
+ * deep-cloned and each path is deleted before hashing. Missing paths
15
+ * are silently ignored.
16
+ *
17
+ * Accepts a single path or an array. Cannot be combined with `only`.
18
+ */
19
+ except?: string | string[];
20
+ /**
21
+ * When `true` (default), the returned key is prefixed with the job
22
+ * type and a `:`. When `false`, only the raw hex digest is returned.
23
+ */
24
+ prefix?: boolean;
25
+ }
26
+ /**
27
+ * Build a function that hashes some or all of an enqueue's payload
28
+ * into a stable key string.
29
+ *
30
+ * The returned function takes the whole `EnqueueInput` so it can read
31
+ * both the payload (for hashing) and the type (for the optional
32
+ * prefix). Suitable for assignment to `uniqueKey` or `batch.key` on
33
+ * `client.enqueue({...})` inputs.
34
+ *
35
+ * @example Full-payload hash with type prefix
36
+ * ```ts
37
+ * uniqueKey: payloadHasher()
38
+ * // => "sendEmail:<sha256>"
39
+ * ```
40
+ *
41
+ * @example Batch by tenant only
42
+ * ```ts
43
+ * batch: {
44
+ * key: payloadHasher({ except: '.notifications' }),
45
+ * when: "...",
46
+ * fold: "...",
47
+ * }
48
+ * ```
49
+ */
50
+ export declare function payloadHasher(options?: PayloadHasherOptions): (input: EnqueueInput) => string;