@ultimat3/jobs 21.0.0 → 22.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/README.md CHANGED
@@ -679,7 +679,7 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
679
679
 
680
680
  | Driver | Status | Backing | Use |
681
681
  |---|---|---|---|
682
- | `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, idempotency_key)`, lease-based leader, `x_job_leases` | zero-infra start, most apps |
682
+ | `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, coalesce(tenant_id, ''), idempotency_key)` over live rows (`x_jobs_name_tenant_idempotency_live_idx`), lease-based leader, `x_job_leases` | zero-infra start, most apps |
683
683
  | `memory` | complete | in-process maps | `x dev`, tests |
684
684
  | `redis` | interface-complete, `X_NOT_IMPLEMENTED` | Streams + consumer groups | planned |
685
685
  | `nats` | interface-complete, `X_NOT_IMPLEMENTED` | JetStream work queue | planned |
@@ -764,10 +764,10 @@ non-empty string is not a timezone: `tz: 'Bogota'` would resolve every occurrenc
764
764
  run five hours off, silently, forever. `0 3 * * *` in a DST zone runs twice or zero times on
765
765
  the switch day. Catch-up after downtime is explicit: `skip` (default) fires the latest missed
766
766
  occurrence and drops the older ones, `run-once` fires the earliest missed one, `run-all` fires
767
- every one of them. `maxCatchUp` (default 10) bounds the WALK for every mode, not just `run-all`:
768
- one tick walks at most that many occurrences forward from the last fire, and the policy then
769
- picks from what that walk found — so after a long outage `skip` fires the latest occurrence
770
- *within the cap*, not the true latest missed.
767
+ every one of them. `maxCatchUp` (default 10) bounds the WALK for `run-all` and `run-once`: one
768
+ tick walks at most that many occurrences forward from the last fire. `skip` is not bounded by it —
769
+ after a long outage it fires the TRUE latest missed occurrence, found by bisection over the gap
770
+ (about 25 cron evaluations for three hours, 35 for a year), and moves the watermark there.
771
771
 
772
772
  `run-once` fires **once**, not once per tick. Dropping the rest means the watermark passes them
773
773
  too, so a scheduler back up after a day down enqueues one catch-up and then waits for the next
@@ -778,7 +778,10 @@ hourly task fire 24 catch-ups a second apart.
778
778
 
779
779
  `{ attempts, backoff: 'exponential' | 'linear' | 'fixed', delay, maxDelay, jitter }`.
780
780
  Equal jitter is on by default so a burst of failures does not retry in lockstep.
781
- Exhausted jobs are dead-lettered, never dropped: `x jobs retry <id>`.
781
+ Exhausted jobs are dead-lettered, never dropped: `x jobs retry <id>`. Only a finished job — dead,
782
+ cancelled, failed or done — can be requeued: a live one is `X_JOB_NOT_REQUEUEABLE` (requeueing a
783
+ running job used to run it twice), and one whose idempotency key a live job now holds is
784
+ `X_JOB_DUPLICATE`. `--from-step <name>` drops that step and every step that started after it.
782
785
 
783
786
  ```
784
787
  retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
@@ -902,6 +905,53 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
902
905
  | `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency` and the driver has no lease store |
903
906
  | `X_NOT_IMPLEMENTED` | redis / nats driver |
904
907
 
908
+ ### Error classes
909
+
910
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
911
+ a job boundary the class is gone and the `code` is what survives — match on that.
912
+
913
+ | Class | Code | Declared in |
914
+ |---|---|---|
915
+ | `ActionJobUnbridgedError` | `X_ACTION_JOB_UNBRIDGED` | `src/errors.ts` |
916
+ | `BackfillAppliedError` | `X_BACKFILL_APPLIED` | `src/backfill-errors.ts` |
917
+ | `BackfillEnvironmentError` | `X_BACKFILL_ENVIRONMENT` | `src/backfill-errors.ts` |
918
+ | `BackfillMigrationPendingError` | `X_BACKFILL_MIGRATION_PENDING` | `src/backfill-errors.ts` |
919
+ | `BackfillPendingError` | `X_BACKFILL_PENDING` | `src/backfill-errors.ts` |
920
+ | `BackfillRunningError` | `X_BACKFILL_RUNNING` | `src/backfill-errors.ts` |
921
+ | `BackfillStalledError` | `X_BACKFILL_STALLED` | `src/backfill-errors.ts` |
922
+ | `BackfillUnknownError` | `X_BACKFILL_UNKNOWN` | `src/backfill-errors.ts` |
923
+ | `CancelUnsupportedError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
924
+ | `ClaimQueuesEmptyError` | `X_JOB_CLAIM_QUEUES_EMPTY` | `src/errors.ts` |
925
+ | `ConcurrencyUnenforceableError` | `X_JOB_CONCURRENCY_UNENFORCEABLE` | `src/errors.ts` |
926
+ | `DriverUnavailableError` | `X_DRIVER_UNAVAILABLE` | `src/errors.ts` |
927
+ | `ExportPartTooLargeError` | `X_EXPORT_PART_TOO_LARGE` | `src/export-errors.ts` |
928
+ | `ExportRowInvalidError` | `X_EXPORT_ROW_INVALID` | `src/export-errors.ts` |
929
+ | `IdempotencyRequiredError` | `X_IDEMPOTENCY_REQUIRED` | `src/errors.ts` |
930
+ | `JobAbortedError` | `X_ABORTED` | `src/errors.ts` |
931
+ | `JobDeclarationInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-declaration.ts` |
932
+ | `JobDrainedError` | `X_DRAINING` | `src/errors.ts` |
933
+ | `JobDuplicateError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
934
+ | `JobMaxAttemptsError` | `X_JOB_MAX_ATTEMPTS` | `src/errors.ts` |
935
+ | `JobNameTakenError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
936
+ | `JobNotCancellableError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
937
+ | `JobNotRequeueableError` | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
938
+ | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
939
+ | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
940
+ | `JobsNotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
941
+ | `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
942
+ | `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
943
+ | `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
944
+ | `OutboxNoTxError` | `X_OUTBOX_NO_TX` | `src/errors.ts` |
945
+ | `StepDuplicateError` | `X_STEP_DUPLICATE` | `src/errors.ts` |
946
+ | `WebhookDeliveryFailedError` | `X_WEBHOOK_DELIVERY_FAILED` | `src/webhook-errors.ts` |
947
+ | `WebhookDeliveryRejectedError` | `X_WEBHOOK_DELIVERY_REJECTED` | `src/webhook-errors.ts` |
948
+ | `WebhookDeliveryThrottledError` | `X_WEBHOOK_DELIVERY_THROTTLED` | `src/webhook-errors.ts` |
949
+ | `WebhookEndpointDisabledError` | `X_WEBHOOK_ENDPOINT_DISABLED` | `src/webhook-errors.ts` |
950
+ | `WebhookEndpointInvalidError` | `X_WEBHOOK_ENDPOINT_INVALID` | `src/webhook-errors.ts` |
951
+ | `WebhookEndpointUnknownError` | `X_WEBHOOK_ENDPOINT_UNKNOWN` | `src/webhook-errors.ts` |
952
+ | `WebhookEventInvalidError` | `X_WEBHOOK_EVENT_INVALID` | `src/webhook-errors.ts` |
953
+ | `WebhookEventUnknownError` | `X_WEBHOOK_EVENT_UNKNOWN` | `src/webhook-errors.ts` |
954
+
905
955
  ## Boundary
906
956
 
907
957
  Tier 3. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/entity`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "21.0.0",
3
+ "version": "22.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,10 +32,10 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "21.0.0",
36
- "@ultimat3/db": "21.0.0",
37
- "@ultimat3/entity": "21.0.0",
38
- "@ultimat3/schema": "21.0.0",
39
- "@ultimat3/time": "21.0.0"
35
+ "@ultimat3/core": "22.0.0",
36
+ "@ultimat3/db": "22.0.0",
37
+ "@ultimat3/entity": "22.0.0",
38
+ "@ultimat3/schema": "22.0.0",
39
+ "@ultimat3/time": "22.0.0"
40
40
  }
41
41
  }
@@ -20,8 +20,9 @@ import type {
20
20
  NackOptions,
21
21
  QueueStats,
22
22
  } from './driver';
23
- import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE } from './driver';
23
+ import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE, REQUEUEABLE_STATES } from './driver';
24
24
  import { JobDuplicateError } from './errors';
25
+ import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
25
26
  import type { LeaseStore } from './leases';
26
27
  import { createMemoryLeaseStore } from './leases';
27
28
  import type { StepStore } from './steps';
@@ -133,11 +134,30 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
133
134
  'requeue a job id returned by enqueue() or stats() — the memory driver holds no state across processes, so an id from another run will not resolve',
134
135
  );
135
136
  const record = existing;
137
+ // The two refusals `SQL_JOB_REQUEUE` and `SQL_JOB_LIVE_HOLDER` give, before anything moves.
138
+ if (!REQUEUEABLE_STATES.has(record.state)) {
139
+ throw new JobNotRequeueableError({ jobId, state: record.state });
140
+ }
141
+ const holder = liveByKey(record.name, record.idempotencyKey, record.tenantId);
142
+ if (holder !== undefined && holder.id !== jobId) {
143
+ throw requeueKeyTaken({ ...record, holderId: holder.id });
144
+ }
136
145
  if (requeueOptions?.fromStep !== undefined) {
137
- // Drop the target step so it re-executes; earlier steps stay memoized.
138
- await steps.del(record.runId, requeueOptions.fromStep);
146
+ // The target step and every step that started AFTER it — `SQL_STEPS_FROM`, whose header
147
+ // says why a tie is kept. Earlier steps stay memoized; a later one replaying the old run's
148
+ // result is what this prevents.
149
+ const all = await steps.list(record.runId);
150
+ const target = all.find((step) => step.name === requeueOptions.fromStep);
151
+ if (target !== undefined) {
152
+ for (const step of all) {
153
+ if (step === target || step.startedAt > target.startedAt) {
154
+ await steps.del(record.runId, step.name);
155
+ }
156
+ }
157
+ }
139
158
  }
140
- update(jobId, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
159
+ // `settle` releases the claim — `SQL_JOB_REQUEUE` writes `claimed_by = null` too.
160
+ settle(jobId, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
141
161
  const next = jobs.get(jobId);
142
162
  return next ?? record;
143
163
  },
@@ -306,10 +326,15 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
306
326
  oldestReadyMs: 0,
307
327
  };
308
328
  const next = { ...current };
309
- if (record.state === 'ready' && record.runAt <= at) {
329
+ // Due is due, whatever state the row was WRITTEN in: a job enqueued `delayed` stays
330
+ // `delayed` once its `runAt` passes (the claim scan reads `runAt`, nothing rewrites the
331
+ // state), and counted by state it never reached `ready` or `oldestReadyMs` — the backlog
332
+ // the autoscaler reads. `SQL_STATS` makes the same split.
333
+ const waiting = record.state === 'ready' || record.state === 'delayed';
334
+ if (waiting && record.runAt <= at) {
310
335
  next.ready += 1;
311
336
  next.oldestReadyMs = Math.max(next.oldestReadyMs, at - record.runAt);
312
- } else if (record.state === 'ready' || record.state === 'delayed') next.delayed += 1;
337
+ } else if (waiting) next.delayed += 1;
313
338
  else if (record.state === 'running') next.running += 1;
314
339
  else if (record.state === 'suspended') next.suspended += 1;
315
340
  else if (record.state === 'dead') next.dead += 1;
@@ -36,10 +36,41 @@ select ${JOB_ROW_COLUMNS}
36
36
  from x_jobs where state = 'dead' order by updated_at desc limit $1
37
37
  `.trim();
38
38
 
39
- /** `run_at = now()` makes the requeued job due immediately; the attempt counter starts over. */
39
+ /**
40
+ * `run_at = now()` makes the requeued job due immediately; the attempt counter starts over. Fenced
41
+ * to FINISHED states and releasing the claim: requeueing a running row left `claimed_by` set with
42
+ * state `ready`, and a second worker claimed it — the job ran twice. `REQUEUEABLE_STATES`
43
+ * (`driver.ts`) is the same set; a row this matches nothing for was refused before it was sent,
44
+ * or raced to live in between.
45
+ */
40
46
  export const SQL_JOB_REQUEUE = `
41
47
  update x_jobs
42
- set state = 'ready', attempt = 0, run_at = now(), updated_at = now()
43
- where id = $1
48
+ set state = 'ready', attempt = 0, run_at = now(), updated_at = now(),
49
+ claimed_by = null, visible_at = null
50
+ where id = $1 and state in ('dead', 'cancelled', 'done', 'failed')
44
51
  returning ${JOB_ROW_COLUMNS}
45
52
  `.trim();
53
+
54
+ /**
55
+ * The LIVE row holding a requeued row's key, if any — `x_jobs_name_tenant_idempotency_live_idx`'s
56
+ * predicate, read before the update so the answer is `X_JOB_DUPLICATE` rather than a raw 23505.
57
+ */
58
+ export const SQL_JOB_LIVE_HOLDER = `
59
+ select id from x_jobs
60
+ where name = $1 and coalesce(tenant_id, '') = coalesce($2, '') and idempotency_key = $3
61
+ and id <> $4 and state in ('ready', 'delayed', 'running', 'suspended')
62
+ limit 1
63
+ `.trim();
64
+
65
+ /**
66
+ * "From that step onward": the step `fromStep` names and every step that started AFTER it.
67
+ * Deleting the named step alone left later steps replaying results computed from the old run.
68
+ * Strictly after, plus the step itself: a step sharing the target's millisecond is not provably
69
+ * later, and re-running an EARLIER step — the charge before the receipt — is the worse mistake.
70
+ */
71
+ export const SQL_STEPS_FROM = `
72
+ delete from x_job_steps
73
+ where run_id = $1
74
+ and (name = $2
75
+ or started_at > (select started_at from x_job_steps where run_id = $1 and name = $2))
76
+ `.trim();
@@ -17,7 +17,9 @@ export {
17
17
  SQL_JOB_DEAD_LETTERS,
18
18
  SQL_JOB_GET,
19
19
  SQL_JOB_LIST,
20
+ SQL_JOB_LIVE_HOLDER,
20
21
  SQL_JOB_REQUEUE,
22
+ SQL_STEPS_FROM,
21
23
  } from './driver-pg-jobs-sql';
22
24
 
23
25
  import { JOB_ROW_COLUMNS } from './driver-pg-jobs-sql';
@@ -150,14 +152,13 @@ returning id
150
152
  */
151
153
  export const SQL_STATS = `
152
154
  select queue,
153
- count(*) filter (where state = 'ready' and run_at <= now()) as ready,
154
- count(*) filter (where state = 'delayed'
155
- or (state = 'ready' and run_at > now())) as delayed,
155
+ count(*) filter (where state in ('ready', 'delayed') and run_at <= now()) as ready,
156
+ count(*) filter (where state in ('ready', 'delayed') and run_at > now()) as delayed,
156
157
  count(*) filter (where state = 'running') as running,
157
158
  count(*) filter (where state = 'suspended') as suspended,
158
159
  count(*) filter (where state = 'dead') as dead,
159
160
  coalesce(max(extract(epoch from now() - run_at)) filter
160
- (where state = 'ready' and run_at <= now()), 0) * 1000 as oldest_ready_ms
161
+ (where state in ('ready', 'delayed') and run_at <= now()), 0) * 1000 as oldest_ready_ms
161
162
  from x_jobs
162
163
  group by queue
163
164
  order by queue
package/src/driver-pg.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
  NackOptions,
21
21
  QueueStats,
22
22
  } from './driver';
23
- import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE } from './driver';
23
+ import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE, REQUEUEABLE_STATES } 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 {
@@ -38,6 +38,7 @@ import {
38
38
  SQL_JOB_DEAD_LETTERS,
39
39
  SQL_JOB_GET,
40
40
  SQL_JOB_LIST,
41
+ SQL_JOB_LIVE_HOLDER,
41
42
  SQL_JOB_REQUEUE,
42
43
  SQL_LEASE_ACQUIRE,
43
44
  SQL_LEASE_RELEASE,
@@ -47,9 +48,11 @@ import {
47
48
  SQL_STEP_GET,
48
49
  SQL_STEP_LIST,
49
50
  SQL_STEP_PUT,
51
+ SQL_STEPS_FROM,
50
52
  SQL_TRY_ADVISORY_LOCK,
51
53
  } from './driver-pg-sql';
52
54
  import { DriverUnavailableError, JobDuplicateError } from './errors';
55
+ import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
53
56
  import type { HeldLease, LeaseStore } from './leases';
54
57
  import type { StepStore } from './steps';
55
58
 
@@ -213,24 +216,33 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
213
216
  return rows.map(toJobRecord);
214
217
  },
215
218
  async requeue(jobId, requeueOptions) {
216
- if (requeueOptions?.fromStep !== undefined) {
217
- const current = await this.job(jobId);
218
- if (current !== undefined) {
219
- await exec().query(`delete from x_job_steps where run_id = $1 and name = $2`, [
220
- current.runId,
221
- requeueOptions.fromStep,
222
- ]);
223
- }
224
- }
225
- const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [jobId]);
226
- const row = rows[0];
227
- if (row === undefined) {
219
+ const current = await this.job(jobId);
220
+ if (current === undefined) {
228
221
  throw new DriverUnavailableError({
229
222
  driver: 'pg',
230
223
  cause: `job ${jobId} does not exist`,
231
224
  fix: 'x jobs ls --state dead --json',
232
225
  });
233
226
  }
227
+ // Both refusals BEFORE anything is deleted or updated — a refused requeue changes nothing.
228
+ if (!REQUEUEABLE_STATES.has(current.state)) {
229
+ throw new JobNotRequeueableError({ jobId, state: current.state });
230
+ }
231
+ const holders = await exec().query<{ id: string }>(SQL_JOB_LIVE_HOLDER, [
232
+ current.name,
233
+ current.tenantId ?? null,
234
+ current.idempotencyKey,
235
+ jobId,
236
+ ]);
237
+ const holder = holders[0];
238
+ if (holder !== undefined) throw requeueKeyTaken({ ...current, holderId: holder.id });
239
+ if (requeueOptions?.fromStep !== undefined) {
240
+ await exec().query(SQL_STEPS_FROM, [current.runId, requeueOptions.fromStep]);
241
+ }
242
+ const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [jobId]);
243
+ const row = rows[0];
244
+ // Read as finished a moment ago and live now: another requeue won the race.
245
+ if (row === undefined) throw new JobNotRequeueableError({ jobId, state: 'live' });
234
246
  return toJobRecord(row);
235
247
  },
236
248
  async cancel(jobId, reason) {
package/src/driver.ts CHANGED
@@ -32,6 +32,17 @@ export const JOB_STATES = [
32
32
 
33
33
  export type JobState = (typeof JOB_STATES)[number];
34
34
 
35
+ /**
36
+ * The states `requeue` — `x jobs retry` — accepts. Everything else is live: requeueing a running
37
+ * row ran it twice, and a queued one has nothing to retry. `SQL_JOB_REQUEUE` fences on the same set.
38
+ */
39
+ export const REQUEUEABLE_STATES: ReadonlySet<JobState> = new Set<JobState>([
40
+ 'dead',
41
+ 'cancelled',
42
+ 'done',
43
+ 'failed',
44
+ ]);
45
+
35
46
  /** Narrows a state read back off a queue row. Never a cast — the list decides. */
36
47
  export const isJobState = (value: string): value is JobState =>
37
48
  (JOB_STATES as readonly string[]).includes(value);
@@ -175,7 +186,11 @@ export interface JobIntrospection {
175
186
  job(jobId: string): Promise<JobRecord | undefined>;
176
187
  list(filter?: JobFilter): Promise<readonly JobRecord[]>;
177
188
  deadLetters(limit?: number): Promise<readonly JobRecord[]>;
178
- /** Re-queue a dead/failed job. `fromStep` drops step records from that step onward. */
189
+ /**
190
+ * Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, and a
191
+ * key a live job holds is `X_JOB_DUPLICATE`. `fromStep` drops that step and every step that
192
+ * started after it.
193
+ */
179
194
  requeue(jobId: string, options?: { readonly fromStep?: string }): Promise<JobRecord>;
180
195
  /**
181
196
  * Stop a job from outside. The only answer to a runaway pass that was otherwise "scale the
@@ -0,0 +1,34 @@
1
+ // Single responsibility: the refusal a `job()` declaration earns when it lacks a field every job
2
+ // declares. Its own module because `errors.ts` sits at the size ceiling; the CODE is registered
3
+ // there, beside every other jobs code, and only the constructor lives here.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+
7
+ /** The edit each required `job()` field takes, for `JobDeclarationInvalidError`'s fix line. */
8
+ const REQUIRED_FIELD_FIX = Object.freeze<Record<string, string>>({
9
+ input: 'input: t.object({ … })',
10
+ idempotencyKey: `idempotencyKey: (input) => \`<name>:\${input.id}\``,
11
+ tenant: "tenant: (input) => input.orgId — or tenant: 'none' for a job no org owns",
12
+ retry: "retry: { attempts: 5, backoff: 'exponential' }",
13
+ run: 'run: async ({ input, step }) => { … }',
14
+ });
15
+
16
+ /**
17
+ * `job()` called without one or more fields the type requires — generated code and JS callers.
18
+ * EVERY missing field in one refusal: a missing `retry` used to crash the app loader with a bare
19
+ * `TypeError … definition.retry.attempts`, reported as `X_CLI_UNEXPECTED`, and fixing fields one
20
+ * restart at a time is the slow way to learn a declaration.
21
+ */
22
+ export class JobDeclarationInvalidError extends UltimateError {
23
+ constructor(input: { job: string; missing: readonly string[] }) {
24
+ const edits = input.missing.map((field) =>
25
+ Object.hasOwn(REQUIRED_FIELD_FIX, field) ? REQUIRED_FIELD_FIX[field] : field,
26
+ );
27
+ super({
28
+ code: 'X_JOB_DECLARATION_INVALID',
29
+ cause: `job "${input.job}" is missing ${input.missing.join(', ')}, which every job declares`,
30
+ fix: `set ${edits.join('; ')} on job('${input.job}')`,
31
+ meta: { job: input.job, missing: [...input.missing] },
32
+ });
33
+ }
34
+ }
@@ -0,0 +1,34 @@
1
+ // Single responsibility: the two refusals `requeue` — `x jobs retry` — gives before it moves a
2
+ // row. Its own module because `errors.ts` sits at the size ceiling; the codes are registered there.
3
+
4
+ import { renderFixShellArg, UltimateError } from '@ultimat3/core';
5
+ import { JobDuplicateError } from './errors';
6
+
7
+ /**
8
+ * `x jobs retry` on a job that is still live. Requeueing a RUNNING row left `claimed_by` set with
9
+ * state `ready`, so a second worker claimed it and it ran twice; a queued one has nothing to retry.
10
+ */
11
+ export class JobNotRequeueableError extends UltimateError {
12
+ constructor(input: { jobId: string; state: string }) {
13
+ super({
14
+ code: 'X_JOB_NOT_REQUEUEABLE',
15
+ cause: `job ${input.jobId} is ${input.state}, and only a dead, cancelled, failed or done job can be requeued — a live one would run twice`,
16
+ fix: `x jobs cancel ${renderFixShellArg(input.jobId, '<job id>')} first, then x jobs retry ${renderFixShellArg(input.jobId, '<job id>')}`,
17
+ meta: { jobId: input.jobId, state: input.state },
18
+ });
19
+ }
20
+ }
21
+
22
+ /** The error a requeue gives when a LIVE job already holds the requeued row's key. */
23
+ export const requeueKeyTaken = (record: {
24
+ readonly id: string;
25
+ readonly name: string;
26
+ readonly idempotencyKey: string;
27
+ readonly holderId: string;
28
+ }): JobDuplicateError =>
29
+ new JobDuplicateError({
30
+ job: record.name,
31
+ idempotencyKey: record.idempotencyKey,
32
+ existingId: record.holderId,
33
+ fix: `x jobs show ${renderFixShellArg(record.holderId, '<live job id>')} — that is the live run of this key; cancel it first (x jobs cancel ${renderFixShellArg(record.holderId, '<live job id>')}) only if this one must run instead`,
34
+ });
package/src/errors.ts CHANGED
@@ -36,6 +36,8 @@ export const JOB_OWNED_ERROR_CODES = [
36
36
  'X_WEBHOOK_DELIVERY_REJECTED',
37
37
  'X_EXPORT_ROW_INVALID',
38
38
  'X_EXPORT_PART_TOO_LARGE',
39
+ 'X_JOB_DECLARATION_INVALID',
40
+ 'X_JOB_NOT_REQUEUEABLE',
39
41
  ] as const;
40
42
 
41
43
  /**
@@ -86,6 +88,8 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
86
88
  X_WEBHOOK_DELIVERY_REJECTED: 'the endpoint refused the delivery in a way a retry cannot change',
87
89
  X_EXPORT_ROW_INVALID: 'row() answered something the declared columns do not carry',
88
90
  X_EXPORT_PART_TOO_LARGE: 'one page encoded to more bytes than a part may hold',
91
+ X_JOB_DECLARATION_INVALID: 'the job declaration is missing a required field',
92
+ X_JOB_NOT_REQUEUEABLE: 'the job is still live and cannot be requeued',
89
93
  };
90
94
 
91
95
  // One unconditional call, so a second package claiming one of jobs' codes throws
@@ -148,11 +152,12 @@ registerErrorRetry({
148
152
 
149
153
  /** An enqueue collided with a live job holding the same idempotency key under `onConflict: 'error'`. */
150
154
  export class JobDuplicateError extends UltimateError {
151
- constructor(input: { job: string; idempotencyKey: string; existingId: string }) {
155
+ /** `fix` for a caller the enqueue-time instruction does not fit — a requeue, where it is moot. */
156
+ constructor(input: { job: string; idempotencyKey: string; existingId: string; fix?: string }) {
152
157
  super({
153
158
  code: 'X_JOB_DUPLICATE',
154
159
  cause: `job "${input.job}" already queued as ${input.existingId} with idempotencyKey "${input.idempotencyKey}"`,
155
- fix: 'pass onConflict: "dedupe" to enqueue, or make idempotencyKey narrower',
160
+ fix: input.fix ?? 'pass onConflict: "dedupe" to enqueue, or make idempotencyKey narrower',
156
161
  });
157
162
  }
158
163
  }
@@ -320,11 +325,20 @@ export class DriverUnavailableError extends UltimateError {
320
325
  * generated code and JS callers, so the guarantee holds at both ends.
321
326
  */
322
327
  export class IdempotencyRequiredError extends UltimateError {
323
- constructor(input: { job: string }) {
328
+ /**
329
+ * `positional` is a name `job()` minted from a counter (`anonymous-job-3`). Spliced into the key
330
+ * template it would be written into persisted idempotency keys — and it MOVES when an import is
331
+ * added above the declaration, so every key written before the move stops matching. The fix
332
+ * names the edit instead of the counter.
333
+ */
334
+ constructor(input: { job: string; positional?: boolean }) {
324
335
  super({
325
336
  code: 'X_IDEMPOTENCY_REQUIRED',
326
337
  cause: `job "${input.job}" has no idempotencyKey — at-least-once delivery would run it twice`,
327
- fix: `add idempotencyKey: (input) => \`${input.job}:\${input.id}\` to the job definition`,
338
+ fix:
339
+ input.positional === true
340
+ ? `give the job a stable name first — job({ name: 'syncInvoices', … }), or register it in apps/web/api/index.ts so its export name is stamped — then add idempotencyKey: (input) => \`syncInvoices:\${input.id}\``
341
+ : `add idempotencyKey: (input) => \`${input.job}:\${input.id}\` to the job definition`,
328
342
  });
329
343
  }
330
344
  }
package/src/index.ts CHANGED
@@ -173,6 +173,8 @@ export {
173
173
  OutboxNoTxError,
174
174
  StepDuplicateError,
175
175
  } from './errors';
176
+ export { JobDeclarationInvalidError } from './errors-declaration';
177
+ export { JobNotRequeueableError } from './errors-requeue';
176
178
  export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
177
179
  export { createMemoryEventBus, eventBus, publishEvent, setEventBus } from './events';
178
180
  export type { PgEventBusOptions } from './events-pg';
package/src/job.ts CHANGED
@@ -18,6 +18,7 @@ import { describeJob } from './describe';
18
18
  import type { EnqueueResult } from './driver';
19
19
  import { DEFAULT_QUEUE } from './driver';
20
20
  import { IdempotencyRequiredError, JobNameTakenError } from './errors';
21
+ import { JobDeclarationInvalidError } from './errors-declaration';
21
22
  import { NO_TENANT, tenantKeyFrom } from './limits';
22
23
  import type { EnqueueOptions } from './outbox';
23
24
  import { jobsFacade } from './outbox';
@@ -178,13 +179,31 @@ interface JobOrigin {
178
179
 
179
180
  const origin = new WeakMap<object, JobOrigin>();
180
181
 
182
+ /** The required fields a definition lacks, in declaration order. Read off the value, not the type. */
183
+ function missingJobFields(definition: object): string[] {
184
+ const loose = definition as unknown as Readonly<Record<string, unknown>>;
185
+ const missing: string[] = [];
186
+ if (typeof loose['input'] !== 'object' || loose['input'] === null) missing.push('input');
187
+ if (typeof loose['idempotencyKey'] !== 'function') missing.push('idempotencyKey');
188
+ if (loose['tenant'] === undefined) missing.push('tenant');
189
+ if (typeof loose['retry'] !== 'object' || loose['retry'] === null) missing.push('retry');
190
+ if (typeof loose['run'] !== 'function') missing.push('run');
191
+ return missing;
192
+ }
193
+
181
194
  export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
182
195
  anonymous += 1;
183
196
  const name = definition.name ?? `anonymous-job-${anonymous}`;
184
197
 
185
- // Runtime backstops for generated code and JS callers; TS already forbids omitting either.
186
- if (typeof definition.idempotencyKey !== 'function') {
187
- throw new IdempotencyRequiredError({ job: name });
198
+ // Runtime backstops for generated code and JS callers; TS already forbids omitting any of them.
199
+ // Collected, never one at a time — see `JobDeclarationInvalidError`. The two fields with a code
200
+ // of their own keep it when they are the only thing missing: shipped codes keep their meaning.
201
+ const missing = missingJobFields(definition);
202
+ if (missing.length === 1 && missing[0] === 'idempotencyKey') {
203
+ throw new IdempotencyRequiredError({ job: name, positional: definition.name === undefined });
204
+ }
205
+ if (missing.length > 0 && !(missing.length === 1 && missing[0] === 'tenant')) {
206
+ throw new JobDeclarationInvalidError({ job: name, missing });
188
207
  }
189
208
  assertJobTenant(name, definition.tenant);
190
209
  assert(