@ultimat3/jobs 1.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +660 -0
- package/README.md +432 -17
- package/package.json +7 -5
- package/src/backfill-gate.ts +97 -0
- package/src/backfill-inspect.ts +73 -0
- package/src/backfill-ledger.ts +183 -0
- package/src/backfill-pass.ts +276 -0
- package/src/backfill-pending.ts +131 -0
- package/src/backfill-rate.ts +109 -0
- package/src/backfill-registry.ts +108 -0
- package/src/backfill-scope.ts +70 -0
- package/src/backfill.ts +213 -0
- package/src/driver-memory.ts +61 -9
- package/src/driver-nats.ts +2 -1
- package/src/driver-pg-ddl.ts +191 -0
- package/src/driver-pg-rows.ts +123 -0
- package/src/driver-pg-sql.ts +312 -55
- package/src/driver-pg.ts +138 -92
- package/src/driver-redis.ts +2 -1
- package/src/driver.ts +91 -7
- package/src/errors.ts +314 -5
- package/src/events-pg.ts +121 -0
- package/src/events.ts +7 -1
- package/src/execute.ts +308 -0
- package/src/heartbeat.ts +148 -0
- package/src/index.ts +128 -27
- package/src/inspect.ts +43 -2
- package/src/job.ts +127 -3
- package/src/leases.ts +90 -0
- package/src/limits.ts +0 -0
- package/src/metrics.ts +35 -0
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +188 -0
- package/src/outbox.ts +204 -59
- package/src/register.ts +1 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +6 -1
- package/src/run-signal.ts +50 -0
- package/src/scheduler-pg.ts +103 -0
- package/src/scheduler.ts +159 -245
- package/src/steps.ts +155 -31
- package/src/task.ts +239 -0
- package/src/tenant.ts +61 -0
- package/src/worker-fleet-slots.ts +129 -0
- package/src/worker-run.ts +132 -0
- package/src/worker.ts +207 -190
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @ultimat3/jobs ⚙️
|
|
2
2
|
|
|
3
|
-
Durable background work. Steps that replay, a transactional outbox
|
|
4
|
-
|
|
3
|
+
Durable background work. Steps that replay, a transactional outbox, and one driver interface so
|
|
4
|
+
a job's code never names a backend.
|
|
5
5
|
|
|
6
6
|
```ts
|
|
7
7
|
import { job, t } from '@ultimat3/jobs';
|
|
@@ -9,6 +9,7 @@ import { job, t } from '@ultimat3/jobs';
|
|
|
9
9
|
export const onboardOrg = job({
|
|
10
10
|
input: t.object({ orgId: t.uuid }),
|
|
11
11
|
idempotencyKey: ({ orgId }) => `onboard:${orgId}`, // REQUIRED by the type
|
|
12
|
+
tenant: ({ orgId }) => orgId, // REQUIRED by the type — or tenant: 'none'
|
|
12
13
|
retry: { attempts: 5, backoff: 'exponential' },
|
|
13
14
|
async run({ input, step, ctx }) {
|
|
14
15
|
const org = await step.run('provision', () => ctx.orgs.provision(input.orgId));
|
|
@@ -90,11 +91,37 @@ usually "nobody thought about it", and the bug — two charges, two welcome emai
|
|
|
90
91
|
provisioned orgs — shows up in production under load and never in a test. There is no way
|
|
91
92
|
to define a job in Ultimate that cannot be deduped.
|
|
92
93
|
|
|
94
|
+
## `tenant` is required by the type
|
|
95
|
+
|
|
96
|
+
The same shape, for the same class of bug. A job body runs with no request behind it, so nothing
|
|
97
|
+
can read the acting org off a caller — and `@ultimat3/entity`'s tenant guard derives from the
|
|
98
|
+
**ambient** context, so a job that declared nothing used to run with no tenant at all: a row naming
|
|
99
|
+
another org was refused over HTTP as `X_TENANCY_ACTOR_MISMATCH` and accepted through the queue.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
tenant: (input) => input.orgId // the run acts under this org
|
|
103
|
+
tenant: 'none' // this job belongs to no tenant
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`executeJob` derives the org, puts it on the run's actor and installs that context, so
|
|
107
|
+
`ctx.actor.orgId` inside the body is the tenant the job declared — and every tenant-scoped read and
|
|
108
|
+
write is checked against it, exactly as it is on every other surface. `'none'` carries **no** org,
|
|
109
|
+
so a tenant-scoped read inside such a `job()` is `X_TENANCY_ACTOR_ORG_REQUIRED`: a job body that
|
|
110
|
+
genuinely spans tenants says so with `crossTenant(reason, fn)` around its own reads. (A
|
|
111
|
+
`backfill()` is the one exception, and it is not a loophole — its `source` is a lazy chain, so the
|
|
112
|
+
author has nothing to wrap and the pass opens the scope itself. See the backfill section below.)
|
|
113
|
+
Omitting the field is a type error, and
|
|
114
|
+
`X_JOB_TENANT_REQUIRED` for generated code and JS callers.
|
|
115
|
+
|
|
116
|
+
A single boot-supplied service actor would have closed the same hole with one identity for every
|
|
117
|
+
job in the app — which is a cross-tenant read waiting for the first job that takes an org id in its
|
|
118
|
+
input. The tenant is a fact about the work, so the job declares it.
|
|
119
|
+
|
|
93
120
|
## Durable steps
|
|
94
121
|
|
|
95
122
|
| Call | Behaviour |
|
|
96
123
|
|---|---|
|
|
97
|
-
| `step.run(name, fn)` | runs once ever; result persisted before the next step starts |
|
|
124
|
+
| `step.run(name, fn)` | runs once ever; result persisted before the next step starts. `fn` receives an `AbortSignal` |
|
|
98
125
|
| `step.sleep(name, '3d')` | suspends the run, requeues it for the wake time |
|
|
99
126
|
| `step.sleep('3d')` | same, step name derived from the duration |
|
|
100
127
|
| `step.waitForEvent(name, event, { match, timeout })` | suspends until `publishEvent()` matches |
|
|
@@ -103,12 +130,211 @@ Step names are the replay key, so they must be deterministic and unique in a run
|
|
|
103
130
|
duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
|
|
104
131
|
(`StepSuspension`), never a failure: it does not burn a retry attempt.
|
|
105
132
|
|
|
106
|
-
##
|
|
133
|
+
## Backfills are jobs
|
|
134
|
+
|
|
135
|
+
`backfill()` is a **factory over `job()`**, not a ninth primitive — one pass over every row a
|
|
136
|
+
chain matches, with the retry policy, the queue, the cancellation and the dead-letter path a job
|
|
137
|
+
already has.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { backfill } from '@ultimat3/jobs';
|
|
141
|
+
import { db } from '@postly/db';
|
|
142
|
+
|
|
143
|
+
export const rewriteSlugs = backfill({
|
|
144
|
+
name: 'rewrite-slugs', // REQUIRED: a durable key
|
|
145
|
+
batch: 1_000, // rows per statement and per step
|
|
146
|
+
rate: 5, // batches/sec — the default
|
|
147
|
+
tenant: 'none', // every tenant — the pass scopes itself
|
|
148
|
+
source: () => db.posts.where({ published: true }),
|
|
149
|
+
async handle({ rows }) {
|
|
150
|
+
await db.posts.upsertAll(rows.map(slugged), { onConflict: ['id'] });
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await rewriteSlugs.enqueue({}); // it is a JobHandle
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The source is read through `inBatches()` — one statement per page, keyset, never OFFSET — and
|
|
158
|
+
each page is handled inside its own `step.run`, named `batch:0`, `batch:1`, … A run killed
|
|
159
|
+
mid-pass therefore **resumes on the page it stopped at**: completed steps replay from storage
|
|
160
|
+
without a statement, and the iteration reopens at the cursor they left behind.
|
|
161
|
+
|
|
162
|
+
| Rule | Why |
|
|
163
|
+
|---|---|
|
|
164
|
+
| the checkpoint is a cursor and a count, never the page | a completed step's output is retained for the whole run — checkpointing rows would hold every processed row until the job ends |
|
|
165
|
+
| `handle` is given no `step` | a step name minted inside it collides with itself on batch 2 (`X_STEP_DUPLICATE`) |
|
|
166
|
+
| `handle` is given a `signal` | the run's deadline composed with the batch's own ceiling — hand it to whatever the body calls |
|
|
167
|
+
| `handle` runs at least once per page | an attempt cancelled between the last row and the checkpoint replays it — write through `upsertAll` / `updateWhere`, never `count + 1` |
|
|
168
|
+
| `idempotencyKey` is the backfill's name | re-enqueueing a live pass is the same pass, not a second writer on one table |
|
|
169
|
+
| `tenant` is required, exactly as on `job()` | a backfill IS a job. `tenant: () => orgId` scopes every page to one org; `tenant: 'none'` sweeps every tenant, and the PASS opens that cross-tenant scope — `source` is a lazy chain, so the author has nothing to wrap |
|
|
170
|
+
| `batch` is refused at declaration | `0`, `1.5` and a `NaN` from an env var fail the build, not the fourth attempt |
|
|
171
|
+
| `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 |
|
|
172
|
+
| the pause is spent **inside** the step | a resumed attempt replays 500 checkpoints and re-pays none of their pauses |
|
|
173
|
+
|
|
174
|
+
### The throttle
|
|
175
|
+
|
|
176
|
+
`rate` is batches per second, defaulting to `DEFAULT_BACKFILL_RATE` (5) — 5,000 rows/sec at the
|
|
177
|
+
default batch, one statement every 200ms, so the pool spends the rest of each interval on the
|
|
178
|
+
app. A rate above what the batches can actually achieve produces no wait, which is why there is
|
|
179
|
+
no unthrottled mode to reach for: `rate: 200` is the fast sweep. Fractions are rates too
|
|
180
|
+
(`rate: 0.5` is one batch every two seconds), a rate that is not finite and positive is refused
|
|
181
|
+
where it was written, and the wait unwinds on the run's cancellation (`X_ABORTED`) rather than
|
|
182
|
+
sitting in a timer nobody is waiting for. `rate` is **not** part of the definition checksum, for
|
|
183
|
+
the reason `batch` is not: pacing is tuning, and changing it does not make a completed sweep a
|
|
184
|
+
different sweep.
|
|
185
|
+
|
|
186
|
+
### The `x_backfills` ledger
|
|
187
|
+
|
|
188
|
+
What has already been swept, the twin of `x_migrations`. It ships in the same DDL as `x_jobs`,
|
|
189
|
+
hangs off the queue driver as `driver.backfills`, and carries one row per **pass**: name,
|
|
190
|
+
definition checksum, status, app version, rows processed, last cursor, started/completed.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// The PASS is the no-op, never the enqueue. The one-live-run index covers `ready`/`delayed`/
|
|
194
|
+
// `running`/`suspended`, and a completed job is in none of them — so this creates a real job row,
|
|
195
|
+
// a worker runs it, it reads the ledger and reports what it found.
|
|
196
|
+
const again = await rewriteSlugs.enqueue({}); // → { deduped: false, … }
|
|
197
|
+
// the run's own result:
|
|
198
|
+
// → { name: 'rewrite-slugs', batches: 0, rows: 0, skipped: true, previousRunId: '…' }
|
|
199
|
+
|
|
200
|
+
await rewriteSlugs.enqueue({ force: true }); // sweeps again, into a NEW row
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Rule | Why |
|
|
204
|
+
|---|---|
|
|
205
|
+
| only a **completed** row blocks | a `running` row is this pass resuming, a `failed` one is an attempt the queue is about to retry |
|
|
206
|
+
| `force` writes a new row | reruns are history, never an edit of the row they rerun |
|
|
207
|
+
| a moved checksum **warns** | it hashes function source text, which a bundler moves without behaviour changing — `@ultimat3/db` throws on the same fact because SQL text is what it applied |
|
|
208
|
+
| the row is a report, never a resume source | where a resumed pass restarts is the step checkpoints' answer, and there is only one |
|
|
209
|
+
| a retry adopts its own row | `started_at` is when the pass began, not when this attempt did |
|
|
210
|
+
| a driver without a ledger runs the pass anyway | the same degradation `introspect` has — no bookkeeping, never a refusal |
|
|
211
|
+
|
|
212
|
+
Three surfaces read it, all through one projection (`inspectBackfills()`), so none of them can
|
|
213
|
+
report a different number:
|
|
214
|
+
|
|
215
|
+
| Surface | Shows |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `x db backfill --list` | the whole ledger, `--name` / `--status` / `--limit`, and `--json` |
|
|
218
|
+
| `x jobs ls` | the passes **in flight** — rows so far and cursor, beside the queue depth |
|
|
219
|
+
| `x jobs show <id>` | the ledger row for that run, under `backfill` (`null` for any other job) |
|
|
220
|
+
| `/_x` → jobs | the whole ledger plus a live count, alongside the queues and the step traces |
|
|
221
|
+
|
|
222
|
+
### What was DECLARED — the other half of the ledger
|
|
223
|
+
|
|
224
|
+
`As of 2026-08`.
|
|
225
|
+
|
|
226
|
+
The ledger says which passes have run. `registeredBackfills()` says which ones **exist**, and the
|
|
227
|
+
diff between them is the alarm: a sweep merged, deployed and never enqueued had no row and showed
|
|
228
|
+
up on no surface at all.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { isBackfill, pendingBackfills, registeredBackfills } from '@ultimat3/jobs';
|
|
232
|
+
|
|
233
|
+
registeredBackfills(); // [{ kind: 'backfill', name, checksum, requires, environments, counts }]
|
|
234
|
+
isBackfill(rewriteSlugs); // true — a plain job() is false, and so is a look-alike
|
|
235
|
+
|
|
236
|
+
pendingBackfills({ declarations, runs, environment });
|
|
237
|
+
// → { environment, rows, pending, orphaned }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`backfill()` stamps its own handle through the `origin` WeakMap `task()` already uses — an app
|
|
241
|
+
registers nothing, because a declaration that has to be registered a second time is a declaration
|
|
242
|
+
half the apps will forget. `x db backfill --pending` is the command, and it exits **non-zero** when
|
|
243
|
+
anything is unswept so a cron or a deploy check can read the exit code alone.
|
|
244
|
+
|
|
245
|
+
| State | Means | In `pending` |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| `pending` | declared, no row under this name | yes |
|
|
248
|
+
| `failed` | the newest pass failed — the queue may have dead-lettered it | yes |
|
|
249
|
+
| `running` | a pass is in flight | no — a check red for the whole of every sweep gets muted |
|
|
250
|
+
| `completed` | a completed row exists, so nothing re-runs without `--force` | no |
|
|
251
|
+
| `excluded` | `environments` does not include this one | no |
|
|
252
|
+
|
|
253
|
+
### Three optional declarations, all of them DATA
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
export const dropLegacy = backfill({
|
|
257
|
+
name: 'drop-legacy',
|
|
258
|
+
tenant: 'none', // required, exactly as on job() — see the table above
|
|
259
|
+
requires: '20260814120000_add_publish_at', // a migration id, checked against x_migrations
|
|
260
|
+
environments: ['staging', 'production'], // omitted = every environment
|
|
261
|
+
count: ({ ctx }) => db.posts.where({ publishedAt: null }).count(),
|
|
262
|
+
source: ({ ctx }) => db.posts.where({ publishedAt: null }),
|
|
263
|
+
handle: async ({ rows }) => { /* … */ },
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Field | Rail | Enforced where |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `requires` | `X_BACKFILL_MIGRATION_PENDING` — the migration is not applied | `x db backfill`, which is where `x_migrations` is readable; this package holds no `@ultimat3/db` dependency and will not grow one to read a ledger |
|
|
270
|
+
| `environments` | `X_BACKFILL_ENVIRONMENT` | **the pass** (`backfillPass`) — the rail, because `.enqueue()` from app code reaches no command — and again in `gateBackfill()` as the CLI's pre-check, so `x db backfill` refuses before it queues work that would only dead-letter |
|
|
271
|
+
| `count` | `X_BACKFILL_STALLED` — the source ran out and this still matches rows | the pass, once, after the last batch |
|
|
272
|
+
|
|
273
|
+
`environments` ships as declared data and never as a hardcoded "cleanups are production": a staging
|
|
274
|
+
rehearsal is correct practice, so which deploys a sweep belongs to is the app's convention and this
|
|
275
|
+
is only the mechanism carrying it. There is deliberately **no** `dependsOn` graph over other
|
|
276
|
+
backfills — the real dependency is almost always "after code tolerating both shapes is serving",
|
|
277
|
+
which the framework cannot observe.
|
|
278
|
+
|
|
279
|
+
`count` is the same predicate `source` selects on, counted. It is what makes a dry run honest and
|
|
280
|
+
"did it converge" arithmetic: a pass that exhausts its source while `count()` still answers above
|
|
281
|
+
zero has two predicates that disagree, which is an authoring bug and not something a retry fixes.
|
|
282
|
+
Its result is parsed rather than trusted — a non-negative safe integer or `X_INVARIANT`, because
|
|
283
|
+
`NaN > 0` and `-1 > 0` are both false and would complete the sweep the detector exists to fail.
|
|
284
|
+
|
|
285
|
+
### Running one
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
x db backfill --pending --json # declared minus completed; non-zero when anything is unswept
|
|
289
|
+
x db backfill drop-legacy # DRY RUN — --write is never implied
|
|
290
|
+
x db backfill drop-legacy --write # gate, then enqueue; the workers sweep
|
|
291
|
+
x db backfill --all --write # every pending one, isolated per name
|
|
292
|
+
x db backfill drop-legacy --write --force # a completed name, again, into a NEW ledger row
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`--write` **enqueues**; it never runs the pass inline, because the queue is a job's execution
|
|
296
|
+
surface. That is what makes the `backfill` deploy role a trigger rather than a gate: it runs after
|
|
297
|
+
the new pods serve, puts the sweeps on the queue and exits, and a slow UPDATE never holds a release
|
|
298
|
+
open against a database still serving the previous build. `--all` isolates per name and continues
|
|
299
|
+
past a failure, so one wedged cleanup cannot block every later one forever.
|
|
300
|
+
|
|
301
|
+
## The deadline cancels
|
|
302
|
+
|
|
303
|
+
A job's `timeout` aborts `ctx.signal` **before** it fails the attempt, because the nack that
|
|
304
|
+
follows makes the job claimable by another worker — a body still running past it is a second
|
|
305
|
+
copy of one job.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
run: async ({ input, ctx, step }) => {
|
|
309
|
+
const res = await fetch(url, { signal: ctx.signal }); // stops at the deadline
|
|
310
|
+
await step.run('save', (signal) => save(res, { signal })); // the step's own ceiling too
|
|
311
|
+
},
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Nothing can kill a body that ignores the signal, so the durable state is fenced: past the
|
|
315
|
+
cancel every step write is refused with `X_ABORTED`, and a run that finishes anyway is logged
|
|
316
|
+
as `jobs.timeout.abandoned` — the one way to find a handler that never reads `ctx.signal`.
|
|
317
|
+
|
|
318
|
+
Three ceilings, declared on the job and nowhere else (`As of 2026-08` — `stepTimeout` and
|
|
319
|
+
`eventPoll` had been implemented in the step runner since 1.0 with no declaration able to reach
|
|
320
|
+
them, so no `job()` could ask for either):
|
|
321
|
+
|
|
322
|
+
| Field | Bounds | Absent |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| `timeout` | the whole attempt — aborts `ctx.signal`, then fails it | no attempt deadline |
|
|
325
|
+
| `stepTimeout` | ONE `step.run` — aborts that step's signal, then fails the step | no per-step ceiling |
|
|
326
|
+
| `eventPoll` | how long a `step.waitForEvent` parks between polls | 30s |
|
|
327
|
+
|
|
328
|
+
A zero or negative `stepTimeout` / `eventPoll` is refused at declaration, the way `concurrency: 0`
|
|
329
|
+
is: `withStepTimeout` reads `<= 0` as "no ceiling at all", which is the opposite of what the author
|
|
330
|
+
wrote.
|
|
331
|
+
|
|
332
|
+
## The transactional outbox
|
|
107
333
|
|
|
108
334
|
```ts
|
|
109
335
|
await ctx.tx(async (tx) => {
|
|
110
336
|
const post = await ctx.posts.publish(input.postId, tx);
|
|
111
|
-
await notifySubscribers.enqueue({ postId: post.id }); // joins `tx`
|
|
337
|
+
await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId }); // joins `tx`
|
|
112
338
|
});
|
|
113
339
|
```
|
|
114
340
|
|
|
@@ -122,18 +348,80 @@ it after commit. The bug class this removes:
|
|
|
122
348
|
|
|
123
349
|
Both are load-dependent, both pass every test you would write, and both produce "the email
|
|
124
350
|
went out but the order isn't in the database". Joining the transaction closes the window.
|
|
125
|
-
The relay publishes *then* marks published, so a crash re-publishes —
|
|
126
|
-
|
|
127
|
-
|
|
351
|
+
The relay publishes *then* marks published, so a crash re-publishes — and **that repeat is
|
|
352
|
+
collapsed only while the first job is still live** (`As of 2026-08`): `SQL_ENQUEUE`'s conflict
|
|
353
|
+
target is a partial index over `ready`/`delayed`/`running`/`suspended`, so a re-publish landing
|
|
354
|
+
after the first job reached a terminal state inserts a second row and the handler runs again.
|
|
355
|
+
**Handlers are at-least-once. Write them idempotent** — that is the standing contract, not a
|
|
356
|
+
caveat on this paragraph. A publish that FAILS stops the batch rather than letting later rows
|
|
357
|
+
overtake it: `claim()` returns rows in `staged_at, id` order — total, so two relays compose the
|
|
358
|
+
same batch in the same order — and an app that stages `createInvoice` then `chargeCard` in one
|
|
359
|
+
transaction must never have the charge run first. Set `mode: 'required'` to
|
|
360
|
+
make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
|
|
361
|
+
|
|
362
|
+
**It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
|
|
363
|
+
things have to be true in a process:
|
|
364
|
+
|
|
365
|
+
| Step | Call |
|
|
366
|
+
|---|---|
|
|
367
|
+
| the table exists | ships in `SQL_JOBS_TABLE` — applying the queue DDL is enough |
|
|
368
|
+
| the facade is installed | `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` |
|
|
369
|
+
| the relay is running | `createOutboxRelay({ store, driver }).start()` |
|
|
370
|
+
|
|
371
|
+
with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
|
|
372
|
+
transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
|
|
373
|
+
`jobsFacade()` answers a fallback whose `currentTx` is `() => undefined` and every enqueue
|
|
374
|
+
publishes straight to the driver — deliberate, so a script and a test enqueue with no wiring, but
|
|
375
|
+
it is a fallback and not the guarantee.
|
|
376
|
+
|
|
377
|
+
`claim()` is a **claim, not a read** (`As of 2026-08`). `for update skip locked` in a bare select
|
|
378
|
+
holds its row locks only until that statement ends — under autocommit, before `claim()` even
|
|
379
|
+
resolves — so two relays polling 200ms apart read the same unpublished rows and both publish them.
|
|
380
|
+
`SQL_ENQUEUE` collapses that repeat only while the first job is still LIVE, because its conflict
|
|
381
|
+
target is a partial index over the live states: a second publish landing after that job finished
|
|
382
|
+
inserts a second row and **the handler runs twice**. So the claim stamps `claimed_at` in the same
|
|
383
|
+
statement that locks the row, and that stamp is a lease — `claimLeaseMs` (a positive whole number
|
|
384
|
+
of ms, default 30s; anything else is `X_INVARIANT` at construction) is how long the rows of a relay
|
|
385
|
+
that DIED mid-batch wait before any relay may take them again. A batch a failed publish stopped is
|
|
386
|
+
handed back at once through `release`, so a pool blip still costs one poll interval and not a lease
|
|
387
|
+
window.
|
|
388
|
+
|
|
389
|
+
**Every outbox mutation is fenced on the claimant, not just the claim** (`As of 2026-08`).
|
|
390
|
+
`release` and `markPublished` both match on `claimed_by`, and `claim()` hands the token back on
|
|
391
|
+
each record as `claimedBy`. A relay that stalls past its lease wakes up owning nothing: its late
|
|
392
|
+
`release` would otherwise unclaim rows the relay that reclaimed them is mid-publish on (a third
|
|
393
|
+
relay claims and republishes them), and its late `markPublished` would retire a row nobody has
|
|
394
|
+
published yet — losing the job outright. Both are no-ops now, in the pg store and in the memory
|
|
395
|
+
store alike.
|
|
396
|
+
|
|
397
|
+
What the lease buys, precisely:
|
|
398
|
+
|
|
399
|
+
| It stops | It does not stop |
|
|
400
|
+
|---|---|
|
|
401
|
+
| two relays holding one batch — a committed row cannot be claimed twice inside its lease | the handler running twice |
|
|
402
|
+
| a lapsed claimant releasing or retiring a newer claimant's rows | a crash between publish and `markPublished` re-publishing after the first job is terminal |
|
|
403
|
+
| a relay that died mid-batch stranding its rows forever | anything a **non-idempotent** handler does on its second run |
|
|
404
|
+
|
|
405
|
+
The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
|
|
406
|
+
`retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
|
|
407
|
+
the audit trail this map is not. A relay pass that throws is logged as `jobs.outbox.tick-failed`
|
|
408
|
+
and the loop re-arms: an unobserved rejection would end the process with rows still staged.
|
|
409
|
+
|
|
410
|
+
`relay.stop()` is **async and joins the pass in flight** — `await` it before closing the database,
|
|
411
|
+
the way `worker.stop()` and `scheduler.stop()` are awaited. A pass is a publish followed by a
|
|
412
|
+
`markPublished`, and a caller that returned between the two closed the pool under the row it was
|
|
413
|
+
about to mark.
|
|
128
414
|
|
|
129
415
|
## Drivers
|
|
130
416
|
|
|
131
417
|
One interface: `enqueue`, `claim` (visibility timeout), `ack`, `nack` (backoff),
|
|
132
|
-
`heartbeat`, `stats`. Zero job-code change
|
|
418
|
+
`heartbeat`, `stats`, plus optional `introspect`, `backfills` and `leases`. Zero job-code change
|
|
419
|
+
between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
|
|
420
|
+
`JobsConfig.driver` has no reader and boot always builds `createPgDriver`.
|
|
133
421
|
|
|
134
422
|
| Driver | Status | Backing | Use |
|
|
135
423
|
|---|---|---|---|
|
|
136
|
-
| `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, partial unique index,
|
|
424
|
+
| `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, idempotency_key)`, lease-based leader, `x_job_leases` | zero-infra start, most apps |
|
|
137
425
|
| `memory` | complete | in-process maps | `x dev`, tests |
|
|
138
426
|
| `redis` | interface-complete, `X_NOT_IMPLEMENTED` | Streams + consumer groups | planned |
|
|
139
427
|
| `nats` | interface-complete, `X_NOT_IMPLEMENTED` | JetStream work queue | planned |
|
|
@@ -145,8 +433,32 @@ debugging a stuck queue can read and run the exact statement.
|
|
|
145
433
|
|
|
146
434
|
| Role | Entry | Behaviour |
|
|
147
435
|
|---|---|---|
|
|
148
|
-
| `worker` | `createWorker({ driver, queues, concurrency })` | per-queue pools, heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
|
|
149
|
-
| `scheduler` | `createScheduler({ driver, leader })` |
|
|
436
|
+
| `worker` | `createWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
|
|
437
|
+
| `scheduler` | `createScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
|
|
438
|
+
|
|
439
|
+
`driver` and `context` are the two required keys on `WorkerOptions`; everything else defaults.
|
|
440
|
+
`context: () => Ctx` supplies the ambient `Ctx` a job run executes under — the app wires its ALS
|
|
441
|
+
and its tenant there, and a worker with no way to build one would run every handler as nobody.
|
|
442
|
+
|
|
443
|
+
**Pass `state` and `leader` in any real deployment.** The defaults are a `Map` and "always the
|
|
444
|
+
leader", and both fail silently: with no durable watermark a redeployed scheduler arms to tomorrow
|
|
445
|
+
and never detects the occurrence the pod it replaced dropped (`catchUp` and `maxCatchUp` are inert
|
|
446
|
+
— "missed" is relative to a watermark that no longer exists), and with no election a rolling
|
|
447
|
+
update runs two leaders.
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
createScheduler({
|
|
451
|
+
driver,
|
|
452
|
+
state: pgSchedulerState(executor),
|
|
453
|
+
leader: createPgLeaseLeader({ executor }),
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
`createPgLeaseLeader` and not `createPgLeader`: `pg_try_advisory_lock` is scoped to a Postgres
|
|
458
|
+
*session*, and the executor this package is handed is a **pool** — the lock is released the moment
|
|
459
|
+
that connection goes back to it, so every node reads itself as leader. The lease is a row with an
|
|
460
|
+
expiry and needs no connection affinity. `acquire()` is also the renewal, called every round, which
|
|
461
|
+
is how a demoted node finds out.
|
|
150
462
|
|
|
151
463
|
```ts
|
|
152
464
|
export const nightlyDigest = task({
|
|
@@ -159,7 +471,17 @@ export const nightlyDigest = task({
|
|
|
159
471
|
`tz` is required by the type *and* validated against the runtime's IANA database, because a
|
|
160
472
|
non-empty string is not a timezone: `tz: 'Bogota'` would resolve every occurrence in UTC and
|
|
161
473
|
run five hours off, silently, forever. `0 3 * * *` in a DST zone runs twice or zero times on
|
|
162
|
-
the switch day. Catch-up after downtime is explicit: `skip` (default)
|
|
474
|
+
the switch day. Catch-up after downtime is explicit: `skip` (default) fires the latest missed
|
|
475
|
+
occurrence and drops the older ones, `run-once` fires the earliest missed one, `run-all` fires
|
|
476
|
+
every one of them. `maxCatchUp` (default 10) bounds the WALK for every mode, not just `run-all`:
|
|
477
|
+
one tick walks at most that many occurrences forward from the last fire, and the policy then
|
|
478
|
+
picks from what that walk found — so after a long outage `skip` fires the latest occurrence
|
|
479
|
+
*within the cap*, not the true latest missed.
|
|
480
|
+
|
|
481
|
+
`run-once` fires **once**, not once per tick. Dropping the rest means the watermark passes them
|
|
482
|
+
too, so a scheduler back up after a day down enqueues one catch-up and then waits for the next
|
|
483
|
+
real occurrence. It used to leave the watermark on the occurrence it had just run, which made an
|
|
484
|
+
hourly task fire 24 catch-ups a second apart.
|
|
163
485
|
|
|
164
486
|
## Retries
|
|
165
487
|
|
|
@@ -172,16 +494,104 @@ retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
|
|
|
172
494
|
// => [1000, 2000, 4000, 8000]
|
|
173
495
|
```
|
|
174
496
|
|
|
497
|
+
**The error decides too, not only the attempt count** (`As of 2026-08`). `executeJob` reads the
|
|
498
|
+
thrown error's retry classification — `@ultimat3/core`'s `registerErrorRetry`, the same table
|
|
499
|
+
`--json` and an HTTP client read — and a code nobody classified keeps the attempt-count path
|
|
500
|
+
exactly as it had before.
|
|
501
|
+
|
|
502
|
+
| Thrown | What the queue does |
|
|
503
|
+
|---|---|
|
|
504
|
+
| 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 |
|
|
505
|
+
| 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 |
|
|
506
|
+
| a `retryable` code (`X_TIMEOUT`, `X_DRAINING`) | the backoff schedule above, unchanged |
|
|
507
|
+
| 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 |
|
|
508
|
+
|
|
509
|
+
Classify your app's codes beside the module that declares them — that import IS the registration:
|
|
510
|
+
|
|
511
|
+
```
|
|
512
|
+
registerErrorRetry({ X_INVOICE_REJECTED: 'terminal', X_GATEWAY_BUSY: 'retry-after' });
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Why a job stopped is on the row and in the log, never inferred: `jobs.attempt.failed` carries
|
|
516
|
+
`stop: 'terminal' | 'attempts-exhausted'`, `JobExecution.stopReason` carries the same, and a
|
|
517
|
+
terminal dead letter appends its verdict to `lastError` so `x jobs show` explains an attempt 1 of 5.
|
|
518
|
+
|
|
175
519
|
## Limits
|
|
176
520
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
521
|
+
Two layers, and the difference matters:
|
|
522
|
+
|
|
523
|
+
| Layer | Scope | Where |
|
|
524
|
+
|---|---|---|
|
|
525
|
+
| `LimitConfig` — `perTenant`, `perQueue`, `global`, `ratePerTenant` | **this process only** | `limits.ts`, three `Map`s in one heap |
|
|
526
|
+
| `job.concurrency` | **the fleet** | `JobDriver.leases` over `x_job_leases` |
|
|
527
|
+
|
|
528
|
+
`LimitConfig` is the fast path and is multiplied by your replica count: `perTenant: 2` on twenty
|
|
529
|
+
pods is forty concurrent runs, and `ratePerTenant`'s window is in memory, so a rolling restart
|
|
530
|
+
grants every tenant a fresh full allowance. Size it as a per-pod budget, never as a partner's
|
|
531
|
+
contractual rate.
|
|
532
|
+
|
|
533
|
+
`job.concurrency` is the one that is fleet-wide, and it is enforced by a row every replica can
|
|
534
|
+
see — one per held slot, keyed `job:<name>`, renewed by the same heartbeat that renews the
|
|
535
|
+
visibility lease and reclaimed by TTL when a worker is SIGKILLed. A driver with no lease store
|
|
536
|
+
cannot hold the cap, so `createWorker().start()` **refuses to boot**
|
|
537
|
+
(`X_JOB_CONCURRENCY_UNENFORCEABLE`) rather than let a documented guarantee do nothing.
|
|
538
|
+
|
|
539
|
+
Over any cap the claim is handed straight back without burning an attempt — one org's 50k-row
|
|
540
|
+
import cannot starve the fleet.
|
|
541
|
+
|
|
542
|
+
## Leases
|
|
543
|
+
|
|
544
|
+
A claim buys `visibilityTimeoutMs` of invisibility; the worker renews it every
|
|
545
|
+
`heartbeatIntervalMs` (default a third of the window) for as long as the job runs. Renewal
|
|
546
|
+
failures are not swallowed:
|
|
547
|
+
|
|
548
|
+
| Fact | Signal |
|
|
549
|
+
|---|---|
|
|
550
|
+
| a renewal failed, the window still has room | `jobs.heartbeat.failed` (warn) |
|
|
551
|
+
| a whole window passed with none landing | `jobs.lease.lost` (error) + `job_leases_lost_total{queue}` |
|
|
552
|
+
|
|
553
|
+
The second one means the queue is free to hand that job to another worker while this one is
|
|
554
|
+
still running it — at-least-once turning into twice. Alert on any non-zero rate. The window is
|
|
555
|
+
measured from the last renewal that **landed**, on this process's clock, so a driver whose
|
|
556
|
+
heartbeat hangs is caught the same as one that rejects.
|
|
557
|
+
|
|
558
|
+
Neither fires for a job that finished (`As of 2026-08`). `stop()` is terminal for the renewal
|
|
559
|
+
already **on the wire**, not only for the next one: a clean completion acks the row out of
|
|
560
|
+
`running`, so the fenced UPDATE already in flight comes back `false` — and reported, that was
|
|
561
|
+
`jobs.lease.lost` at error plus the counter, a page for a non-event, on every completed job whose
|
|
562
|
+
pool was slow enough. The fleet slot's `jobs.worker.slot-lost` had the same shape and the same
|
|
563
|
+
fix (`renewal-timer.ts`).
|
|
180
564
|
|
|
181
565
|
## Introspection
|
|
182
566
|
|
|
183
567
|
`inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
|
|
184
|
-
`inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
|
|
568
|
+
`cancelJob`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
|
|
569
|
+
|
|
570
|
+
`cancelJob(driver, id, reason?)` is the answer to a runaway pass. A queued row becomes `cancelled`
|
|
571
|
+
immediately; a RUNNING one stops at its next heartbeat, which no longer matches its own row and
|
|
572
|
+
aborts the attempt — `steps.ts` then refuses every write. `ack` and `nack` are fenced on
|
|
573
|
+
`state = 'running'`, so the worker that was cancelled cannot un-cancel it on the way out.
|
|
574
|
+
|
|
575
|
+
## Metrics
|
|
576
|
+
|
|
577
|
+
| Series | Kind | Answers |
|
|
578
|
+
|---|---|---|
|
|
579
|
+
| `queue_depth{queue}` | gauge | how much work is waiting — the HPA's signal |
|
|
580
|
+
| `jobs_total{queue,outcome}` | counter | is any of it succeeding |
|
|
581
|
+
| `queue_oldest_ready_seconds{queue}` | gauge | *"page if the oldest job in `payments` is older than 5 minutes"* |
|
|
582
|
+
| `queue_dead_jobs{queue}` | gauge | a dead-letter queue that filled overnight and stopped growing — a counter's rate is flat there |
|
|
583
|
+
|
|
584
|
+
## Trace and actor
|
|
585
|
+
|
|
586
|
+
An enqueue stamps the current span's `traceparent` onto the row, and the worker opens the job's
|
|
587
|
+
span as a **child** of it: a checkout trace shows the HTTP span, the action span and the charge
|
|
588
|
+
that ran two seconds later as one trace.
|
|
589
|
+
|
|
590
|
+
`handle.as(actor, input)` also records `enqueuedBy` — the actor's id. **Attribution, never
|
|
591
|
+
authority.** A job body runs with system authority; the framework does not impersonate the
|
|
592
|
+
enqueuer, because a job that sleeps three days or dead-letters and is retried next quarter would
|
|
593
|
+
act as somebody whose role, org membership or employment has changed since. A job that must act
|
|
594
|
+
FOR a user takes that user's id in its input and re-authorises it in the body.
|
|
185
595
|
|
|
186
596
|
## Errors
|
|
187
597
|
|
|
@@ -194,6 +604,11 @@ burning an attempt — one org's 50k-row import cannot starve the fleet.
|
|
|
194
604
|
| `X_JOB_MAX_ATTEMPTS` | retries exhausted, job dead-lettered |
|
|
195
605
|
| `X_OUTBOX_NO_TX` | enqueue outside a transaction with `mode: 'required'` |
|
|
196
606
|
| `X_DRIVER_UNAVAILABLE` | no `DATABASE_URL` / executor for the pg driver |
|
|
607
|
+
| `X_ABORTED` | a cancelled attempt tried to write a step — core's code, not a second name for it |
|
|
608
|
+
| `X_JOB_LEASE_LOST` | the job was cancelled, or its lease lapsed and the queue re-delivered it, while this worker was still running it |
|
|
609
|
+
| `X_JOB_SLOT_LOST` | the fleet `concurrency` slot this run held was taken by another worker — a different row on a different clock from the lease above |
|
|
610
|
+
| `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that already finished, or a driver with no `cancel` |
|
|
611
|
+
| `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency` and the driver has no lease store |
|
|
197
612
|
| `X_NOT_IMPLEMENTED` | redis / nats driver |
|
|
198
613
|
|
|
199
614
|
## Boundary
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
23
|
+
"CLAUDE.md",
|
|
22
24
|
"README.md",
|
|
23
25
|
"LICENSE"
|
|
24
26
|
],
|
|
@@ -30,9 +32,9 @@
|
|
|
30
32
|
"test": "bun test"
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/entity": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
36
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "3.0.0",
|
|
36
|
+
"@ultimat3/entity": "3.0.0",
|
|
37
|
+
"@ultimat3/schema": "3.0.0",
|
|
38
|
+
"@ultimat3/time": "3.0.0"
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// May this named sweep run here, now? One decision, pure, so the pass, the CLI, a test and a
|
|
2
|
+
// deploy container all read the same verdict instead of four almost-identical `if`s. It RETURNS
|
|
3
|
+
// the refusal rather than throwing it: `x db backfill --all` isolates per name and continues past
|
|
4
|
+
// a failure, and a thrown verdict would let one wedged cleanup block every later one forever.
|
|
5
|
+
//
|
|
6
|
+
// The environment half is enforced inside the pass as well, because a backfill enqueued by app
|
|
7
|
+
// code never passes through the CLI — the check has to sit where the work is, or it is a
|
|
8
|
+
// convention rather than a rail (axiom 3).
|
|
9
|
+
|
|
10
|
+
import type { Environment, UltimateError } from '@ultimat3/core';
|
|
11
|
+
// `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
|
|
12
|
+
// own row shape, which would make this a second reader of `x_backfills`.
|
|
13
|
+
import type { BackfillProgress } from './backfill-inspect';
|
|
14
|
+
import type { BackfillDeclaration } from './backfill-registry';
|
|
15
|
+
import {
|
|
16
|
+
BackfillAppliedError,
|
|
17
|
+
BackfillEnvironmentError,
|
|
18
|
+
BackfillMigrationPendingError,
|
|
19
|
+
} from './errors';
|
|
20
|
+
|
|
21
|
+
export type BackfillGate =
|
|
22
|
+
| { readonly run: true }
|
|
23
|
+
| { readonly run: false; readonly error: UltimateError };
|
|
24
|
+
|
|
25
|
+
const ALLOWED: BackfillGate = { run: true };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An absent `environments` means EVERY environment. Never an implied "production only": a staging
|
|
29
|
+
* rehearsal is correct practice, and a framework that guessed which deploys a cleanup belongs to
|
|
30
|
+
* would be shipping one business's convention to every app (axiom 8).
|
|
31
|
+
*/
|
|
32
|
+
export function checkBackfillEnvironment(
|
|
33
|
+
backfill: string,
|
|
34
|
+
declared: readonly Environment[] | null,
|
|
35
|
+
environment: Environment,
|
|
36
|
+
): BackfillEnvironmentError | undefined {
|
|
37
|
+
if (declared === null || declared.length === 0) return undefined;
|
|
38
|
+
if (declared.includes(environment)) return undefined;
|
|
39
|
+
return new BackfillEnvironmentError({ backfill, environment, declared });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BackfillGateInput {
|
|
43
|
+
readonly declaration: BackfillDeclaration;
|
|
44
|
+
readonly environment: Environment;
|
|
45
|
+
/**
|
|
46
|
+
* Migration ids `x_migrations` records as applied. `undefined` means the caller could not read
|
|
47
|
+
* the ledger, and an unreadable ledger is deliberately NOT a refusal — a `requires` that blocked
|
|
48
|
+
* on "I could not check" would make every driver with no database an unrunnable backfill.
|
|
49
|
+
*/
|
|
50
|
+
readonly appliedMigrations: readonly string[] | undefined;
|
|
51
|
+
/** The newest COMPLETED pass under this name, when the ledger holds one. */
|
|
52
|
+
readonly completed: BackfillProgress | undefined;
|
|
53
|
+
readonly force: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Order is the order an operator can act in: environment first (nothing else matters if this
|
|
58
|
+
* process may not run it at all), then the migration it waits on, then whether it already ran.
|
|
59
|
+
* A live pass is NOT judged here — that verdict belongs to the enqueue, which is the only thing
|
|
60
|
+
* that can see the one live idempotency key without racing it.
|
|
61
|
+
*/
|
|
62
|
+
export function gateBackfill(input: BackfillGateInput): BackfillGate {
|
|
63
|
+
const { declaration } = input;
|
|
64
|
+
const environment = checkBackfillEnvironment(
|
|
65
|
+
declaration.name,
|
|
66
|
+
declaration.environments,
|
|
67
|
+
input.environment,
|
|
68
|
+
);
|
|
69
|
+
if (environment !== undefined) return { run: false, error: environment };
|
|
70
|
+
|
|
71
|
+
const requires = declaration.requires;
|
|
72
|
+
if (
|
|
73
|
+
requires !== null &&
|
|
74
|
+
input.appliedMigrations !== undefined &&
|
|
75
|
+
!input.appliedMigrations.includes(requires)
|
|
76
|
+
) {
|
|
77
|
+
return {
|
|
78
|
+
run: false,
|
|
79
|
+
error: new BackfillMigrationPendingError({ backfill: declaration.name, migration: requires }),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const completed = input.completed;
|
|
84
|
+
if (completed !== undefined && !input.force) {
|
|
85
|
+
return {
|
|
86
|
+
run: false,
|
|
87
|
+
error: new BackfillAppliedError({
|
|
88
|
+
backfill: declaration.name,
|
|
89
|
+
runId: completed.runId,
|
|
90
|
+
// Verbatim: the projection already rendered it as ISO, and the repo forbids a date
|
|
91
|
+
// formatted without an explicit zone — not formatting at all is the one render with none.
|
|
92
|
+
completedAt: completed.completedAt ?? completed.startedAt,
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return ALLOWED;
|
|
97
|
+
}
|