@ultimat3/jobs 29.4.0 → 30.1.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 +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +149 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +99 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- 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` | `
|
|
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;
|
|
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 `
|
|
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(
|
|
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
|
|
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": "
|
|
3
|
+
"version": "30.1.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": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/entity": "
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
47
|
+
"@ultimat3/core": "30.1.0",
|
|
48
|
+
"@ultimat3/db": "30.1.0",
|
|
49
|
+
"@ultimat3/entity": "30.1.0",
|
|
50
|
+
"@ultimat3/http": "30.1.0",
|
|
51
|
+
"@ultimat3/schema": "30.1.0",
|
|
52
|
+
"@ultimat3/time": "30.1.0"
|
|
41
53
|
}
|
|
42
54
|
}
|