@ultimat3/jobs 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
package/src/import.ts ADDED
@@ -0,0 +1,175 @@
1
+ // `importRows()` — a file in object storage read a page at a time into the app's tables, declared
2
+ // as a `job` and NOT as a ninth primitive: the twin of `exportRows()`, a FACTORY over `job()` as
3
+ // `backfill()`, `purge()` and `webhook()` are. That is what gives an import `.enqueue()`, the
4
+ // worker's cancellation, the dead-letter path, `x jobs show` and a manifest row.
5
+ //
6
+ // WHY IT IS THE FRAMEWORK'S: the memory bound and the resume. `parse(await disk.get(key))` holds
7
+ // the whole upload, and a retried job re-applies every row from zero. What ships is the quote-aware
8
+ // record boundary, the byte-offset checkpoint and the per-row error report; which columns mean
9
+ // what, and who may import, never do. The pass lives in `import-pass.ts`.
10
+
11
+ import type { Clock, Ctx } from '@ultimat3/core';
12
+ import { assert, systemClock } from '@ultimat3/core';
13
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
14
+ import type { Pacer } from './backfill-rate';
15
+ import { backfillPacer } from './backfill-rate';
16
+ import type { DurationInput } from './clock';
17
+ import type { ExportFormat } from './export-format';
18
+ import { EXPORT_FORMATS } from './export-format';
19
+ import type { ExportSink } from './export-sink';
20
+ import { importPass } from './import-pass';
21
+ import type { ImportSource } from './import-source';
22
+ import type { JobHandle } from './job';
23
+ import { job } from './job';
24
+ import type { RetryPolicy } from './retry';
25
+ import { DEFAULT_RETRY } from './retry';
26
+ import type { JobTenant } from './tenant';
27
+
28
+ /** Records per `handle` call: one statement's worth. A page hands over its whole read in these. */
29
+ export const DEFAULT_IMPORT_BATCH = 500;
30
+ /** Bytes one page reads. The heap bound: a window is held, never the file. */
31
+ export const DEFAULT_IMPORT_PAGE_BYTES = 1_024 * 1_024;
32
+ /** The longest one record may be. Must stay below `pageBytes`, so every window holds a record. */
33
+ export const DEFAULT_IMPORT_MAX_RECORD_BYTES = 64 * 1_024;
34
+ /** Refused rows past which the import ends failed. `0` stops at the first bad page. */
35
+ export const DEFAULT_IMPORT_MAX_ROW_ERRORS = 1_000;
36
+
37
+ export interface ImportDefinition<Rec, I> {
38
+ /** REQUIRED, as on `exportRows()`: the queue row, the step trace and every page carry it. */
39
+ readonly name: string;
40
+ /** The payload: a pointer to the file (`{ orgId, uploadKey }`), never its rows. */
41
+ readonly input: StandardSchemaV1<unknown, I>;
42
+ /**
43
+ * REQUIRED, exactly as on `job()`. `handle` runs under it, so a scoped write in a `'none'` import
44
+ * fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED` — deliberately not relaxed: an import writes
45
+ * one file's rows, and the file names no tenant the app did not put in `input`.
46
+ */
47
+ readonly tenant: JobTenant<NoInfer<I>>;
48
+ /** Where the file is — a THUNK, read per page: `source: () => disk('imports')`. */
49
+ readonly source: () => ImportSource;
50
+ /**
51
+ * The file's object key, from the input. `I` and `Rec` are inferred from `input` and `schema`
52
+ * ALONE (`NoInfer` everywhere else), so a helper typed for half the input cannot narrow it.
53
+ */
54
+ key(input: NoInfer<I>): string;
55
+ readonly format: ExportFormat;
56
+ /** The declared columns. A csv header must name exactly these; ndjson keys must be among them. */
57
+ readonly columns: readonly string[];
58
+ /**
59
+ * Each record, keyed by column, through this. A csv record is text, coerced as an HTTP form is
60
+ * (`coerceInput`): an unquoted empty cell is `null`, a quoted `""` is `''`. No formula guard is
61
+ * undone: an import cannot know a leading `'` was the exporter's.
62
+ */
63
+ readonly schema: StandardSchemaV1<unknown, Rec>;
64
+ /**
65
+ * One page of accepted rows. AT LEAST ONCE per page: a page that dies after `handle` and before
66
+ * its checkpoint runs again. `upsertAll` is the idempotent spelling, as for `backfill()`.
67
+ */
68
+ handle(args: {
69
+ readonly rows: readonly NoInfer<Rec>[];
70
+ readonly ctx: Ctx;
71
+ readonly input: NoInfer<I>;
72
+ }): unknown;
73
+ /** Where refused rows are reported: `<prefix>/<runId>/errors-00000.ndjson`, one part per page. */
74
+ readonly errors: { readonly sink: () => ExportSink; prefix(input: NoInfer<I>): string };
75
+ /** Records per `handle` call, never per read. Defaults to `DEFAULT_IMPORT_BATCH`. */
76
+ readonly batch?: number;
77
+ /** Defaults to `DEFAULT_IMPORT_PAGE_BYTES`. */
78
+ readonly pageBytes?: number;
79
+ /** Defaults to `DEFAULT_IMPORT_MAX_RECORD_BYTES`; below `pageBytes`. */
80
+ readonly maxRecordBytes?: number;
81
+ /** Defaults to `DEFAULT_IMPORT_MAX_ROW_ERRORS`. */
82
+ readonly maxRowErrors?: number;
83
+ /** Pages per second. No default, as an export's: omit it for unthrottled. */
84
+ readonly rate?: number;
85
+ readonly clock?: Clock;
86
+ readonly queue?: string;
87
+ readonly retry?: RetryPolicy;
88
+ /** Per attempt: a resumed attempt picks up at the last checkpointed byte offset. */
89
+ readonly timeout?: DurationInput;
90
+ }
91
+
92
+ /** What one completed pass reports — bounded, so `x jobs show` can print it. */
93
+ export interface ImportReport {
94
+ readonly name: string;
95
+ readonly key: string;
96
+ /** Where this run's refused rows are: `<errors.prefix>/<runId>`, the parts under it. */
97
+ readonly errorPrefix: string;
98
+ readonly rows: number;
99
+ readonly rejected: number;
100
+ /** Error parts written. */
101
+ readonly parts: number;
102
+ readonly bytes: number;
103
+ }
104
+
105
+ /** Everything fixed at declaration, validated once. Read by `importPass`. */
106
+ export interface ImportPlan<Rec, I> {
107
+ readonly definition: ImportDefinition<Rec, I>;
108
+ readonly batch: number;
109
+ readonly pageBytes: number;
110
+ readonly maxRecordBytes: number;
111
+ readonly maxRowErrors: number;
112
+ readonly pace: Pacer | undefined;
113
+ }
114
+
115
+ export function importRows<Rec, I>(definition: ImportDefinition<Rec, I>): JobHandle<I> {
116
+ const name = definition.name;
117
+ const batch = definition.batch ?? DEFAULT_IMPORT_BATCH;
118
+ const pageBytes = definition.pageBytes ?? DEFAULT_IMPORT_PAGE_BYTES;
119
+ const maxRecordBytes = definition.maxRecordBytes ?? DEFAULT_IMPORT_MAX_RECORD_BYTES;
120
+ const maxRowErrors = definition.maxRowErrors ?? DEFAULT_IMPORT_MAX_ROW_ERRORS;
121
+ assert(
122
+ Number.isSafeInteger(batch) && batch >= 1,
123
+ `import "${name}" declares batch: ${String(batch)} — a batch is a whole number of records, at least one`,
124
+ `set batch: ${DEFAULT_IMPORT_BATCH} on importRows("${name}") — the records one page hands handle()`,
125
+ );
126
+ assert(
127
+ Number.isSafeInteger(maxRecordBytes) &&
128
+ Number.isSafeInteger(pageBytes) &&
129
+ maxRecordBytes >= 1 &&
130
+ maxRecordBytes < pageBytes,
131
+ `import "${name}" declares pageBytes: ${String(pageBytes)} and maxRecordBytes: ${String(maxRecordBytes)} — a page must be longer than the longest record, or a record can fill a window with no end in it`,
132
+ `set pageBytes: ${DEFAULT_IMPORT_PAGE_BYTES} and maxRecordBytes: ${DEFAULT_IMPORT_MAX_RECORD_BYTES} on importRows("${name}"), or leave both out`,
133
+ );
134
+ assert(
135
+ Number.isSafeInteger(maxRowErrors) && maxRowErrors >= 0,
136
+ `import "${name}" declares maxRowErrors: ${String(maxRowErrors)}`,
137
+ `set maxRowErrors: ${DEFAULT_IMPORT_MAX_ROW_ERRORS} on importRows("${name}") — a whole number, 0 to stop at the first bad page`,
138
+ );
139
+ assert(
140
+ (EXPORT_FORMATS as readonly string[]).includes(definition.format),
141
+ `import "${name}" declares format: ${String(definition.format)}`,
142
+ `set format to one of ${EXPORT_FORMATS.join(' | ')} on importRows("${name}")`,
143
+ );
144
+ assert(
145
+ definition.columns.length > 0 && new Set(definition.columns).size === definition.columns.length,
146
+ `import "${name}" declares ${definition.columns.length} column(s) and ${new Set(definition.columns).size} distinct one(s)`,
147
+ `declare at least one column on importRows("${name}") and no duplicates — the list is what a csv header must name`,
148
+ );
149
+ const clock = definition.clock ?? systemClock;
150
+ const pace =
151
+ definition.rate === undefined
152
+ ? undefined
153
+ : backfillPacer({ rate: definition.rate, job: name, clock });
154
+ const plan: ImportPlan<Rec, I> = {
155
+ definition,
156
+ batch,
157
+ pageBytes,
158
+ maxRecordBytes,
159
+ maxRowErrors,
160
+ pace,
161
+ };
162
+
163
+ return job<I>({
164
+ name,
165
+ input: definition.input,
166
+ // ONE live pass per file, derived rather than declared, as an export's per prefix: two passes
167
+ // over one file would hand every row to `handle` twice and race over every error part.
168
+ idempotencyKey: (input) => `${name}:${definition.key(input)}`,
169
+ tenant: definition.tenant,
170
+ retry: definition.retry ?? DEFAULT_RETRY,
171
+ ...(definition.queue === undefined ? {} : { queue: definition.queue }),
172
+ ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
173
+ run: (args) => importPass<Rec, I>(plan, args),
174
+ });
175
+ }
package/src/index.ts CHANGED
@@ -18,7 +18,11 @@ export type {
18
18
  BackfillBatch,
19
19
  BackfillDefinition,
20
20
  BackfillInput,
21
+ BackfillPage,
22
+ BackfillPages,
21
23
  BackfillReport,
24
+ ScopedBackfillDefinition,
25
+ ScopedBackfillInput,
22
26
  } from './backfill';
23
27
  export { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
24
28
  export {
@@ -27,6 +31,7 @@ export {
27
31
  BackfillMigrationPendingError,
28
32
  BackfillPendingError,
29
33
  BackfillRunningError,
34
+ BackfillSourceInvalidError,
30
35
  BackfillStalledError,
31
36
  BackfillUnknownError,
32
37
  } from './backfill-errors';
@@ -75,6 +80,29 @@ export type { AnnounceExhaustedOptions, ExhaustedCounts } from './claim-exhauste
75
80
  export { announceExhausted } from './claim-exhausted';
76
81
  export type { JobConcurrency, KeyedConcurrency, WhenBusy } from './concurrency';
77
82
  export { MAX_CONCURRENCY_KEY_LENGTH, WHEN_BUSY } from './concurrency';
83
+ export { consumer } from './consumer';
84
+ export { ConsumerInvalidError, ConsumerSelfEnqueueError } from './consumer-errors';
85
+ export {
86
+ CONSUMER_LINGER_MS,
87
+ CONSUMER_PROGRESS_NOTE,
88
+ DEFAULT_CONSUMER_BATCH,
89
+ } from './consumer-loop';
90
+ // `stampConsumer` is deliberately absent, for the reason `stampBackfill` is.
91
+ export type { ConsumerDeclaration, ConsumerRow } from './consumer-registry';
92
+ export {
93
+ consumerDeclarationOf,
94
+ consumerRow,
95
+ isConsumer,
96
+ registeredConsumers,
97
+ } from './consumer-registry';
98
+ export type {
99
+ ConsumerBatch,
100
+ ConsumerCursor,
101
+ ConsumerDefinition,
102
+ ConsumerHandle,
103
+ ConsumerInput,
104
+ ConsumerReport,
105
+ } from './consumer-types';
78
106
  export type { JobConcurrencyDescriptor, JobDescriptor } from './describe';
79
107
  export type {
80
108
  AckOptions,
@@ -89,6 +117,7 @@ export type {
89
117
  JobRecord,
90
118
  JobState,
91
119
  NackOptions,
120
+ QueueDemand,
92
121
  QueueStats,
93
122
  SettleBy,
94
123
  } from './driver';
@@ -116,18 +145,18 @@ export { postgresJobDriver, postgresLeader } from './driver-pg';
116
145
  // too — the array-parameter proofs are `driver-pg-array.live.test.ts` (25.0.0).
117
146
  export { SQL_JOBS_TABLE } from './driver-pg-sql';
118
147
  export { signalEnqueued, signalStaged } from './enqueue-signal';
119
- export type { JobErrorCode } from './errors';
148
+ export type { JobErrorCode } from './error-codes';
149
+ export { JOB_ERROR_CODES, JOB_ERROR_TITLES } from './error-codes';
120
150
  export {
121
151
  ActionJobUnbridgedError,
122
152
  CancelUnsupportedError,
123
153
  ClaimQueuesEmptyError,
124
154
  DriverUnavailableError,
125
155
  IdempotencyRequiredError,
126
- JOB_ERROR_CODES,
127
- JOB_ERROR_TITLES,
128
156
  JobAbortedError,
129
157
  JobDrainedError,
130
158
  JobDuplicateError,
159
+ JobEnqueueBatchTooLargeError,
131
160
  JobMaxAttemptsError,
132
161
  JobNameTakenError,
133
162
  JobNotCancellableError,
@@ -153,6 +182,16 @@ export {
153
182
  JobPageInvalidError,
154
183
  } from './errors-operator';
155
184
  export { JobNotFoundError, JobNotRequeueableError } from './errors-requeue';
185
+ export {
186
+ COMPENSATE_PREFIX,
187
+ JobCompensationFailedError,
188
+ StepCompensateUndeclaredError,
189
+ StepNameReservedError,
190
+ StepNotResolvableError,
191
+ StepOutcomeUnknownError,
192
+ UNWINDING_STEP,
193
+ } from './errors-saga';
194
+ export { JobEventInvalidError, StepOutputUnserializableError } from './errors-steps';
156
195
  export { JobTenantMismatchError } from './errors-tenant';
157
196
  export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
158
197
  export {
@@ -180,7 +219,28 @@ export {
180
219
  exportPartKey,
181
220
  memoryExportSink,
182
221
  } from './export-sink';
222
+ export type { CallerFailure } from './failure-row';
223
+ export { callerFailure } from './failure-row';
183
224
  export { IDLE_POLL_CEILING_MS } from './idle-backoff';
225
+ export type { ImportDefinition, ImportReport } from './import';
226
+ export {
227
+ DEFAULT_IMPORT_BATCH,
228
+ DEFAULT_IMPORT_MAX_RECORD_BYTES,
229
+ DEFAULT_IMPORT_MAX_ROW_ERRORS,
230
+ DEFAULT_IMPORT_PAGE_BYTES,
231
+ importRows,
232
+ } from './import';
233
+ export {
234
+ ImportHeaderMismatchError,
235
+ ImportRecordTooLargeError,
236
+ ImportTooManyErrorsError,
237
+ } from './import-errors';
238
+ export type { ImportRejection } from './import-format';
239
+ export { IMPORT_REJECTIONS } from './import-format';
240
+ export type { ImportRowError } from './import-pass';
241
+ export { importErrorKey } from './import-pass';
242
+ export type { ImportObject, ImportSource, MemoryImportSource } from './import-source';
243
+ export { memoryImportSource } from './import-source';
184
244
  export type {
185
245
  DeadLetterEntry,
186
246
  JobsManifest,
@@ -197,7 +257,13 @@ export {
197
257
  inspectQueues,
198
258
  retryFromStep,
199
259
  } from './inspect';
200
- export { pauseQueue, promoteJob, removeJob, resumeQueue } from './inspect-operator';
260
+ export {
261
+ pauseQueue,
262
+ promoteJob,
263
+ removeJob,
264
+ resolveJobStep,
265
+ resumeQueue,
266
+ } from './inspect-operator';
201
267
  export type {
202
268
  BulkFilter,
203
269
  BulkResult,
@@ -209,6 +275,7 @@ export type {
209
275
  JobIntrospection,
210
276
  JobProgress,
211
277
  PausedName,
278
+ StepResolution,
212
279
  TaskFire,
213
280
  WorkerAnnouncement,
214
281
  WorkerRecord,
@@ -235,6 +302,8 @@ export type {
235
302
  JobRunArgs,
236
303
  } from './job';
237
304
  export { describeJobs, getJob, isJobHandle, job, registeredJobs, resetJobs } from './job';
305
+ export type { EnqueueManyOptions } from './job-enqueue-many';
306
+ export { MAX_ENQUEUE_BATCH } from './job-enqueue-many';
238
307
  export type { JobsRetentionOptions } from './jobs-retention';
239
308
  export { jobsPurgeTarget } from './jobs-retention';
240
309
  export type { HeldLease, LeaseStore, MemoryLeaseStore, MemoryLeaseStoreOptions } from './leases';
@@ -251,8 +320,10 @@ export type {
251
320
  export { concurrencyLimiter, NO_TENANT, tenantKeyFrom } from './limits';
252
321
  export {
253
322
  queueDeadJobs,
323
+ queueDemand,
254
324
  queueOldestReady,
255
325
  recordQueueDeadJobs,
326
+ recordQueueDemand,
256
327
  recordQueueOldestReady,
257
328
  } from './metrics';
258
329
  export type {
@@ -265,7 +336,6 @@ export type {
265
336
  OutboxStore,
266
337
  } from './outbox';
267
338
  export {
268
- enqueueInTx,
269
339
  jobsFacade,
270
340
  memoryOutboxStore,
271
341
  outboxJobsFacade,
@@ -291,13 +361,40 @@ export type {
291
361
  } from './purge';
292
362
  export { DEFAULT_PURGE_CRON, purge } from './purge';
293
363
  export type { PurgeBatching } from './purge-batches';
364
+ export type { PurgeEntities } from './purge-entities';
365
+ export {
366
+ resetPurgeCoverage,
367
+ retainedEntities,
368
+ retainTarget,
369
+ unsweptEntities,
370
+ } from './purge-entities';
294
371
  export type { PgListener, QueueWake, QueueWakeOptions } from './queue-wake';
295
372
  export { startQueueWake } from './queue-wake';
373
+ export type { RefreshViewDefinition, RefreshViewReport } from './refresh-view';
374
+ export { refreshView } from './refresh-view';
296
375
  export type { IntervalScheduler } from './renewal-timer';
297
376
  export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
298
- export { backoffDelayMs, DEFAULT_RETRY, isFinalAttempt, nextRetry, retrySchedule } from './retry';
377
+ export {
378
+ backoffDelayMs,
379
+ DEFAULT_RETRY,
380
+ enqueuedAttempts,
381
+ isFinalAttempt,
382
+ isUnwindAttempt,
383
+ nextRetry,
384
+ retrySchedule,
385
+ UNWIND_MARGIN,
386
+ } from './retry';
299
387
  export type { JobRetryDecision, JobStopReason } from './retry-classification';
300
388
  export { failureForRow, nextRetryForError } from './retry-classification';
389
+ export type {
390
+ RollupBucket,
391
+ RollupDefinition,
392
+ RollupInput,
393
+ RollupReport,
394
+ RollupTarget,
395
+ } from './rollup';
396
+ export { rollup } from './rollup';
397
+ export { RefreshViewInvalidError, RollupInvalidError } from './rollup-errors';
301
398
  export type {
302
399
  CronResolver,
303
400
  DispatchedOccurrence,
@@ -323,27 +420,65 @@ export {
323
420
  export type { ScheduledFire, SchedulerState } from './scheduler-state';
324
421
  export { fireThroughDriver, memorySchedulerState } from './scheduler-state';
325
422
  export type { JobCompleted, JobFailed, JobSettled } from './settled';
326
- export { ON_SETTLED_ATTEMPTS } from './settled';
423
+ export { ON_SETTLED_ATTEMPTS, ON_SETTLED_TIMEOUT_MS } from './settled';
327
424
  export type {
425
+ CompensateContext,
328
426
  EventLookup,
329
427
  StepApi,
330
428
  StepFence,
331
429
  StepRecord,
332
430
  StepRunner,
333
431
  StepRunnerOptions,
432
+ StepRunOptions,
334
433
  StepStatus,
335
434
  StepStore,
336
435
  WaitForEventOptions,
337
436
  } from './steps';
338
437
  export {
438
+ DEFAULT_EVENT_POLL_MS,
339
439
  isStepStatus,
340
440
  isStepSuspension,
341
441
  MAX_TRACE_NAMES,
342
442
  STEP_STATUSES,
343
443
  StepSuspension,
344
444
  stepRunner,
445
+ WAKING_EVENT_POLL_MS,
345
446
  } from './steps';
346
447
  export { memoryStepStore } from './steps-memory';
448
+ export type { StepOutput } from './steps-output';
449
+ export type {
450
+ RetainedTarget,
451
+ Subject,
452
+ SubjectInit,
453
+ SubjectRef,
454
+ SubjectTarget,
455
+ SubjectTargetEntry,
456
+ SubjectTargetPage,
457
+ SubjectTreatment,
458
+ } from './subject';
459
+ export { DEFAULT_SUBJECT_BATCH, defineSubject, registeredSubjects, resetSubjects } from './subject';
460
+ export type { SubjectEraseReport, SubjectTally } from './subject-erase-pass';
461
+ export {
462
+ SubjectCycleError,
463
+ SubjectTableUndeclaredError,
464
+ SubjectTreatmentInvalidError,
465
+ } from './subject-errors';
466
+ export type { SubjectExportReport, SubjectIndexEntry } from './subject-export-pass';
467
+ export type { SubjectExportDefinition, SubjectJobDefinition } from './subject-jobs';
468
+ export {
469
+ plannedSubject,
470
+ subjectErase,
471
+ subjectEraseTables,
472
+ subjectExport,
473
+ } from './subject-jobs';
474
+ export type {
475
+ SubjectPlan,
476
+ SubjectPlanInput,
477
+ SubjectStep,
478
+ SubjectUnlink,
479
+ SubjectVia,
480
+ } from './subject-plan';
481
+ export { planSubject } from './subject-plan';
347
482
  export type {
348
483
  CatchUpPolicy,
349
484
  TaskDefinition,
@@ -384,5 +519,26 @@ export {
384
519
  } from './webhook-errors';
385
520
  export type { MemoryWebhookLedger, WebhookAttempt, WebhookLedger } from './webhook-ledger';
386
521
  export { DEFAULT_MAX_WEBHOOK_ATTEMPTS, memoryWebhookLedger } from './webhook-ledger';
522
+ export { webhookReceiver, webhookReceiverRoutes } from './webhook-receiver';
523
+ export {
524
+ WEBHOOK_EVENT_ID_PATTERN,
525
+ WEBHOOK_RECEIVER_RETRY_AFTER_SECONDS,
526
+ WebhookEventIdInvalidError,
527
+ WebhookReceiverInvalidError,
528
+ WebhookReceiverUnavailableError,
529
+ } from './webhook-receiver-errors';
530
+ export type {
531
+ ReceivedEvent,
532
+ ReceivedEventFacts,
533
+ WebhookAnswer,
534
+ WebhookHandleArgs,
535
+ WebhookInbound,
536
+ WebhookReceiver,
537
+ WebhookReceiverDefinition,
538
+ WebhookReceiverInput,
539
+ WebhookReceiverTenant,
540
+ } from './webhook-receiver-types';
387
541
  export type { Worker, WorkerOptions, WorkerStats } from './worker';
388
542
  export { jobWorker } from './worker';
543
+ export type { CutoffTimes } from './worker-drain-cutoff';
544
+ export { cutoffTimes, DRAIN_CANCEL_MARGIN_MS } from './worker-drain-cutoff';
@@ -1,12 +1,13 @@
1
- // The operator verbs `x jobs` and a dashboard bind to: remove, promote, pause, resume. Each one
2
- // refuses LOUDLY rather than answering "nothing happened" — an operator clearing a queue has to
1
+ // The operator verbs `x jobs` and a dashboard bind to: remove, promote, pause, resume, resolve a
2
+ // step. Each one refuses LOUDLY rather than answering "nothing happened" — an operator has to
3
3
  // know whether the job is gone or was never theirs to remove. Apart from `inspect.ts`, which is
4
4
  // the read side.
5
5
 
6
6
  import { NotImplementedError } from '@ultimat3/core';
7
7
  import type { JobDriver, JobRecord } from './driver';
8
8
  import { JobNotPromotableError } from './errors-operator';
9
- import type { JobIntrospection, PausedName } from './introspection';
9
+ import type { JobIntrospection, PausedName, StepResolution } from './introspection';
10
+ import type { StepRecord } from './steps-store';
10
11
 
11
12
  function operatorOf(driver: JobDriver): JobIntrospection {
12
13
  if (driver.introspect === undefined) {
@@ -47,3 +48,23 @@ export async function resumeQueue(
47
48
  await operator.resumeQueue(queue);
48
49
  return operator.pausedQueues();
49
50
  }
51
+
52
+ /**
53
+ * A person's answer to `X_STEP_OUTCOME_UNKNOWN`: the resolved record. Then `x jobs retry <id>`.
54
+ * A driver with no `resolveStep` is refused as one with no operator surface at all.
55
+ */
56
+ export function resolveJobStep(
57
+ driver: JobDriver,
58
+ jobId: string,
59
+ step: string,
60
+ resolution: StepResolution,
61
+ ): Promise<StepRecord> {
62
+ const operator = operatorOf(driver);
63
+ if (operator.resolveStep === undefined) {
64
+ throw new NotImplementedError({
65
+ cause: `the "${driver.name}" jobs driver keeps no step records an operator can resolve: its introspect has no resolveStep`,
66
+ fix: 'setJobDriver(postgresJobDriver()) # at boot: the pg and memory drivers implement resolveStep — then x jobs resolve-step <id> <step> --ran --output <json>',
67
+ });
68
+ }
69
+ return operator.resolveStep(jobId, step, resolution);
70
+ }
package/src/inspect.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  // object so `x jobs ... --json` and the MCP tool share one shape — an agent debugging a stuck
3
3
  // queue reads exactly what the dashboard renders.
4
4
 
5
- import { NotImplementedError } from '@ultimat3/core';
5
+ import { NotImplementedError, REDACTED } from '@ultimat3/core';
6
+ import { maskSensitive } from '@ultimat3/schema';
6
7
  import type { BackfillProgress } from './backfill-inspect';
7
8
  import { backfillForRun } from './backfill-inspect';
8
9
  import type { WhenBusy } from './concurrency';
@@ -78,8 +79,10 @@ export interface JobTrace {
78
79
  /** The thrown value's stack for `lastError`, bounded. `null` when the failure carried none. */
79
80
  readonly stack: string | null;
80
81
  /**
81
- * The stored payload, with every key the app declared secret (`redactKeys`) replaced. App data
82
- * all the same: a surface that shows it gates it like any other read of a tenant's rows.
82
+ * The stored payload, with every `.sensitive()` field of the job's schema and every key the app
83
+ * declared secret (`redactKeys`) replaced — `[redacted]` whole for a job whose declaration is
84
+ * gone. App data all the same: a surface that shows it gates it like any other read of a
85
+ * tenant's rows.
83
86
  */
84
87
  readonly input: unknown;
85
88
  /** What the body last reported through `progress()`; `null` until it reports. */
@@ -129,6 +132,16 @@ function concurrencyKeyOf(handle: AnyJobHandle | undefined, record: JobRecord):
129
132
  }
130
133
  }
131
134
 
135
+ /**
136
+ * The stored payload as an operator may read it: the job's own `.sensitive()` fields masked by its
137
+ * schema, THEN `redactKeys`. A job this process has not registered has no schema to say which keys
138
+ * are sensitive, so its input is hidden whole — never shown on the hope that none were.
139
+ */
140
+ function operatorInput(handle: AnyJobHandle | undefined, record: JobRecord): unknown {
141
+ if (handle === undefined) return REDACTED;
142
+ return redactInput(maskSensitive(handle.input, record.input, REDACTED));
143
+ }
144
+
132
145
  function toStepTrace(record: StepRecord): StepTrace {
133
146
  return {
134
147
  name: record.name,
@@ -160,7 +173,7 @@ export async function inspectJob(driver: JobDriver, jobId: string): Promise<JobT
160
173
  runAt: new Date(record.runAt).toISOString(),
161
174
  lastError: record.lastError ?? null,
162
175
  stack: record.lastErrorStack ?? null,
163
- input: redactInput(record.input),
176
+ input: operatorInput(handle, record),
164
177
  progress: record.progress ?? null,
165
178
  tenantId: record.tenantId ?? null,
166
179
  traceparent: record.traceparent ?? null,
@@ -7,6 +7,7 @@
7
7
  import { finiteCount } from '@ultimat3/core';
8
8
  import type { ClaimIdentity, JobRecord, JobState } from './driver';
9
9
  import { JobPageInvalidError } from './errors-operator';
10
+ import type { StepRecord } from './steps-store';
10
11
 
11
12
  /** Rows one `list()` call may answer. A page, never a table: a dashboard walks with `after`. */
12
13
  export const MAX_JOB_PAGE = 200;
@@ -99,6 +100,14 @@ export function pageLimit(subject: string, limit: number | undefined): number {
99
100
  return asked;
100
101
  }
101
102
 
103
+ /** What a person found out about a `once` step: it ran (and answered `output`), or it did not. */
104
+ export type StepResolution =
105
+ | { readonly ran: true; readonly output: unknown }
106
+ | { readonly ran: false };
107
+
108
+ /** The step statuses an operator may resolve: a `once` step that began and never completed. */
109
+ export const RESOLVABLE_STEP_STATUSES: ReadonlySet<string> = new Set(['started', 'failed']);
110
+
102
111
  /** Which rows a bulk call addresses. `state` is REQUIRED: "every job" is never one call. */
103
112
  export interface BulkFilter {
104
113
  readonly state: JobState;
@@ -144,6 +153,12 @@ export interface WorkerAnnouncement {
144
153
  * what it holds. Absent: serving.
145
154
  */
146
155
  readonly draining?: boolean;
156
+ /**
157
+ * The build this process runs: `BUILD_ID`, the id `x-ultimate-build` answers with. Optional so a
158
+ * custom driver still compiles, and absent on a row a worker from before 30.0.0 wrote. What
159
+ * `x deploy verify` tells a stalled worker roll by: the old pods still claim the queue.
160
+ */
161
+ readonly build?: string;
147
162
  }
148
163
 
149
164
  export interface WorkerRecord extends WorkerAnnouncement {
@@ -151,7 +166,7 @@ export interface WorkerRecord extends WorkerAnnouncement {
151
166
  readonly heartbeatAt: number;
152
167
  /**
153
168
  * Epoch ms of the FIRST announcement that said `draining` — kept by every later one. Absent while
154
- * the worker serves. What `SQL_SCALER_DEPTH` excludes a worker's held jobs by.
169
+ * the worker serves. What `SQL_SCALER_DEMAND` excludes a worker's held jobs by.
155
170
  */
156
171
  readonly drainingAt?: number;
157
172
  }
@@ -241,6 +256,14 @@ export interface JobIntrospection {
241
256
  * `runAt` is due now. `filter.state` must be `ready` (a retry backing off) or `delayed`.
242
257
  */
243
258
  promoteMany(filter: BulkFilter): Promise<BulkResult>;
259
+ /**
260
+ * Answer a `once` step whose outcome is unknown (`X_STEP_OUTCOME_UNKNOWN`): `ran` writes it
261
+ * `completed` with `output`, so a replay returns it; not `ran` writes `cleared`, so the next run
262
+ * calls `fn` again. ONE write, fenced to a job no worker holds and a step that `started` or
263
+ * `failed`; anything else is `X_STEP_NOT_RESOLVABLE`, an unknown id `X_JOB_NOT_FOUND`. Optional,
264
+ * as `cancel` is: a driver may keep no step records an operator can reach.
265
+ */
266
+ resolveStep?(jobId: string, step: string, resolution: StepResolution): Promise<StepRecord>;
244
267
  /** A paused queue is never claimed, by any worker; enqueues still land. Idempotent. */
245
268
  pauseQueue(queue: string): Promise<void>;
246
269
  resumeQueue(queue: string): Promise<void>;