@makerkit/workers-outbox 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,959 @@
1
+ import { t as SqlRunner } from "./catalog-DzvOuOHQ.mjs";
2
+ import { i as QueueSender, n as LaneConnection, r as OutboxDb, t as Clock } from "./types-ClomUJ_8.mjs";
3
+ import { StandardSchemaV1 } from "@standard-schema/spec";
4
+ type MessageRedactor = (message: string) => string;
5
+ /** Database-only and re-runnable: it may run again after a transient fault. */
6
+ type CommitFn = (tx: OutboxDb) => Promise<void>;
7
+ /** Expected results are values; throw only for unanticipated faults (`classify` maps them). */
8
+ type HandlerOutcome = {
9
+ type: 'ok';
10
+ commit?: CommitFn;
11
+ } | {
12
+ type: 'retry';
13
+ code: string;
14
+ afterMs?: number;
15
+ message?: string;
16
+ } | {
17
+ type: 'fail';
18
+ code: string;
19
+ message?: string;
20
+ } | {
21
+ type: 'cancel';
22
+ reason: string;
23
+ };
24
+ /** `commit` runs in the settle transaction: its writes and `succeeded` land together, lease-fenced. */
25
+ declare const ok: (options?: {
26
+ commit?: CommitFn;
27
+ }) => HandlerOutcome;
28
+ /** `after` overrides the backoff (e.g. a provider's Retry-After). */
29
+ declare const retry: (input: {
30
+ code: string;
31
+ after?: number | undefined;
32
+ message?: string | undefined;
33
+ }) => HandlerOutcome;
34
+ declare const fail: (input: {
35
+ code: string;
36
+ message?: string | undefined;
37
+ }) => HandlerOutcome;
38
+ declare const cancel: (input: {
39
+ reason: string;
40
+ }) => HandlerOutcome;
41
+ /** Budgets count attempts since the last replay; crashes are charged only to `maxCrashes`. */
42
+ interface RetryPolicy {
43
+ /** Handled attempts including the first; `Infinity` never gives up. */
44
+ readonly maxAttempts: number;
45
+ readonly backoff: {
46
+ readonly baseMs: number;
47
+ readonly maxMs: number;
48
+ readonly factor: number;
49
+ };
50
+ /** Consecutive same-code attempt caps; reaching one is exhaustion. */
51
+ readonly perCode: Readonly<Record<string, number>>;
52
+ /** Consecutive crashes before `dead(crash_loop)`. */
53
+ readonly maxCrashes: number;
54
+ readonly codes: Readonly<Record<string, CodeRule>>;
55
+ }
56
+ interface CodeRule {
57
+ /** Only ever widens the job's `maxAttempts`. */
58
+ readonly maxAttempts?: number;
59
+ /** This code's `maxAttempts` keeps applying to every later code until replay. */
60
+ readonly sticky: boolean;
61
+ /** Milliseconds. */
62
+ readonly maxDelayMs?: number;
63
+ readonly onExhaustion: 'dead' | 'cancel';
64
+ }
65
+ /** A number is a streak cap; an object adds `CodeRule` fields plus `streak`. */
66
+ type CodeRuleInput = number | {
67
+ streak?: number;
68
+ maxAttempts?: number;
69
+ sticky?: boolean;
70
+ maxDelayMs?: number;
71
+ onExhaustion?: 'dead' | 'cancel';
72
+ };
73
+ interface RetryPolicyInput {
74
+ maxAttempts?: number;
75
+ backoff?: {
76
+ baseMs?: number;
77
+ maxMs?: number;
78
+ factor?: number;
79
+ };
80
+ /** Merged over the defaults; an object without `streak` keeps the default cap. */
81
+ perCode?: Record<string, CodeRuleInput>;
82
+ maxCrashes?: number;
83
+ }
84
+ interface JobContext<Deps = unknown> {
85
+ readonly jobId: string;
86
+ readonly kind: string;
87
+ /** 1-based over the job's whole life. */
88
+ readonly attempt: number;
89
+ /** Stable across attempts; delivery is at-least-once, so pass it to providers that dedupe. */
90
+ readonly idempotencyKey: string;
91
+ /**
92
+ * Aborts once the attempt has been settled as `timeout`, to stop abandoned I/O: pass it to every
93
+ * I/O call. A result returned after the abort is discarded, so to stop early and still return
94
+ * `ok()`, watch `deadline` / `remainingMs()` instead.
95
+ */
96
+ readonly signal: AbortSignal;
97
+ /**
98
+ * Epoch ms, on the outbox's clock, at which this attempt is settled as `timeout`: the attempt's
99
+ * start plus `timeoutMs` (payload validation counts against it). Return before it, with room
100
+ * for the work after your last check, and enqueue the rest as a continuation.
101
+ */
102
+ readonly deadline: number;
103
+ /**
104
+ * Milliseconds left until `deadline` on the outbox's clock, never below 0. A test clock moves it
105
+ * only when advanced; the timeout itself is real time.
106
+ */
107
+ remainingMs(): number;
108
+ /**
109
+ * The lane's own session (the outbox's `connect`, its role and Hyperdrive config), not your
110
+ * app's database. Fenced: once the attempt returns or is settled as `timeout`, every method call
111
+ * on it, on a `tx` from its `transaction()`, or on a captured method rejects. A query builder
112
+ * created before that and awaited after it is not covered. Typed as the minimal `OutboxDb`; the
113
+ * object is whatever your connector's `db` is (e.g. a drizzle database). Never await an external
114
+ * call inside `ctx.db.transaction()`; for writes that must commit with the job's completion, use
115
+ * `ok({ commit })`.
116
+ */
117
+ readonly db: OutboxDb;
118
+ readonly deps: Deps;
119
+ readonly tenantKey: string | null;
120
+ readonly correlationId: string | null;
121
+ readonly metadata: Readonly<Record<string, unknown>>;
122
+ }
123
+ interface JobDefinitionInput<K extends string, S extends StandardSchemaV1, D> {
124
+ kind: K;
125
+ payload: S;
126
+ timeoutMs: number;
127
+ lane?: string;
128
+ retry?: RetryPolicyInput;
129
+ /**
130
+ * Maps a thrown error to an outcome. Return `undefined` to fall back to `defaultClassify` (retry
131
+ * with code `unexpected`), which is also the default when `classify` is omitted.
132
+ */
133
+ classify?: (error: unknown) => HandlerOutcome | undefined;
134
+ handle: (payload: StandardSchemaV1.InferOutput<S>, ctx: JobContext<D>) => Promise<HandlerOutcome>;
135
+ }
136
+ interface JobDefinition<K extends string = string, S extends StandardSchemaV1 = StandardSchemaV1, D = unknown> {
137
+ readonly kind: K;
138
+ readonly payload: S;
139
+ readonly timeoutMs: number;
140
+ readonly lane: string;
141
+ readonly graceMs: number;
142
+ /** Derived: always outlives the handler plus its grace. */
143
+ readonly leaseMs: number;
144
+ readonly retry: RetryPolicy;
145
+ readonly classify: (error: unknown) => HandlerOutcome;
146
+ handle(payload: StandardSchemaV1.InferOutput<S>, ctx: JobContext<D>): Promise<HandlerOutcome>;
147
+ }
148
+ type AnyJob = JobDefinition<string, StandardSchemaV1, any>;
149
+ /** The `deps` a job's handler expects; `unknown` when it declares none. */
150
+ type JobDeps<T> = T extends JobDefinition<string, StandardSchemaV1, infer D> ? D : never;
151
+ declare function defineJob<K extends string, S extends StandardSchemaV1, D = unknown>(input: JobDefinitionInput<K, S, D>): JobDefinition<K, S, D>;
152
+ /**
153
+ * `defineJob` with `ctx.deps` typed, without annotating every handler:
154
+ *
155
+ * ```ts
156
+ * const defineAppJob = defineJobFor<AppDeps>();
157
+ * export const welcome = defineAppJob({ kind: 'welcome', …, handle: async (p, ctx) => ctx.deps.mailer… });
158
+ * ```
159
+ */
160
+ declare function defineJobFor<D>(): <K extends string, S extends StandardSchemaV1>(input: JobDefinitionInput<K, S, D>) => JobDefinition<K, S, D>;
161
+ /** What a thrown error becomes when `classify` is omitted or returns `undefined`: retry with code `unexpected`. */
162
+ declare function defaultClassify(error: unknown): HandlerOutcome;
163
+ interface KindBacklog {
164
+ readonly kind: string;
165
+ readonly due: number;
166
+ readonly aged: number;
167
+ readonly scheduled: number;
168
+ readonly processing: number;
169
+ readonly stuck: number;
170
+ readonly oldestDueAgeMs: number | null;
171
+ }
172
+ interface BacklogThresholds {
173
+ readonly maxDue: number;
174
+ readonly maxAged: number;
175
+ readonly maxStuck: number;
176
+ /** Critical at `criticalFactor ×` the threshold (any value over a 0 threshold). */
177
+ readonly criticalFactor: number;
178
+ /** `'warning'` for lanes that must never page. */
179
+ readonly maxSeverity: 'warning' | 'critical';
180
+ }
181
+ type BacklogSeverity = 'ok' | 'warning' | 'critical';
182
+ type BacklogMetric = 'due' | 'aged' | 'stuck';
183
+ interface BacklogBreach {
184
+ readonly metric: BacklogMetric;
185
+ readonly value: number;
186
+ readonly threshold: number;
187
+ readonly severity: Exclude<BacklogSeverity, 'ok'>;
188
+ }
189
+ interface BacklogTotals {
190
+ readonly due: number;
191
+ readonly aged: number;
192
+ readonly scheduled: number;
193
+ readonly processing: number;
194
+ readonly stuck: number;
195
+ }
196
+ interface LaneBacklog extends BacklogTotals {
197
+ readonly lane: string;
198
+ readonly kinds: readonly KindBacklog[];
199
+ readonly oldestDueAgeMs: number | null;
200
+ readonly severity: BacklogSeverity;
201
+ readonly breaches: readonly BacklogBreach[];
202
+ readonly thresholds: BacklogThresholds;
203
+ }
204
+ interface BacklogAlert {
205
+ readonly severity: Exclude<BacklogSeverity, 'ok'>;
206
+ readonly message: string;
207
+ readonly lanes: readonly LaneBacklog[];
208
+ readonly totals: BacklogTotals;
209
+ }
210
+ interface BacklogReport {
211
+ readonly severity: BacklogSeverity;
212
+ readonly totals: BacklogTotals;
213
+ readonly lanes: readonly LaneBacklog[];
214
+ readonly alert: BacklogAlert | null;
215
+ }
216
+ declare const DEFAULT_BACKLOG_THRESHOLDS: BacklogThresholds;
217
+ /** Milliseconds a due job may wait before it counts as aged. */
218
+ declare const DEFAULT_BACKLOG_AGED_MS = 900000;
219
+ declare function evaluateBacklog(input: {
220
+ counts: readonly KindBacklog[];
221
+ laneOf: (kind: string) => string | undefined;
222
+ thresholds?: Partial<BacklogThresholds>;
223
+ lanes?: Readonly<Record<string, Partial<BacklogThresholds>>>;
224
+ }): BacklogReport;
225
+ interface ClaimableKind {
226
+ readonly kind: string;
227
+ readonly leaseMs: number;
228
+ }
229
+ declare function createJobRegistry(jobs: readonly AnyJob[]): {
230
+ get: (kind: string) => AnyJob | undefined;
231
+ laneOf: (kind: string) => string | undefined;
232
+ lanes: () => string[];
233
+ claimable: (budgetMs?: number, options?: {
234
+ lane?: string;
235
+ }) => ClaimableKind[];
236
+ };
237
+ type JobRegistry = ReturnType<typeof createJobRegistry>;
238
+ declare const JOB_STATUSES: readonly ['pending', 'processing', 'retrying', 'succeeded', 'dead', 'cancelled'];
239
+ type JobStatus = (typeof JOB_STATUSES)[number];
240
+ declare const ATTEMPT_TRIGGERS: readonly ['nudge', 'sweep', 'drain'];
241
+ type AttemptTrigger = (typeof ATTEMPT_TRIGGERS)[number];
242
+ declare const ATTEMPT_OUTCOMES: readonly ['succeeded', 'retry', 'failed', 'cancelled', 'timeout', 'lease_expired'];
243
+ type AttemptOutcome = (typeof ATTEMPT_OUTCOMES)[number];
244
+ /** Open: values may grow in minor versions. */
245
+ type DeadReason = 'failed' | 'exhausted' | 'crash_loop' | (string & {});
246
+ /** Delays are relative to the DB clock. Absent `stickyCode` keeps the row's episode. */
247
+ type Transition = {
248
+ to: 'succeeded';
249
+ attemptOutcome: 'succeeded';
250
+ } | {
251
+ to: 'cancelled';
252
+ reason: string;
253
+ attemptOutcome: 'cancelled';
254
+ } | {
255
+ to: 'cancelled';
256
+ reason: 'exhausted';
257
+ code: string;
258
+ message: string | null;
259
+ codeStreak: number;
260
+ stickyCode?: string;
261
+ attemptOutcome: Extract<AttemptOutcome, 'retry' | 'timeout'>;
262
+ } | {
263
+ to: 'retrying';
264
+ delayMs: number;
265
+ code: string;
266
+ message: string | null;
267
+ codeStreak: number;
268
+ stickyCode?: string;
269
+ attemptOutcome: Extract<AttemptOutcome, 'retry' | 'timeout' | 'lease_expired'>;
270
+ } | {
271
+ to: 'dead';
272
+ reason: DeadReason;
273
+ code: string;
274
+ message: string | null;
275
+ codeStreak: number;
276
+ stickyCode?: string;
277
+ attemptOutcome: Extract<AttemptOutcome, 'failed' | 'retry' | 'timeout' | 'lease_expired'>;
278
+ };
279
+ interface ClaimedJob {
280
+ id: string;
281
+ kind: string;
282
+ payload: unknown;
283
+ tenantKey: string | null;
284
+ correlationId: string | null;
285
+ metadata: Record<string, unknown>;
286
+ leaseToken: string;
287
+ attemptNo: number;
288
+ spentAttempts: number;
289
+ codeStreak: number;
290
+ lastErrorCode: string | null;
291
+ /** Always set by the store; optional only for hand-built values. */
292
+ stickyCode?: string | null;
293
+ }
294
+ type ClaimByIdResult = {
295
+ id: string;
296
+ result: 'claimed';
297
+ job: ClaimedJob;
298
+ } | {
299
+ id: string;
300
+ result: 'not_found' | 'unregistered_kind' | 'over_budget' | 'newer_version' | 'terminal' | 'leased' | 'not_due' | 'locked';
301
+ };
302
+ /**
303
+ * A worker's own deploy version: it skips jobs a newer deploy enqueued (their payload may
304
+ * be a shape it can't read) until `gateMs` after enqueue, so a rollback never strands them.
305
+ */
306
+ interface VersionGate {
307
+ mineMs: number;
308
+ gateMs: number;
309
+ }
310
+ /** `undefined` = every tenant, `null` = untenanted only; out-of-scope jobs read as `not_found`. */
311
+ type TenantScope$1 = string | null | undefined;
312
+ /** A job mid-attempt is `in_flight`: its worker owns it until it settles. */
313
+ declare function cancelJob(db: SqlRunner, input: {
314
+ /** A dedupe key is unique per tenant, so it needs its tenant (`null` = none). */
315
+ ref: {
316
+ id: string;
317
+ } | {
318
+ dedupeKey: string;
319
+ tenantKey: string | null;
320
+ };
321
+ reason: string;
322
+ now: Date | null;
323
+ tenantKey?: TenantScope$1;
324
+ }): Promise<"already_terminal" | "cancelled" | "in_flight" | "not_found">;
325
+ /** Dead or cancelled → pending, with a fresh retry budget. */
326
+ declare function replayJob(db: SqlRunner, input: {
327
+ id: string;
328
+ now: Date | null;
329
+ tenantKey?: TenantScope$1;
330
+ }): Promise<"not_found" | "not_replayable" | "replayed">;
331
+ interface JobSummary {
332
+ id: string;
333
+ kind: string;
334
+ status: JobStatus;
335
+ dedupeKey: string;
336
+ tenantKey: string | null;
337
+ correlationId: string | null;
338
+ metadata: Record<string, unknown>;
339
+ startedAttempts: number;
340
+ finishedAttempts: number;
341
+ spentAttempts: number;
342
+ codeStreak: number;
343
+ stickyCode: string | null;
344
+ lastErrorCode: string | null;
345
+ lastErrorMessage: string | null;
346
+ terminalReason: string | null;
347
+ availableAt: Date;
348
+ nextAttemptAt: Date | null;
349
+ leasedUntil: Date | null;
350
+ createdAt: Date;
351
+ updatedAt: Date;
352
+ finishedAt: Date | null;
353
+ nudgeSentAt: Date | null;
354
+ nudgeAttempts: number;
355
+ lastNudgeError: string | null;
356
+ }
357
+ interface JobFilter {
358
+ status: JobStatus;
359
+ kind?: string;
360
+ tenantKey?: TenantScope$1;
361
+ dedupeKey?: string;
362
+ terminalReason?: string;
363
+ createdAfter?: Date;
364
+ createdBefore?: Date;
365
+ finishedAfter?: Date;
366
+ finishedBefore?: Date;
367
+ }
368
+ interface AttemptOutcomeGroup {
369
+ kind: string;
370
+ tenantKey: string | null;
371
+ outcome: AttemptOutcome;
372
+ code: string | null;
373
+ count: number;
374
+ }
375
+ /** Makes a pending or retrying job due now ("retry now"). */
376
+ declare function rescheduleNow(db: SqlRunner, input: {
377
+ id: string;
378
+ now: Date | null;
379
+ tenantKey?: TenantScope$1;
380
+ }): Promise<{
381
+ result: 'not_found';
382
+ kind: null;
383
+ } | {
384
+ result: "not_waiting" | "rescheduled";
385
+ kind: string;
386
+ }>;
387
+ /** Cancels many waiting jobs in one statement; every requested id gets a result. */
388
+ declare function cancelJobs(db: SqlRunner, input: {
389
+ ids: readonly string[];
390
+ reason: string;
391
+ now: Date | null;
392
+ tenantKey?: TenantScope$1;
393
+ }): Promise<Record<string, "already_terminal" | "cancelled" | "in_flight" | "not_found">>;
394
+ /** Replays many dead or cancelled jobs in one statement, each with a fresh budget. */
395
+ declare function replayJobs(db: SqlRunner, input: {
396
+ ids: readonly string[];
397
+ now: Date | null;
398
+ tenantKey?: TenantScope$1;
399
+ }): Promise<Record<string, "not_found" | "not_replayable" | "replayed">>;
400
+ /**
401
+ * Default bound on one inspection read. A long scan holds back the xmin horizon, so autovacuum
402
+ * can't clean the queue table's dead tuples while it runs.
403
+ */
404
+ declare const DEFAULT_INSPECT_STATEMENT_TIMEOUT_MS = 30000;
405
+ interface InspectBound {
406
+ /** Per-statement bound for this read. Default 30 s; `0` leaves the session's own setting. */
407
+ statementTimeoutMs?: number | undefined;
408
+ }
409
+ declare const aggregateAttemptOutcomes: (db: SqlRunner, input: {
410
+ windowMs: number;
411
+ maxRows: number;
412
+ now: Date | null;
413
+ kinds?: readonly string[];
414
+ tenantKey?: TenantScope$1;
415
+ outcomes?: readonly AttemptOutcome[];
416
+ code?: string;
417
+ } & InspectBound) => Promise<{
418
+ cutoff: Date;
419
+ groups: AttemptOutcomeGroup[];
420
+ truncated: boolean;
421
+ }>;
422
+ declare const countBacklog: (db: SqlRunner, input: {
423
+ agedMs: number;
424
+ now: Date | null;
425
+ kinds?: readonly string[];
426
+ tenantKey?: TenantScope$1;
427
+ } & InspectBound) => Promise<KindBacklog[]>;
428
+ declare const countJobs: (db: SqlRunner, input: JobFilter & InspectBound) => Promise<number>;
429
+ declare const findJobsByCorrelationId: (db: SqlRunner, input: {
430
+ correlationId: string;
431
+ limit: number;
432
+ tenantKey?: TenantScope$1;
433
+ } & InspectBound) => Promise<JobSummary[]>;
434
+ declare const getJob: (db: SqlRunner, input: {
435
+ id: string;
436
+ tenantKey?: TenantScope$1;
437
+ } & InspectBound) => Promise<{
438
+ id: string;
439
+ kind: string;
440
+ status: JobStatus;
441
+ dedupeKey: string;
442
+ tenantKey: string | null;
443
+ correlationId: string | null;
444
+ metadata: Record<string, unknown>;
445
+ startedAttempts: number;
446
+ finishedAttempts: number;
447
+ spentAttempts: number;
448
+ codeStreak: number;
449
+ stickyCode: string | null;
450
+ lastErrorCode: string | null;
451
+ lastErrorMessage: string | null;
452
+ terminalReason: string | null;
453
+ availableAt: Date;
454
+ nextAttemptAt: Date | null;
455
+ leasedUntil: Date | null;
456
+ createdAt: Date;
457
+ updatedAt: Date;
458
+ finishedAt: Date | null;
459
+ nudgeSentAt: Date | null;
460
+ nudgeAttempts: number;
461
+ lastNudgeError: string | null;
462
+ attempts: {
463
+ attemptNo: number;
464
+ trigger: "drain" | "nudge" | "sweep";
465
+ outcome: "cancelled" | "failed" | "lease_expired" | "retry" | "succeeded" | "timeout" | null;
466
+ code: string | null;
467
+ message: string | null;
468
+ durationMs: number | null;
469
+ startedAt: Date;
470
+ finishedAt: Date | null;
471
+ }[];
472
+ } | null>;
473
+ declare const listJobs: (db: SqlRunner, input: JobFilter & {
474
+ limit: number;
475
+ cursor?: string | null;
476
+ } & InspectBound) => Promise<{
477
+ jobs: JobSummary[];
478
+ nextCursor: string | null;
479
+ }>;
480
+ type TransitionHook = (tx: OutboxDb, event: TransitionHookEvent) => Promise<void>;
481
+ interface TransitionHookEvent {
482
+ job: ClaimedJob;
483
+ transition: Transition;
484
+ }
485
+ interface RuntimeConfig {
486
+ registry: JobRegistry;
487
+ deps: unknown;
488
+ connect: () => Promise<LaneConnection>;
489
+ lanes: number;
490
+ queues: Readonly<Record<string, QueueSender>>;
491
+ recordNudges: boolean;
492
+ onTransition: TransitionHook | undefined;
493
+ emit: Emit;
494
+ clock: Clock;
495
+ redact: MessageRedactor | undefined;
496
+ warnAtStreak: number | undefined;
497
+ settleRetry: {
498
+ attempts: number;
499
+ delaysMs: readonly number[];
500
+ lockTimeoutMs: number;
501
+ /** One settlement's whole budget, retries included; inside the lease margin. */
502
+ budgetMs: number;
503
+ };
504
+ /** This deploy's version gate; claims skip jobs a newer deploy enqueued. */
505
+ version: VersionGate | undefined;
506
+ }
507
+ interface Budget {
508
+ maxJobs: number;
509
+ deadlineMs: number;
510
+ }
511
+ interface OutcomeTally {
512
+ claimed: number;
513
+ succeeded: number;
514
+ retrying: number;
515
+ dead: number;
516
+ cancelled: number;
517
+ leaseLost: number;
518
+ settleFailed: number;
519
+ }
520
+ interface RunReport extends OutcomeTally {
521
+ byKind: Record<string, OutcomeTally>;
522
+ /** Runtime faults, not handler failures. */
523
+ errored: number;
524
+ erroredSamples: string[];
525
+ reachedLimit: boolean;
526
+ reachedDeadline: boolean;
527
+ }
528
+ /** One connection at a time, owned by the runtime for an invocation. */
529
+ interface Lane {
530
+ readonly db: OutboxDb;
531
+ /** Holds a live connection and was not retired. */
532
+ readonly usable: boolean;
533
+ /** Its connection reports a dead session (`LaneConnection.isBroken`). */
534
+ readonly broken: boolean;
535
+ /** Before each claim: reconnects a lane whose reconnect failed or whose session died; false once retired. */
536
+ ready(deadlineAt: number): Promise<boolean>;
537
+ /** Destroys the session, then acquires a replacement. */
538
+ replace(): Promise<void>;
539
+ /** Destroys the session; the lane claims nothing more this invocation. */
540
+ retire(): Promise<void>;
541
+ /** `work` on this lane's session, bounded: past the deadline the lane retires and this throws. */
542
+ bounded<T>(work: () => PromiseLike<T>, deadlineAt: number, what: string): Promise<T>;
543
+ close(): Promise<void>;
544
+ }
545
+ interface NudgeVia {
546
+ /** An idle connection the caller holds and bounds itself (an operator action). */
547
+ db?: SqlRunner;
548
+ /** The idle lane that just settled: bookkeeping runs on it, bounded, and retires it on expiry. */
549
+ lane?: Lane;
550
+ /** The deadline this nudge (its send and bookkeeping) must not outlive. */
551
+ deadlineAt?: number;
552
+ /** Stamp even with `recordNudges` off: the handoff cooldown reads `nudge_sent_at`. */
553
+ record?: boolean;
554
+ }
555
+ interface Scope {
556
+ lane?: string;
557
+ kinds?: readonly string[];
558
+ }
559
+ declare function createRuntime(config: RuntimeConfig): {
560
+ emit: (event: OutboxEvent) => void;
561
+ nudge: (jobs: readonly (string | {
562
+ id: string;
563
+ kind: string;
564
+ })[], delaySeconds?: number, via?: NudgeVia) => Promise<void>;
565
+ processNudge: (ids: readonly string[], budget: Budget, scope?: Scope, options?: {
566
+ deadlineAt?: number;
567
+ }) => Promise<RunReport>;
568
+ drain: (budget: Budget, trigger: 'sweep' | 'drain', scope?: Scope, options?: {
569
+ deadlineAt?: number;
570
+ }) => Promise<RunReport>;
571
+ handoff: (budgetMs: number, limit?: number, options?: {
572
+ deadlineAt?: number;
573
+ }) => Promise<number>;
574
+ recover: (limit: number, options?: {
575
+ deadlineAt?: number;
576
+ }) => Promise<RunReport>;
577
+ cleanup: (retention: {
578
+ succeededRetentionMs: number;
579
+ terminalRetentionMs: number;
580
+ batch: number;
581
+ maxBatches: number;
582
+ }, options?: {
583
+ deadlineAt?: number;
584
+ }) => Promise<number>;
585
+ hasBacklog: (scope?: Scope, options?: {
586
+ deadlineAt?: number;
587
+ }) => Promise<boolean>;
588
+ monitorBacklog: (options?: {
589
+ agedMs?: number;
590
+ thresholds?: Partial<BacklogThresholds>;
591
+ lanes?: Readonly<Record<string, Partial<BacklogThresholds>>>;
592
+ deadlineAt?: number;
593
+ }) => Promise<BacklogReport>;
594
+ continueDrain: (lane?: string, options?: {
595
+ deadlineAt?: number;
596
+ }) => Promise<boolean>;
597
+ lanes: () => string[];
598
+ nowMs: () => number;
599
+ sleep: (ms: number) => Promise<void>;
600
+ };
601
+ type Runtime = ReturnType<typeof createRuntime>;
602
+ interface SerializedError {
603
+ name: string;
604
+ message: string;
605
+ stack?: string;
606
+ code?: string;
607
+ cause?: SerializedError;
608
+ }
609
+ /** Transition events fire only after the settle (and its hook) committed. */
610
+ type OutboxEvent = {
611
+ type: 'enqueued';
612
+ jobId: string;
613
+ kind: string;
614
+ created: boolean;
615
+ } | {
616
+ type: 'transition';
617
+ severity: 'info' | 'warn' | 'error';
618
+ jobId: string;
619
+ kind: string;
620
+ tenantKey: string | null;
621
+ correlationId: string | null;
622
+ attemptNo: number;
623
+ to: 'succeeded' | 'retrying' | 'dead' | 'cancelled';
624
+ reason: string | null;
625
+ code: string | null;
626
+ codeStreak: number;
627
+ attemptsUsed: number;
628
+ maxAttempts: number;
629
+ delayMs: number | null;
630
+ recovered: boolean;
631
+ trigger: 'nudge' | 'sweep' | 'drain' | 'recovery';
632
+ } | {
633
+ type: 'attempt_threshold';
634
+ jobId: string;
635
+ kind: string;
636
+ code: string;
637
+ codeStreak: number;
638
+ threshold: number;
639
+ } | {
640
+ type: 'handler_threw';
641
+ jobId: string;
642
+ kind: string;
643
+ attemptNo: number;
644
+ error: SerializedError;
645
+ } | {
646
+ type: 'handler_timeout';
647
+ jobId: string;
648
+ kind: string;
649
+ attemptNo: number;
650
+ timeoutMs: number;
651
+ } | {
652
+ type: 'lease_lost';
653
+ jobId: string;
654
+ kind: string;
655
+ attemptNo: number;
656
+ } | {
657
+ type: 'hook_failed';
658
+ jobId: string;
659
+ kind: string;
660
+ to: string;
661
+ error: SerializedError;
662
+ } | {
663
+ type: 'settle_retried';
664
+ jobId: string;
665
+ kind: string;
666
+ dbCode: string | null;
667
+ retry: number;
668
+ } | {
669
+ type: 'settle_failed';
670
+ jobId: string;
671
+ kind: string;
672
+ attemptNo: number;
673
+ dbCode: string | null;
674
+ transient: boolean;
675
+ error: SerializedError;
676
+ } | {
677
+ type: 'nudge_miss';
678
+ jobId: string;
679
+ result: Exclude<ClaimByIdResult['result'], 'claimed'>;
680
+ } | {
681
+ /** The claim's reply arrived too late to run the handler: it never started. */
682
+ type: 'claim_late';
683
+ jobId: string;
684
+ kind: string;
685
+ attemptNo: number;
686
+ claimLatencyMs: number;
687
+ } | {
688
+ type: 'nudge_sent';
689
+ ids: string[];
690
+ lane: string;
691
+ delaySeconds: number;
692
+ } | {
693
+ type: 'nudge_failed';
694
+ ids: string[];
695
+ lane: string;
696
+ error: SerializedError;
697
+ } | {
698
+ type: 'nudge_skipped';
699
+ ids: string[];
700
+ lane: string;
701
+ reason: 'no_queue';
702
+ } | {
703
+ type: 'cleanup';
704
+ deleted: number;
705
+ } | {
706
+ type: 'backlog';
707
+ report: BacklogReport;
708
+ } | {
709
+ type: 'continuation_sent';
710
+ lane: string;
711
+ } | {
712
+ type: 'continuation_skipped';
713
+ lane: string;
714
+ reason: 'no_queue';
715
+ } | {
716
+ type: 'continuation_failed';
717
+ lane: string;
718
+ error: SerializedError;
719
+ } | {
720
+ type: 'message_received';
721
+ messageType: 'nudge' | 'drain';
722
+ ids: string[];
723
+ attempts: number;
724
+ } | {
725
+ type: 'message_rejected';
726
+ reason: 'malformed' | 'unknown_version';
727
+ body: string;
728
+ } | {
729
+ type: 'message_retried';
730
+ messageType: 'nudge' | 'drain';
731
+ delaySeconds: number;
732
+ attempts: number;
733
+ error: SerializedError;
734
+ } | {
735
+ type: 'dlq_message';
736
+ queue: string;
737
+ attempts: number;
738
+ body: string;
739
+ } | {
740
+ type: 'phase_failed';
741
+ phase: 'recover' | 'reserved' | 'drain' | 'handoff' | 'cleanup' | 'backlog';
742
+ cron: string | null;
743
+ error: SerializedError;
744
+ } | {
745
+ /** A schedule's occurrence committed. `existing` > 0 means a repeated tick found its jobs. */
746
+ type: 'schedule_ran';
747
+ name: string;
748
+ cron: string;
749
+ scheduledAt: string;
750
+ created: number;
751
+ existing: number;
752
+ /** Enqueues a `run` schedule got back as `invalid_payload` or `dedupe_conflict`. */
753
+ rejected: number;
754
+ attempts: number;
755
+ } | {
756
+ /** The occurrence enqueued nothing: it is skipped, not retried on a later tick. */
757
+ type: 'schedule_failed';
758
+ severity: 'error';
759
+ name: string;
760
+ cron: string;
761
+ scheduledAt: string;
762
+ attempts: number;
763
+ error: SerializedError;
764
+ } | {
765
+ type: 'sweep_finished';
766
+ cron: string | null;
767
+ recovered: number;
768
+ report: RunReport;
769
+ deleted: number;
770
+ continued: boolean;
771
+ } | {
772
+ type: 'infra_error';
773
+ operation: string;
774
+ dbCode: string | null;
775
+ transient: boolean;
776
+ error: SerializedError;
777
+ };
778
+ type EventSink = (event: OutboxEvent) => void;
779
+ declare function createEmitter(sink: EventSink | undefined): (event: OutboxEvent) => void;
780
+ type Emit = ReturnType<typeof createEmitter>;
781
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
782
+ /** What `deps` must satisfy: every registered job's needs at once. */
783
+ type DepsOf<J extends readonly AnyJob[]> = UnionToIntersection<JobDeps<J[number]>>;
784
+ type DepsOption<D> = [D] extends [never] ? {
785
+ deps?: unknown;
786
+ } : unknown extends D ? {
787
+ deps?: unknown;
788
+ } : {
789
+ deps: D;
790
+ };
791
+ type OutboxOptions<J extends readonly AnyJob[]> = OutboxBaseOptions<J> & DepsOption<NoInfer<DepsOf<J>>>;
792
+ interface OutboxBaseOptions<J extends readonly AnyJob[]> {
793
+ jobs: J;
794
+ /** One call per lane per invocation, plus short-lived ones; never at construction. */
795
+ connect: () => Promise<LaneConnection>;
796
+ /** Default 1; one connection per lane. */
797
+ lanes?: number;
798
+ /** One queue or one per lane; a lane without a queue is driven by cron only. */
799
+ queue?: QueueSender | Readonly<Record<string, QueueSender>>;
800
+ /** Costs one connection per nudge. */
801
+ recordNudges?: boolean;
802
+ /**
803
+ * Bound for one operator action (cancel, replay, retryNow, eraseTenant, …): acquisition, SQL
804
+ * and close. Past it the connection is destroyed and the call rejects. Default 60 s.
805
+ */
806
+ operationTimeoutMs?: number;
807
+ /**
808
+ * Largest payload `enqueue` accepts, in UTF-8 bytes of its JSON form; a bigger one returns
809
+ * `invalid_payload`. Every claim reads the payload back from the hot table, so put large data in
810
+ * object storage (e.g. R2) and enqueue its key. Default 256 KiB.
811
+ */
812
+ maxPayloadBytes?: number;
813
+ /** Runs in the settle transaction before the job row write; commits only if the transition applies. */
814
+ onTransition?: TransitionHook;
815
+ events?: EventSink;
816
+ redactError?: MessageRedactor;
817
+ warnAtStreak?: number;
818
+ clock?: Clock;
819
+ /**
820
+ * Mints job ids. Default: `uuidv7()`. Must return a UUID string (the `id` column is `uuid`);
821
+ * time-ordered ids keep the primary-key index and `(created_at, id)` pagination well-behaved.
822
+ */
823
+ generateId?: () => string;
824
+ /**
825
+ * Opt-in: `enqueue`, `transaction`'s `enqueue` and a `run` schedule's `enqueue` throw
826
+ * `EnqueueRefusedError` instead of returning `invalid_payload` or `dedupe_conflict`, so a
827
+ * business write can't commit without its job. With `true`, `createOutbox` returns a
828
+ * `StrictOutbox`, accepted wherever an `Outbox` is. Default `false`: refusals are returned.
829
+ */
830
+ strictEnqueue?: boolean;
831
+ /**
832
+ * This deploy's version, e.g. `env.CF_VERSION_METADATA`. Jobs are stamped with it, and an
833
+ * older deploy skips newer jobs for `gateMs` (default 6 h) during a gradual rollout.
834
+ */
835
+ version?: {
836
+ timestamp: string | number;
837
+ gateMs?: number;
838
+ };
839
+ }
840
+ type EnqueueRequest<T extends AnyJob> = {
841
+ payload: StandardSchemaV1.InferInput<T['payload']>;
842
+ dedupeKey: string;
843
+ tenantKey?: string | null;
844
+ correlationId?: string | null;
845
+ metadata?: Record<string, unknown>;
846
+ /** Milliseconds. */
847
+ delayMs?: number;
848
+ };
849
+ type EnqueueResult = {
850
+ status: 'created' | 'exists';
851
+ id: string;
852
+ kind: string;
853
+ } | {
854
+ status: 'dedupe_conflict';
855
+ reason: 'kind';
856
+ id: string;
857
+ existingKind: string;
858
+ } | {
859
+ status: 'invalid_payload';
860
+ issues: readonly StandardSchemaV1.Issue[];
861
+ };
862
+ type AcceptedEnqueueResult = Extract<EnqueueResult, {
863
+ status: 'created' | 'exists';
864
+ }>;
865
+ type RefusedEnqueueResult = Exclude<EnqueueResult, AcceptedEnqueueResult>;
866
+ /** Thrown by a strict outbox's enqueue (`strictEnqueue: true`) instead of returning a refusal. */
867
+ declare class EnqueueRefusedError extends Error {
868
+ readonly kind: string;
869
+ readonly result: RefusedEnqueueResult;
870
+ constructor(kind: string, result: RefusedEnqueueResult);
871
+ }
872
+ /** Not part of the public API. */
873
+ declare const RUNTIME: unique symbol;
874
+ declare function createOutbox<const J extends readonly AnyJob[]>(options: OutboxOptions<J> & {
875
+ strictEnqueue: true;
876
+ }): StrictOutbox<J>;
877
+ declare function createOutbox<const J extends readonly AnyJob[]>(options: OutboxOptions<J>): Outbox<J>;
878
+ /**
879
+ * Restricts an operator action to one tenant. Out-of-scope jobs report `not_found`.
880
+ * - omitted (or `undefined`): any tenant — the unscoped, admin form;
881
+ * - `null`: only jobs enqueued without a tenant;
882
+ * - a string: only that tenant's jobs.
883
+ */
884
+ interface TenantScope {
885
+ tenantKey?: string | null | undefined;
886
+ }
887
+ /** `enqueue` bound to one transaction. `R` narrows to `AcceptedEnqueueResult` on a strict outbox. */
888
+ type ScopedEnqueue<J extends readonly AnyJob[], R extends EnqueueResult = EnqueueResult> = <T extends J[number]>(job: T, request: EnqueueRequest<T>) => Promise<R>;
889
+ /**
890
+ * What `createOutbox` returns. Named, so an exported `outboxFor` compiles under `declaration`
891
+ * without reaching the runtime's internal key.
892
+ */
893
+ interface Outbox<J extends readonly AnyJob[] = readonly AnyJob[],
894
+ /** What an enqueue resolves to: `AcceptedEnqueueResult` on a `StrictOutbox`, whose refusals throw. */
895
+ R extends EnqueueResult = EnqueueResult> {
896
+ /** Runs iff the caller's transaction commits; a duplicate `dedupeKey` returns the existing job. */
897
+ enqueue<T extends J[number]>(tx: SqlRunner, job: T, request: EnqueueRequest<T>): Promise<R>;
898
+ /**
899
+ * Runs `work` in `db.transaction` and nudges the jobs it created once that commits. A throw
900
+ * rolls back and nudges nothing. Pass the top-level db, not a transaction (drizzle would open a
901
+ * savepoint, and the nudge would precede the outer commit). With `recordNudges`, recording the
902
+ * nudge checks out a connection from `connect`: if `db` holds a client from that same pool, size
903
+ * the pool for both, or pass a pool-backed db that releases its client when the commit returns.
904
+ */
905
+ transaction<Tx extends SqlRunner, Result>(db: {
906
+ transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
907
+ }, work: (tx: Tx, enqueue: ScopedEnqueue<J, R>) => Promise<Result>): Promise<Result>;
908
+ /** Call after commit; bare ids go to the default lane. Never throws. */
909
+ nudge(jobs: readonly (string | {
910
+ id: string;
911
+ kind: string;
912
+ })[]): Promise<void>;
913
+ cancel(ref: {
914
+ id: string;
915
+ } | {
916
+ dedupeKey: string;
917
+ tenantKey: string | null;
918
+ }, reason: string, scope?: TenantScope): Promise<Result<typeof cancelJob>>;
919
+ /** dead/cancelled → a fresh retry budget. */
920
+ replay(id: string, scope?: TenantScope): Promise<Result<typeof replayJob>>;
921
+ /** Makes a pending or retrying job due now, and nudges it. */
922
+ retryNow(id: string, scope?: TenantScope): Promise<Result<typeof rescheduleNow>['result']>;
923
+ /** One statement; every id gets a result. */
924
+ cancelMany(ids: readonly string[], reason: string, scope?: TenantScope): Promise<Result<typeof cancelJobs>>;
925
+ /** One statement; every id gets a result. */
926
+ replayMany(ids: readonly string[], scope?: TenantScope): Promise<Result<typeof replayJobs>>;
927
+ /** Erasure: deletes every job of one tenant, including in-flight ones. Returns the count. */
928
+ eraseTenant(tenantKey: string, opts?: {
929
+ batch?: number;
930
+ }): Promise<number>;
931
+ /** Not part of the public API. */
932
+ readonly [RUNTIME]: OutboxRuntime;
933
+ }
934
+ /** `ScopedEnqueue` of a strict outbox: a refusal throws `EnqueueRefusedError`, so it resolves only to an accepted result. */
935
+ type StrictScopedEnqueue<J extends readonly AnyJob[]> = ScopedEnqueue<J, AcceptedEnqueueResult>;
936
+ /**
937
+ * What `createOutbox({ strictEnqueue: true })` returns: an `Outbox` whose enqueues (`enqueue`,
938
+ * `transaction`'s, a `run` schedule's) throw `EnqueueRefusedError` on a refusal. Assignable to
939
+ * `Outbox`, so every helper taking one accepts it.
940
+ */
941
+ type StrictOutbox<J extends readonly AnyJob[] = readonly AnyJob[]> = Outbox<J, AcceptedEnqueueResult>;
942
+ /** Jobs a transaction created, by nudge delay in seconds, awaiting their post-commit nudge. */
943
+ type PendingNudges = Map<number, {
944
+ id: string;
945
+ kind: string;
946
+ }[]>;
947
+ /** Not part of the public API: the runtime plus a bounded connection for one-off work. */
948
+ type OutboxRuntime = Runtime & {
949
+ withConnection<T>(work: (connection: LaneConnection) => Promise<T>, invocationDeadlineAt?: number): Promise<T>;
950
+ /** `outbox.transaction` without its nudges, for a caller that must release its connection first. */
951
+ collectTransaction<Tx extends SqlRunner, R>(db: {
952
+ transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
953
+ }, work: (tx: Tx, enqueue: ScopedEnqueue<readonly AnyJob[]>) => Promise<R>): Promise<{
954
+ result: R;
955
+ pending: PendingNudges;
956
+ }>;
957
+ nudgePending(pending: PendingNudges, deadlineAt?: number): Promise<void>;
958
+ };
959
+ export { cancel as $, DeadReason as A, KindBacklog as B, countBacklog as C, listJobs as D, getJob as E, BacklogReport as F, JobDefinitionInput as G, AnyJob as H, BacklogSeverity as I, defineJob as J, JobDeps as K, BacklogThresholds as L, AttemptOutcome as M, AttemptTrigger as N, DEFAULT_INSPECT_STATEMENT_TIMEOUT_MS as O, JobStatus as P, HandlerOutcome as Q, DEFAULT_BACKLOG_AGED_MS as R, aggregateAttemptOutcomes as S, findJobsByCorrelationId as T, JobContext as U, evaluateBacklog as V, JobDefinition as W, RetryPolicy as X, defineJobFor as Y, RetryPolicyInput as Z, Budget as _, EnqueueResult as a, TransitionHook as b, RUNTIME as c, StrictOutbox as d, fail as et, StrictScopedEnqueue as f, OutboxEvent as g, EventSink as h, EnqueueRequest as i, Transition as j, InspectBound as k, RefusedEnqueueResult as l, createOutbox as m, DepsOf as n, retry as nt, Outbox as o, TenantScope as p, defaultClassify as q, EnqueueRefusedError as r, MessageRedactor as rt, OutboxOptions as s, AcceptedEnqueueResult as t, ok as tt, ScopedEnqueue as u, OutcomeTally as v, countJobs as w, TransitionHookEvent as x, RunReport as y, DEFAULT_BACKLOG_THRESHOLDS as z };