@ultimat3/jobs 17.0.0 → 18.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
@@ -370,7 +370,7 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
370
370
  renders a dead letter at attempt 1 of 5 as a silent early stop.
371
371
  - **The backoff arithmetic is `@ultimat3/core`'s, and `retry.ts` is the option names over it**
372
372
  (`As of 2026-08-23`). `backoffDelayMs` is `backoffDelay({ curve, jitter, base, max, attempt })`
373
- with this package's spellings applied on the way in — `DurationInput` through `toMs`, the
373
+ with this package's spellings applied on the way in — `DurationInput` through `finiteDurationMs`, the
374
374
  `DEFAULT_RETRY` fallbacks, and `jitter: boolean` mapped to `'equal' | 'none'`. **EQUAL, never
375
375
  `full`**: `jitter: true` here has meant half-fixed-half-random since it shipped, and `full`
376
376
  would hand a job that already failed twice a near-zero wait. The public `RetryPolicy`,
@@ -842,8 +842,30 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
842
842
 
843
843
  `clock.ts` calls `parseDuration(str)` and `scheduler.ts` calls
844
844
  `nextCronOccurrence(cron, { tz, from })`. Both are normalised in one place each
845
- (`toMs`, `defaultCronResolver`) and the scheduler's resolver is injectable, so a signature
846
- change in `@ultimat3/time` is a one-line fix, not a sweep.
845
+ (`finiteDurationMs`, `defaultCronResolver`) and the scheduler's resolver is injectable, so a
846
+ signature change in `@ultimat3/time` is a one-line fix, not a sweep.
847
+
848
+ **`clock.ts`'s conversion is `finiteDurationMs(duration, subject, option)`, never a bare `toMs`**
849
+ (`As of 2026-08-26`). It was `toMs` — a THIRD implementation of duration→ms with no finiteness
850
+ screen at all, and the one every scheduling decision in this package goes through:
851
+ `step.sleep(Number(process.env.DELAY))` on an unset variable made `wakeAt = at + NaN`, and every
852
+ `wakeAt <= now` against a `NaN` is false forever — a sleep that never ends, on a row `x jobs show`
853
+ prints as `sleeping`. Partial screening is what hid it: `events.ts`, `events-pg.ts` and `steps.ts`
854
+ wrapped the RESULT in `finiteOption` while `retry.ts` and `step.sleep` did not, so a reader saw
855
+ `finiteOption` in the file and concluded the package was screened.
856
+
857
+ Three things about the shape are load-bearing. The floor is `finiteOption` and NOT `finiteCount`,
858
+ measured rather than assumed: `retry-core-parity.test.ts` pins `maxDelay: -5` at `0` and four
859
+ `.job.test.ts` suites configure `retry: { delay: 0 }`, so a negative and a zero duration are
860
+ shipped behaviour here — only a non-finite one is refused, and a caller wanting a positive whole
861
+ number narrows on top, which is what `job()` does for `stepTimeout` and `eventPoll`. `subject` and
862
+ `option` are REQUIRED, so a call site that does not name the app author's own key
863
+ (`retry.delay`, `job("x") timeout`, `step.sleep`'s argument) is `TS2554` at the call rather than a
864
+ review note — `@ultimat3/time`'s `toMs` screens under the subject `toMs`, which names a framework
865
+ internal. And the callee CARRIES `Finite`, because `bun run finite-bounds` reads a repair off the
866
+ callee's name: that is what lets `finiteDurationMs(options.defaultTtl ?? 604_800_000, …)` be
867
+ recognised as screened with no second wrapper around it. `duration-bounds.test.ts` is the
868
+ enforcement `finite-bounds` cannot be — a `typeof duration === 'number'` arm has no `??` in it.
847
869
 
848
870
  `driver-pg.ts`'s `PgExecutor` (`:62-64`) is a one-method duck-typed interface — this package still
849
871
  has no `@ultimat3/db` dependency, and nothing here knows what an observer, a span or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "17.0.0",
3
+ "version": "18.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,16 +25,16 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.3.0"
28
+ "bun": ">=1.4.0"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "17.0.0",
36
- "@ultimat3/entity": "17.0.0",
37
- "@ultimat3/schema": "17.0.0",
38
- "@ultimat3/time": "17.0.0"
35
+ "@ultimat3/core": "18.0.0",
36
+ "@ultimat3/entity": "18.0.0",
37
+ "@ultimat3/schema": "18.0.0",
38
+ "@ultimat3/time": "18.0.0"
39
39
  }
40
40
  }
package/src/clock.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // injected Clock, so tests never sleep and a frozen clock cannot be bypassed.
3
3
 
4
4
  import type { Clock } from '@ultimat3/core';
5
- import { systemClock } from '@ultimat3/core';
5
+ import { finiteOption, systemClock } from '@ultimat3/core';
6
6
  import { parseDuration } from '@ultimat3/time';
7
7
 
8
8
  export type DurationInput = string | number;
@@ -14,10 +14,38 @@ export function nowMs(clock: Clock = systemClock): number {
14
14
  return Number(reading);
15
15
  }
16
16
 
17
- /** `'3d'` | `'30s'` | `1500` -> ms. Numbers pass through so callers may stay explicit. */
18
- export function toMs(duration: DurationInput): number {
19
- if (typeof duration === 'number') return duration;
20
- const parsed: unknown = parseDuration(duration);
21
- if (parsed instanceof Date) return parsed.getTime();
22
- return Number(parsed);
17
+ /**
18
+ * `'3d'` | `'30s'` | `1500` -> ms, refused under the name the CALLER wrote.
19
+ *
20
+ * NOT a copy of `@ultimat3/time`'s `toMs`, and the name says which one this is — three functions
21
+ * spelled `toMs`, `toMs` and `toDurationMs` are exactly the trap a rule spelled by NAME falls into.
22
+ * The STRING arm delegates to `parseDuration`, the one duration vocabulary, which refuses
23
+ * everything it cannot read (an overflowing amount included). What this adds is the NUMBER arm and
24
+ * the two names on it.
25
+ *
26
+ * The number arm passed straight through, and this is the conversion every scheduling decision in
27
+ * this package goes through: `step.sleep(Number(process.env.DELAY))` on an unset variable made
28
+ * `wakeAt = at + NaN`, and every `wakeAt <= now` against a `NaN` is false forever — a sleep that
29
+ * never ends, a retry ceiling that is not one, an event that never expires, with no error
30
+ * anywhere. `??` does not guard it, because `NaN` is not nullish.
31
+ *
32
+ * `finiteOption`, not `finiteCount`, and the floor is measured rather than assumed:
33
+ * `backoffDelayMs({ ...policy, maxDelay: -5 }, 1) === 0` is pinned by `retry-core-parity.test.ts`
34
+ * and `retry: { delay: 0 }` is what four of this package's own `.job.test.ts` suites configure, so
35
+ * a negative and a zero duration are shipped behaviour here. Only a non-finite one is refused. A
36
+ * caller needing a positive whole number narrows ON TOP — `job()` does exactly that for
37
+ * `stepTimeout` and `eventPoll`.
38
+ *
39
+ * `subject` and `option` are REQUIRED, and that is the enforcement rather than a convention: a new
40
+ * call site that does not name the key the app author actually wrote is `TS2554: Expected 3
41
+ * arguments` at the call. `@ultimat3/time`'s screen names the subject `toMs`, a framework internal
42
+ * that tells an app author nothing about which knob of theirs is wrong — the shape
43
+ * `@ultimat3/notify`'s `toDurationMs` already has. The name no longer has to carry `Finite`:
44
+ * `bun run finite-bounds` read a repair off the callee's NAME until 2026-08-26 — which is what
45
+ * forced this rename — and now reads `SCREENING_CALLEES` in `scripts/lib/finite-screens.ts`.
46
+ * Rename it freely; move the row with it.
47
+ */
48
+ export function finiteDurationMs(duration: DurationInput, subject: string, option: string): number {
49
+ if (typeof duration === 'number') return finiteOption(subject, option, duration);
50
+ return parseDuration(duration);
23
51
  }
package/src/events-pg.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import type { Clock } from '@ultimat3/core';
10
10
  import { finiteOption, logger, renderThrowable, systemClock, uuid } from '@ultimat3/core';
11
11
  import type { DurationInput } from './clock';
12
- import { nowMs, toMs } from './clock';
12
+ import { finiteDurationMs, nowMs } from './clock';
13
13
  import type { PgExecutor } from './driver-pg';
14
14
  import {
15
15
  SQL_EVENT_FIND,
@@ -49,10 +49,10 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
49
49
  // TWO screens, for the reason `events.ts` states: `defaultTtl` is the constructor's knob and
50
50
  // `ttl` is the publish call's, so one screen over `ttl ?? defaultTtl` names the wrong one for
51
51
  // whichever value actually arrived.
52
- const defaultTtlMs = finiteOption(
52
+ const defaultTtlMs = finiteDurationMs(
53
+ options.defaultTtl ?? 604_800_000,
53
54
  'the pg event bus',
54
55
  'defaultTtl',
55
- toMs(options.defaultTtl ?? 604_800_000),
56
56
  );
57
57
  const listLimit = finiteOption('the pg event bus', 'listLimit', options.listLimit ?? 1_000);
58
58
  const exec = options.executor;
@@ -79,7 +79,7 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
79
79
  at +
80
80
  (publishOptions.ttl === undefined
81
81
  ? defaultTtlMs
82
- : finiteOption('the pg event bus', 'ttl', toMs(publishOptions.ttl))),
82
+ : finiteDurationMs(publishOptions.ttl, 'the pg event bus', 'ttl')),
83
83
  ...(publishOptions.correlationKey === undefined
84
84
  ? {}
85
85
  : { correlationKey: publishOptions.correlationKey }),
package/src/events.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import type { Clock } from '@ultimat3/core';
6
6
  import { finiteOption, logger, systemClock, uuid } from '@ultimat3/core';
7
7
  import type { DurationInput } from './clock';
8
- import { nowMs, toMs } from './clock';
8
+ import { finiteDurationMs, nowMs } from './clock';
9
9
  import type { EventLookup } from './steps';
10
10
 
11
11
  export interface JobEvent {
@@ -44,10 +44,10 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
44
44
  // told a caller who wrote `{ ttl: NaN }` to "pass a finite defaultTtl" — an instruction naming an
45
45
  // option they never set, on a constructor usually in another file. `steps.ts` names `timeout` for
46
46
  // the same value shape.
47
- const defaultTtlMs = finiteOption(
47
+ const defaultTtlMs = finiteDurationMs(
48
+ options.defaultTtl ?? 604_800_000,
48
49
  'the memory event bus',
49
50
  'defaultTtl',
50
- toMs(options.defaultTtl ?? 604_800_000),
51
51
  );
52
52
  const maxEvents = finiteOption('the memory event bus', 'maxEvents', options.maxEvents ?? 10_000);
53
53
  const events = new Map<string, JobEvent>();
@@ -77,7 +77,7 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
77
77
  at +
78
78
  (publishOptions.ttl === undefined
79
79
  ? defaultTtlMs
80
- : finiteOption('the memory event bus', 'ttl', toMs(publishOptions.ttl))),
80
+ : finiteDurationMs(publishOptions.ttl, 'the memory event bus', 'ttl')),
81
81
  ...(publishOptions.correlationKey === undefined
82
82
  ? {}
83
83
  : { correlationKey: publishOptions.correlationKey }),
package/src/job.ts CHANGED
@@ -12,7 +12,7 @@ import { assert } from '@ultimat3/core';
12
12
  import type { StandardSchemaV1 } from '@ultimat3/schema';
13
13
  import { parse } from '@ultimat3/schema';
14
14
  import type { DurationInput } from './clock';
15
- import { toMs } from './clock';
15
+ import { finiteDurationMs } from './clock';
16
16
  import type { JobDescriptor } from './describe';
17
17
  import { describeJob } from './describe';
18
18
  import type { EnqueueResult } from './driver';
@@ -204,8 +204,13 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
204
204
  );
205
205
 
206
206
  const stepTimeoutMs =
207
- definition.stepTimeout === undefined ? undefined : toMs(definition.stepTimeout);
208
- const eventPollMs = definition.eventPoll === undefined ? undefined : toMs(definition.eventPoll);
207
+ definition.stepTimeout === undefined
208
+ ? undefined
209
+ : finiteDurationMs(definition.stepTimeout, `job "${name}"`, 'stepTimeout');
210
+ const eventPollMs =
211
+ definition.eventPoll === undefined
212
+ ? undefined
213
+ : finiteDurationMs(definition.eventPoll, `job "${name}"`, 'eventPoll');
209
214
  // `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
210
215
  // resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
211
216
  // so they are refused where they are written — the same answer `concurrency: 0` gets.
@@ -231,7 +236,10 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
231
236
  queue: definition.queue ?? DEFAULT_QUEUE,
232
237
  retry: { ...DEFAULT_RETRY, ...definition.retry },
233
238
  concurrency: definition.concurrency,
234
- timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
239
+ timeoutMs:
240
+ definition.timeout === undefined
241
+ ? undefined
242
+ : finiteDurationMs(definition.timeout, `job "${name}"`, 'timeout'),
235
243
  stepTimeoutMs,
236
244
  eventPollMs,
237
245
  input: definition.input,
package/src/outbox.ts CHANGED
@@ -205,7 +205,7 @@ export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): Memo
205
205
  }
206
206
 
207
207
  export interface EnqueueOptions {
208
- /** Epoch ms, or a delay via `runAt: nowMs() + toMs('5m')`. */
208
+ /** Epoch ms, or a delay off the caller's own clock: `clock.now().getTime() + toMs('5m')`. */
209
209
  readonly runAt?: number;
210
210
  readonly tenantId?: string;
211
211
  readonly queue?: string;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { ErrorRetry } from '@ultimat3/core';
6
6
  import { classifyThrown, statedDelayMs } from '@ultimat3/core';
7
- import { toMs } from './clock';
7
+ import { finiteDurationMs } from './clock';
8
8
  import type { Random, RetryDecision, RetryPolicy } from './retry';
9
9
  import { DEFAULT_RETRY, nextRetry } from './retry';
10
10
 
@@ -67,7 +67,7 @@ export function nextRetryForError(
67
67
  if (stated === undefined) return { ...decision, stoppedBy: undefined, classification };
68
68
  // Clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder naming a
69
69
  // day is still a responder this deployment has not agreed to wait a day for.
70
- const cap = toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay);
70
+ const cap = finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay');
71
71
  return { ...decision, delayMs: Math.min(stated, cap), stoppedBy: undefined, classification };
72
72
  }
73
73
 
package/src/retry.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { BackoffCurve, Random } from '@ultimat3/core';
7
7
  import { backoffDelay } from '@ultimat3/core';
8
8
  import type { DurationInput } from './clock';
9
- import { toMs } from './clock';
9
+ import { finiteDurationMs } from './clock';
10
10
 
11
11
  /**
12
12
  * This package's name for core's curve, and an ALIAS rather than a second union: two spellings of
@@ -60,8 +60,8 @@ export type { Random };
60
60
  export function backoffDelayMs(policy: RetryPolicy, attempt: number, random?: Random): number {
61
61
  return backoffDelay({
62
62
  attempt,
63
- base: toMs(policy.delay ?? DEFAULT_RETRY.delay),
64
- max: toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay),
63
+ base: finiteDurationMs(policy.delay ?? DEFAULT_RETRY.delay, 'retry', 'delay'),
64
+ max: finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay'),
65
65
  curve: policy.backoff ?? DEFAULT_RETRY.backoff,
66
66
  // EQUAL, not full: a job that has already failed twice must not be handed a near-zero wait,
67
67
  // and this package's `jitter: true` has meant "half fixed, half random" since it shipped.
package/src/steps.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import type { Clock } from '@ultimat3/core';
10
10
  import { finiteOption, logger, renderThrowable } from '@ultimat3/core';
11
11
  import type { DurationInput } from './clock';
12
- import { nowMs, toMs } from './clock';
12
+ import { finiteDurationMs, nowMs } from './clock';
13
13
  import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
14
14
  import { createRunSignal } from './run-signal';
15
15
 
@@ -361,7 +361,8 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
361
361
  throw new StepSuspension({ step: name, resumeAt: existing.wakeAt, reason: 'sleep' });
362
362
  }
363
363
 
364
- const wakeAt = at + toMs(duration);
364
+ const wakeAt =
365
+ at + finiteDurationMs(duration, `job "${jobName}" step.sleep("${name}")`, 'duration');
365
366
  await put({
366
367
  runId,
367
368
  name,
@@ -389,7 +390,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
389
390
  const startedAt = existing?.startedAt ?? at;
390
391
  const deadline =
391
392
  startedAt +
392
- finiteOption('step.waitForEvent', 'timeout', toMs(waitOptions.timeout ?? 86_400_000));
393
+ finiteDurationMs(waitOptions.timeout ?? 86_400_000, 'step.waitForEvent', 'timeout');
393
394
  const correlationKey = waitOptions.match;
394
395
 
395
396
  const hit = await options.events?.find(event, correlationKey, startedAt);
@@ -413,10 +414,10 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
413
414
  throw new JobTimeoutError({
414
415
  job: jobName,
415
416
  step: name,
416
- timeoutMs: finiteOption(
417
+ timeoutMs: finiteDurationMs(
418
+ waitOptions.timeout ?? 86_400_000,
417
419
  'step.waitForEvent',
418
420
  'timeout',
419
- toMs(waitOptions.timeout ?? 86_400_000),
420
421
  ),
421
422
  });
422
423
  }