@ultimat3/jobs 24.0.0 → 25.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.
Files changed (55) hide show
  1. package/CLAUDE.md +27 -27
  2. package/README.md +72 -44
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +1 -1
  6. package/src/backfill-rate.ts +2 -2
  7. package/src/backfill.ts +2 -2
  8. package/src/counters.ts +1 -1
  9. package/src/driver-memory-operator.ts +3 -3
  10. package/src/driver-memory.ts +14 -17
  11. package/src/driver-pg-settle-sql.ts +2 -3
  12. package/src/driver-pg-sql.ts +1 -1
  13. package/src/driver-pg.ts +18 -18
  14. package/src/driver.ts +9 -15
  15. package/src/errors-concurrency.ts +1 -1
  16. package/src/errors-tenant.ts +22 -0
  17. package/src/errors.ts +10 -5
  18. package/src/events-pg.ts +4 -4
  19. package/src/events.ts +6 -6
  20. package/src/execute.ts +46 -5
  21. package/src/export.ts +2 -2
  22. package/src/heartbeat.ts +7 -2
  23. package/src/index.ts +39 -97
  24. package/src/inspect-operator.ts +1 -1
  25. package/src/inspect.ts +1 -1
  26. package/src/job.ts +1 -1
  27. package/src/leases.ts +1 -1
  28. package/src/limits.ts +13 -8
  29. package/src/outbox-lease.ts +1 -1
  30. package/src/outbox-pg.ts +5 -5
  31. package/src/outbox-relay.ts +6 -6
  32. package/src/outbox.ts +13 -13
  33. package/src/renewal-timer.ts +1 -1
  34. package/src/retry-classification.ts +52 -15
  35. package/src/scheduler-leader.ts +2 -2
  36. package/src/scheduler-pg.ts +16 -14
  37. package/src/scheduler-state.ts +2 -2
  38. package/src/scheduler.ts +5 -5
  39. package/src/steps-memory.ts +2 -2
  40. package/src/steps.ts +35 -7
  41. package/src/task.ts +1 -1
  42. package/src/webhook-attempt.ts +206 -0
  43. package/src/webhook-ledger.ts +15 -4
  44. package/src/webhook.ts +141 -127
  45. package/src/worker-admit.ts +7 -3
  46. package/src/worker-drain-cutoff.ts +110 -0
  47. package/src/worker-fleet-slots.ts +5 -2
  48. package/src/worker-held.ts +100 -0
  49. package/src/worker-options.ts +19 -15
  50. package/src/worker-run.ts +38 -8
  51. package/src/worker-tally.ts +53 -0
  52. package/src/worker-types.ts +7 -4
  53. package/src/worker.ts +85 -77
  54. package/src/driver-nats.ts +0 -89
  55. package/src/driver-redis.ts +0 -92
package/CLAUDE.md CHANGED
@@ -47,7 +47,7 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
47
47
  - **`SQL_JOBS_TABLE` is the ONE install point** for every durable table (`x_jobs`, `x_job_*`, `x_backfills`,
48
48
  `x_outbox`, `x_scheduler_*`). A NEW table is also a name in
49
49
  `packages/cli/src/framework-schema.ts`. A shipped table grows by `alter table ... add column if
50
- not exists`. Its comments carry NO apostrophes and NO semicolons (`dev-queue.ts` splits on `;`; `driver-pg-sql.test.ts` checks
50
+ not exists`. Its comments carry NO apostrophes and NO semicolons (it splits on `;`; `driver-pg-sql.test.ts` checks
51
51
  quote parity).
52
52
  - **`claim({ queues: [] })` is REFUSED by every driver** (`assertClaimQueues`,
53
53
  `X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's reads are `async` so a refusal rejects on both.
@@ -67,13 +67,13 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
67
67
  - Drivers implement the six `JobDriver` methods plus optional `introspect`, `backfills`, `leases`. New
68
68
  capabilities go behind the interface. `inspect.ts` returns plain JSON for CLI, `/_x` and MCP.
69
69
  - Step results are persisted BEFORE the step returns. All time is epoch ms (`nowMs()`, `clock.ts`).
70
- - **`createPgLeader` is correct only on a DEDICATED connection**; boot uses `createPgLeaseLeader`.
70
+ - **`postgresLeader` is correct only on a DEDICATED connection**; boot uses `postgresLeaseLeader`.
71
71
 
72
72
  ## Rules — numbers and limits
73
73
 
74
74
  - **Every numeric knob is refused when not FINITE** — core's `finiteOption()` (a bound) and
75
- `finiteCount()` (a count, caller's minimum); `worker-options.ts` is where `createWorker` reads them.
76
- **`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers are screened
75
+ `finiteCount()` (a count, caller's minimum); `worker-options.ts` is where `jobWorker` reads them.
76
+ **`bun run finite-bounds` is a floor, never proof**: `concurrencyLimiter`'s five numbers are screened
77
77
  with min 0 (zero is a HARD STOP). **A row count is `finiteCount` (min 0)**; `retry.attempts` is one with min 1.
78
78
  - **`WorkerOptions.concurrency` is read by OWN key** — a queue named `constructor`
79
79
  (`worker-slots.test.ts`).
@@ -98,16 +98,14 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
98
98
  - The scheduler's key is occurrence-scoped (`task:occurrenceMs:jobKey`); `task.enqueue()` uses the
99
99
  job's plain key.
100
100
  - **TWO shutdown hooks per worker, one per PHASE**: `accept` is `stopAccepting()` (flip state, clear
101
- the poll timer, abort held runs); `close` is the teardown. `stop()` hands both back in a `finally`;
101
+ the poll timer, arm the drain cut-off — it aborts nothing); `close` is the teardown. `stop()` hands both back in a `finally`;
102
102
  `start()` refuses while draining.
103
- - **A claimed job is counted with core's `beginWork()`**, so the drain's in-flight phase does the
104
- waiting. **The teardown's wait is bounded on SIGTERM** (`settleAllBy` in `drain-wait.ts`,
105
- `jobs.worker.drain-abandoned`) and unbounded on a manual `stop()`. A worker always reaches `'stopped'`;
106
- `state` is set in the `finally`. **One teardown, joined.**
107
- - **SIGTERM reaches the job**: the worker's one `AbortController` (`drainSignal`) is composed into every
108
- run (`worker-run.ts`); `stopAccepting(reason)` aborts it with `JobDrainedError` (`X_DRAINING`). A
109
- drained attempt settles as **`interrupted`** (`nack`, `countsAsAttempt: false`, no park, no dead
110
- letter), read off the SIGNAL. `WorkerStats.interrupted`; `worker-drain-signal.test.ts`.
103
+ - **A claimed job is counted with core's `beginWork()`**: the drain's in-flight phase waits. The
104
+ teardown's wait is bounded on SIGTERM (`drain-wait.ts`), unbounded on `stop()`; one teardown, joined.
105
+ - **A deploy lets a running job FINISH**: SIGTERM only stops claiming. `drainSignal` aborts
106
+ (`X_DRAINING`) at `deadlineAt − margin` (`worker-drain-cutoff.ts`), then held claims go back
107
+ uncounted (`worker-held.ts`). Settles **`interrupted`**; `docs/history/jobs.md`, "Drain, 2026-10".
108
+ - **`X_RATE_LIMITED` defers uncounted** (`rateLimitDeferralMs`).
111
109
  - **The drain waits out the claim round it races** — `tick()` registers its round in `rounds`
112
110
  synchronously with its guard.
113
111
  - **A fleet slot is taken INSIDE a `try`, released AWAITED, and HELD, not owned**: `false`, or a TTL
@@ -123,7 +121,8 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
123
121
  - **The claim loop re-arms on the PASS, never the jobs**; `tick()` resolves with that pass's executions.
124
122
  - **A deadline CANCELS, then fails the attempt**, never the reverse (`raceTimeout`, `withStepTimeout`);
125
123
  the attempt is cancelled in `executeJob`'s `finally`.
126
- - **A cancelled runner writes NOTHING** — `put()` in `steps.ts` (`X_ABORTED`), and the STORE fences
124
+ - **A cancelled runner writes NOTHING** — `put()` in `steps.ts` (`X_ABORTED`) — save a fenced
125
+ `X_DRAINING` on a live attempt: a step done in the drain is recorded once. The STORE fences
127
126
  the claim (`StepFence`, `SQL_STEP_PUT` as `SQL_ACK`: `X_JOB_LEASE_LOST`). `executeJob` passes it.
128
127
  - **`traceparent` is stamped at ENQUEUE time, in `outbox.ts`**, and an empty `spanId` sends none.
129
128
  - **The scheduler runs one dispatch round at a time**; every other `tick()` joins it; `stop()` waits it
@@ -140,8 +139,8 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
140
139
  - **Suspension is control flow, and a SHED is not a suspension**: `StepSuspension` →
141
140
  `nack({ countsAsAttempt: false, park: true })`; a shed is `countsAsAttempt: false` without `park`,
142
141
  stays `ready`, logs `jobs.worker.shed` (no `last_error`). `driver-parity.test.ts`.
143
- - **A run's acquisitions are handed back even when the wiring throws** (`worker-run.ts`: `context()`
144
- above the heartbeat; `createRunSignal` and `fleetSlots.startRenewal` inside the `try`).
142
+ - **A wiring throw hands back the run's holdings, and its ROW until `executeJob` starts**
143
+ (`worker-run.ts`), as a failed shed does its job (`worker-admit.ts`).
145
144
  - **`step.run` hydrates the run's steps from ONE `store.list(runId)`**; `sleep` and `waitForEvent`
146
145
  record replays through `trace()`. **`claimName` reads a Set**; `usedNames()` keeps
147
146
  `MAX_TRACE_NAMES` (200).
@@ -155,9 +154,8 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
155
154
  suite (memory + real pg).
156
155
  - **A settle answers whether it landed**; a miss is `jobs.settle.unowned`, not a throw.
157
156
  - **The counter moves in the SETTLING statement** (`driver-pg-settle-sql.ts`): one-minute bucket per
158
- job name; `nackOutcome()` decides what a nack adds (a shed, a suspension, a drain: nothing), and
159
- `ack({ counted: false })` — `x jobs drain` — adds nothing. The scheduler leader folds tiers
160
- (`rollupCounters`), at most once a bucket.
157
+ job name; `nackOutcome()` decides what a nack adds (a shed, a suspension, a drain: nothing).
158
+ The scheduler leader folds tiers (`rollupCounters`), at most once a bucket.
161
159
  - **`list()` is KEYSET** (`after: jobCursor(lastRow)`, newest first by `(created_at, id)`); the pg
162
160
  seek reads the cursor row's own `created_at` — the cursor's ms is rounded. A page past
163
161
  `MAX_JOB_PAGE` or a foreign cursor is `X_JOB_PAGE_INVALID`, never a bare invariant.
@@ -197,7 +195,7 @@ Long form: `docs/history/jobs.md`, "the cross-process wake".
197
195
  - **`idle-cost.test.ts` / `queue-wake.test.ts` count statements per idle minute** — scheduler 6,
198
196
  worker and relay 30 with no wake, 12 with. Raising one is a regression.
199
197
  - **The memory driver stores a payload's JSON form**, as pg binds it (`driver-settle-parity.test.ts`).
200
- - **The event bus has ONE clock**: `createPgEventBus` takes no `clock`; `EventLookup.now()` is
198
+ - **The event bus has ONE clock**: `postgresEventBus` takes no `clock`; `EventLookup.now()` is
201
199
  REQUIRED, stamps a NEW wait, and calls the TIMEOUT (asked once the runner thinks time is up).
202
200
  `purgeExpired()` is awaited and counted; `eventsPurgeTarget(bus)` is its `PurgeTarget`.
203
201
 
@@ -228,8 +226,9 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
228
226
  - **`purge()`** is the one caller of every `purgeExpired()`: one table per `step.run`, one clock
229
227
  reading per pass, duplicate names refused.
230
228
  - **`exportRows()`**: one object per PAGE, named by page index; NO cross-tenant escape.
231
- - **`webhook()`** delivers ONE event to ONE endpoint; no steps; every attempt recorded before the
232
- throw. **The wire format is core's**, re-exported.
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.** An org
231
+ tenant reads `orgId` OFF THE INPUT; none, or not the run's org: TERMINAL `X_JOB_TENANT_MISMATCH`.
233
232
  - A `-fixture.ts` file does not ship; `backfill-pass-fixture.ts` raises a plain `Error` subclass on
234
233
  purpose (it stands in for app code).
235
234
 
@@ -255,28 +254,28 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
255
254
  | `steps-timeout.ts` / `steps-suspension.ts` | a step's ceiling; `StepSuspension` and the timed-out marker |
256
255
  | `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
257
256
  | `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks |
258
- | `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
257
+ | `outbox-pg.ts` | `postgresOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
259
258
  | `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
260
259
  | `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
261
260
  | `concurrency.ts` | `job.concurrency` as declared, resolved once: `KeyedConcurrency`, `WhenBusy`, the declaration and key refusals |
262
261
  | `errors-concurrency.ts` | every concurrency refusal's class; codes stay in `errors.ts` |
263
262
  | `worker-key-busy.ts` | one run refused by its key: settled `failed`, body never run |
264
263
  | `metrics.ts` | `queue_oldest_ready_seconds` and `queue_dead_jobs`, the two alertable gauges |
265
- | `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark, the atomic fire) + `createPgLeaseLeader` |
266
- | `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
264
+ | `scheduler-pg.ts` | `postgresSchedulerState` (the durable watermark, the atomic fire) + `postgresLeaseLeader` |
265
+ | `events-pg.ts` | `postgresEventBus` — `step.waitForEvent` across processes |
267
266
  | `driver.ts` | `JobDriver` contract + wire records |
268
- | `driver-pg.ts` | default driver, and `createPgLeader` |
267
+ | `driver-pg.ts` | default driver, and `postgresLeader` |
269
268
  | `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the ONE install point. Its comments carry no `;` and no `'` |
270
269
  | `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and `JOB_ROW_COLUMNS`; re-exported from `driver-pg-sql.ts` |
271
270
  | `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
272
271
  | `driver-memory.ts` / `claim-exhausted.ts` | `x dev` / tests; a buried row's log line and `onSettled` |
273
- | `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
274
272
  | `retry.ts` | the dead-letter decision, and this package's option names over core's `backoffDelay` — no curve of its own |
275
273
  | `retry-classification.ts` | the other half: what the thrown error says, and the stop reason |
276
274
  | `execute.ts` / `run-deadline.ts` | `executeJob` — one claimed job run and settled; the run's deadline: cancel, then fail |
277
275
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
278
276
  | `renewal-timer.ts` | the renewal interval and its `stopped()` latch |
279
277
  | `worker.ts` | `worker` role, claim round, drain |
278
+ | `worker-drain-cutoff.ts` / `worker-held.ts` / `worker-tally.ts` | drain cut-off · held claims · counters |
280
279
  | `worker-admit.ts` | may this claimed job start: the limiter, the fleet slot, and the shed / refusal |
281
280
  | `worker-registry.ts` | one worker's registry row: announce, heartbeat, forget |
282
281
  | `worker-types.ts` | the worker's public contract: `WorkerOptions`, `WorkerStats`, `Worker` |
@@ -303,6 +302,7 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
303
302
  | `worker-loop.ts` | the claim loop's timer: wake, `again`, the kicks |
304
303
  | `driver-pg-outbox-sql.ts` | the `x_outbox` statements |
305
304
  | `errors-operator.ts` / `errors-requeue.ts` | the operator verbs' refusals; `requeue`'s |
305
+ | `errors-tenant.ts` | `JobTenantMismatchError` |
306
306
 
307
307
  Which `-fixture.ts` harness each suite shares, and what each `.job.` suite proves:
308
308
  `docs/history/jobs.md`, "Moved 2026-10-01 — test harnesses".
package/README.md CHANGED
@@ -192,7 +192,7 @@ input. The tenant is a fact about the work, so the job declares it.
192
192
  expires, so on a bus shared by one `bun test` process an answer one test published resumes the
193
193
  next test's wait — call it between tests.
194
194
 
195
- The event bus has ONE clock. `createPgEventBus({ executor })` stamps `publishedAt` and the expiry
195
+ The event bus has ONE clock. `postgresEventBus({ executor })` stamps `publishedAt` and the expiry
196
196
  with the database's `now()` — it takes no `clock` — and `bus.now()` answers that same clock. A
197
197
  consumer that wants "published after I asked" (`@ultimat3/scraping`'s `eventPrompt`) reads its
198
198
  "asked at" from `bus.now()`, never from its own process: a skew between two pods must not decide
@@ -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
 
@@ -537,9 +541,11 @@ x-ultimate-webhook-signature: t=1700000000,v1=<hex hmac-sha256>
537
541
 
538
542
  The receiving half is `verifyWebhookSignature(request, { secret })` in `@ultimat3/http`. The format
539
543
  itself — the canonical string, the mac and the header names — is **one module in `@ultimat3/core`**
540
- (`webhook-signature.ts`), re-exported by both packages and re-declared by neither: this package
541
- signs, `http` verifies, and neither may import the other, so the one copy lives at the tier both
542
- can reach. Same argument `timing-safe-equal.ts` makes for itself.
544
+ (`webhook-signature.ts`), imported from `@ultimat3/core` by both packages and re-declared by
545
+ neither: this package signs, `http` verifies, and neither may import the other, so the one copy
546
+ lives at the tier both can reach. A receiver or a test imports `WEBHOOK_SIGNATURE_HEADER`,
547
+ `webhookSignature` and the rest from `@ultimat3/core` — 25.0.0 dropped this package's
548
+ re-exports (`X_HELPER_COPY`). Same argument `timing-safe-equal.ts` makes for itself.
543
549
 
544
550
  | Rule | Why |
545
551
  |---|---|
@@ -550,9 +556,27 @@ can reach. Same argument `timing-safe-equal.ts` makes for itself.
550
556
  | `redirect: 'manual'` | following a 3xx would re-POST a body signed for one host to whatever the receiver named |
551
557
  | the endpoint is never checkpointed | a `step.run` output lands in `x_job_steps`, and the endpoint carries the secret |
552
558
  | every attempt is recorded **before** the throw | a failure the ledger cannot see is a failure the consecutive count cannot see, which is an endpoint that never gets disabled |
553
- | a `Retry-After` the receiver names is honoured | `X_WEBHOOK_DELIVERY_THROTTLED` carries `meta.retryAfterSeconds`, which the nack waits out (clamped by `retry.maxDelay`) rather than guessing a curve against an answer it already has |
559
+ | a `Retry-After` the receiver names is honoured | `X_WEBHOOK_DELIVERY_THROTTLED` carries `meta.retryAfterSeconds`, which the nack waits out — read by core's `retryAfterSecondsOf` (delta-seconds, or an HTTP-date against the response's own `Date`; `0` or a past date is unstated), clamped by `retry.maxDelay`, plus core's `jitterStatedDelay` spread so deliveries handed one instant do not wake together |
560
+ | `WebhookLedger.isDisabled(endpointId)` is asked before every socket, beside the row's own `disabled` | `disableAfter` writes only the ledger: a verdict nobody asks for again is an endpoint that keeps receiving POSTs. Answer it from the column your `disable` sets |
561
+ | the deadline and a lost lease abort `fetch`; a failure that abort caused writes no ledger row | the nack behind them hands the job to another worker at once, so a request left open is a duplicate delivery, and an abandoned attempt's failure would count toward `disableAfter` |
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 |
554
563
  | re-enabling is always yours | an endpoint the framework un-disabled on its own is a retry loop with no end |
555
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.
572
+
573
+ **Set `timeout` below the drain budget.** A webhook has no timeout unless you declare one, and a
574
+ POST on the wire when SIGTERM lands runs until the receiver answers. With a `timeout`, a drained
575
+ request is still torn down at the attempt's deadline — where `executeJob` hands the job on — and
576
+ writes no ledger row. Without one, the only bound is `configureLifecycle({ deadlineMs })`, 25 s by
577
+ default: past it the worker logs `jobs.worker.drain-abandoned` and exits, and the row comes back
578
+ only when its lease lapses, as a COUNTED attempt the receiver may already have processed.
579
+ `timeout: '10s'` against the default budget ends every drained delivery inside it.
556
580
 
557
581
  ## The deadline cancels
558
582
 
@@ -670,15 +694,15 @@ things have to be true in a process:
670
694
  | Step | Call |
671
695
  |---|---|
672
696
  | the table exists | ships in `SQL_JOBS_TABLE` — applying the queue DDL is enough |
673
- | the facade is installed | `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` |
674
- | the relay is running | `createOutboxRelay({ store, driver }).start()` |
697
+ | the facade is installed | `setJobsFacade(outboxJobsFacade({ store, driver }, currentTx))` |
698
+ | the relay is running | `outboxRelay({ store, driver }).start()` |
675
699
 
676
- `start()` registers the same two shutdown hooks `createWorker` does — `accept` stops polling
700
+ `start()` registers the same two shutdown hooks `jobWorker` does — `accept` stops polling
677
701
  (the worker's also aborts every held job's `ctx.signal`), `close` waits out the pass in flight
678
702
  under the drain's deadline — and `stop()` hands both back.
679
703
  `drainOnShutdown: false` opts out, for a caller that drives its own teardown.
680
704
 
681
- with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
705
+ with `store = postgresOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
682
706
  transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
683
707
  `jobsFacade()` answers a fallback whose `currentTx` is `() => undefined` and every enqueue
684
708
  publishes straight to the driver — deliberate, so a script and a test enqueue with no wiring, but
@@ -713,7 +737,7 @@ What the lease buys, precisely:
713
737
  | a relay that died mid-batch stranding its rows forever | anything a **non-idempotent** handler does on its second run |
714
738
  | — | a lapsed visibility lease re-delivering a job that is still running |
715
739
 
716
- The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
740
+ The memory store (`memoryOutboxStore`, `x dev` and tests) **drops** a published row —
717
741
  `retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
718
742
  the audit trail this map is not. A relay pass that throws is logged as `jobs.outbox.tick-failed`
719
743
  and the loop re-arms: an unobserved rejection would end the process with rows still staged.
@@ -730,17 +754,21 @@ One interface: `enqueue`, `claim` (visibility timeout), `ack(jobId, { workerId,
730
754
  `backfills` and `leases`. Both settles are fenced on the CLAIM — `claimOf(claimed)` is the
731
755
  `{ workerId, claim }` a `ClaimedJob` carries — and answer whether they landed. Zero job-code change
732
756
  between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
733
- `JobsConfig.driver` has no reader and boot always builds `createPgDriver`.
757
+ `JobsConfig.driver` has no reader and boot always builds `postgresJobDriver`.
734
758
 
735
759
  | Driver | Status | Backing | Use |
736
760
  |---|---|---|---|
737
761
  | `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 |
738
762
  | `memory` | complete | in-process maps | `x dev`, tests |
739
- | `redis` | interface-complete, `X_NOT_IMPLEMENTED` | Streams + consumer groups | planned |
740
- | `nats` | interface-complete, `X_NOT_IMPLEMENTED` | JetStream work queue | planned |
741
763
 
742
- The pg SQL is exported verbatim (`SQL_CLAIM`, `SQL_ENQUEUE`, `SQL_NACK`, …) so an agent
743
- debugging a stuck queue can read and run the exact statement.
764
+ There is **no NATS or Redis jobs driver**: `createNatsDriver` and `createRedisDriver`, all-throw
765
+ stubs, were deleted in 25.0.0. A real `redisJobDriver()` would arrive as a minor.
766
+
767
+ The pg SQL lives verbatim in `src/driver-pg-*sql.ts` (`SQL_CLAIM`, `SQL_ENQUEUE`, `SQL_NACK`, …)
768
+ so an agent debugging a stuck queue can read and run the exact statement. The barrel exports
769
+ only the statement another package runs: `SQL_JOBS_TABLE` (the install) — 25.0.0 took the rest
770
+ off, `SQL_CLAIM` and `SQL_OUTBOX_RELEASE` included (their array-parameter proofs are this package's
771
+ `driver-pg-array.live.test.ts`).
744
772
 
745
773
  **A lease that lapses on a row's final attempt is buried by the claim.** `claim` re-takes a
746
774
  `running` row whose lease lapsed — the worker died — but one already at `attempt >= maxAttempts`
@@ -754,7 +782,7 @@ the job's `onSettled`, and the worker's claim round does exactly that. The row c
754
782
  `maxAttempts` and not the policy, so the CALLER names the jobs declaring `retry.deadLetter: false`
755
783
  (`ClaimOptions.dropExhausted` — the worker reads them off its registry): those rows are buried
756
784
  `failed` and announced `dropped`, every other one `dead` / `dead-lettered`. A caller that names
757
- none (`x jobs drain`) buries every such row `dead`.
785
+ none buries every such row `dead`.
758
786
 
759
787
  A driver you write reads a nack's target state through `nackState(options)` — `dead`
760
788
  (`deadLetter`), `failed` (`fail`, terminal and out of the dead-letter queue), `suspended` (`park`)
@@ -765,8 +793,8 @@ or `ready` — so a new branch cannot land in one driver and not another. Its le
765
793
 
766
794
  | Role | Entry | Behaviour |
767
795
  |---|---|---|
768
- | `worker` | `createWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
769
- | `scheduler` | `createScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
796
+ | `worker` | `jobWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close. `concurrency` is a number for every queue or a table per queue; a queue it does not name gets core's `JOBS_CONCURRENCY_DEFAULT` (8 — this package's own default was 5 before 25.0.0) |
797
+ | `scheduler` | `jobScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
770
798
 
771
799
  Both roles register the same **pair** of hooks and bound the `close` half the same way. The
772
800
  scheduler's abandoned case differs in one respect: a round still enqueueing keeps the lease rather
@@ -798,24 +826,24 @@ update runs two leaders.
798
826
 
799
827
  ```ts
800
828
  import {
801
- createPgLeaseLeader,
802
- createScheduler,
829
+ postgresLeaseLeader,
830
+ jobScheduler,
803
831
  type JobDriver,
804
832
  type PgExecutor,
805
- pgSchedulerState,
833
+ postgresSchedulerState,
806
834
  } from '@ultimat3/jobs';
807
835
 
808
836
  declare const driver: JobDriver;
809
837
  declare const executor: PgExecutor; // see "A `PgExecutor`" below
810
838
 
811
- createScheduler({
839
+ jobScheduler({
812
840
  driver,
813
- state: pgSchedulerState(executor),
814
- leader: createPgLeaseLeader({ executor }),
841
+ state: postgresSchedulerState(executor),
842
+ leader: postgresLeaseLeader({ executor }),
815
843
  });
816
844
  ```
817
845
 
818
- `createPgLeaseLeader` and not `createPgLeader`: `pg_try_advisory_lock` is scoped to a Postgres
846
+ `postgresLeaseLeader` and not `postgresLeader`: `pg_try_advisory_lock` is scoped to a Postgres
819
847
  *session*, and the executor this package is handed is a **pool** — the lock is released the moment
820
848
  that connection goes back to it, so every node reads itself as leader. The lease is a row with an
821
849
  expiry and needs no connection affinity. `acquire()` is also the renewal, called every round, which
@@ -850,18 +878,18 @@ hourly task fire 24 catch-ups a second apart.
850
878
 
851
879
  ### A `PgExecutor`
852
880
 
853
- `PgExecutor` is one method — `query(text, values)` — and the boot supplies it (`@ultimat3/cli`'s
854
- `pgExecutorFor(client)`). Outside the boot — a test, a script — build it over `@ultimat3/db`'s
855
- 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:
856
886
 
857
887
  ```ts
858
- import { createPostgresClient } from '@ultimat3/db';
888
+ import { dbExecutor, postgresClient } from '@ultimat3/db';
859
889
  import type { PgExecutor } from '@ultimat3/jobs';
860
890
 
861
- const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
862
- export const executor: PgExecutor = {
863
- query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
864
- };
891
+ const client = postgresClient({ url: 'postgres://localhost:5432/app_test' });
892
+ export const executor: PgExecutor = dbExecutor(() => client);
865
893
  ```
866
894
 
867
895
  | Not this | Because |
@@ -886,11 +914,11 @@ A queue with nothing to do costs almost nothing, and a job starts when it is com
886
914
  `startQueueWake({ listener, executor })` holds ONE `LISTEN` session per worker pod and turns "another process committed" into the signal the idle loops already hear. The boot starts it for the `worker` role.
887
915
 
888
916
  ```ts
889
- import { createPostgresClient } from '@ultimat3/db';
917
+ import { postgresClient } from '@ultimat3/db';
890
918
  import type { PgExecutor } from '@ultimat3/jobs';
891
919
  import { startQueueWake } from '@ultimat3/jobs';
892
920
 
893
- const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
921
+ const client = postgresClient({ url: 'postgres://localhost:5432/app_test' });
894
922
  const executor: PgExecutor = {
895
923
  query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
896
924
  };
@@ -959,7 +987,7 @@ exactly as it had before.
959
987
  | Thrown | What the queue does |
960
988
  |---|---|
961
989
  | a `terminal` code (`X_SCRAPE_AUTH_FAILED`, a validation fault, a permission denial) | dead-lettered on the attempt it happened, `attempt` recorded, remaining attempts unspent — a rotated password retried five times is five more wrong passwords at a site that locks the account after three |
962
- | a `retry-after` code (`X_RATE_LIMITED`, `X_OVERLOADED`) | retried at the time the responder NAMED — `meta.retryAfterSeconds`, clamped by the policy's `maxDelay` — instead of the backoff. Still an attempt, still under the ceiling |
990
+ | a `retry-after` code (`X_RATE_LIMITED`, `X_OVERLOADED`) | retried at the time the responder NAMED — `meta.retryAfterSeconds`, clamped by the policy's `maxDelay`, plus core's `jitterStatedDelay` spread unless `jitter: false` — instead of the backoff. Still an attempt, still under the ceiling |
963
991
  | a `retryable` code (`X_TIMEOUT`, `X_DRAINING`) | the backoff schedule above, unchanged |
964
992
  | an **unclassified** code, or anything that is not an `UltimateError` | the backoff schedule above, unchanged. Most codes are unclassified and `retryFor` answers `terminal` for all of them, so reading that would have stopped every transient retry in every app |
965
993
 
@@ -990,7 +1018,7 @@ contractual rate.
990
1018
  `job.concurrency` is the one that is fleet-wide, and it is enforced by a row every replica can
991
1019
  see — one per held slot, keyed `job:<name>`, renewed by the same heartbeat that renews the
992
1020
  visibility lease and reclaimed by TTL when a worker is SIGKILLed. A driver with no lease store
993
- cannot hold the cap, so `createWorker().start()` **refuses to boot**
1021
+ cannot hold the cap, so `jobWorker().start()` **refuses to boot**
994
1022
  (`X_JOB_CONCURRENCY_UNENFORCEABLE`) rather than let a documented guarantee do nothing.
995
1023
 
996
1024
  Over any cap the claim is handed straight back without burning an attempt — one org's 50k-row
@@ -1028,7 +1056,7 @@ export const syncAccount = job({
1028
1056
  |---|---|---|
1029
1057
  | a cap of `0`, negative, fractional, `NaN` or `Infinity` — `concurrency: 0` and `limit: 0` alike; a `key` that is not a function; a `whenBusy` outside the two | where the job is declared | `X_JOB_DECLARATION_INVALID` |
1030
1058
  | `key(input)` answering `''`, a non-string, or more than 200 characters | at that **enqueue** — the facade and the scheduler both ask | `X_JOB_DECLARATION_INVALID` |
1031
- | a driver with no lease store, for any `concurrency` | `createWorker().start()` | `X_JOB_CONCURRENCY_UNENFORCEABLE` |
1059
+ | a driver with no lease store, for any `concurrency` | `jobWorker().start()` | `X_JOB_CONCURRENCY_UNENFORCEABLE` |
1032
1060
  | a `LeaseStore` with no `holders(key)` | the build — the member is required | `TS2741` |
1033
1061
 
1034
1062
  What `'fail'` decides, exactly:
@@ -1116,7 +1144,6 @@ the pg and the memory driver alike. `x jobs`, `/_x` and a dashboard read nothing
1116
1144
  | the counter moves in the statement that settles the row | a count that can disagree with the rows is worse than none — and no second round trip per job |
1117
1145
  | a shed, a suspension and a drained attempt add nothing | they are handed back uncounted; counted, every `step.sleep` would read as a failure |
1118
1146
  | `ack` / `nack` / `heartbeat` / `recordProgress` are fenced on the CLAIM | `{ workerId, claim }`: a body whose lease lapsed must not settle, renew or report on the run that replaced it — claimed by another worker, or by the SAME one, whose id did not change. A miss answers `false` and logs `jobs.settle.unowned` |
1119
- | `ack(id, { …, counted: false })` adds nothing to the counters | `x jobs drain` settles the row it moved: `done` here, and not a completed run |
1120
1147
  | a pause is a row the claim reads | never a column on every queued row: pausing a million-row queue takes effect within one poll |
1121
1148
  | an occurrence fires in one statement | the watermark and its jobs move together (`SchedulerState.fire`), so a crash between them cannot fire it twice |
1122
1149
 
@@ -1235,7 +1262,7 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
1235
1262
  | `X_JOB_NOT_PROMOTABLE` | `promote` reached a job not waiting on its run time |
1236
1263
  | `X_JOB_PAGE_INVALID` | `list()` was asked for more than `MAX_JOB_PAGE` rows, or handed a cursor no page produced. The cause names which cursor (`after` / `before`). Fix: `x jobs ls --limit 200 --json` |
1237
1264
  | `X_JOB_ON_SETTLED_FAILED` | a declared `onSettled` threw on every one of its tries; logged and reported, never thrown |
1238
- | `X_NOT_IMPLEMENTED` | redis / nats driver |
1265
+ | `X_NOT_IMPLEMENTED` | introspection or the operator surface on a driver with no `introspect` (a hand-rolled one) |
1239
1266
 
1240
1267
  ### Error classes
1241
1268
 
@@ -1278,6 +1305,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
1278
1305
  | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
1279
1306
  | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
1280
1307
  | `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
1308
+ | `JobTenantMismatchError` | `X_JOB_TENANT_MISMATCH` | `src/errors-tenant.ts` |
1281
1309
  | `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
1282
1310
  | `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
1283
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": "24.0.0",
3
+ "version": "25.1.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,17 +25,17 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "24.0.0",
36
- "@ultimat3/db": "24.0.0",
37
- "@ultimat3/entity": "24.0.0",
38
- "@ultimat3/schema": "24.0.0",
39
- "@ultimat3/time": "24.0.0"
35
+ "@ultimat3/core": "25.1.0",
36
+ "@ultimat3/db": "25.1.0",
37
+ "@ultimat3/entity": "25.1.0",
38
+ "@ultimat3/schema": "25.1.0",
39
+ "@ultimat3/time": "25.1.0"
40
40
  }
41
41
  }
@@ -5,7 +5,7 @@
5
5
  // declared in ONE place; only the classes that throw them live here, beside `backfill-ledger.ts`,
6
6
  // `backfill-pending.ts` and `backfill-registry.ts`.
7
7
 
8
- import { UltimateError } from '@ultimat3/core';
8
+ import { renderFixShellArg, UltimateError } from '@ultimat3/core';
9
9
 
10
10
  /**
11
11
  * The seven backfill codes below all answer one question — "why is this sweep not running?" — and
@@ -64,7 +64,8 @@ export class BackfillEnvironmentError extends UltimateError {
64
64
  fix:
65
65
  target === undefined
66
66
  ? 'x db backfill --pending --json'
67
- : `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
67
+ : // Both are the declaration's own strings and the class is public: screened (sweep 1c).
68
+ `ULTIMATE_ENV=${renderFixShellArg(target, "'<environment>'")} x db backfill ${renderFixShellArg(input.backfill, "'<backfill>'")} --write --json`,
68
69
  });
69
70
  }
70
71
  }
@@ -153,7 +153,7 @@ export function decideBackfill(
153
153
  return { run: force, previous: completed, changed: completed.checksum !== checksum };
154
154
  }
155
155
 
156
- export function createMemoryBackfillLedger(clock: Clock = systemClock): BackfillLedger {
156
+ export function memoryBackfillLedger(clock: Clock = systemClock): BackfillLedger {
157
157
  const runs = new Map<string, BackfillRun>();
158
158
  const patch = (runId: string, fields: Partial<BackfillRun>): void => {
159
159
  const existing = runs.get(runId);
@@ -69,14 +69,14 @@ function timerSleep(ms: number, signal: AbortSignal): Promise<void> {
69
69
  * rate is a property of the table being swept and the pool it is swept through, not of whichever
70
70
  * attempt happens to hold the run.
71
71
  */
72
- export function createPacer(options: PacerOptions): Pacer {
72
+ export function backfillPacer(options: PacerOptions): Pacer {
73
73
  // Refused HERE and not only at `backfill()`: `rate: 0` makes `intervalMs` Infinity, which the
74
74
  // timer clamps to about a millisecond — so an unvalidated zero reads as "no throttle at all",
75
75
  // which is the one setting this module exists to make unreachable. A negative rate is the same
76
76
  // bug with a negative wait.
77
77
  assert(
78
78
  Number.isFinite(options.rate) && options.rate > 0,
79
- `createPacer({ rate: ${String(options.rate)} }) for "${options.job}" — a rate is batches per second, greater than zero`,
79
+ `backfillPacer({ rate: ${String(options.rate)} }) for "${options.job}" — a rate is batches per second, greater than zero`,
80
80
  `pass rate: ${DEFAULT_BACKFILL_RATE} — to sweep faster raise the number, there is no unthrottled mode`,
81
81
  );
82
82
  const clock = options.clock ?? systemClock;
package/src/backfill.ts CHANGED
@@ -15,7 +15,7 @@ import type { ReadBuilder } from '@ultimat3/entity';
15
15
  import { t } from '@ultimat3/schema';
16
16
  import { backfillChecksum } from './backfill-ledger';
17
17
  import { backfillPass } from './backfill-pass';
18
- import { createPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
18
+ import { backfillPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
19
19
  import type { BackfillCount } from './backfill-registry';
20
20
  import { stampBackfill } from './backfill-registry';
21
21
  import type { DurationInput } from './clock';
@@ -176,7 +176,7 @@ export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<Ba
176
176
  const checksum = backfillChecksum(definition.source, definition.handle);
177
177
  // Built here rather than per attempt: the interval belongs to the table and the pool, not to
178
178
  // whichever attempt holds the run, so a retrying pass keeps the pace it was declared with.
179
- const pace = createPacer({ rate, job: definition.name });
179
+ const pace = backfillPacer({ rate, job: definition.name });
180
180
 
181
181
  // Bound to the definition rather than passed bare: `count` is declared as a method, so a
182
182
  // reference torn off the object literal would run with `this` undefined the first time an
package/src/counters.ts CHANGED
@@ -45,7 +45,7 @@ export interface MemoryCounters {
45
45
  }
46
46
 
47
47
  /** The memory driver's counters. Same buckets, same tiers, same fold as `x_job_counters`. */
48
- export function createMemoryCounters(): MemoryCounters {
48
+ export function memoryCounters(): MemoryCounters {
49
49
  const buckets = new Map<string, JobCounter>();
50
50
  const keyOf = (job: string, bucketMs: number, bucketStart: number): string =>
51
51
  `${bucketMs}\u0000${bucketStart}\u0000${job}`;
@@ -7,7 +7,7 @@ import type { Clock } from '@ultimat3/core';
7
7
  import { assert, finiteOption } from '@ultimat3/core';
8
8
  import { nowMs } from './clock';
9
9
  import type { MemoryCounters } from './counters';
10
- import { createMemoryCounters } from './counters';
10
+ import { memoryCounters } from './counters';
11
11
  import type { JobRecord } from './driver';
12
12
  import { REQUEUEABLE_STATES } from './driver';
13
13
  import { signalEnqueued } from './enqueue-signal';
@@ -92,9 +92,9 @@ function pauseSet(clock: Clock): {
92
92
  };
93
93
  }
94
94
 
95
- export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
95
+ export function memoryOperator(state: MemoryQueueState): MemoryOperator {
96
96
  const { jobs, clock } = state;
97
- const counters = createMemoryCounters();
97
+ const counters = memoryCounters();
98
98
  const queues = pauseSet(clock);
99
99
  const tasks = pauseSet(clock);
100
100
  const workers = new Map<string, WorkerRecord & { readonly expiresAt: number }>();