@ultimat3/jobs 29.4.0 → 30.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.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Durable background work. Steps that replay, a transactional outbox, and one driver interface so
4
4
  a job's code never names a backend.
5
5
 
6
+ `bun add @ultimat3/jobs` · guide: [Jobs And Workflows](https://github.com/developerz-ai/ultimate/wiki/Jobs-And-Workflows)
7
+
6
8
  ```ts
7
9
  import { job, t } from '@ultimat3/jobs';
8
10
 
@@ -36,6 +38,7 @@ nightlyDigest.describe(); // { kind: 'task', cron, tz, j
36
38
  | Member | On | Behaviour |
37
39
  |---|---|---|
38
40
  | `enqueue(input, options?)` | `JobHandle` | queues through `jobsFacade()`; joins the caller's `tx` when the outbox is installed. Answers `{ id, runId, deduped }` — the job's own ids on every path, staged or direct. `options.runId` names the run instead of having one minted |
41
+ | `enqueueMany(inputs, options?)` | `JobHandle` | many of one job: one round trip per 1,000 inputs (a statement on Postgres, a script on Redis) on the same path `enqueue` takes, answered in input order. At most `MAX_ENQUEUE_BATCH` (10,000, else `X_JOB_ENQUEUE_BATCH_TOO_LARGE`); `EnqueueManyOptions` is `EnqueueOptions` without `runId`. A loop of `enqueue` is `X_N_PLUS_ONE_WRITE`, whose fix names this call. `JobDriver.enqueueMany` is required of every driver (it opens with `assertEnqueueMany`: `onConflict: 'error'` is refused in a batch); `OutboxStore.stageMany` / `markPublishedMany` are optional: absent, the facade and the relay loop |
39
42
  | `as(actor, input, options?)` | `JobHandle` | enqueue on behalf of an actor — fills `tenantId` from `actor.orgId` so per-tenant limits apply |
40
43
  | `describe()` | `JobHandle` | the `JobDescriptor` the manifest, `/_x` and MCP read |
41
44
  | `entries()` | `TaskHandle` | the declared `[job, input]` pairs |
@@ -175,6 +178,20 @@ author has nothing to wrap and the pass opens the scope itself. See the backfill
175
178
  Omitting the field is a type error, and
176
179
  `X_JOB_TENANT_REQUIRED` for generated code and JS callers.
177
180
 
181
+ **The declared tenant is checked at the enqueue.** A payload whose `tenant(input)` names org A,
182
+ enqueued with `tenantId` B — explicitly, or by `.as(actorOfB, …)` — or under an ambient actor of
183
+ org B, is `X_JOB_TENANT_MISMATCH` before anything is staged, on `enqueue` and `enqueueMany`. A row
184
+ already queued that way is refused at its run, terminally, before the body. An actor with no org,
185
+ and `tenant: 'none'`, are never compared. The ambient half has one escape, `@ultimat3/entity`'s:
186
+ inside `crossTenant('<why>', () => onboardOrg.enqueue({ orgId: newOrg }))`, by an actor holding
187
+ `tenancy:cross`, the enqueue is admitted (read through entity's own write guard, re-proved at the
188
+ call). An explicit `tenantId` or `.as()` that contradicts the payload is never admitted.
189
+
190
+ **A row is queued for the org its payload names** when the enqueue states none: a plain
191
+ `enqueue(input)` stamps `tenant(input)`, so two orgs' identical idempotency keys are two jobs and
192
+ `perTenant` / `ratePerTenant` count the org. An explicit `tenantId` or `.as()` still wins — and the
193
+ refusal above guarantees they agree.
194
+
178
195
  A single boot-supplied service actor would have closed the same hole with one identity for every
179
196
  job in the app — which is a cross-tenant read waiting for the first job that takes an org id in its
180
197
  input. The tenant is a fact about the work, so the job declares it.
@@ -183,10 +200,25 @@ input. The tenant is a fact about the work, so the job declares it.
183
200
 
184
201
  | Call | Behaviour |
185
202
  |---|---|
186
- | `step.run(name, fn)` | runs once ever; result persisted before the next step starts. `fn` receives an `AbortSignal` |
203
+ | `step.run(name, fn)` | runs once ever; result persisted before the next step starts, and it answers what it persisted: `StepOutput<T>`, the JSON form, on every attempt. `fn` receives an `AbortSignal` |
204
+ | `step.run(name, fn, { compensate })` | `compensate(output, { signal })` undoes it if the run fails FOR GOOD; needs `job({ compensates: true })` |
205
+ | `step.run(name, fn, { once: true })` | at most once: `started` persisted before `fn`; a replay without `completed` is terminal `X_STEP_OUTCOME_UNKNOWN` |
187
206
  | `step.sleep(name, '3d')` | suspends the run, requeues it for the wake time |
188
207
  | `step.sleep('3d')` | same, step name derived from the duration |
189
- | `step.waitForEvent(name, event, { match, timeout })` | suspends until `publishEvent()` matches |
208
+ | `step.waitForEvent(name, event, { schema, match, timeout })` | suspends until `publishEvent()` matches; answers the payload parsed by the required `schema` |
209
+
210
+ | A step answer | Is |
211
+ |---|---|
212
+ | a `Date`, anything with a `toJSON()` | what that returns: `new Date(value)` at the read, a compile error otherwise |
213
+ | `undefined`, a function or a symbol at the top, or in an array | `null`: compare `=== null` |
214
+ | `undefined` or a function on a member | absent |
215
+ | a `bigint`, a cycle | `X_STEP_OUTPUT_UNSERIALIZABLE`, terminal: `String(id)`, the ids rather than the object graph |
216
+ | a stored event the wait's `schema` refuses | `X_JOB_EVENT_INVALID`, terminal, on a hit or a replay. The error carries the issues, never the payload |
217
+
218
+ A boot's bus runs on the open transaction's connection (`currentTx() ?? client`, as the outbox):
219
+ `publishEvent` inside a transaction commits and rolls back with it. `published_at` is the
220
+ statement's time, so a wait that begins between that publish and its commit compares later than the
221
+ stamp and never matches; the window is the length of the publishing transaction.
190
222
 
191
223
  `resetEventBus()` is the test seam beside `resetJobDriver()`: an event is matchable until it
192
224
  expires, so on a bus shared by one `bun test` process an answer one test published resumes the
@@ -204,10 +236,68 @@ again before declaring a timeout, so a worker running ahead of the database stil
204
236
  published after it asked and does not give up early. `eventsPurgeTarget(bus)` is the bus as a
205
237
  `purge()` target: `purgeExpired()` is awaited and answers the rows it removed.
206
238
 
239
+ **A publish wakes its waiters** on `postgresEventBus({ executor, wakes: true })` — what boot
240
+ installs over its own `postgresJobDriver`: `SQL_EVENT_PUBLISH_WAKE` inserts the event, makes every
241
+ `suspended` run with a matching `waiting` step due, and notifies each of their queues, in ONE
242
+ statement (a rollback undoes all three; `x_job_steps_waiting_idx` keeps it a seek). A waiting step
243
+ then parks for `WAKING_EVENT_POLL_MS` (15 min) — the backstop and the timeout's clock — instead of
244
+ `DEFAULT_EVENT_POLL_MS` (30 s), and asks `find` once more after writing `waiting`, so an event
245
+ committed in between is not left to the backstop. A declared `eventPoll` wins.
246
+
247
+ ### Compensation and `once`
248
+
249
+ ```ts
250
+ import { job, t } from '@ultimat3/jobs';
251
+
252
+ type Order = { readonly orgId: string; readonly orderId: string };
253
+ declare function charge(order: Order): Promise<{ id: string }>;
254
+ declare function refund(chargeId: string): Promise<void>;
255
+ declare function ship(order: Order): Promise<void>;
256
+ declare const vendor: { pay(order: Order): Promise<void> };
257
+
258
+ export const checkout = job({
259
+ input: t.object({ orgId: t.uuid, orderId: t.uuid }),
260
+ tenant: ({ orgId }) => orgId,
261
+ idempotencyKey: ({ orderId }) => `checkout:${orderId}`,
262
+ compensates: true,
263
+ retry: { attempts: 3 },
264
+ run: async ({ input, step }) => {
265
+ await step.run('charge', () => charge(input), { compensate: (c) => refund(c.id) });
266
+ await step.run('ship', () => ship(input));
267
+ await step.run('payout', () => vendor.pay(input), { once: true });
268
+ },
269
+ });
270
+ ```
271
+
272
+ | Rule | Behaviour |
273
+ |---|---|
274
+ | when | only once the run fails for good (`!decision.retry`); never a retry, cancel, drain or suspension |
275
+ | order | last-in first-out, each as step `compensate:<name>`, so a replay skips what ran |
276
+ | durable | `unwinding` is written first; any later attempt replays to its first incomplete step (`UnwindReached`) and unwinds — `x jobs retry` too |
277
+ | crash on the final attempt | rows are enqueued with `UNWIND_MARGIN` (2) attempts past `retry.attempts` (`enqueuedAttempts`); the claim re-offers the lapsed row and it unwinds |
278
+ | a compensator throws | handed back while the unwind has attempts; then dead, `X_JOB_COMPENSATION_FAILED` |
279
+ | `once` unknown | `x jobs resolve-step <id> <step> --ran --output '<json>'` or `--not-ran` (`resolveJobStep`), then `x jobs retry <id>` |
280
+ | reserved | `unwinding`, `compensate:*` as a body's step name: `X_JOB_DECLARATION_INVALID` |
281
+
207
282
  Step names are the replay key, so they must be deterministic and unique in a run — a
208
283
  duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
209
284
  (`StepSuspension`), never a failure: it does not burn a retry attempt.
210
285
 
286
+ What a crash does to an effect left outside a step is proven, not reviewed: `x exercise` kills
287
+ every job it reaches at every step boundary (`crashProof`, `@ultimat3/testing`), and an effect a
288
+ killed run makes more often than a clean one is `X_JOB_CRASH_DIVERGED`, red in `x verify`. A job
289
+ test asks the same with `crashProof(job, input)`.
290
+ [Testing](https://github.com/developerz-ai/ultimate/wiki/Testing#the-crash-proof).
291
+
292
+ ## Rollups and materialized views are jobs
293
+
294
+ `rollup({ name, into, table, key, lookback, bucket, source, map, tenant })` re-aggregates every
295
+ bucket from the one holding `now - lookback` on, read from that bucket's first instant in
296
+ `bucket.timeZone`, into the app's table with one `upsertAll` on `key` (a unique index on `into`, checked at
297
+ declaration: `X_JOB_ROLLUP_INVALID`). `refreshView({ name, view, concurrently? })` refreshes a
298
+ materialized view entity (`X_JOB_REFRESH_VIEW_INVALID`). Both are factories over `job()`; the
299
+ schedule is the app's `task`. Reference: [Jobs and workflows](https://github.com/developerz-ai/ultimate/wiki/Jobs-And-Workflows#rollups-and-materialized-views).
300
+
211
301
  ## Backfills are jobs
212
302
 
213
303
  `backfill()` is a **factory over `job()`**, not a ninth primitive — one pass over every row a
@@ -249,6 +339,13 @@ without a statement, and the iteration reopens at the cursor they left behind.
249
339
  | `rate` throttles, and there is no way off | a sweep shares its pool with the requests the app is still serving; to go faster raise the number |
250
340
  | the pause is spent **inside** the step | a resumed attempt replays 500 checkpoints and re-pays none of their pauses |
251
341
 
342
+ ### A remote source, and one pass per scope
343
+
344
+ `pages: async ({ ctx, cursor, signal, input }) => ({ rows, next })` beside `source` — exactly one
345
+ of the two, or `X_BACKFILL_SOURCE_INVALID`. One page per step, checkpointed `{ cursor, rows: n }`;
346
+ `next: null` ends the pass. `input` + `scope(input)` makes one pass per scope: the ledger row and
347
+ the live idempotency key are `<name>:<scope>`. An `input` with no `scope` is refused.
348
+
252
349
  ### The throttle
253
350
 
254
351
  `rate` is batches per second, defaulting to `DEFAULT_BACKFILL_RATE` (5) — 5,000 rows/sec at the
@@ -376,6 +473,44 @@ the new pods serve, puts the sweeps on the queue and exits, and a slow UPDATE ne
376
473
  open against a database still serving the previous build. `--all` isolates per name and continues
377
474
  past a failure, so one wedged cleanup cannot block every later one forever.
378
475
 
476
+ ## Consumers are jobs too
477
+
478
+ `consumer()` reads a queue, a change stream or a vendor's cursor: a factory over `job()` plus a
479
+ `.supervisor` `task()`. One running body per key, `idempotencyKey: consumer:<name>:<key>`, no
480
+ timeout, the cursor saved in the batch's own transaction, the drain honoured.
481
+
482
+ ```ts
483
+ import { consumer } from '@ultimat3/jobs';
484
+
485
+ interface Order { readonly id: string }
486
+ declare const feed: { since(cursor: string | null, signal: AbortSignal): AsyncIterable<Order> };
487
+ declare function upsertOrders(rows: readonly Order[]): Promise<void>;
488
+ declare const cursors: {
489
+ of(key: string): Promise<string | null>;
490
+ save(key: string, next: string): Promise<void>;
491
+ };
492
+
493
+ export const syncOrders = consumer<Order, string>({
494
+ name: 'sync-orders',
495
+ tenant: 'none',
496
+ keys: () => ['orders'],
497
+ open: ({ cursor, signal }) => feed.since(cursor, signal), // the app's AsyncIterable
498
+ handle: async ({ items }) => {
499
+ await upsertOrders(items);
500
+ return items[items.length - 1]?.id ?? '';
501
+ },
502
+ cursor: { load: (key) => cursors.of(key), save: (key, next) => cursors.save(key, next) },
503
+ });
504
+ ```
505
+
506
+ | Rule | Why |
507
+ |---|---|
508
+ | `handle` returns the next cursor; the framework saves it in the same `withTransaction` | cursor and effect commit together or not at all |
509
+ | `.supervisor` (`<name>.supervisor`, every `rearm`, UTC) enqueues `<name>.arm`, which enqueues each of `keys()` | a live key's re-arm is deduped; a settled or dead one restarts from its saved cursor |
510
+ | a self-`.enqueue` from inside the run is `X_CONSUMER_SELF_ENQUEUE` | it would dedupe onto the running row and arm nothing |
511
+ | no `cursor`, a `timeout`, a bad `batch` or `rearm` is `X_CONSUMER_INVALID` | refused at declaration |
512
+ | at least once per item | a drained or failed batch is delivered again: `handle` is idempotent per item |
513
+
379
514
  ## Retention sweeps are jobs too
380
515
 
381
516
  `purge()` is the **second factory over `job()`**, and it exists because three framework stores
@@ -503,6 +638,91 @@ exports/<orgId>/<exportId>/manifest.json
503
638
  this package holds no storage dependency, and taking one so a queue could name a disk would put the
504
639
  object store on tier 3's import graph.
505
640
 
641
+ ## Importing a large file is a job too
642
+
643
+ `importRows()` is `exportRows()`'s twin: a factory over `job()` that reads one object from storage
644
+ a page at a time, through the app's schema, into the app's `handle`. It exists because
645
+ `parse(await disk.get(key))` holds the whole upload, and a retried job re-applies every row from
646
+ zero. Which columns mean what, and who may import, stay the app's.
647
+
648
+ ```ts
649
+ import { importRows, t } from '@ultimat3/jobs';
650
+ import { disk } from '@ultimat3/storage';
651
+
652
+ interface Order {
653
+ readonly id: string;
654
+ readonly orgId: string;
655
+ readonly total: number;
656
+ }
657
+
658
+ // The app's own table: `handle` takes whatever `db.orders.upsertAll(…)` answers.
659
+ declare const db: {
660
+ readonly orders: {
661
+ upsertAll(rows: readonly Order[], args: { onConflict: readonly string[] }): Promise<unknown>;
662
+ };
663
+ };
664
+
665
+ export const importOrders = importRows({
666
+ name: 'orders.import',
667
+ input: t.object({ orgId: t.uuid, key: t.string }),
668
+ tenant: ({ orgId }) => orgId,
669
+ source: () => disk('imports'), // a thunk; a StorageDriver IS an ImportSource
670
+ key: ({ key }) => key,
671
+ format: 'csv',
672
+ columns: ['id', 'total'],
673
+ schema: t.object({ id: t.uuid, total: t.number.int() }),
674
+ handle: ({ rows, input }) =>
675
+ db.orders.upsertAll(rows.map((row) => ({ ...row, orgId: input.orgId })), {
676
+ onConflict: ['orgId', 'id'],
677
+ }),
678
+ errors: { sink: () => disk('imports'), prefix: ({ orgId }) => `imports/${orgId}/errors` },
679
+ });
680
+ ```
681
+
682
+ | Rule | Why |
683
+ |---|---|
684
+ | one step per page; its output `{ offset, line, rows, rejected, errorPart, header }` is the checkpoint | a killed attempt resumes at the byte offset. Never the rows: a step's output is retained for the whole run |
685
+ | no carry between steps | bytes after the last complete record are re-read by the next page; a record boundary is found on BYTES (`0x0A` outside `"`), which no UTF-8 sequence contains |
686
+ | `handle` runs at least once per page | `upsertAll` is the idempotent spelling, as for `backfill()` |
687
+ | `pageBytes` (1 MiB) must exceed `maxRecordBytes` (64 KiB) | so every window holds a whole record; past `maxRecordBytes` is `X_IMPORT_RECORD_TOO_LARGE` |
688
+ | a page uses its whole `pageBytes` read, handed to `handle` `batch` (500) records at a time | bytes read are O(file) and steps about file ÷ `pageBytes`, while one call is still one bounded statement. Measured: a 10 MB file of `a\n` rows is 10 reads, 10,000,004 bytes and 10 page steps |
689
+ | csv: header = `columns`, any order; empty cell `null`, quoted `""` the empty string; cells coerced as an HTTP form is (`coerceInput`) | `X_IMPORT_HEADER_MISMATCH` names missing and undeclared |
690
+ | ndjson: a key outside `columns` refuses the row | `record.undeclared`, naming the key |
691
+ | a refused row is `{ line, field, code }` in `<prefix>/<runId>/errors-NNNNN.ndjson` (`importErrorKey`); the result's `errorPrefix` names it, so a re-import never mixes runs | the code is a format rule (`IMPORT_REJECTIONS`) or the schema's `rule`; **never the value** |
692
+ | past `maxRowErrors` (1,000) the run fails `X_IMPORT_TOO_MANY_ERRORS` | the `batch` that crossed it unhandled, its report written; `0` stops at the first bad batch |
693
+ | the report is `{ rows, rejected, parts, bytes, errorPrefix }` | bounded, for `x jobs show` |
694
+ | a csv with no first line — empty, a BOM, blank lines — is `X_IMPORT_HEADER_MISMATCH` | zero rows from a file that never named its columns is not a success |
695
+ | a header mismatch prints the DECLARED names whole and the file's as a count plus five escaped, cut names | a header cell is file content: raw, it forges a log line or prompts the agent reading `x jobs show` |
696
+ | `tenant: 'none'` gets no cross-tenant escape | a scoped write in `handle` fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED` |
697
+ | no formula guard is undone | an import cannot know a leading `'` was the exporter's |
698
+
699
+ Measured `As of 2026-11-03` (`pageBytes` 1 MiB, `batch` 5,000): a 200 MB csv imported with the
700
+ heap up 42 MB, a 20 MB one with it up 24 MB — a page and the garbage behind it, not the file.
701
+ `ImportSource` is a seam for `ExportSink`'s reason; `scripts/export-sink.test.ts` compiles
702
+ `StorageDriver` into both. `memoryImportSource()` is the test source.
703
+
704
+ ## One person's data is a job too
705
+
706
+ `defineSubject(entity, { driver, key, tables, targets?, columns?, batch? })` declares the person
707
+ once; `subjectExport({ name, input, tenant, subject, sink, prefix, format })` and
708
+ `subjectErase({ name, input, tenant, subject })` are factories over `job()` that take it. The
709
+ reach is planned when a run starts (`planSubject`, pure), from every registered entity's foreign
710
+ keys, continued only through tables the erase deletes; the first refusal of the declaration
711
+ throws before a row is touched (`X_SUBJECT_TABLE_UNDECLARED`, `X_SUBJECT_TREATMENT_INVALID`,
712
+ `X_SUBJECT_CYCLE`, all terminal).
713
+
714
+ | Piece | Does |
715
+ |---|---|
716
+ | `subject-plan.ts` | reach, children-first order, loop-closing keys to clear first, every refusal |
717
+ | `subject-select.ts` | a table's rows of the subject a page of parent keys at a time, through `database()` over the app's driver |
718
+ | `subject-erase-pass.ts` | targets (fresh subject row each step), cleared keys, then each table; one step per page, each write `deleteWhere`/`updateWhere` on one key |
719
+ | `subject-export-pass.ts` | one object per page per table and target, `index.json` last; sealed columns left out, instants as ISO UTC |
720
+ | `subjectEraseTables(subject, key, step)` | the table passes alone, under the caller's context — what `@ultimat3/testing`'s `subjectProof` drives |
721
+
722
+ `SubjectTarget` (`name`, `columns`, `exportPage`, `erase`) is structural, as `PurgeTarget` is:
723
+ `authSubjectTarget`, `auditSubjectTarget` and `storageSubjectTarget` satisfy it from their own
724
+ packages. `{ target, retain: '<why>' }` exports a target and never erases it.
725
+
506
726
  ## Outbound webhooks are jobs too
507
727
 
508
728
  `webhook()` is a factory over `job()` and delivers **one event to one endpoint**. Retry with
@@ -591,6 +811,51 @@ default: past it the worker logs `jobs.worker.drain-abandoned` and exits, and th
591
811
  only when its lease lapses, as a COUNTED attempt the receiver may already have processed.
592
812
  `timeout: '10s'` against the default budget ends every drained delivery inside it.
593
813
 
814
+ ## Inbound webhooks are jobs too
815
+
816
+ `webhookReceiver()` is the twin of `webhook()`: a factory over `job()` plus the plain `Route` that
817
+ feeds it. The route reads the exact bytes under `maxBytes`, asks your `verify`, enqueues the
818
+ payload it answered with `outbox: false`, and only then answers `200 { ok: true, eventId,
819
+ deduped }`. `handle` is the job's body and receives that payload, already verified. **No vendor
820
+ verifier ships**: `verify` is a plain function, `verifyBodyHmac` from `@ultimat3/http` the one
821
+ helper. **Declaring it is its registration**: list it in `defineApi({ jobs })`, and both boots mount
822
+ its routes (`webhookReceiverRoutes()`), with no `runtime.ts` line.
823
+
824
+ ```ts
825
+ import { verifyBodyHmac } from '@ultimat3/http';
826
+ import { webhookReceiver } from '@ultimat3/jobs';
827
+
828
+ export const vendorEvents = webhookReceiver({
829
+ name: 'vendor.events',
830
+ path: '/hooks/vendor/:account',
831
+ tenant: 'none',
832
+ verify: (inbound) => {
833
+ const { text } = verifyBodyHmac({
834
+ bytes: inbound.bytes,
835
+ secret: Bun.env['VENDOR_SECRET'] ?? '',
836
+ given: inbound.headers.get('x-vendor-signature'),
837
+ encoding: 'hex',
838
+ });
839
+ // The id from the SIGNED bytes: an unsigned header would replay one signature forever.
840
+ const id = /"id":"([^"]{1,256})"/.exec(text ?? '')?.[1] ?? '';
841
+ return { eventId: id, payload: { text } };
842
+ },
843
+ handle: async ({ eventId, payload }) => ({ eventId, payload }),
844
+ });
845
+ ```
846
+
847
+ | Rule | Why |
848
+ |---|---|
849
+ | nothing is parsed before `verify`, and no content type is required | a schema that answers first turns a vendor's shape change into a refusal, and XML, `octet-stream` or no type would never reach the check |
850
+ | `verify` answers `{ eventId, payload }` after the mac check, and `handle` receives the payload | a retry an hour later must not re-verify against a 5-minute window; what the queue holds was verified once, at the door |
851
+ | `eventId` comes from verified data, 1–256 printable ASCII characters | it is the dedupe key, so an unsigned one replays a captured signature under fresh ids. Anything else is `X_WEBHOOK_EVENT_ID_INVALID` (422), never echoed, never a 5xx a vendor retries forever |
852
+ | the ack follows the durable row | `outbox: false`: the row is written, not staged behind a commit. An enqueue failure is 503 + `Retry-After` (`X_WEBHOOK_RECEIVER_UNAVAILABLE`), so the vendor redelivers |
853
+ | dedupe is the live window | a live redelivery answers `deduped: true`. After the job settles, `handle` runs again: at least once, and the seen-set is your table — a claim before the enqueue would lose the event if the enqueue failed |
854
+ | the payload is JSON, `.sensitive()` | `x jobs show` prints `[redacted]`. It stays until `jobs.doneRetentionMs` / `deadRetentionMs` |
855
+ | `verify` may answer `{ answer: Response }`, and `handshake` mounts `GET` | a subscription confirm or a challenge is the vendor's protocol; nothing queues |
856
+ | `.routes()` is set on the job handle in place, never on a copy | `isJobHandle` proves the object `job()` built, so `defineApi({ jobs })` registers it like any job |
857
+ | the path is absolute and outside `/api` and `/_x` | `X_WEBHOOK_RECEIVER_INVALID` at declaration, beside a missing `verify`/`handle` or a bad `maxBytes` |
858
+
594
859
  ## The deadline cancels
595
860
 
596
861
  A job's `timeout` aborts `ctx.signal` **before** it fails the attempt, because the nack that
@@ -636,7 +901,8 @@ them, so no `job()` could ask for either):
636
901
  |---|---|---|
637
902
  | `timeout` | the whole attempt — aborts `ctx.signal`, then fails it | no attempt deadline |
638
903
  | `stepTimeout` | ONE `step.run` — aborts that step's signal, then fails the step | no per-step ceiling |
639
- | `eventPoll` | how long a `step.waitForEvent` parks between polls | 30s |
904
+ | `eventPoll` | how long a `step.waitForEvent` (its `schema` required) parks between polls | 30s; 15 min on a waking bus |
905
+ | `compensates` | `step.run(…, { compensate })` is legal; rows carry `UNWIND_MARGIN` more attempts | false |
640
906
 
641
907
  A zero or negative `stepTimeout` / `eventPoll` is refused at declaration, the way `concurrency: 0`
642
908
  is: `withStepTimeout` reads `<= 0` as "no ceiling at all", which is the opposite of what the author
@@ -772,14 +1038,14 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
772
1038
  | Driver | Status | Backing | Use |
773
1039
  |---|---|---|---|
774
1040
  | `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 |
775
- | `redis` | complete, `As of 2026-10` | `Bun.redis`; one Lua script per operation, every key in one `{hash tag}` slot | a queue off the database; no `introspect`, `backfills` or `leases` |
1041
+ | `redis` | complete, `As of 2026-10` | core's `redisClient()` (`@ultimat3/core/redis`); one Lua script per operation, every key in one `{hash tag}` slot | a queue off the database; no `introspect`, `backfills` or `leases` |
776
1042
  | `memory` | complete | in-process maps | `x dev`, tests |
777
1043
 
778
1044
  **`redisJobDriver({ client?, prefix?, clock?, doneTtlMs? })`** passes `@ultimat3/testing`'s
779
1045
  `jobDriverConformance`, the suite the other two pass, and `driver-redis.live.test.ts` runs one
780
1046
  script of operations on it and on the memory driver and compares every answer. Installed with
781
1047
  `setJobDriver(redisJobDriver())` (or `ServeOptions.runtime.jobs`), imported from its own entry
782
- `@ultimat3/jobs/redis` so the barrel every role boots carries none of it; `Bun.redis` reads `REDIS_URL`.
1048
+ `@ultimat3/jobs/redis` so the barrel every role boots carries none of it; the client reads `REDIS_URL`.
783
1049
  What it does not carry: `introspect` (so `x jobs show`/`retry`/`cancel` and queue pauses answer
784
1050
  for Postgres only), `backfills`, and `leases` — a job declaring `concurrency` refuses
785
1051
  `jobWorker().start()` (`X_JOB_CONCURRENCY_UNENFORCEABLE`). A `done` row and its steps expire
@@ -787,6 +1053,16 @@ after `doneTtlMs` (a week; `0` keeps them); `dead` and `failed` rows are kept. T
787
1053
  stages in Postgres — the relay publishes committed rows onto it with their ids. The NATS stub
788
1054
  deleted in 25.0.0 stays deleted.
789
1055
 
1056
+ **Redis down refuses, it never waits** `As of 2026-11`. The driver takes core's `redisClient()`,
1057
+ not `Bun.redis`: no offline queue, so a command while disconnected is refused at once, and a
1058
+ reconnect loop that never gives up, so a restarted server is back on every pod with no restart.
1059
+ `Bun.redis` blocked an `enqueue` while it retried (31 s, measured on a stopped server), then
1060
+ stayed closed after its retries ran out. A refusal for want of a connection (`ERR_REDIS_CONNECTION_CLOSED`) is
1061
+ `X_DRIVER_UNAVAILABLE` (`driver-redis-refusal.ts`), on `enqueue` and on `claim` alike; nothing was
1062
+ queued, so the same call is safe to repeat. Catch it where a queued retry was implied. A server's
1063
+ own error reply passes through unmapped. There is no per-command deadline here — a stuck command
1064
+ has the request's. A `client` handed in keeps its own behaviour and still gets the mapping.
1065
+
790
1066
  The pg SQL lives verbatim in `src/driver-pg-*sql.ts` (`SQL_CLAIM`, `SQL_ENQUEUE`, `SQL_NACK`, …)
791
1067
  so an agent debugging a stuck queue can read and run the exact statement. The barrel exports
792
1068
  only the statement another package runs: `SQL_JOBS_TABLE` (the install) — 25.0.0 took the rest
@@ -918,7 +1194,7 @@ export const executor: PgExecutor = dbExecutor(() => client);
918
1194
  | Not this | Because |
919
1195
  |---|---|
920
1196
  | `{ executor: Bun.sql }` | `Bun.sql` is a tagged template with no `.query(text, values)`: the first claim is a `TypeError` |
921
- | a hand adapter over raw `Bun.SQL`'s `unsafe(text, values)` | observed `As of 2026-10`: a `$n::jsonb` parameter is encoded twice unless the client is built with `prepare: false`, and a JS array is refused where the statement expects `text[]` (`SQL_CLAIM`'s `queue = any($1::text[])`) unless it is written as an array literal. `@ultimat3/db`'s client handles both |
1197
+ | a hand adapter over raw `Bun.SQL`'s `unsafe(text, values)` | observed `As of 2026-10`: a `$n::jsonb` parameter is encoded twice unless the client is built with `prepare: false`, and a JS array is refused where the statement expects `text[]` (`SQL_CLAIM`'s `unnest($1::text[])`) unless it is written as an array literal. `@ultimat3/db`'s client handles both |
922
1198
 
923
1199
  ## Idle cost and pickup latency
924
1200
 
@@ -1066,10 +1342,11 @@ queue slots is the bound, as before.
1066
1342
  What a scaler reads, per process: `worker.stats()` → `inFlight` and `maxInFlight` (`null` when
1067
1343
  uncapped); the registry row's `concurrency` is the tighter of the cap and the slot sum; and two
1068
1344
  unlabelled gauges, `job_worker_in_flight` and `job_worker_capacity`, each the SUM over the live workers in the process (a stopped worker leaves both). Pods needed is
1069
- `ceil((queue_depth + Σ job_worker_in_flight) / job_worker_capacity)`. A fleet-wide ceiling is a
1345
+ `ceil(queue_demand / job_worker_capacity)`, with the demand deduplicated: `max by (queue) (queue_demand)`. A fleet-wide ceiling is a
1070
1346
  different number: `maxInFlight` × replicas, or `job.concurrency` for one job.
1071
1347
 
1072
- **A scaler reading Postgres** (KEDA's `postgresql` trigger) runs the statement below — the
1348
+ **A scaler reading Postgres** (KEDA's `postgresql` trigger) runs the statement below — the rows
1349
+ `queue_demand` counts per queue (`driver-pg-demand-sql.ts`), in the
1073
1350
  package's `scaler-depth-fixture.ts`, proved against a real Postgres (`scaler-depth.job.test.ts`)
1074
1351
  and pinned to this block (`scaler-depth-readme.test.ts`); KEDA binds
1075
1352
  nothing, so the queues are inlined as `'{a,b}'::text[]`: running jobs plus due,
@@ -1206,7 +1483,7 @@ the pg and the memory driver alike. `x jobs`, `/_x` and a dashboard read nothing
1206
1483
  | `promote(id)` | a job waiting on its `runAt` — delayed, or backing off — is due now | — |
1207
1484
  | `pauseQueue(q)` / `resumeQueue(q)` / `pausedQueues()` | a paused queue is claimed by no worker; enqueues still land | effective at every worker's next claim |
1208
1485
  | `pauseTask(t)` / `resumeTask(t)` / `pausedTasks()` | a paused task is not dispatched; on resume its own `catchUp` decides what it missed | — |
1209
- | `workers()` | every live worker: id, host, started, queues, slots, in-flight job ids, last heartbeat, `drainingAt` once it drains | rows expire one visibility timeout after the last heartbeat; `MAX_WORKER_IN_FLIGHT` = 100 ids |
1486
+ | `workers()` | every live worker: id, host, `build` when `jobWorker({ build })` named one, started, queues, slots, in-flight job ids, last heartbeat, `drainingAt` once it drains | rows expire one visibility timeout after the last heartbeat; `MAX_WORKER_IN_FLIGHT` = 100 ids |
1210
1487
  | `counters({ job, sinceMs })` / `counterTotals(sinceMs)` | per-job history: `done`, `retried`, `failed`, `dead`, `durationMs` per bucket | `COUNTER_TIERS`: 1-minute buckets for 24 h, 5-minute for 7 d, 1-hour for 30 d — 4,176 rows per job name at most |
1211
1488
  | `rollupCounters()` | fold buckets past their tier into the next; the scheduler leader calls it once a minute | idempotent |
1212
1489
  | `recordProgress(id, { workerId, claim }, progress)` | what `run({ progress })` writes | at most one write per `PROGRESS_INTERVAL_MS` (1 s) per run, plus the last before the settle |
@@ -1287,6 +1564,10 @@ export const syncAccountWatched = job({
1287
1564
  last `progress`), `inspectDeadLetters`, `retryFromStep`, `cancelJob`, `removeJob`, `promoteJob`,
1288
1565
  `pauseQueue`, `resumeQueue`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
1289
1566
 
1567
+ `inspectJob`'s `input` masks every `.sensitive()` field of the job's own `input:` schema, then every
1568
+ `redactKeys` key. A job this process has not registered — its declaration deleted or renamed —
1569
+ answers `input: '[redacted]'` whole: with no schema nothing can say which keys were sensitive.
1570
+
1290
1571
  `cancelJob(driver, id, reason?)` is the answer to a runaway pass. A queued row becomes `cancelled`
1291
1572
  immediately; a RUNNING one stops at its next heartbeat, which no longer matches its own row and
1292
1573
  aborts the attempt — `steps.ts` then refuses every write. `ack` and `nack` are fenced on
@@ -1296,7 +1577,8 @@ aborts the attempt — `steps.ts` then refuses every write. `ack` and `nack` are
1296
1577
 
1297
1578
  | Series | Kind | Answers |
1298
1579
  |---|---|---|
1299
- | `queue_depth{queue}` | gauge | how much work is waiting — the HPA's signal |
1580
+ | `queue_depth{queue}` | gauge | how much work is waiting: the backlog alert's series. It reads 0 while a fleet holds every job it has, so nothing scales on it |
1581
+ | `queue_demand{queue}` | gauge | what serving workers must hold: running on one, or due and claimable. The chart's worker HPA reads it. `JobDriver.demand()`, asked only when `stats()` shows work, required of every driver; Redis has no worker registry, so it counts every running row. A draining worker publishes 0 |
1300
1582
  | `jobs_total{queue,outcome}` | counter | is any of it succeeding |
1301
1583
  | `queue_oldest_ready_seconds{queue}` | gauge | *"page if the oldest job in `payments` is older than 5 minutes"* |
1302
1584
  | `queue_dead_jobs{queue}` | gauge | a dead-letter queue that filled overnight and stopped growing — a counter's rate is flat there |
@@ -1319,6 +1601,8 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
1319
1601
  |---|---|
1320
1602
  | `X_IDEMPOTENCY_REQUIRED` | job defined without an idempotency key (JS callers) |
1321
1603
  | `X_STEP_DUPLICATE` | two steps share a name in one run |
1604
+ | `X_STEP_OUTPUT_UNSERIALIZABLE` | a step returned a value JSON cannot hold |
1605
+ | `X_JOB_EVENT_INVALID` | a waited event failed its schema |
1322
1606
  | `X_JOB_DUPLICATE` | enqueue collided with a live key under `onConflict: 'error'`; or two different job/task handles registered under one name |
1323
1607
  | `X_JOB_TIMEOUT` | job or required `waitForEvent` exceeded its timeout |
1324
1608
  | `X_JOB_MAX_ATTEMPTS` | retries exhausted, job dead-lettered |
@@ -1379,11 +1663,13 @@ a job boundary the class is gone and the `code` is what survives — match on th
1379
1663
  | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
1380
1664
  | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
1381
1665
  | `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
1666
+ | `JobEventInvalidError` | `X_JOB_EVENT_INVALID` | `src/errors-steps.ts` |
1382
1667
  | `JobTenantMismatchError` | `X_JOB_TENANT_MISMATCH` | `src/errors-tenant.ts` |
1383
1668
  | `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
1384
1669
  | `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
1385
1670
  | `OutboxNoTxError` | `X_OUTBOX_NO_TX` | `src/errors.ts` |
1386
1671
  | `StepDuplicateError` | `X_STEP_DUPLICATE` | `src/errors.ts` |
1672
+ | `StepOutputUnserializableError` | `X_STEP_OUTPUT_UNSERIALIZABLE` | `src/errors-steps.ts` |
1387
1673
  | `WebhookDeliveryFailedError` | `X_WEBHOOK_DELIVERY_FAILED` | `src/webhook-errors.ts` |
1388
1674
  | `WebhookDeliveryRejectedError` | `X_WEBHOOK_DELIVERY_REJECTED` | `src/webhook-errors.ts` |
1389
1675
  | `WebhookDeliveryThrottledError` | `X_WEBHOOK_DELIVERY_THROTTLED` | `src/webhook-errors.ts` |
@@ -1397,3 +1683,11 @@ a job boundary the class is gone and the `code` is what survives — match on th
1397
1683
 
1398
1684
  Tier 3. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/entity`,
1399
1685
  `@ultimat3/policy`, `@ultimat3/cache`, `@ultimat3/time`. Never HTTP, render or UI.
1686
+
1687
+ ## Supporting exports
1688
+
1689
+ `As of 2026-11-02`.
1690
+
1691
+ | Name | What |
1692
+ |---|---|
1693
+ | `ON_SETTLED_TIMEOUT_MS` | 30,000: the `onSettled` ceiling across every try unless the job declares `onSettledTimeout` |
package/package.json CHANGED
@@ -1,7 +1,18 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "29.4.0",
3
+ "version": "30.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "jobs",
10
+ "queue",
11
+ "workflows",
12
+ "cron",
13
+ "outbox"
14
+ ],
15
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/Jobs-And-Workflows",
5
16
  "license": "MIT",
6
17
  "type": "module",
7
18
  "repository": {
@@ -33,10 +44,11 @@
33
44
  "test": "bun test"
34
45
  },
35
46
  "dependencies": {
36
- "@ultimat3/core": "29.4.0",
37
- "@ultimat3/db": "29.4.0",
38
- "@ultimat3/entity": "29.4.0",
39
- "@ultimat3/schema": "29.4.0",
40
- "@ultimat3/time": "29.4.0"
47
+ "@ultimat3/core": "30.0.0",
48
+ "@ultimat3/db": "30.0.0",
49
+ "@ultimat3/entity": "30.0.0",
50
+ "@ultimat3/http": "30.0.0",
51
+ "@ultimat3/schema": "30.0.0",
52
+ "@ultimat3/time": "30.0.0"
41
53
  }
42
54
  }