@ultimat3/jobs 25.0.0 → 25.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -227,7 +227,8 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
227
227
  reading per pass, duplicate names refused.
228
228
  - **`exportRows()`**: one object per PAGE, named by page index; NO cross-tenant escape.
229
229
  - **`webhook()`**: ONE event to ONE endpoint, no steps; each attempt but a CANCELLED one recorded
230
- before the throw; `ledger.isDisabled` gates the socket. **The wire format is core's.**
230
+ before the throw; `ledger.isDisabled` gates the socket. **The wire format is core's.** An org
231
+ tenant reads `orgId` OFF THE INPUT; none, or not the run's org: TERMINAL `X_JOB_TENANT_MISMATCH`.
231
232
  - A `-fixture.ts` file does not ship; `backfill-pass-fixture.ts` raises a plain `Error` subclass on
232
233
  purpose (it stands in for app code).
233
234
 
@@ -301,6 +302,7 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
301
302
  | `worker-loop.ts` | the claim loop's timer: wake, `again`, the kicks |
302
303
  | `driver-pg-outbox-sql.ts` | the `x_outbox` statements |
303
304
  | `errors-operator.ts` / `errors-requeue.ts` | the operator verbs' refusals; `requeue`'s |
305
+ | `errors-tenant.ts` | `JobTenantMismatchError` |
304
306
 
305
307
  Which `-fixture.ts` harness each suite shares, and what each `.job.` suite proves:
306
308
  `docs/history/jobs.md`, "Moved 2026-10-01 — test harnesses".
package/README.md CHANGED
@@ -502,13 +502,17 @@ subscription table, one `enqueue` per endpoint.
502
502
  import { memoryWebhookLedger, webhook } from '@ultimat3/jobs';
503
503
 
504
504
  declare const db: {
505
- endpoints: { byId(id: string): Promise<{ id: string; url: string; secret: string } | null> };
505
+ endpoints: {
506
+ byId(id: string): Promise<{ id: string; orgId: string; url: string; secret: string } | null>;
507
+ };
506
508
  events: { byId(id: string): Promise<{ topic: string; body: string } | null> };
507
509
  };
508
510
 
509
511
  export const deliver = webhook({
510
512
  name: 'partner.webhooks',
511
- tenant: 'none',
513
+ // The org that owns the endpoint, carried on every enqueue — or `tenant: 'none'` for endpoints
514
+ // no tenant owns. `tenant` is synchronous over the input, so the org rides beside the two ids.
515
+ tenant: ({ orgId }) => orgId,
512
516
  // Read once per ATTEMPT and never checkpointed: the endpoint carries a secret, and a step's
513
517
  // output is written to `x_job_steps`.
514
518
  endpoint: ({ endpointId }) => db.endpoints.byId(endpointId),
@@ -518,11 +522,11 @@ export const deliver = webhook({
518
522
  });
519
523
 
520
524
  // The fan-out is yours, because the subscription table is yours.
521
- declare function subscribersOf(topic: string): Promise<readonly { id: string }[]>;
525
+ declare function subscribersOf(topic: string): Promise<readonly { id: string; orgId: string }[]>;
522
526
  declare const event: { readonly id: string };
523
527
 
524
528
  for (const endpoint of await subscribersOf('orders.paid')) {
525
- await deliver.enqueue({ endpointId: endpoint.id, eventId: event.id });
529
+ await deliver.enqueue({ endpointId: endpoint.id, eventId: event.id, orgId: endpoint.orgId });
526
530
  }
527
531
  ```
528
532
 
@@ -558,6 +562,13 @@ re-exports (`X_HELPER_COPY`). Same argument `timing-safe-equal.ts` makes for its
558
562
  | the DRAIN never aborts a POST already on the wire; it stops one not yet sent | torn down, the job goes to the next pod, which re-POSTs the same event on every deploy. Finished, it is recorded like any other outcome — a real failure during the drain included |
559
563
  | re-enabling is always yours | an endpoint the framework un-disabled on its own is a retry loop with no end |
560
564
  | `WebhookLedger` is a seam, not a table | retention is seven years for one business and thirty days for the next, so shipping a schema would ship one of those answers |
565
+ | an org-owned delivery carries `orgId` on its input | `tenant` is a synchronous function of the input, so an org looked up from the endpoint id would be a read before the run has an org to read under. `tenant: ({ orgId }) => orgId` needs it on every enqueue, and both seams are handed it. A tenant that reads no `orgId` (`'none'`, or 25.0.0's `({ endpointId }) => …`) enqueues without one, as before |
566
+ | the payload's org is the run's org, or the delivery dies | a tenant that names no org for the payload, or an `orgId` that is not the org the run is under (with `tenant: 'none'`, any `orgId`), is `X_JOB_TENANT_MISMATCH` before either seam reads — **terminal**, dead-lettered on attempt 1, because the same row is refused on every attempt and by `x jobs retry` |
567
+
568
+ **Upgrading a webhook to `tenant: ({ orgId }) => orgId`.** Deliveries already queued without an
569
+ `orgId` dead-letter on their next attempt with `X_JOB_TENANT_MISMATCH`, and a retry cannot add the
570
+ field. Drain the queue before deploying the new declaration, or re-enqueue what dead-letters with its
571
+ org: `x jobs ls --state dead --name partner.webhooks --json`, then `deliver.enqueue({ endpointId, eventId, orgId })` for each.
561
572
 
562
573
  **Set `timeout` below the drain budget.** A webhook has no timeout unless you declare one, and a
563
574
  POST on the wire when SIGTERM lands runs until the receiver answers. With a `timeout`, a drained
@@ -867,18 +878,18 @@ hourly task fire 24 catch-ups a second apart.
867
878
 
868
879
  ### A `PgExecutor`
869
880
 
870
- `PgExecutor` is one method — `query(text, values)` — and the boot supplies it (`@ultimat3/cli`'s
871
- `pgExecutorFor(client)`). Outside the boot — a test, a script — build it over `@ultimat3/db`'s
872
- client, which already speaks `(text, values)` and decodes `jsonb`:
881
+ `PgExecutor` is one method — `query(text, values)` — and there is ONE builder for it:
882
+ `@ultimat3/db`'s `dbExecutor()`, which the boot uses too (`dbExecutor(() => client)`). It resolves
883
+ its client per statement — by default the installed one, which inside `withTransaction` is the
884
+ transaction's own connection — and that client already speaks `(text, values)` and decodes `jsonb`.
885
+ Outside the boot — a test, a script — hand it the client to stay on:
873
886
 
874
887
  ```ts
875
- import { postgresClient } from '@ultimat3/db';
888
+ import { dbExecutor, postgresClient } from '@ultimat3/db';
876
889
  import type { PgExecutor } from '@ultimat3/jobs';
877
890
 
878
891
  const client = postgresClient({ url: 'postgres://localhost:5432/app_test' });
879
- export const executor: PgExecutor = {
880
- query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
881
- };
892
+ export const executor: PgExecutor = dbExecutor(() => client);
882
893
  ```
883
894
 
884
895
  | Not this | Because |
@@ -1294,6 +1305,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
1294
1305
  | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
1295
1306
  | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
1296
1307
  | `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
1308
+ | `JobTenantMismatchError` | `X_JOB_TENANT_MISMATCH` | `src/errors-tenant.ts` |
1297
1309
  | `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
1298
1310
  | `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
1299
1311
  | `OutboxNoTxError` | `X_OUTBOX_NO_TX` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "25.0.0",
3
+ "version": "25.2.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": "25.0.0",
36
- "@ultimat3/db": "25.0.0",
37
- "@ultimat3/entity": "25.0.0",
38
- "@ultimat3/schema": "25.0.0",
39
- "@ultimat3/time": "25.0.0"
35
+ "@ultimat3/core": "25.2.0",
36
+ "@ultimat3/db": "25.2.0",
37
+ "@ultimat3/entity": "25.2.0",
38
+ "@ultimat3/schema": "25.2.0",
39
+ "@ultimat3/time": "25.2.0"
40
40
  }
41
41
  }
@@ -0,0 +1,22 @@
1
+ // The one refusal for a job whose PAYLOAD and declared tenant disagree — a webhook delivery's
2
+ // `orgId` that is not the org the run is under, a tenant that derives no org from the payload, an
3
+ // agent actor resolved in another org. Its code stays declared in `errors.ts`, classified terminal.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+
7
+ export class JobTenantMismatchError extends UltimateError {
8
+ constructor(input: {
9
+ readonly job: string;
10
+ /** What disagrees, in words — org ids only; never a secret, never a row. */
11
+ readonly reason: string;
12
+ /** The enqueue, or the declaration, that repairs it — specific to the caller. */
13
+ readonly fix: string;
14
+ }) {
15
+ super({
16
+ code: 'X_JOB_TENANT_MISMATCH',
17
+ cause: `job "${input.job}" ${input.reason} — the same payload is refused on every attempt, so it is dead-lettered on this one`,
18
+ fix: input.fix,
19
+ meta: { job: input.job },
20
+ });
21
+ }
22
+ }
package/src/errors.ts CHANGED
@@ -44,6 +44,7 @@ export const JOB_OWNED_ERROR_CODES = [
44
44
  'X_JOB_PAGE_INVALID',
45
45
  'X_JOB_ON_SETTLED_FAILED',
46
46
  'X_JOB_NOT_FOUND',
47
+ 'X_JOB_TENANT_MISMATCH',
47
48
  ] as const;
48
49
 
49
50
  /**
@@ -102,6 +103,7 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
102
103
  X_JOB_PAGE_INVALID: 'a job list page was asked for outside its bounds',
103
104
  X_JOB_ON_SETTLED_FAILED: 'the onSettled hook failed after the run was settled',
104
105
  X_JOB_NOT_FOUND: 'the queue holds no job with this id',
106
+ X_JOB_TENANT_MISMATCH: "a job's payload names an org other than the tenant it runs under",
105
107
  };
106
108
 
107
109
  // One unconditional call, so a second package claiming one of jobs' codes throws
@@ -155,6 +157,9 @@ registerErrorRetry({
155
157
  X_EXPORT_PART_TOO_LARGE: 'terminal',
156
158
  // The worker settles a busy key itself; listed so a body that RETHROWS one it was handed stops.
157
159
  X_JOB_KEY_BUSY: 'terminal',
160
+ // The payload names the wrong org, or none its tenant can run under — a fact about the ROW, so
161
+ // every attempt and every `x jobs retry` is refused identically: the repair is a new enqueue.
162
+ X_JOB_TENANT_MISMATCH: 'terminal',
158
163
  });
159
164
 
160
165
  // No `docs:` on any class below, here or in `backfill-errors.ts`. `UltimateError` fills it from
package/src/index.ts CHANGED
@@ -153,6 +153,7 @@ export {
153
153
  JobPageInvalidError,
154
154
  } from './errors-operator';
155
155
  export { JobNotFoundError, JobNotRequeueableError } from './errors-requeue';
156
+ export { JobTenantMismatchError } from './errors-tenant';
156
157
  export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
157
158
  export {
158
159
  EVENTS_PURGE_TARGET,
@@ -361,6 +362,7 @@ export type {
361
362
  WebhookEndpoint,
362
363
  WebhookEvent,
363
364
  WebhookReport,
365
+ WebhookTenant,
364
366
  } from './webhook';
365
367
  export { DEFAULT_WEBHOOK_DISABLE_AFTER, webhook } from './webhook';
366
368
  export type { WebhookFetch } from './webhook-attempt';
package/src/webhook.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { t } from '@ultimat3/schema';
28
28
  import type { DurationInput } from './clock';
29
29
  import { nowMs } from './clock';
30
+ import { JobTenantMismatchError } from './errors-tenant';
30
31
  import type { JobHandle } from './job';
31
32
  import { job } from './job';
32
33
  import type { RetryPolicy } from './retry';
@@ -94,8 +95,23 @@ export interface WebhookDeliveryInput {
94
95
  readonly endpointId: string;
95
96
  /** Also the id the receiver dedupes on — it is signed, so it cannot be moved in transit. */
96
97
  readonly eventId: string;
98
+ /**
99
+ * The org that owns the endpoint, for a delivery declared `tenant: ({ orgId }) => orgId`. On the
100
+ * input because `tenant` is a synchronous function of the input alone — derived from the
101
+ * endpoint id it would be a read before the run has an org to read under. Neither signed nor
102
+ * sent: the receiver learns nothing it did not already know.
103
+ */
104
+ readonly orgId?: string | undefined;
97
105
  }
98
106
 
107
+ /**
108
+ * Who a delivery runs as: `'none'`, or the org derived from its payload. May answer `undefined` —
109
+ * `({ orgId }) => orgId` over a row that carries none — and that answer is refused, TERMINALLY,
110
+ * before either seam reads (`X_JOB_TENANT_MISMATCH`). One signature for both shapes on purpose: a
111
+ * 25.0.0 `({ endpointId }) => …` that never reads `orgId` keeps enqueuing without one.
112
+ */
113
+ export type WebhookTenant = 'none' | ((input: WebhookDeliveryInput) => string | undefined);
114
+
99
115
  /** What one landed delivery reports. Bounded, so `x jobs show` can print it. */
100
116
  export interface WebhookReport {
101
117
  readonly endpointId: string;
@@ -111,12 +127,13 @@ export interface WebhookDefinition {
111
127
  */
112
128
  readonly name: string;
113
129
  /**
114
- * REQUIRED, exactly as on `job()`: `tenant: ({ endpointId }) => …` for a delivery scoped to the
115
- * org that owns the endpoint, or the explicit `tenant: 'none'`. A delivery reads the app's own
116
- * endpoint and event rows through the seams below, so the org those reads run under is a fact
117
- * about the WORK and is declared here rather than inherited from whichever worker claimed it.
130
+ * REQUIRED, exactly as on `job()`: `tenant: ({ orgId }) => orgId` for a delivery scoped to the
131
+ * org that owns the endpoint — every enqueue then carries `orgId` — or the explicit
132
+ * `tenant: 'none'`. A delivery reads the app's own endpoint and event rows
133
+ * through the seams below, so the org those reads run under is a fact about the WORK and is
134
+ * declared here rather than inherited from whichever worker claimed it.
118
135
  */
119
- readonly tenant: JobTenant<WebhookDeliveryInput>;
136
+ readonly tenant: WebhookTenant;
120
137
  /**
121
138
  * The endpoint this delivery is for. Read once PER ATTEMPT and never checkpointed: it carries a
122
139
  * secret, and a `step.run` output is written to `x_job_steps` — a credential in a durable table
@@ -124,11 +141,18 @@ export interface WebhookDefinition {
124
141
  */
125
142
  endpoint(args: {
126
143
  readonly endpointId: string;
144
+ /**
145
+ * The delivery's own `orgId`, off the input — by the time a seam runs, the org the run is
146
+ * under, or absent. Handed over so a seam names its tenant from the work rather than reading it
147
+ * back off `ctx.actor`.
148
+ */
149
+ readonly orgId: string | undefined;
127
150
  readonly ctx: Ctx;
128
151
  }): Promise<WebhookEndpoint | null> | WebhookEndpoint | null;
129
152
  /** The event's bytes. Read per attempt as well, out of the app's own table. */
130
153
  event(args: {
131
154
  readonly eventId: string;
155
+ readonly orgId: string | undefined;
132
156
  readonly ctx: Ctx;
133
157
  }): Promise<WebhookEvent | null> | WebhookEvent | null;
134
158
  /** Where every attempt is recorded, and where the consecutive-failure count comes from. */
@@ -175,11 +199,11 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
175
199
  // Named so `run` can read the deadline `job()` resolved — one parse of `timeout`, never two.
176
200
  const handle: JobHandle<WebhookDeliveryInput> = job<WebhookDeliveryInput>({
177
201
  name: definition.name,
178
- input: t.object({ endpointId: t.string, eventId: t.string }),
202
+ input: t.object({ endpointId: t.string, eventId: t.string, orgId: t.optional(t.string) }),
179
203
  // Endpoint AND event: the same event fans out to every subscribed endpoint, so a key on the
180
204
  // event alone would dedupe every one of those deliveries into the first endpoint's row.
181
205
  idempotencyKey: ({ endpointId, eventId }) => `${definition.name}:${endpointId}:${eventId}`,
182
- tenant: definition.tenant,
206
+ tenant: tenantOf(definition),
183
207
  retry: definition.retry ?? DEFAULT_RETRY,
184
208
  ...(definition.queue === undefined ? {} : { queue: definition.queue }),
185
209
  ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
@@ -194,7 +218,13 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
194
218
  job: definition.name,
195
219
  timeoutMs: handle.timeoutMs,
196
220
  };
197
- const endpoint = await definition.endpoint({ endpointId: input.endpointId, ctx });
221
+ // Before either seam: the org they are handed must be the org every read runs under.
222
+ assertInputOrg(definition.name, input.orgId, ctx);
223
+ const endpoint = await definition.endpoint({
224
+ endpointId: input.endpointId,
225
+ orgId: input.orgId,
226
+ ctx,
227
+ });
198
228
  if (endpoint === null) {
199
229
  throw new WebhookEndpointUnknownError({
200
230
  webhook: definition.name,
@@ -222,7 +252,7 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
222
252
  resolve: definition.resolve ?? resolveWebhookHost,
223
253
  });
224
254
 
225
- const event = await definition.event({ eventId: input.eventId, ctx });
255
+ const event = await definition.event({ eventId: input.eventId, orgId: input.orgId, ctx });
226
256
  if (event === null) {
227
257
  throw new WebhookEventUnknownError({ webhook: definition.name, eventId: input.eventId });
228
258
  }
@@ -323,6 +353,44 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
323
353
  return handle;
324
354
  }
325
355
 
356
+ /**
357
+ * The declared tenant, asked as declared, with one refusal added: an answer that names NO org. Only
358
+ * then — a 25.0.0 `tenant: ({ endpointId }) => …` never reads `orgId` and needs none, so requiring
359
+ * one would refuse every delivery it queued. The usual cause is a row queued before `orgId` existed
360
+ * under a `({ orgId }) => orgId` declaration. Coded and TERMINAL rather than `jobTenantFor`'s
361
+ * `assert`: an unclassified refusal spent the whole retry policy, and `x jobs retry`, re-proving it.
362
+ */
363
+ function tenantOf(definition: WebhookDefinition): JobTenant<WebhookDeliveryInput> {
364
+ const declared = definition.tenant;
365
+ if (typeof declared !== 'function') return declared;
366
+ return (input) => {
367
+ const orgId = declared(input);
368
+ if (typeof orgId === 'string' && orgId.length > 0) return orgId;
369
+ throw new JobTenantMismatchError({
370
+ job: definition.name,
371
+ reason: `declares an org tenant, and this delivery's payload names no org for it${input.orgId === undefined ? ' (it carries no orgId)' : ''}`,
372
+ fix: `${definition.name}.enqueue({ endpointId, eventId, orgId }) — re-enqueue it with the org that owns the endpoint; a queued row cannot be retried into having one`,
373
+ });
374
+ };
375
+ }
376
+
377
+ /**
378
+ * The org a seam is HANDED is the org the run is UNDER, or the delivery is refused before either
379
+ * seam reads: a lookup filtered by `orgId` would otherwise read another org's endpoint, secret and
380
+ * all, inside this org's run. With `tenant: 'none'` nothing would check it at all, so any `orgId`
381
+ * is refused there. Terminal for `tenantOf`'s reason — the payload is the same on every attempt.
382
+ */
383
+ function assertInputOrg(name: string, orgId: string | undefined, ctx: Ctx): void {
384
+ if (orgId === undefined) return;
385
+ const runOrg = ctx.actor.orgId ?? undefined;
386
+ if (orgId === runOrg) return;
387
+ throw new JobTenantMismatchError({
388
+ job: name,
389
+ reason: `was handed orgId ${orgId}, but the run is under ${runOrg === undefined ? "tenant 'none'" : `org ${runOrg}`}`,
390
+ fix: `webhook({ name: '${name}', tenant: ({ orgId }) => orgId, … }) — derive the tenant from the payload's own org, or enqueue ${name} with the orgId its tenant resolves to`,
391
+ });
392
+ }
393
+
326
394
  /** A URL no delivery may open, or a secret that would make the POST unsigned. The parsed url. */
327
395
  function assertDeliverable(name: string, endpoint: WebhookEndpoint): URL {
328
396
  if (endpoint.secret.length === 0) {