@ultimat3/jobs 11.2.0 → 12.0.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/CLAUDE.md CHANGED
@@ -75,6 +75,19 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
75
75
  has the table, so a column added only there reaches new installs and nothing else. Comments in
76
76
  that constant carry NO apostrophes and NO semicolons — `dev-queue.ts` splits it on `;` and
77
77
  `driver-pg-sql.test.ts` checks quote parity, neither of which can tell prose from a literal.
78
+ - **`claim({ queues: [] })` is REFUSED by every driver, `As of 2026-08-24`.** It used to mean two
79
+ different things: EVERY queue on `driver-memory.ts` (`wanted.size === 0 ||`) and the `default`
80
+ queue on `driver-pg.ts` (`queues.length > 0 ? queues : [DEFAULT_QUEUE]`), with `ClaimOptions.queues`
81
+ documenting neither — so the memory driver every test in this repo runs against and the pg driver
82
+ production runs against answered one question two ways. Nothing reached it (`createWorker` passes
83
+ exactly ONE queue per pass, which is what keeps a slow queue from starving the others), so it could
84
+ only ever be found by an embedder, in production. Both meanings are silently wrong in the other's
85
+ deployment: claiming every queue is a worker taking work it was never configured for, claiming
86
+ `default` is a worker that drains nothing and reads as an idle queue. `assertClaimQueues` in
87
+ `driver.ts` is the one refusal (`X_JOB_CLAIM_QUEUES_EMPTY`), called by both drivers, and
88
+ `driver-memory.ts`'s `claim` is `async` so an empty list REJECTS on both rather than throwing
89
+ synchronously on one — a sync throw out of a method typed `Promise<…>` is itself a divergence.
90
+ **Breaking**: `driver-pg.test.ts` was leaning on the pg default and now names its queue.
78
91
  - **`ack` and `nack` are FENCED on `state = 'running'`, and that fence is what makes cancellation
79
92
  possible** (`As of 2026-08`). Without it the only way to stop a runaway pass was
80
93
  `UPDATE x_jobs SET state='dead'`, which the worker's next settle wrote straight over. Same fence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "11.2.0",
3
+ "version": "12.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,9 +32,9 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "11.2.0",
36
- "@ultimat3/entity": "11.2.0",
37
- "@ultimat3/schema": "11.2.0",
38
- "@ultimat3/time": "11.2.0"
35
+ "@ultimat3/core": "12.0.0",
36
+ "@ultimat3/entity": "12.0.0",
37
+ "@ultimat3/schema": "12.0.0",
38
+ "@ultimat3/time": "12.0.0"
39
39
  }
40
40
  }
@@ -20,7 +20,7 @@ import type {
20
20
  NackOptions,
21
21
  QueueStats,
22
22
  } from './driver';
23
- import { DEFAULT_QUEUE } from './driver';
23
+ import { assertClaimQueues, DEFAULT_QUEUE } from './driver';
24
24
  import { JobDuplicateError } from './errors';
25
25
  import type { LeaseStore } from './leases';
26
26
  import { createMemoryLeaseStore } from './leases';
@@ -194,11 +194,15 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
194
194
  return Promise.resolve({ id: record.id, runId: record.runId, deduped: false });
195
195
  },
196
196
 
197
- claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
197
+ // `async`, so an empty queue list REJECTS here exactly as it does on the pg driver: a
198
+ // synchronous throw out of a method typed `Promise<…>` is a different answer to the same
199
+ // question, which is the class of divergence this pair is checked for.
200
+ async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
201
+ assertClaimQueues('memory', claimOptions);
198
202
  const at = nowMs(clock);
199
203
  const wanted = new Set(claimOptions.queues);
200
204
  const claimable = [...jobs.values()]
201
- .filter((record) => wanted.size === 0 || wanted.has(record.queue))
205
+ .filter((record) => wanted.has(record.queue))
202
206
  .filter((record) => {
203
207
  if (record.runAt > at) return false;
204
208
  if (record.state === 'ready' || record.state === 'delayed') return true;
package/src/driver-pg.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
  NackOptions,
21
21
  QueueStats,
22
22
  } from './driver';
23
- import { DEFAULT_QUEUE } from './driver';
23
+ import { assertClaimQueues, DEFAULT_QUEUE } from './driver';
24
24
  import type { BackfillRow, JobRow, StepRow } from './driver-pg-rows';
25
25
  import { num, toBackfillRun, toJobRecord, toStepRecord } from './driver-pg-rows';
26
26
  import {
@@ -57,7 +57,7 @@ import type { StepStore } from './steps';
57
57
  * The one thing this driver needs from the DB layer, declared structurally so this package can
58
58
  * depend on no database package at all.
59
59
  *
60
- * **Not satisfied by `Bun.sql`** — verified against Bun 1.3.14: `Bun.sql.query` is `undefined`.
60
+ * **Not satisfied by `Bun.sql`** — verified against Bun 1.4.0: `Bun.sql.query` is `undefined`.
61
61
  * `Bun.sql` is a tagged template whose positional form is `unsafe`, so a `{ executor: Bun.sql }`
62
62
  * would `TypeError` on the first claim. What satisfies it is a one-line adapter over a client that
63
63
  * already speaks `(text, values)` — `@ultimat3/cli`'s `pgExecutorFor(client)` is the framework's
@@ -292,9 +292,9 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
292
292
  },
293
293
 
294
294
  async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
295
- const queues = claimOptions.queues.length > 0 ? claimOptions.queues : [DEFAULT_QUEUE];
295
+ assertClaimQueues('pg', claimOptions);
296
296
  const rows = await exec().query<JobRow>(SQL_CLAIM, [
297
- queues,
297
+ claimOptions.queues,
298
298
  claimOptions.limit,
299
299
  claimOptions.workerId,
300
300
  claimOptions.visibilityTimeoutMs,
package/src/driver.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // `setJobDriver(other)` and ZERO job-code change — and that is what the interface buys.
10
10
 
11
11
  import type { BackfillLedger } from './backfill-ledger';
12
+ import { ClaimQueuesEmptyError } from './errors';
12
13
  import type { LeaseStore } from './leases';
13
14
  import type { StepStore } from './steps';
14
15
 
@@ -104,6 +105,11 @@ export interface EnqueueResult {
104
105
  }
105
106
 
106
107
  export interface ClaimOptions {
108
+ /**
109
+ * The queues this pass may take work from, by name. **At least one, and an empty list is
110
+ * refused** (`X_JOB_CLAIM_QUEUES_EMPTY`) — see `assertClaimQueues`. `createWorker` passes exactly
111
+ * one per pass, which is what keeps a slow queue from starving the others.
112
+ */
107
113
  readonly queues: readonly string[];
108
114
  readonly limit: number;
109
115
  /** Lease length. A worker that dies without ack makes the job claimable again after this. */
@@ -249,3 +255,18 @@ export function jobDriver(): JobDriver | undefined {
249
255
  export function resetJobDriver(): void {
250
256
  ambient = undefined;
251
257
  }
258
+
259
+ /**
260
+ * Every driver's `claim` opens with this, so the two cannot answer an empty list differently — and
261
+ * they DID: `driver-memory.ts` read it as every queue and `driver-pg.ts` as the `default` one, with
262
+ * this interface documenting neither. A semantic only one driver holds is a guarantee that passes
263
+ * CI on memory and behaves differently on the deploy that runs Postgres, which is the whole reason
264
+ * `driver-parity.test.ts` exists.
265
+ *
266
+ * Refused, never defaulted, for `X_RATE_LIMIT_BUCKET_CONFLICT`'s reason one package over: whichever
267
+ * meaning a merge picked would leave the other a deployment somebody wrote against and nothing
268
+ * enforces.
269
+ */
270
+ export const assertClaimQueues = (driver: string, options: ClaimOptions): void => {
271
+ if (options.queues.length === 0) throw new ClaimQueuesEmptyError(driver);
272
+ };
package/src/errors.ts CHANGED
@@ -25,6 +25,7 @@ export const JOB_OWNED_ERROR_CODES = [
25
25
  'X_BACKFILL_UNKNOWN',
26
26
  'X_JOB_ROW_STATUS_UNKNOWN',
27
27
  'X_ACTION_JOB_UNBRIDGED',
28
+ 'X_JOB_CLAIM_QUEUES_EMPTY',
28
29
  ] as const;
29
30
 
30
31
  /**
@@ -62,6 +63,7 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
62
63
  X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
63
64
  X_JOB_ROW_STATUS_UNKNOWN: 'a queue row carries a status this build does not know',
64
65
  X_ACTION_JOB_UNBRIDGED: 'an action projection was registered as a job',
66
+ X_JOB_CLAIM_QUEUES_EMPTY: 'a claim named no queue, and the drivers do not agree what that means',
65
67
  };
66
68
 
67
69
  // One unconditional call, so a second package claiming one of jobs' codes throws
@@ -386,3 +388,25 @@ export class JobsNotImplementedError extends UltimateError {
386
388
  });
387
389
  }
388
390
  }
391
+
392
+ /**
393
+ * `claim({ queues: [] })`, refused by every driver rather than answered by each.
394
+ *
395
+ * The two shipped drivers had two meanings for it — every queue on `driver-memory.ts`, the
396
+ * `default` queue on `driver-pg.ts` — and `ClaimOptions.queues` documented neither. Nothing reached
397
+ * it (`createWorker` passes exactly one queue per pass), so the divergence could only ever be found
398
+ * by an embedder in production, and each meaning is silently wrong in the other's deployment: one
399
+ * takes work this worker was never configured for, the other drains nothing and reads as an idle
400
+ * queue. There is no third meaning to pick — an empty list is a caller that has not said what to
401
+ * claim.
402
+ */
403
+ export class ClaimQueuesEmptyError extends UltimateError {
404
+ constructor(driver: string) {
405
+ super({
406
+ code: 'X_JOB_CLAIM_QUEUES_EMPTY',
407
+ cause: `the ${driver} driver was asked to claim with queues: [] — an empty list names no queue, and "every queue" and "the default queue" are different deployments`,
408
+ fix: "pass the queue by name — driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId }) — or read the list from config.jobs.queues, which is what createWorker walks one queue at a time",
409
+ meta: { driver },
410
+ });
411
+ }
412
+ }
package/src/index.ts CHANGED
@@ -84,6 +84,7 @@ export type {
84
84
  QueueStats,
85
85
  } from './driver';
86
86
  export {
87
+ assertClaimQueues,
87
88
  DEFAULT_QUEUE,
88
89
  DEFAULT_VISIBILITY_TIMEOUT_MS,
89
90
  isJobState,
@@ -134,6 +135,7 @@ export type { JobErrorCode } from './errors';
134
135
  export {
135
136
  ActionJobUnbridgedError,
136
137
  CancelUnsupportedError,
138
+ ClaimQueuesEmptyError,
137
139
  ConcurrencyUnenforceableError,
138
140
  DriverUnavailableError,
139
141
  IdempotencyRequiredError,