@makerkit/workers-outbox 0.0.0-stage → 0.0.1

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,1758 @@
1
+ import { A as isUuid, C as readAttemptOutcome, D as rescheduleNow, E as replayJobs, O as setSettlementTimeouts, T as replayJob, _ as hasDueJobs, b as listDueJobIds, c as cancelJobs, d as countBacklog, k as settleJob, l as claimByIds, m as deleteTenantJobs, p as deleteFinishedJobs, r as evaluateBacklog, s as cancelJob, u as claimDue, v as insertJob, w as recordNudgeResult, x as listExpiredLeases, y as isLeaseHeld } from "./backlog-Bd62tjCT.mjs";
2
+ import { v7 } from "uuid";
3
+ // src/core/error-message.ts
4
+ /** An error's message, or a non-error's string form. */
5
+ function errorMessage(error) {
6
+ return error instanceof Error ? error.message : String(error);
7
+ }
8
+ // src/core/outcome.ts
9
+ /** `commit` runs in the settle transaction: its writes and `succeeded` land together, lease-fenced. */
10
+ const ok = (options = {}) => options.commit ? {
11
+ type: "ok",
12
+ commit: options.commit
13
+ } : { type: "ok" };
14
+ /** `after` overrides the backoff (e.g. a provider's Retry-After). */
15
+ const retry = (input) => {
16
+ const outcome = {
17
+ type: "retry",
18
+ code: input.code
19
+ };
20
+ if (input.after !== void 0) outcome.afterMs = input.after;
21
+ if (input.message !== void 0) outcome.message = input.message;
22
+ return outcome;
23
+ };
24
+ const fail = (input) => input.message === void 0 ? {
25
+ type: "fail",
26
+ code: input.code
27
+ } : {
28
+ type: "fail",
29
+ code: input.code,
30
+ message: input.message
31
+ };
32
+ const cancel = (input) => ({
33
+ type: "cancel",
34
+ reason: input.reason
35
+ });
36
+ const TIMEOUT_CODE = "timeout";
37
+ const LEASE_EXPIRED_CODE = "lease_expired";
38
+ const HOOK_FAILED_CODE = "hook_failed";
39
+ /** The claim's reply arrived too late to run the handler safely; it never started. */
40
+ const CLAIM_LATE_CODE = "claim_late";
41
+ /** Retried soon: lateness is the infrastructure's, not the job's. */
42
+ const CLAIM_LATE_DELAY_MS = 5e3;
43
+ // src/core/retry-policy.ts
44
+ const DEFAULT_RETRY_POLICY = {
45
+ maxAttempts: 20,
46
+ backoff: {
47
+ baseMs: 3e4,
48
+ maxMs: 36e5,
49
+ factor: 2
50
+ },
51
+ perCode: {
52
+ timeout: 5,
53
+ invalid_payload: 3
54
+ },
55
+ maxCrashes: 5,
56
+ codes: {}
57
+ };
58
+ const RESERVED_CRASH_CODE = "lease_expired";
59
+ /** Queues can't delay past a day. */
60
+ const MAX_RETRY_AFTER_MS = 864e5;
61
+ function resolveRetryPolicy(input = {}) {
62
+ const { perCode, codes } = splitPerCode(input.perCode ?? {});
63
+ const policy = {
64
+ maxAttempts: input.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
65
+ backoff: {
66
+ baseMs: input.backoff?.baseMs ?? DEFAULT_RETRY_POLICY.backoff.baseMs,
67
+ maxMs: input.backoff?.maxMs ?? DEFAULT_RETRY_POLICY.backoff.maxMs,
68
+ factor: input.backoff?.factor ?? DEFAULT_RETRY_POLICY.backoff.factor
69
+ },
70
+ perCode: {
71
+ ...DEFAULT_RETRY_POLICY.perCode,
72
+ ...perCode
73
+ },
74
+ maxCrashes: input.maxCrashes ?? DEFAULT_RETRY_POLICY.maxCrashes,
75
+ codes
76
+ };
77
+ assertAttemptBudget("retry.maxAttempts", policy.maxAttempts);
78
+ assertPositiveInteger("retry.maxCrashes", policy.maxCrashes);
79
+ assertPositiveInteger("retry.backoff.baseMs", policy.backoff.baseMs);
80
+ assertPositiveInteger("retry.backoff.maxMs", policy.backoff.maxMs);
81
+ if (policy.backoff.maxMs < policy.backoff.baseMs) throw new Error("retry.backoff.maxMs must be >= retry.backoff.baseMs");
82
+ if (policy.backoff.maxMs > 864e5) throw new Error(`retry.backoff.maxMs must be <= ${MAX_RETRY_AFTER_MS} (24h)`);
83
+ if (!Number.isFinite(policy.backoff.factor) || policy.backoff.factor < 1) throw new Error("retry.backoff.factor must be a finite number >= 1");
84
+ for (const [code, cap] of Object.entries(policy.perCode)) assertPositiveInteger(`retry.perCode.${code}`, cap);
85
+ for (const [code, rule] of Object.entries(policy.codes)) {
86
+ if (rule.maxAttempts !== void 0) assertAttemptBudget(`retry.perCode.${code}.maxAttempts`, rule.maxAttempts);
87
+ if (rule.maxDelayMs !== void 0) assertPositiveInteger(`retry.perCode.${code}.maxDelayMs`, rule.maxDelayMs);
88
+ if (rule.sticky && rule.maxAttempts === void 0) throw new Error(`retry.perCode.${code}: sticky needs a maxAttempts to carry`);
89
+ }
90
+ return policy;
91
+ }
92
+ function splitPerCode(input) {
93
+ const perCode = {};
94
+ const codes = {};
95
+ for (const [code, entry] of Object.entries(input)) {
96
+ if (code === RESERVED_CRASH_CODE) throw new Error(`retry.perCode.${code}: crashes are budgeted by retry.maxCrashes`);
97
+ if (typeof entry === "number") {
98
+ perCode[code] = entry;
99
+ continue;
100
+ }
101
+ if (entry.streak !== void 0) perCode[code] = entry.streak;
102
+ codes[code] = {
103
+ ...entry.maxAttempts === void 0 ? {} : { maxAttempts: entry.maxAttempts },
104
+ ...entry.maxDelayMs === void 0 ? {} : { maxDelayMs: entry.maxDelayMs },
105
+ sticky: entry.sticky ?? false,
106
+ onExhaustion: entry.onExhaustion ?? "dead"
107
+ };
108
+ }
109
+ return {
110
+ perCode,
111
+ codes
112
+ };
113
+ }
114
+ function assertAttemptBudget(name, value) {
115
+ if (value !== Number.POSITIVE_INFINITY) assertPositiveInteger(name, value);
116
+ }
117
+ function assertPositiveInteger(name, value) {
118
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer, got ${value}`);
119
+ }
120
+ // src/core/define-job.ts
121
+ const MAX_TIMEOUT_MS = 6e5;
122
+ const HANDLER_GRACE_MS = 5e3;
123
+ const LEASE_MARGIN_MS = 3e4;
124
+ /** The longest lease any legal job can hold. */
125
+ const MAX_LEASE_MS = 635e3;
126
+ const KIND_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,199}$/;
127
+ const LANE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,49}$/;
128
+ function defineJob(input) {
129
+ if (!KIND_PATTERN.test(input.kind)) throw new Error(`defineJob: kind ${JSON.stringify(input.kind)} must match ${KIND_PATTERN} (lowercase, ≤ 200 chars)`);
130
+ if (!Number.isInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 6e5) throw new Error(`defineJob(${input.kind}): timeoutMs must be an integer in [1, ${MAX_TIMEOUT_MS}], got ${input.timeoutMs}`);
131
+ const lane = input.lane ?? "default";
132
+ if (!LANE_PATTERN.test(lane)) throw new Error(`defineJob(${input.kind}): lane ${JSON.stringify(lane)} must match ${LANE_PATTERN} (lowercase, ≤ 50 chars)`);
133
+ return {
134
+ kind: input.kind,
135
+ payload: input.payload,
136
+ timeoutMs: input.timeoutMs,
137
+ lane,
138
+ graceMs: HANDLER_GRACE_MS,
139
+ leaseMs: input.timeoutMs + HANDLER_GRACE_MS + LEASE_MARGIN_MS,
140
+ retry: resolveRetryPolicy(input.retry),
141
+ classify: withDefaultClassify(input.classify),
142
+ handle: input.handle
143
+ };
144
+ }
145
+ /**
146
+ * `defineJob` with `ctx.deps` typed, without annotating every handler:
147
+ *
148
+ * ```ts
149
+ * const defineAppJob = defineJobFor<AppDeps>();
150
+ * export const welcome = defineAppJob({ kind: 'welcome', …, handle: async (p, ctx) => ctx.deps.mailer… });
151
+ * ```
152
+ */
153
+ function defineJobFor() {
154
+ return (input) => defineJob(input);
155
+ }
156
+ /** What a thrown error becomes when `classify` is omitted or returns `undefined`: retry with code `unexpected`. */
157
+ function defaultClassify(error) {
158
+ return retry({
159
+ code: "unexpected",
160
+ message: errorMessage(error)
161
+ });
162
+ }
163
+ function withDefaultClassify(classify) {
164
+ if (!classify) return defaultClassify;
165
+ return (error) => classify(error) ?? defaultClassify(error);
166
+ }
167
+ // src/core/registry.ts
168
+ function createJobRegistry(jobs) {
169
+ if (jobs.length === 0) throw new Error("createJobRegistry: at least one job is required");
170
+ const byKind = /* @__PURE__ */ new Map();
171
+ for (const job of jobs) {
172
+ if (byKind.has(job.kind)) throw new Error(`createJobRegistry: duplicate kind ${JSON.stringify(job.kind)}`);
173
+ byKind.set(job.kind, job);
174
+ }
175
+ return {
176
+ get: (kind) => byKind.get(kind),
177
+ laneOf: (kind) => byKind.get(kind)?.lane,
178
+ lanes: () => [...new Set([...byKind.values()].map((job) => job.lane))],
179
+ claimable: (budgetMs = Number.POSITIVE_INFINITY, options = {}) => [...byKind.values()].filter((job) => job.leaseMs <= budgetMs).filter((job) => options.lane === void 0 || job.lane === options.lane).map((job) => ({
180
+ kind: job.kind,
181
+ leaseMs: job.leaseMs
182
+ }))
183
+ };
184
+ }
185
+ // src/runtime/deadline.ts
186
+ /** A bounded wait ran out: the work may still be running, and its outcome is unknown. */
187
+ var DeadlineExceededError = class extends Error {
188
+ constructor(what) {
189
+ super(`${what} exceeded its deadline`);
190
+ this.name = "DeadlineExceededError";
191
+ }
192
+ };
193
+ const POLL_MS = 250;
194
+ /**
195
+ * Waits for `work` until `deadlineAt` on `clock` — or until the same span of real time passes,
196
+ * whichever is first, so a stalled or frozen clock can't stretch it. Never rejects. A late result
197
+ * is dropped: the caller must make abandoned work harmless (destroy its session, discard a late
198
+ * connection).
199
+ */
200
+ async function untilDeadline(clock, work, deadlineAt) {
201
+ let settled;
202
+ const watched = work.then((value) => {
203
+ settled = {
204
+ done: true,
205
+ value
206
+ };
207
+ }, (error) => {
208
+ settled = {
209
+ done: true,
210
+ error: error ?? /* @__PURE__ */ new Error("rejected")
211
+ };
212
+ });
213
+ let realDeadline = Infinity;
214
+ for (;;) {
215
+ if (settled) return settled;
216
+ realDeadline = Math.min(realDeadline, Date.now() + (deadlineAt - clock.nowMs()));
217
+ const remaining = realDeadline - Date.now();
218
+ if (remaining <= 0) return { done: false };
219
+ let timer;
220
+ await Promise.race([watched, new Promise((resolve) => {
221
+ timer = setTimeout(resolve, Math.min(remaining, POLL_MS));
222
+ })]);
223
+ clearTimeout(timer);
224
+ }
225
+ }
226
+ /** Resolves when `work` settles or `ms` of real time pass, whichever is first; never rejects. */
227
+ async function settledWithin(work, ms) {
228
+ let timer;
229
+ const timeout = new Promise((resolve) => {
230
+ timer = setTimeout(() => resolve({ done: false }), Math.max(0, ms));
231
+ });
232
+ try {
233
+ return await Promise.race([work.then((value) => ({
234
+ done: true,
235
+ value
236
+ }), (error) => ({
237
+ done: true,
238
+ error: error ?? /* @__PURE__ */ new Error("rejected")
239
+ })), timeout]);
240
+ } finally {
241
+ clearTimeout(timer);
242
+ }
243
+ }
244
+ /** The value of a bounded wait, or a throw: the work's own error, or `DeadlineExceededError`. */
245
+ function unwrap(result, what) {
246
+ if (!result.done) throw new DeadlineExceededError(what);
247
+ if (result.error !== void 0) throw result.error;
248
+ return result.value;
249
+ }
250
+ // src/runtime/connection.ts
251
+ const DESTROY_WAIT_MS = 5e3;
252
+ const CLOSE_WAIT_MS = 2e3;
253
+ /**
254
+ * `connect()` bounded by `deadlineAt`: throws `DeadlineExceededError` past it, and destroys the
255
+ * connection if it arrives later anyway.
256
+ */
257
+ async function acquireConnection(connect, clock, deadlineAt) {
258
+ const connecting = connect();
259
+ const got = await untilDeadline(clock, connecting, deadlineAt);
260
+ if (!got.done) connecting.then(discardConnection, () => void 0);
261
+ return unwrap(got, "connection acquisition");
262
+ }
263
+ /** Destroys a session nobody will wait on. */
264
+ function discardConnection(connection) {
265
+ connection.destroy().catch(() => void 0);
266
+ }
267
+ /** Destroys a session, waiting a bounded time for it. */
268
+ const destroyConnection = (connection) => settledWithin(connection.destroy(), DESTROY_WAIT_MS);
269
+ /** Returns a healthy session; one whose `close()` hangs is destroyed instead. */
270
+ async function closeConnection(connection) {
271
+ if (!(await settledWithin(connection.close(), CLOSE_WAIT_MS)).done) discardConnection(connection);
272
+ }
273
+ // src/runtime/events.ts
274
+ function createEmitter(sink) {
275
+ return (event) => {
276
+ if (!sink) return;
277
+ try {
278
+ sink(event);
279
+ } catch {}
280
+ };
281
+ }
282
+ // src/core/backoff.ts
283
+ function computeBackoff(policy, jobId, attempt) {
284
+ const { baseMs, maxMs, factor } = policy.backoff;
285
+ const exponent = Math.max(0, attempt - 1);
286
+ const half = Math.min(maxMs, baseMs * factor ** exponent) / 2;
287
+ return Math.round(half + half * unitHash(`${jobId}:${attempt}`));
288
+ }
289
+ function unitHash(input) {
290
+ let hash = 2166136261;
291
+ for (let i = 0; i < input.length; i++) {
292
+ hash ^= input.charCodeAt(i);
293
+ hash = Math.imul(hash, 16777619);
294
+ }
295
+ return (hash >>> 0) / 4294967296;
296
+ }
297
+ // src/core/transition.ts
298
+ function decideTransition(input) {
299
+ const { outcome, job, policy, timing } = input;
300
+ switch (outcome.type) {
301
+ case "ok": return {
302
+ to: "succeeded",
303
+ attemptOutcome: "succeeded"
304
+ };
305
+ case "cancel": return {
306
+ to: "cancelled",
307
+ reason: outcome.reason,
308
+ attemptOutcome: "cancelled"
309
+ };
310
+ case "fail": return {
311
+ to: "dead",
312
+ reason: "failed",
313
+ code: outcome.code,
314
+ message: outcome.message ?? null,
315
+ codeStreak: nextStreak(job, outcome.code),
316
+ attemptOutcome: "failed"
317
+ };
318
+ case "retry": return decideRetry({
319
+ job,
320
+ policy,
321
+ code: outcome.code,
322
+ message: outcome.message ?? null,
323
+ attemptOutcome: "retry",
324
+ afterMs: outcome.afterMs,
325
+ minDelayMs: 0
326
+ });
327
+ case "timeout": return decideRetry({
328
+ job,
329
+ policy,
330
+ code: TIMEOUT_CODE,
331
+ message: null,
332
+ attemptOutcome: "timeout",
333
+ afterMs: void 0,
334
+ minDelayMs: timing.graceMs
335
+ });
336
+ case "claim_late": return {
337
+ to: "retrying",
338
+ delayMs: CLAIM_LATE_DELAY_MS,
339
+ code: CLAIM_LATE_CODE,
340
+ message: null,
341
+ codeStreak: nextStreak(job, CLAIM_LATE_CODE),
342
+ ...episode(job.stickyCode ?? null),
343
+ attemptOutcome: "lease_expired"
344
+ };
345
+ case "lease_expired": return decideRetry({
346
+ job,
347
+ policy,
348
+ code: LEASE_EXPIRED_CODE,
349
+ message: null,
350
+ attemptOutcome: "lease_expired",
351
+ afterMs: void 0,
352
+ minDelayMs: 0
353
+ });
354
+ }
355
+ }
356
+ function decideRetry(input) {
357
+ const { job, policy, code, message, attemptOutcome } = input;
358
+ const codeStreak = nextStreak(job, code);
359
+ if (attemptOutcome === "lease_expired" || code === "lease_expired") {
360
+ const kept = episode(job.stickyCode ?? null);
361
+ if (codeStreak >= policy.maxCrashes) return {
362
+ to: "dead",
363
+ reason: "crash_loop",
364
+ code,
365
+ message,
366
+ codeStreak,
367
+ ...kept,
368
+ attemptOutcome
369
+ };
370
+ return {
371
+ to: "retrying",
372
+ delayMs: 0,
373
+ code,
374
+ message,
375
+ codeStreak,
376
+ ...kept,
377
+ attemptOutcome
378
+ };
379
+ }
380
+ const rule = ownRule(policy, code);
381
+ const stickyCode = job.stickyCode ?? (rule?.sticky ? code : null);
382
+ const failure = {
383
+ code,
384
+ message,
385
+ codeStreak,
386
+ ...episode(stickyCode),
387
+ attemptOutcome
388
+ };
389
+ const exhausted = () => rule?.onExhaustion === "cancel" ? {
390
+ to: "cancelled",
391
+ reason: "exhausted",
392
+ ...failure
393
+ } : {
394
+ to: "dead",
395
+ reason: "exhausted",
396
+ ...failure
397
+ };
398
+ const codeCap = Object.hasOwn(policy.perCode, code) ? policy.perCode[code] : void 0;
399
+ if (codeCap !== void 0 && codeStreak >= codeCap) return exhausted();
400
+ const attemptsUsed = job.spentAttempts + 1;
401
+ if (attemptsUsed >= attemptCeiling(policy, code, stickyCode)) return exhausted();
402
+ const requested = input.afterMs === void 0 || !Number.isFinite(input.afterMs) ? computeBackoff(policy, job.id, attemptsUsed) : Math.min(MAX_RETRY_AFTER_MS, Math.max(0, Math.round(input.afterMs)));
403
+ const capped = Math.min(requested, rule?.maxDelayMs ?? Number.POSITIVE_INFINITY);
404
+ return {
405
+ to: "retrying",
406
+ delayMs: Math.max(capped, input.minDelayMs),
407
+ ...failure
408
+ };
409
+ }
410
+ const ownRule = (policy, code) => Object.hasOwn(policy.codes, code) ? policy.codes[code] : void 0;
411
+ function attemptCeiling(policy, code, stickyCode) {
412
+ const own = ownRule(policy, code)?.maxAttempts ?? policy.maxAttempts;
413
+ const carried = stickyCode === null ? policy.maxAttempts : ownRule(policy, stickyCode)?.maxAttempts ?? 0;
414
+ return Math.max(policy.maxAttempts, own, carried);
415
+ }
416
+ function episode(stickyCode) {
417
+ return stickyCode === null ? {} : { stickyCode };
418
+ }
419
+ function nextStreak(job, code) {
420
+ return job.lastErrorCode === code ? job.codeStreak + 1 : 1;
421
+ }
422
+ // src/runtime/db-errors.ts
423
+ const TRANSIENT_SQLSTATES = /* @__PURE__ */ new Set([
424
+ "40001",
425
+ "40003",
426
+ "40P01",
427
+ "55P03",
428
+ "57014"
429
+ ]);
430
+ const CONNECTION_SQLSTATES = /* @__PURE__ */ new Set([
431
+ "57P01",
432
+ "57P02",
433
+ "57P03",
434
+ "25P03",
435
+ "25P04"
436
+ ]);
437
+ const TIMEOUT_SQLSTATES = /* @__PURE__ */ new Set([
438
+ "57014",
439
+ "55P03",
440
+ "25P03",
441
+ "25P04"
442
+ ]);
443
+ const SOCKET_CODES = /* @__PURE__ */ new Set([
444
+ "ECONNRESET",
445
+ "ECONNREFUSED",
446
+ "EPIPE",
447
+ "ETIMEDOUT",
448
+ "EHOSTUNREACH",
449
+ "ENOTCONN",
450
+ "ENETUNREACH",
451
+ "ECONNABORTED",
452
+ "EAI_AGAIN",
453
+ "CONNECTION_CLOSED",
454
+ "CONNECTION_ENDED",
455
+ "CONNECTION_DESTROYED",
456
+ "CONNECT_TIMEOUT"
457
+ ]);
458
+ const NODE_PG_TERMINATED = /^Connection terminated/;
459
+ const NODE_PG_NOT_QUERYABLE = /^Client (?:has encountered a connection error|was closed) and is not queryable$/;
460
+ const MAX_NODES = 16;
461
+ /** Searches the `cause` chain and `AggregateError.errors`: Drizzle's wrapper has no `code`. */
462
+ function findInChain(error, pick) {
463
+ const seen = /* @__PURE__ */ new Set();
464
+ const queue = [error];
465
+ while (queue.length > 0 && seen.size < MAX_NODES) {
466
+ const node = queue.shift();
467
+ if (!node || typeof node !== "object" || seen.has(node)) continue;
468
+ seen.add(node);
469
+ const picked = pick(node);
470
+ if (picked !== null) return picked;
471
+ queue.push(node.cause);
472
+ const nested = node.errors;
473
+ if (Array.isArray(nested)) queue.push(...nested);
474
+ }
475
+ return null;
476
+ }
477
+ function sqlstateOf(error) {
478
+ return findInChain(error, (node) => typeof node.code === "string" && /^[0-9A-Z]{5}$/.test(node.code) ? node.code : null);
479
+ }
480
+ /**
481
+ * The connection dropped mid-statement: whether it committed is unknown, so a retry
482
+ * must use a fresh connection and read back what landed.
483
+ */
484
+ function isConnectionError(error) {
485
+ return findInChain(error, (node) => {
486
+ const code = typeof node.code === "string" ? node.code : "";
487
+ const message = typeof node.message === "string" ? node.message : "";
488
+ const terminated = NODE_PG_TERMINATED.test(message) || NODE_PG_NOT_QUERYABLE.test(message);
489
+ return code.startsWith("08") || CONNECTION_SQLSTATES.has(code) || SOCKET_CODES.has(code) || terminated ? true : null;
490
+ }) === true;
491
+ }
492
+ function isTimeoutError(error) {
493
+ const code = sqlstateOf(error);
494
+ return code !== null && TIMEOUT_SQLSTATES.has(code);
495
+ }
496
+ function isTransientDbError(error) {
497
+ const code = sqlstateOf(error);
498
+ return code !== null && (TRANSIENT_SQLSTATES.has(code) || code.startsWith("53")) || isConnectionError(error);
499
+ }
500
+ // src/runtime/execute-handler.ts
501
+ async function executeHandler(input) {
502
+ const { job, claimed } = input;
503
+ const controller = new AbortController();
504
+ const handlerDb = closable(input.db);
505
+ const deadline = input.clock.nowMs() + job.timeoutMs;
506
+ const ctx = {
507
+ jobId: claimed.id,
508
+ kind: claimed.kind,
509
+ attempt: claimed.attemptNo,
510
+ idempotencyKey: claimed.id,
511
+ signal: controller.signal,
512
+ deadline,
513
+ remainingMs: () => Math.max(0, deadline - input.clock.nowMs()),
514
+ deps: input.deps,
515
+ tenantKey: claimed.tenantKey,
516
+ correlationId: claimed.correlationId,
517
+ metadata: claimed.metadata,
518
+ db: handlerDb.db
519
+ };
520
+ let expired = false;
521
+ let timer;
522
+ const timeout = new Promise((resolve) => {
523
+ timer = setTimeout(() => {
524
+ expired = true;
525
+ resolve({ settled: "timeout" });
526
+ }, job.timeoutMs);
527
+ });
528
+ const run = (async () => {
529
+ const validated = await validatePayload(job, claimed.payload);
530
+ if (expired) return {
531
+ settled: "error",
532
+ error: /* @__PURE__ */ new Error("validation finished after the attempt timed out")
533
+ };
534
+ if (!validated.ok) return {
535
+ settled: "invalid_payload",
536
+ message: validated.message
537
+ };
538
+ try {
539
+ return {
540
+ settled: "value",
541
+ value: await job.handle(validated.value, ctx)
542
+ };
543
+ } catch (error) {
544
+ return {
545
+ settled: "error",
546
+ error
547
+ };
548
+ }
549
+ })();
550
+ const first = await Promise.race([run, timeout]);
551
+ clearTimeout(timer);
552
+ handlerDb.close();
553
+ if (first.settled === "timeout") {
554
+ controller.abort(/* @__PURE__ */ new Error(`job ${claimed.id} timed out after ${job.timeoutMs}ms`));
555
+ return {
556
+ outcome: { type: "timeout" },
557
+ runaway: !await Promise.race([run.then(() => true), input.clock.sleep(job.graceMs).then(() => false)])
558
+ };
559
+ }
560
+ if (first.settled === "invalid_payload") return {
561
+ outcome: {
562
+ type: "retry",
563
+ code: "invalid_payload",
564
+ message: first.message
565
+ },
566
+ runaway: false
567
+ };
568
+ if (first.settled === "error") return {
569
+ outcome: classifySafely(job, first.error),
570
+ thrown: first.error,
571
+ runaway: false
572
+ };
573
+ if (!isHandlerOutcome(first.value)) return {
574
+ outcome: retry({
575
+ code: "invalid_outcome",
576
+ message: "handler returned a value that is not an outcome"
577
+ }),
578
+ runaway: false
579
+ };
580
+ return {
581
+ outcome: first.value,
582
+ runaway: false
583
+ };
584
+ }
585
+ async function validatePayload(job, payload) {
586
+ try {
587
+ const validated = await job.payload["~standard"].validate(payload);
588
+ if (validated.issues) return {
589
+ ok: false,
590
+ message: validated.issues.map((issue) => issue.message).join("; ")
591
+ };
592
+ return {
593
+ ok: true,
594
+ value: validated.value
595
+ };
596
+ } catch (error) {
597
+ return {
598
+ ok: false,
599
+ message: errorMessage(error)
600
+ };
601
+ }
602
+ }
603
+ /**
604
+ * Revocation is checked when a method is called, not when it is read, so a captured
605
+ * `ctx.db.execute` or a `tx` handed to `transaction()` stops working too. Query builders
606
+ * created before close and awaited after it are not covered: don't let work outlive the handler.
607
+ */
608
+ function closable(db) {
609
+ let closed = false;
610
+ const revoked = () => /* @__PURE__ */ new Error("ctx.db used after the handler returned");
611
+ const wrap = (target) => new Proxy(target, { get(inner, prop) {
612
+ const value = Reflect.get(inner, prop, inner);
613
+ if (typeof value !== "function") {
614
+ if (closed) throw revoked();
615
+ return value;
616
+ }
617
+ if (prop === "transaction") return (fn, ...rest) => closed ? Promise.reject(revoked()) : value.call(inner, (tx) => fn(wrap(tx)), ...rest);
618
+ return (...args) => closed ? Promise.reject(revoked()) : value.apply(inner, args);
619
+ } });
620
+ return {
621
+ db: wrap(db),
622
+ close: () => {
623
+ closed = true;
624
+ }
625
+ };
626
+ }
627
+ function classifySafely(job, error) {
628
+ try {
629
+ const outcome = job.classify(error);
630
+ return isHandlerOutcome(outcome) ? outcome : retry({ code: "unexpected" });
631
+ } catch {
632
+ return defaultClassify(error);
633
+ }
634
+ }
635
+ const isOptional = (value, type) => value == null || typeof value === type;
636
+ /** Checks every field settlement reads, not just the tag: JS or an unsafe cast can return anything. */
637
+ function isHandlerOutcome(value) {
638
+ if (typeof value !== "object" || value === null) return false;
639
+ const outcome = value;
640
+ switch (outcome["type"]) {
641
+ case "ok": return isOptional(outcome["commit"], "function");
642
+ case "retry":
643
+ case "fail": return isCode(outcome["code"]) && isOptional(outcome["message"], "string");
644
+ case "cancel": return typeof outcome["reason"] === "string";
645
+ default: return false;
646
+ }
647
+ }
648
+ const isCode = (value) => typeof value === "string" && value !== "";
649
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]+/g;
650
+ function sanitizeErrorMessage(message, redact) {
651
+ const flattened = (redact ? redact(message) : message).replace(CONTROL_CHARACTERS, " ").replace(/ {2,}/g, " ").trim();
652
+ if (flattened.length <= 2e3) return flattened;
653
+ return `${flattened.slice(0, 1999)}…`;
654
+ }
655
+ // src/runtime/serialize-error.ts
656
+ const MAX_CAUSE_DEPTH = 5;
657
+ function serializeError(error, depth = 0) {
658
+ if (!(error instanceof Error)) return {
659
+ name: "NonError",
660
+ message: safeString(error)
661
+ };
662
+ const serialized = {
663
+ name: error.name,
664
+ message: error.message
665
+ };
666
+ if (error.stack !== void 0) serialized.stack = error.stack;
667
+ const code = error.code;
668
+ if (typeof code === "string") serialized.code = code;
669
+ if (error.cause !== void 0 && depth + 1 < MAX_CAUSE_DEPTH) serialized.cause = serializeError(error.cause, depth + 1);
670
+ return serialized;
671
+ }
672
+ function safeString(value) {
673
+ try {
674
+ return String(value);
675
+ } catch {
676
+ return "[unprintable]";
677
+ }
678
+ }
679
+ // src/runtime/types.ts
680
+ const systemClock = {
681
+ dbNow: () => null,
682
+ nowMs: () => Date.now(),
683
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
684
+ };
685
+ const MAX_QUEUE_DELAY_SECONDS = 86400;
686
+ // src/runtime/runtime.ts
687
+ const emptyTally = () => ({
688
+ claimed: 0,
689
+ succeeded: 0,
690
+ retrying: 0,
691
+ dead: 0,
692
+ cancelled: 0,
693
+ leaseLost: 0,
694
+ settleFailed: 0
695
+ });
696
+ const emptyReport = () => ({
697
+ ...emptyTally(),
698
+ byKind: {},
699
+ errored: 0,
700
+ erroredSamples: [],
701
+ reachedLimit: false,
702
+ reachedDeadline: false
703
+ });
704
+ const MAX_ERROR_SAMPLES = 5;
705
+ function tally(report, kind, key) {
706
+ report[key]++;
707
+ const forKind = Object.hasOwn(report.byKind, kind) ? report.byKind[kind] : report.byKind[kind] = emptyTally();
708
+ forKind[key]++;
709
+ }
710
+ function recordError(report, error) {
711
+ report.errored++;
712
+ if (report.erroredSamples.length < MAX_ERROR_SAMPLES) report.erroredSamples.push(errorMessage(error));
713
+ }
714
+ /** What an event reports about a database fault. */
715
+ const dbFault = (error) => ({
716
+ dbCode: sqlstateOf(error),
717
+ transient: isTransientDbError(error),
718
+ error: serializeError(error)
719
+ });
720
+ /** A settlement that ran out of time: its outcome is unknown, and left to recovery. */
721
+ const settlementExpired = (claimed) => ({
722
+ type: "settle_failed",
723
+ jobId: claimed.id,
724
+ kind: claimed.kind,
725
+ attemptNo: claimed.attemptNo,
726
+ dbCode: null,
727
+ transient: true,
728
+ error: serializeError(/* @__PURE__ */ new Error("settlement deadline exceeded; outcome left to recovery"))
729
+ });
730
+ var HookError = class extends Error {
731
+ original;
732
+ constructor(original) {
733
+ super("onTransition hook failed", { cause: original });
734
+ this.original = original;
735
+ }
736
+ };
737
+ /** The handler's `ok({ commit })` callback threw inside the settle transaction. */
738
+ var CommitError = class extends Error {
739
+ original;
740
+ constructor(original) {
741
+ super("ok({ commit }) failed", { cause: original });
742
+ this.original = original;
743
+ }
744
+ };
745
+ var LeaseLostRollback = class extends Error {};
746
+ const SETTLE_SAFETY_MS = 2e3;
747
+ const WATCHDOG_SLACK_MS = 1e3;
748
+ const QUEUE_SEND_TIMEOUT_MS = 1e4;
749
+ const CLAIM_LATE_SETTLE_MS = 5e3;
750
+ /** A claimed job starts only if this much lease and deadline remain after its timeout and grace. */
751
+ const MIN_SETTLE_WINDOW_MS = 1e4;
752
+ /** For a phase called without an invocation deadline (direct runtime use, operator paths). */
753
+ const DEFAULT_PHASE_DEADLINE_MS = 6e4;
754
+ /** Recovery's share of an invocation when none is given: count-limited *and* time-limited. */
755
+ const DEFAULT_RECOVERY_BUDGET_MS = 12e4;
756
+ const RENUDGE_DELAY_SECONDS = 5;
757
+ const HANDOFF_COOLDOWN_MS = 9e5;
758
+ function createRuntime(config) {
759
+ const { registry, emit, clock } = config;
760
+ const gate = config.version ? { version: config.version } : {};
761
+ const queueFor = (lane) => Object.hasOwn(config.queues, lane) ? config.queues[lane] : void 0;
762
+ const phaseDeadline = (options) => options.deadlineAt ?? clock.nowMs() + 6e4;
763
+ const kindFilter = (remainingMs, scope = {}) => {
764
+ const lane = scope.lane === void 0 ? {} : { lane: scope.lane };
765
+ const inScope = (kind) => scope.kinds === void 0 || scope.kinds.includes(kind);
766
+ const fitting = new Set(registry.claimable(remainingMs, lane).map((k) => k.kind));
767
+ return registry.claimable(Number.POSITIVE_INFINITY, lane).filter((k) => inScope(k.kind)).map((k) => ({
768
+ ...k,
769
+ fits: fitting.has(k.kind)
770
+ }));
771
+ };
772
+ async function withLanes(count, work, deadlineAt = clock.nowMs() + DEFAULT_PHASE_DEADLINE_MS) {
773
+ const opening = await Promise.allSettled(Array.from({ length: Math.max(1, count) }, () => openLane(deadlineAt)));
774
+ const lanes = opening.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
775
+ const failed = opening.find((r) => r.status === "rejected");
776
+ if (failed) {
777
+ await Promise.all(lanes.map((lane) => lane.close().catch(() => void 0)));
778
+ throw failed.reason;
779
+ }
780
+ try {
781
+ const settled = await Promise.allSettled(lanes.map((lane, index) => work(lane, index)));
782
+ const rejected = settled.find((r) => r.status === "rejected");
783
+ if (rejected) throw rejected.reason;
784
+ return settled.map((r) => r.value);
785
+ } finally {
786
+ await Promise.all(lanes.map((lane) => lane.close()));
787
+ }
788
+ }
789
+ /** One bounded statement on a short-lived lane: its session is destroyed if it overruns. */
790
+ async function onOneLane(what, deadlineAt, read) {
791
+ const [result] = await withLanes(1, (lane) => lane.bounded(() => read(lane.db), deadlineAt, what), deadlineAt);
792
+ return result;
793
+ }
794
+ /** Acquisition bounded by `deadlineAt`; a connection that arrives after it is destroyed. */
795
+ async function acquire(deadlineAt, isWanted = () => true) {
796
+ const connection = await acquireConnection(() => config.connect(), clock, deadlineAt);
797
+ if (!isWanted()) {
798
+ discardConnection(connection);
799
+ throw new Error("runtime: lane was retired while reconnecting");
800
+ }
801
+ return connection;
802
+ }
803
+ /**
804
+ * A lane owns one connection at a time. A runaway or timed-out session is destroyed — never
805
+ * returned to a pool — *before* its replacement is acquired, so a one-client pool still works.
806
+ */
807
+ async function openLane(deadlineAt) {
808
+ let connection = await acquire(deadlineAt);
809
+ let generation = 0;
810
+ let retired = false;
811
+ const discard = async () => {
812
+ const stale = connection;
813
+ connection = null;
814
+ generation++;
815
+ if (stale) await destroyConnection(stale);
816
+ };
817
+ const lane = {
818
+ get db() {
819
+ if (!connection) throw new Error("runtime: lane has no connection");
820
+ return connection.db;
821
+ },
822
+ get usable() {
823
+ return connection !== null && !retired;
824
+ },
825
+ get broken() {
826
+ return connection?.isBroken?.() === true;
827
+ },
828
+ ready: async (readyBy) => {
829
+ if (retired) return false;
830
+ if (connection?.isBroken?.() === true) await discard();
831
+ if (connection) return true;
832
+ const mine = generation;
833
+ try {
834
+ connection = await acquire(readyBy, () => !retired && generation === mine);
835
+ return true;
836
+ } catch (error) {
837
+ emit({
838
+ type: "infra_error",
839
+ operation: "run_job",
840
+ ...dbFault(error)
841
+ });
842
+ return false;
843
+ }
844
+ },
845
+ replace: async () => {
846
+ await discard();
847
+ const mine = generation;
848
+ const fresh = await config.connect();
849
+ if (retired || generation !== mine) {
850
+ discardConnection(fresh);
851
+ throw new Error("runtime: lane was retired while reconnecting");
852
+ }
853
+ connection = fresh;
854
+ },
855
+ retire: async () => {
856
+ retired = true;
857
+ await discard();
858
+ },
859
+ bounded: async (work, until, what) => {
860
+ const running = Promise.resolve().then(work);
861
+ const result = await untilDeadline(clock, running, until);
862
+ if (!result.done) await lane.retire();
863
+ return unwrap(result, what);
864
+ },
865
+ close: async () => {
866
+ const open = connection;
867
+ connection = null;
868
+ if (open) await closeConnection(open);
869
+ }
870
+ };
871
+ return lane;
872
+ }
873
+ /**
874
+ * One absolute deadline for a settlement: shared by its retries, reconnects, fallbacks and
875
+ * read-back, never reset. It never runs past the lease (less a safety margin).
876
+ */
877
+ function startSettlement(leaseEndsAtMs, invocationDeadlineAt) {
878
+ return {
879
+ deadlineAt: Math.min(clock.nowMs() + config.settleRetry.budgetMs, leaseEndsAtMs === void 0 ? Infinity : leaseEndsAtMs - SETTLE_SAFETY_MS, invocationDeadlineAt ?? Infinity),
880
+ abandoned: false
881
+ };
882
+ }
883
+ const remainingOf = (settlement) => settlement.abandoned ? 0 : settlement.deadlineAt - clock.nowMs();
884
+ async function runClaimed(lane, claimed, report, trigger, claimedAtMs, deadlineAt) {
885
+ tally(report, claimed.kind, "claimed");
886
+ const job = registry.get(claimed.kind);
887
+ if (!job) throw new Error(`runtime: claimed unregistered kind ${claimed.kind}`);
888
+ const leaseEndsAtMs = claimedAtMs + job.leaseMs;
889
+ const startedAtMs = clock.nowMs();
890
+ const needMs = job.timeoutMs + job.graceMs + MIN_SETTLE_WINDOW_MS;
891
+ if (leaseEndsAtMs - startedAtMs < needMs || deadlineAt - startedAtMs < needMs) {
892
+ emit({
893
+ type: "claim_late",
894
+ jobId: claimed.id,
895
+ kind: claimed.kind,
896
+ attemptNo: claimed.attemptNo,
897
+ claimLatencyMs: startedAtMs - claimedAtMs
898
+ });
899
+ await settle(lane, job, claimed, { type: "claim_late" }, report, {
900
+ thrown: false,
901
+ recovered: false,
902
+ trigger
903
+ }, {
904
+ deadlineAt: clock.nowMs() + CLAIM_LATE_SETTLE_MS,
905
+ abandoned: false
906
+ });
907
+ return;
908
+ }
909
+ const execution = await executeHandler({
910
+ job,
911
+ claimed,
912
+ db: lane.db,
913
+ deps: config.deps,
914
+ clock
915
+ });
916
+ if (execution.thrown !== void 0) emit({
917
+ type: "handler_threw",
918
+ jobId: claimed.id,
919
+ kind: claimed.kind,
920
+ attemptNo: claimed.attemptNo,
921
+ error: serializeError(execution.thrown)
922
+ });
923
+ if (execution.outcome.type === "timeout") emit({
924
+ type: "handler_timeout",
925
+ jobId: claimed.id,
926
+ kind: claimed.kind,
927
+ attemptNo: claimed.attemptNo,
928
+ timeoutMs: job.timeoutMs
929
+ });
930
+ await settle(lane, job, claimed, execution.outcome, report, {
931
+ thrown: execution.thrown !== void 0,
932
+ recovered: false,
933
+ trigger
934
+ }, startSettlement(leaseEndsAtMs, deadlineAt), execution.runaway ? () => lane.replace() : void 0);
935
+ }
936
+ /**
937
+ * Native timeouts bound SQL; this watchdog bounds everything else a settlement waits on —
938
+ * connection acquisition, hooks and commit callbacks between statements. When it fires the
939
+ * outcome is unknown (a timeout doesn't prove rollback): the lane's session is destroyed, the
940
+ * lane retires, and recovery resolves the job through the fenced settle path.
941
+ */
942
+ async function settle(lane, job, claimed, outcome, report, context, settlement, before) {
943
+ const work = (async () => {
944
+ if (before) await before();
945
+ return persistOutcome(lane, job, claimed, outcome, context, settlement);
946
+ })();
947
+ work.catch(() => void 0);
948
+ const raced = await untilDeadline(clock, work, Math.max(settlement.deadlineAt, clock.nowMs()) + WATCHDOG_SLACK_MS);
949
+ if (!raced.done) {
950
+ settlement.abandoned = true;
951
+ tally(report, claimed.kind, "settleFailed");
952
+ emit(settlementExpired(claimed));
953
+ await lane.retire();
954
+ return;
955
+ }
956
+ if (raced.error !== void 0) throw raced.error;
957
+ const settled = raced.value;
958
+ return afterSettle(job, claimed, settled.transition, settled.result, report, settled.context, lane, settlement);
959
+ }
960
+ async function persistOutcome(lane, job, claimed, outcome, context, settlement) {
961
+ const budget = {
962
+ id: claimed.id,
963
+ spentAttempts: claimed.spentAttempts,
964
+ codeStreak: claimed.codeStreak,
965
+ lastErrorCode: claimed.lastErrorCode,
966
+ stickyCode: claimed.stickyCode ?? null
967
+ };
968
+ const decide = (decided) => sanitize(decideTransition({
969
+ outcome: decided,
970
+ job: budget,
971
+ policy: job.retry,
972
+ timing: { graceMs: job.graceMs }
973
+ }));
974
+ const transition = decide(outcome);
975
+ const commit = outcome.type === "ok" ? outcome.commit : void 0;
976
+ let commitError;
977
+ const applied = await persist(lane, claimed, transition, settlement, {
978
+ hooked: true,
979
+ requireExpiredLease: context.recovered,
980
+ ...commit ? {
981
+ commit,
982
+ onCommitError: (error) => commitError = error
983
+ } : {}
984
+ });
985
+ if (applied === "commit_failed") {
986
+ emitWhileLive(settlement, {
987
+ type: "handler_threw",
988
+ jobId: claimed.id,
989
+ kind: claimed.kind,
990
+ attemptNo: claimed.attemptNo,
991
+ error: serializeError(commitError)
992
+ });
993
+ return persistOutcome(lane, job, claimed, classifySafely(job, commitError), {
994
+ ...context,
995
+ thrown: true
996
+ }, settlement);
997
+ }
998
+ if (applied === "hook_failed") {
999
+ const fallback = decide(retry({ code: HOOK_FAILED_CODE }));
1000
+ return {
1001
+ transition: fallback,
1002
+ result: await persist(lane, claimed, fallback, settlement, {
1003
+ hooked: false,
1004
+ requireExpiredLease: context.recovered
1005
+ }),
1006
+ context: {
1007
+ ...context,
1008
+ thrown: true
1009
+ }
1010
+ };
1011
+ }
1012
+ return {
1013
+ transition,
1014
+ result: applied,
1015
+ context
1016
+ };
1017
+ }
1018
+ function emitWhileLive(settlement, event) {
1019
+ if (!settlement.abandoned) emit(event);
1020
+ }
1021
+ function sanitize(transition) {
1022
+ if ("code" in transition && transition.message !== null) return {
1023
+ ...transition,
1024
+ message: sanitizeErrorMessage(transition.message, config.redact)
1025
+ };
1026
+ return transition;
1027
+ }
1028
+ async function persist(lane, claimed, transition, settlement, options) {
1029
+ const hook = options.hooked ? config.onTransition : void 0;
1030
+ const { commit } = options;
1031
+ const fenced = hook !== void 0 || commit !== void 0;
1032
+ const settleInput = {
1033
+ jobId: claimed.id,
1034
+ leaseToken: claimed.leaseToken,
1035
+ transition,
1036
+ now: clock.dbNow(),
1037
+ requireExpiredLease: options.requireExpiredLease
1038
+ };
1039
+ const deadlineFailure = () => {
1040
+ emitWhileLive(settlement, settlementExpired(claimed));
1041
+ return "settle_failed";
1042
+ };
1043
+ let ambiguous = false;
1044
+ const resolve = async (result) => {
1045
+ if (result === "lease_lost" && ambiguous) {
1046
+ if (remainingOf(settlement) <= 0) return deadlineFailure();
1047
+ return await readAttemptOutcome(lane.db, settleInput) === transition.attemptOutcome ? "applied" : "lease_lost";
1048
+ }
1049
+ return result;
1050
+ };
1051
+ for (let attempt = 0;; attempt++) {
1052
+ const remaining = remainingOf(settlement);
1053
+ if (remaining <= 0) return deadlineFailure();
1054
+ try {
1055
+ if (lane.broken) await lane.replace();
1056
+ return await resolve(await lane.db.transaction(async (tx) => {
1057
+ await setSettlementTimeouts(tx, {
1058
+ transactionMs: remaining,
1059
+ statementMs: remaining,
1060
+ lockMs: Math.min(config.settleRetry.lockTimeoutMs, remaining)
1061
+ });
1062
+ if (fenced && !await isLeaseHeld(tx, settleInput)) return "lease_lost";
1063
+ if (commit) try {
1064
+ await commit(tx);
1065
+ } catch (error) {
1066
+ throw new CommitError(error);
1067
+ }
1068
+ if (hook) try {
1069
+ await hook(tx, {
1070
+ job: claimed,
1071
+ transition
1072
+ });
1073
+ } catch (error) {
1074
+ throw new HookError(error);
1075
+ }
1076
+ const settled = await settleJob(tx, settleInput);
1077
+ if (settled === "lease_lost" && fenced) throw new LeaseLostRollback();
1078
+ return settled;
1079
+ }));
1080
+ } catch (error) {
1081
+ if (error instanceof LeaseLostRollback) return resolve("lease_lost");
1082
+ const fault = error instanceof HookError || error instanceof CommitError ? error.original : error;
1083
+ if (isTransientDbError(fault) && attempt < config.settleRetry.attempts) {
1084
+ if (isConnectionError(fault) || isTimeoutError(fault)) ambiguous = true;
1085
+ if (isConnectionError(fault)) await lane.replace();
1086
+ if (settlement.abandoned) return "settle_failed";
1087
+ emit({
1088
+ type: "settle_retried",
1089
+ jobId: claimed.id,
1090
+ kind: claimed.kind,
1091
+ dbCode: sqlstateOf(fault),
1092
+ retry: attempt + 1
1093
+ });
1094
+ const base = config.settleRetry.delaysMs[attempt] ?? 8e3;
1095
+ const delay = Math.round(base * (1 + Math.random() * .25));
1096
+ await clock.sleep(Math.max(0, Math.min(delay, remainingOf(settlement))));
1097
+ continue;
1098
+ }
1099
+ if (error instanceof CommitError) {
1100
+ options.onCommitError?.(fault);
1101
+ return "commit_failed";
1102
+ }
1103
+ if (error instanceof HookError) {
1104
+ emitWhileLive(settlement, {
1105
+ type: "hook_failed",
1106
+ jobId: claimed.id,
1107
+ kind: claimed.kind,
1108
+ to: transition.to,
1109
+ error: serializeError(fault)
1110
+ });
1111
+ return "hook_failed";
1112
+ }
1113
+ emitWhileLive(settlement, {
1114
+ type: "settle_failed",
1115
+ jobId: claimed.id,
1116
+ kind: claimed.kind,
1117
+ attemptNo: claimed.attemptNo,
1118
+ ...dbFault(fault)
1119
+ });
1120
+ return "settle_failed";
1121
+ }
1122
+ }
1123
+ }
1124
+ async function afterSettle(job, claimed, transition, result, report, context, lane, settlement) {
1125
+ if (result === "settle_failed" || result === "hook_failed" || result === "commit_failed") {
1126
+ tally(report, claimed.kind, "settleFailed");
1127
+ return;
1128
+ }
1129
+ if (result === "lease_lost") {
1130
+ tally(report, claimed.kind, "leaseLost");
1131
+ emit({
1132
+ type: "lease_lost",
1133
+ jobId: claimed.id,
1134
+ kind: claimed.kind,
1135
+ attemptNo: claimed.attemptNo
1136
+ });
1137
+ return;
1138
+ }
1139
+ tally(report, claimed.kind, transition.to);
1140
+ emit(transitionEvent(job, claimed, transition, context));
1141
+ if ((transition.to === "retrying" || transition.to === "dead") && config.warnAtStreak !== void 0 && transition.codeStreak === config.warnAtStreak) emit({
1142
+ type: "attempt_threshold",
1143
+ jobId: claimed.id,
1144
+ kind: claimed.kind,
1145
+ code: transition.code,
1146
+ codeStreak: transition.codeStreak,
1147
+ threshold: config.warnAtStreak
1148
+ });
1149
+ if (transition.to === "retrying") await nudgeJobs([{
1150
+ id: claimed.id,
1151
+ kind: claimed.kind
1152
+ }], Math.ceil(transition.delayMs / 1e3), {
1153
+ ...lane.usable ? { lane } : {},
1154
+ deadlineAt: settlement.deadlineAt
1155
+ });
1156
+ }
1157
+ function transitionEvent(job, claimed, transition, context) {
1158
+ const failing = "code" in transition;
1159
+ const charged = transition.attemptOutcome === "lease_expired" ? 0 : 1;
1160
+ return {
1161
+ type: "transition",
1162
+ severity: transition.to === "dead" ? "error" : transition.to === "retrying" && context.thrown ? "warn" : "info",
1163
+ jobId: claimed.id,
1164
+ kind: claimed.kind,
1165
+ tenantKey: claimed.tenantKey,
1166
+ correlationId: claimed.correlationId,
1167
+ attemptNo: claimed.attemptNo,
1168
+ to: transition.to,
1169
+ reason: transition.to === "dead" || transition.to === "cancelled" ? transition.reason : null,
1170
+ code: failing ? transition.code : null,
1171
+ codeStreak: failing ? transition.codeStreak : 0,
1172
+ attemptsUsed: claimed.spentAttempts + charged,
1173
+ maxAttempts: job.retry.maxAttempts,
1174
+ delayMs: transition.to === "retrying" ? transition.delayMs : null,
1175
+ recovered: context.recovered,
1176
+ trigger: context.trigger
1177
+ };
1178
+ }
1179
+ async function nudgeJobs(jobs, delaySeconds = 0, via = {}) {
1180
+ const byLane = /* @__PURE__ */ new Map();
1181
+ for (const job of jobs) {
1182
+ const lane = job.kind === void 0 ? "default" : registry.laneOf(job.kind) ?? "default";
1183
+ const ids = byLane.get(lane) ?? [];
1184
+ ids.push(job.id);
1185
+ byLane.set(lane, ids);
1186
+ }
1187
+ for (const [lane, ids] of byLane) await nudgeLane(lane, ids, delaySeconds, via);
1188
+ }
1189
+ async function nudgeLane(lane, ids, delaySeconds, via = {}) {
1190
+ if (ids.length === 0) return;
1191
+ const delay = Number.isFinite(delaySeconds) ? Math.min(MAX_QUEUE_DELAY_SECONDS, Math.max(0, Math.ceil(delaySeconds))) : 0;
1192
+ const queue = queueFor(lane);
1193
+ if (!queue) {
1194
+ emit({
1195
+ type: "nudge_skipped",
1196
+ ids: [...ids],
1197
+ lane,
1198
+ reason: "no_queue"
1199
+ });
1200
+ await stampNudges(ids, `no_queue: lane ${lane} has no queue`, via);
1201
+ return;
1202
+ }
1203
+ for (let i = 0; i < ids.length; i += 100) {
1204
+ const chunk = ids.slice(i, i + 100);
1205
+ const message = {
1206
+ v: 1,
1207
+ type: "nudge",
1208
+ ids: chunk
1209
+ };
1210
+ try {
1211
+ const limitMs = via.deadlineAt === void 0 ? QUEUE_SEND_TIMEOUT_MS : Math.min(QUEUE_SEND_TIMEOUT_MS, via.deadlineAt - clock.nowMs());
1212
+ if (limitMs <= 0) throw new Error("settlement deadline exceeded before the nudge was sent");
1213
+ const sent = await untilDeadline(clock, queue.send(message, delay > 0 ? { delaySeconds: delay } : void 0), clock.nowMs() + limitMs);
1214
+ if (!sent.done) throw new Error(`queue send exceeded its ${limitMs}ms deadline`);
1215
+ if (sent.error !== void 0) throw sent.error;
1216
+ emit({
1217
+ type: "nudge_sent",
1218
+ ids: chunk,
1219
+ lane,
1220
+ delaySeconds: delay
1221
+ });
1222
+ await stampNudges(chunk, null, via);
1223
+ } catch (error) {
1224
+ emit({
1225
+ type: "nudge_failed",
1226
+ ids: chunk,
1227
+ lane,
1228
+ error: serializeError(error)
1229
+ });
1230
+ await stampNudges(chunk, errorMessage(error), via);
1231
+ }
1232
+ }
1233
+ }
1234
+ /** On `db` when the caller holds an idle connection: a second checkout can deadlock a full pool. */
1235
+ async function stampNudges(ids, error, via = {}) {
1236
+ if (!config.recordNudges && !via.record) return;
1237
+ const deadlineAt = phaseDeadline(via);
1238
+ const record = (runner) => recordNudgeResult(runner, {
1239
+ ids,
1240
+ error: error === null ? null : sanitizeErrorMessage(error, config.redact),
1241
+ now: clock.dbNow()
1242
+ });
1243
+ try {
1244
+ if (via.lane?.usable) await via.lane.bounded(() => record(via.lane.db), deadlineAt, "nudge bookkeeping");
1245
+ else if (via.db) await record(via.db);
1246
+ else await onOneLane("nudge bookkeeping", deadlineAt, record);
1247
+ } catch (stampError) {
1248
+ emit({
1249
+ type: "infra_error",
1250
+ operation: "record_nudge",
1251
+ ...dbFault(stampError)
1252
+ });
1253
+ }
1254
+ }
1255
+ async function processNudge(ids, budget, scope = {}, options = {}) {
1256
+ const report = emptyReport();
1257
+ const unique = [...new Set(ids)];
1258
+ const started = clock.nowMs();
1259
+ const deadlineAt = Math.min(started + budget.deadlineMs, options.deadlineAt ?? Infinity);
1260
+ const laneCount = Math.min(config.lanes, unique.length);
1261
+ if (unique.length === 0) return report;
1262
+ const renudge = [];
1263
+ let reserved = 0;
1264
+ await withLanes(laneCount, async (lane, index) => {
1265
+ const mine = unique.filter((_, i) => i % laneCount === index);
1266
+ for (const id of mine) {
1267
+ if (reserved >= budget.maxJobs) {
1268
+ renudge.push(id);
1269
+ continue;
1270
+ }
1271
+ reserved++;
1272
+ if (!await lane.ready(deadlineAt)) {
1273
+ reserved--;
1274
+ renudge.push(id);
1275
+ continue;
1276
+ }
1277
+ const remaining = deadlineAt - clock.nowMs();
1278
+ const claimedAtMs = clock.nowMs();
1279
+ const [result] = await lane.bounded(() => claimByIds(lane.db, {
1280
+ ...gate,
1281
+ ids: [id],
1282
+ kinds: kindFilter(remaining, scope),
1283
+ leaseToken: v7(),
1284
+ now: clock.dbNow()
1285
+ }), deadlineAt, "claim");
1286
+ if (!result) {
1287
+ reserved--;
1288
+ continue;
1289
+ }
1290
+ if (result.result !== "claimed") {
1291
+ reserved--;
1292
+ emit({
1293
+ type: "nudge_miss",
1294
+ jobId: id,
1295
+ result: result.result
1296
+ });
1297
+ if (result.result === "over_budget" || result.result === "locked" || result.result === "newer_version") renudge.push(id);
1298
+ continue;
1299
+ }
1300
+ await runClaimed(lane, result.job, report, "nudge", claimedAtMs, deadlineAt);
1301
+ }
1302
+ }, deadlineAt);
1303
+ await nudgeLane(scope.lane ?? "default", renudge, RENUDGE_DELAY_SECONDS, { ...options.deadlineAt === void 0 ? {} : { deadlineAt: options.deadlineAt } });
1304
+ return report;
1305
+ }
1306
+ async function drain(budget, trigger, scope = {}, options = {}) {
1307
+ const report = emptyReport();
1308
+ const started = clock.nowMs();
1309
+ const deadlineAt = Math.min(started + budget.deadlineMs, options.deadlineAt ?? Infinity);
1310
+ let reserved = 0;
1311
+ await withLanes(config.lanes, async (lane) => {
1312
+ for (;;) {
1313
+ if (!await lane.ready(deadlineAt)) return;
1314
+ const remaining = deadlineAt - clock.nowMs();
1315
+ if (reserved >= budget.maxJobs) {
1316
+ report.reachedLimit = true;
1317
+ return;
1318
+ }
1319
+ if (remaining <= 0) {
1320
+ report.reachedDeadline = true;
1321
+ return;
1322
+ }
1323
+ const kinds = kindFilter(remaining, scope);
1324
+ if (kinds.length > 0 && !kinds.some((k) => k.fits)) {
1325
+ report.reachedDeadline = true;
1326
+ return;
1327
+ }
1328
+ reserved++;
1329
+ const claimedAtMs = clock.nowMs();
1330
+ const [claimed] = await lane.bounded(() => claimDue(lane.db, {
1331
+ ...gate,
1332
+ kinds,
1333
+ limit: 1,
1334
+ trigger,
1335
+ leaseToken: v7(),
1336
+ now: clock.dbNow()
1337
+ }), deadlineAt, "claim");
1338
+ if (!claimed) {
1339
+ reserved--;
1340
+ return;
1341
+ }
1342
+ try {
1343
+ await runClaimed(lane, claimed, report, trigger, claimedAtMs, deadlineAt);
1344
+ } catch (error) {
1345
+ recordError(report, error);
1346
+ emit({
1347
+ type: "infra_error",
1348
+ operation: "run_job",
1349
+ ...dbFault(error)
1350
+ });
1351
+ }
1352
+ }
1353
+ }, deadlineAt);
1354
+ return report;
1355
+ }
1356
+ async function handoff(budgetMs, limit = 100, options = {}) {
1357
+ const deadlineAt = phaseDeadline(options);
1358
+ let handed = 0;
1359
+ for (const laneName of registry.lanes()) {
1360
+ const long = kindFilter(budgetMs, { lane: laneName }).filter((k) => !k.fits).map((k) => k.kind);
1361
+ if (long.length === 0) continue;
1362
+ const ids = await onOneLane("handoff", deadlineAt, (db) => listDueJobIds(db, {
1363
+ kinds: long,
1364
+ limit,
1365
+ now: clock.dbNow(),
1366
+ nudgeCooldownMs: HANDOFF_COOLDOWN_MS,
1367
+ ...gate
1368
+ }));
1369
+ await nudgeLane(laneName, ids, 0, {
1370
+ deadlineAt,
1371
+ record: true
1372
+ });
1373
+ handed += ids.length;
1374
+ }
1375
+ return handed;
1376
+ }
1377
+ async function recover(limit, options = {}) {
1378
+ const report = emptyReport();
1379
+ const kinds = registry.claimable().map((k) => k.kind);
1380
+ const deadlineAt = options.deadlineAt ?? clock.nowMs() + 12e4;
1381
+ await withLanes(1, async (lane) => {
1382
+ const expired = await lane.bounded(() => listExpiredLeases(lane.db, {
1383
+ limit,
1384
+ now: clock.dbNow(),
1385
+ kinds
1386
+ }), deadlineAt, "recovery listing");
1387
+ for (const lease of expired) {
1388
+ const job = registry.get(lease.kind);
1389
+ if (deadlineAt - clock.nowMs() < MIN_SETTLE_WINDOW_MS) {
1390
+ report.reachedDeadline = true;
1391
+ return;
1392
+ }
1393
+ if (!await lane.ready(deadlineAt)) return;
1394
+ if (!job) continue;
1395
+ await settle(lane, job, {
1396
+ ...lease,
1397
+ payload: null,
1398
+ stickyCode: lease.stickyCode ?? null
1399
+ }, { type: "lease_expired" }, report, {
1400
+ thrown: false,
1401
+ recovered: true,
1402
+ trigger: "recovery"
1403
+ }, startSettlement(void 0, deadlineAt));
1404
+ }
1405
+ }, deadlineAt);
1406
+ return report;
1407
+ }
1408
+ async function cleanup(retention, options = {}) {
1409
+ const deadlineAt = phaseDeadline(options);
1410
+ let deleted = 0;
1411
+ await withLanes(1, async (lane) => {
1412
+ for (let i = 0; i < retention.maxBatches; i++) {
1413
+ const batch = await lane.bounded(() => deleteFinishedJobs(lane.db, {
1414
+ ...retention,
1415
+ now: clock.dbNow()
1416
+ }), deadlineAt, "cleanup");
1417
+ deleted += batch;
1418
+ if (batch < retention.batch) return;
1419
+ }
1420
+ }, deadlineAt);
1421
+ emit({
1422
+ type: "cleanup",
1423
+ deleted
1424
+ });
1425
+ return deleted;
1426
+ }
1427
+ async function monitorBacklog(options = {}) {
1428
+ const counts = await onOneLane("backlog", phaseDeadline(options), (db) => countBacklog(db, {
1429
+ agedMs: options.agedMs ?? 9e5,
1430
+ now: clock.dbNow()
1431
+ }));
1432
+ const report = evaluateBacklog({
1433
+ counts,
1434
+ laneOf: (kind) => registry.laneOf(kind),
1435
+ ...options.thresholds ? { thresholds: options.thresholds } : {},
1436
+ ...options.lanes ? { lanes: options.lanes } : {}
1437
+ });
1438
+ emit({
1439
+ type: "backlog",
1440
+ report
1441
+ });
1442
+ return report;
1443
+ }
1444
+ async function hasBacklog(scope = {}, options = {}) {
1445
+ return onOneLane("backlog check", phaseDeadline(options), (db) => hasDueJobs(db, {
1446
+ ...gate,
1447
+ kinds: kindFilter(Number.POSITIVE_INFINITY, scope),
1448
+ now: clock.dbNow()
1449
+ }));
1450
+ }
1451
+ async function continueDrain(lane = "default", options = {}) {
1452
+ const queue = queueFor(lane);
1453
+ if (!queue) {
1454
+ emit({
1455
+ type: "continuation_skipped",
1456
+ lane,
1457
+ reason: "no_queue"
1458
+ });
1459
+ return false;
1460
+ }
1461
+ const message = {
1462
+ v: 1,
1463
+ type: "drain",
1464
+ lane
1465
+ };
1466
+ try {
1467
+ const deadlineAt = Math.min(options.deadlineAt ?? Infinity, clock.nowMs() + QUEUE_SEND_TIMEOUT_MS);
1468
+ unwrap(await untilDeadline(clock, queue.send(message), deadlineAt), "continuation send");
1469
+ emit({
1470
+ type: "continuation_sent",
1471
+ lane
1472
+ });
1473
+ return true;
1474
+ } catch (error) {
1475
+ emit({
1476
+ type: "continuation_failed",
1477
+ lane,
1478
+ error: serializeError(error)
1479
+ });
1480
+ return false;
1481
+ }
1482
+ }
1483
+ const nudge = (jobs, delaySeconds = 0, via = {}) => nudgeJobs(jobs.map((job) => typeof job === "string" ? { id: job } : job), delaySeconds, via);
1484
+ return {
1485
+ emit,
1486
+ nudge,
1487
+ processNudge,
1488
+ drain,
1489
+ handoff,
1490
+ recover,
1491
+ cleanup,
1492
+ hasBacklog,
1493
+ monitorBacklog,
1494
+ continueDrain,
1495
+ lanes: () => registry.lanes(),
1496
+ nowMs: () => clock.nowMs(),
1497
+ sleep: (ms) => clock.sleep(ms)
1498
+ };
1499
+ }
1500
+ // src/runtime/create-outbox.ts
1501
+ const DEFAULT_VERSION_GATE_MS = 216e5;
1502
+ function parseVersion(version) {
1503
+ if (version === void 0) return;
1504
+ const mineMs = typeof version.timestamp === "number" ? version.timestamp : Date.parse(version.timestamp);
1505
+ if (!Number.isFinite(mineMs)) throw new Error(`createOutbox: version.timestamp ${JSON.stringify(version.timestamp)} is not a date`);
1506
+ return {
1507
+ mineMs,
1508
+ gateMs: version.gateMs ?? DEFAULT_VERSION_GATE_MS
1509
+ };
1510
+ }
1511
+ /** Thrown by a strict outbox's enqueue (`strictEnqueue: true`) instead of returning a refusal. */
1512
+ var EnqueueRefusedError = class extends Error {
1513
+ kind;
1514
+ result;
1515
+ constructor(kind, result) {
1516
+ const detail = result.status === "dedupe_conflict" ? `: its dedupeKey belongs to kind ${JSON.stringify(result.existingKind)}` : "";
1517
+ super(`outbox.enqueue: ${JSON.stringify(kind)} refused (${result.status})${detail}`);
1518
+ this.name = "EnqueueRefusedError";
1519
+ this.kind = kind;
1520
+ this.result = result;
1521
+ }
1522
+ };
1523
+ /** Not part of the public API. */
1524
+ const RUNTIME = Symbol("workers-outbox.runtime");
1525
+ const MAX_DEDUPE_KEY_LENGTH = 512;
1526
+ const MAX_ERASE_BATCH = 1e4;
1527
+ function createOutbox(options) {
1528
+ const registry = createJobRegistry(options.jobs);
1529
+ const emit = createEmitter(options.events);
1530
+ const clock = options.clock ?? systemClock;
1531
+ const lanes = options.lanes ?? 1;
1532
+ if (!Number.isInteger(lanes) || lanes < 1) throw new Error(`createOutbox: lanes must be a positive integer, got ${lanes}`);
1533
+ const version = parseVersion(options.version);
1534
+ const generateId = options.generateId ?? v7;
1535
+ const strictEnqueue = options.strictEnqueue ?? false;
1536
+ const refused = (kind, result) => {
1537
+ if (strictEnqueue) throw new EnqueueRefusedError(kind, result);
1538
+ return result;
1539
+ };
1540
+ const runtime = createRuntime({
1541
+ version,
1542
+ registry,
1543
+ deps: options.deps,
1544
+ connect: options.connect,
1545
+ lanes,
1546
+ queues: normalizeQueues(options.queue),
1547
+ recordNudges: options.recordNudges ?? false,
1548
+ onTransition: options.onTransition,
1549
+ emit,
1550
+ clock,
1551
+ redact: options.redactError,
1552
+ warnAtStreak: options.warnAtStreak,
1553
+ settleRetry: {
1554
+ attempts: 6,
1555
+ delaysMs: [
1556
+ 250,
1557
+ 750,
1558
+ 1500,
1559
+ 3e3,
1560
+ 5e3,
1561
+ 8e3
1562
+ ],
1563
+ lockTimeoutMs: 5e3,
1564
+ budgetMs: 25e3
1565
+ }
1566
+ });
1567
+ const operationTimeoutMs = options.operationTimeoutMs ?? 6e4;
1568
+ if (!Number.isInteger(operationTimeoutMs) || operationTimeoutMs < 1) throw new Error(`createOutbox: operationTimeoutMs must be a positive integer, got ${operationTimeoutMs}`);
1569
+ const maxPayloadBytes = options.maxPayloadBytes ?? 262144;
1570
+ if (!Number.isInteger(maxPayloadBytes) || maxPayloadBytes < 1) throw new Error(`createOutbox: maxPayloadBytes must be a positive integer, got ${maxPayloadBytes}`);
1571
+ const withConnection = async (work, invocationDeadlineAt = Infinity) => {
1572
+ const deadlineAt = Math.min(clock.nowMs() + operationTimeoutMs, invocationDeadlineAt);
1573
+ const connection = await acquireConnection(() => options.connect(), clock, deadlineAt);
1574
+ const running = Promise.resolve().then(() => work(connection));
1575
+ const result = await untilDeadline(clock, running, deadlineAt);
1576
+ if (!result.done) {
1577
+ running.catch(() => void 0);
1578
+ await destroyConnection(connection);
1579
+ return unwrap(result, "operator action");
1580
+ }
1581
+ await closeConnection(connection);
1582
+ return unwrap(result, "operator action");
1583
+ };
1584
+ /**
1585
+ * Runs `work` in a transaction and returns what it created, unannounced: the caller nudges once
1586
+ * it no longer holds the connection, since recording a nudge may check out another.
1587
+ */
1588
+ const collectTransaction = async (db, work) => {
1589
+ const pending = /* @__PURE__ */ new Map();
1590
+ return {
1591
+ result: await db.transaction((tx) => work(tx, async (job, request) => {
1592
+ const enqueued = await outbox.enqueue(tx, job, request);
1593
+ if (enqueued.status === "created") {
1594
+ const delayMs = Number.isFinite(request.delayMs) ? Math.max(0, request.delayMs) : 0;
1595
+ const delaySeconds = Math.ceil(delayMs / 1e3);
1596
+ if (delaySeconds <= 86400) {
1597
+ const group = pending.get(delaySeconds) ?? [];
1598
+ group.push({
1599
+ id: enqueued.id,
1600
+ kind: enqueued.kind
1601
+ });
1602
+ pending.set(delaySeconds, group);
1603
+ }
1604
+ }
1605
+ return enqueued;
1606
+ })),
1607
+ pending
1608
+ };
1609
+ };
1610
+ /** Never throws: a nudge is best effort, the cron sweep is the backstop. */
1611
+ const nudgePending = async (pending, deadlineAt) => {
1612
+ for (const [delaySeconds, jobs] of pending) await runtime.nudge(jobs, delaySeconds, deadlineAt === void 0 ? {} : { deadlineAt });
1613
+ };
1614
+ const outbox = {
1615
+ /** Runs iff the caller's transaction commits; a duplicate `dedupeKey` returns the existing job. */
1616
+ async enqueue(tx, job, request) {
1617
+ if (registry.get(job.kind) !== job) throw new Error(`outbox.enqueue: job ${JSON.stringify(job.kind)} is not registered with this outbox`);
1618
+ if (request.tenantKey === "") throw new Error("outbox.enqueue: tenantKey must be non-empty, or null for no tenant");
1619
+ if (request.dedupeKey.length === 0 || request.dedupeKey.length > MAX_DEDUPE_KEY_LENGTH) throw new Error(`outbox.enqueue: dedupeKey must be 1–${MAX_DEDUPE_KEY_LENGTH} characters`);
1620
+ const wire = toWire(request.payload, maxPayloadBytes);
1621
+ if (!wire.ok) return refused(job.kind, {
1622
+ status: "invalid_payload",
1623
+ issues: [{ message: wire.message }]
1624
+ });
1625
+ const validated = await job.payload["~standard"].validate(wire.value);
1626
+ if (validated.issues) return refused(job.kind, {
1627
+ status: "invalid_payload",
1628
+ issues: validated.issues
1629
+ });
1630
+ const id = generateId();
1631
+ if (!isUuid(id)) throw new Error(`outbox.enqueue: generateId returned ${JSON.stringify(id)}, not a UUID`);
1632
+ const row = await insertJob(tx, {
1633
+ id,
1634
+ kind: job.kind,
1635
+ dedupeKey: request.dedupeKey,
1636
+ payload: wire.value,
1637
+ tenantKey: request.tenantKey ?? null,
1638
+ correlationId: request.correlationId ?? null,
1639
+ metadata: request.metadata ?? {},
1640
+ delayMs: Math.max(0, request.delayMs ?? 0),
1641
+ minVersionMs: version?.mineMs ?? null
1642
+ }, clock.dbNow());
1643
+ if (row.kind !== job.kind) return refused(job.kind, {
1644
+ status: "dedupe_conflict",
1645
+ reason: "kind",
1646
+ id: row.id,
1647
+ existingKind: row.kind
1648
+ });
1649
+ emit({
1650
+ type: "enqueued",
1651
+ jobId: row.id,
1652
+ kind: row.kind,
1653
+ created: row.created
1654
+ });
1655
+ return {
1656
+ status: row.created ? "created" : "exists",
1657
+ id: row.id,
1658
+ kind: row.kind
1659
+ };
1660
+ },
1661
+ async transaction(db, work) {
1662
+ const { result, pending } = await collectTransaction(db, work);
1663
+ await nudgePending(pending);
1664
+ return result;
1665
+ },
1666
+ /** Call after commit; bare ids go to the default lane. Never throws. */
1667
+ nudge: (jobs) => runtime.nudge(jobs),
1668
+ cancel: (ref, reason, scope = {}) => withConnection((c) => cancelJob(c.db, {
1669
+ ref,
1670
+ reason,
1671
+ now: clock.dbNow(),
1672
+ ...tenantScope(scope)
1673
+ })),
1674
+ replay: (id, scope = {}) => withConnection((c) => replayJob(c.db, {
1675
+ id,
1676
+ now: clock.dbNow(),
1677
+ ...tenantScope(scope)
1678
+ })),
1679
+ /** Makes a pending or retrying job due now. */
1680
+ retryNow: (id, scope = {}) => withConnection(async (c) => {
1681
+ const { result, kind } = await rescheduleNow(c.db, {
1682
+ id,
1683
+ now: clock.dbNow(),
1684
+ ...tenantScope(scope)
1685
+ });
1686
+ if (result === "rescheduled" && kind !== null) await runtime.nudge([{
1687
+ id,
1688
+ kind
1689
+ }], 0, { db: c.db });
1690
+ return result;
1691
+ }),
1692
+ /** One statement; every id gets a result. */
1693
+ cancelMany: (ids, reason, scope = {}) => withConnection((c) => cancelJobs(c.db, {
1694
+ ids,
1695
+ reason,
1696
+ now: clock.dbNow(),
1697
+ ...tenantScope(scope)
1698
+ })),
1699
+ /** One statement; every id gets a result. */
1700
+ replayMany: (ids, scope = {}) => withConnection((c) => replayJobs(c.db, {
1701
+ ids,
1702
+ now: clock.dbNow(),
1703
+ ...tenantScope(scope)
1704
+ })),
1705
+ /** Erasure: deletes every job of one tenant, including in-flight ones. Returns the count. */
1706
+ eraseTenant: (tenantKey, opts = {}) => withConnection(async (c) => {
1707
+ const batch = opts.batch ?? 1e3;
1708
+ if (!Number.isInteger(batch) || batch < 1 || batch > MAX_ERASE_BATCH) throw new Error(`outbox.eraseTenant: batch must be an integer in [1, ${MAX_ERASE_BATCH}], got ${batch}`);
1709
+ let deleted = 0;
1710
+ for (;;) {
1711
+ const removed = await deleteTenantJobs(c.db, {
1712
+ tenantKey,
1713
+ batch
1714
+ });
1715
+ deleted += removed;
1716
+ if (removed < batch) return deleted;
1717
+ }
1718
+ }),
1719
+ [RUNTIME]: {
1720
+ ...runtime,
1721
+ withConnection,
1722
+ collectTransaction,
1723
+ nudgePending
1724
+ }
1725
+ };
1726
+ return outbox;
1727
+ }
1728
+ const isQueueSender = (value) => typeof value === "object" && value !== null && typeof value.send === "function";
1729
+ function normalizeQueues(queue) {
1730
+ if (queue === void 0) return {};
1731
+ return isQueueSender(queue) ? { default: queue } : { ...queue };
1732
+ }
1733
+ const tenantScope = (scope) => scope.tenantKey === void 0 ? {} : { tenantKey: scope.tenantKey };
1734
+ const utf8 = new TextEncoder();
1735
+ function toWire(payload, maxBytes) {
1736
+ try {
1737
+ const json = JSON.stringify(payload);
1738
+ if (json === void 0) return {
1739
+ ok: false,
1740
+ message: "payload is not JSON-serializable"
1741
+ };
1742
+ const bytes = json.length * 3 <= maxBytes ? json.length : utf8.encode(json).byteLength;
1743
+ if (bytes > maxBytes) return {
1744
+ ok: false,
1745
+ message: `payload is ${bytes} bytes as JSON, over maxPayloadBytes (${maxBytes}); store it elsewhere and enqueue a reference`
1746
+ };
1747
+ return {
1748
+ ok: true,
1749
+ value: JSON.parse(json)
1750
+ };
1751
+ } catch (error) {
1752
+ return {
1753
+ ok: false,
1754
+ message: `payload is not JSON-serializable: ${errorMessage(error)}`
1755
+ };
1756
+ }
1757
+ }
1758
+ export { emptyReport as a, createEmitter as c, defineJob as d, defineJobFor as f, retry as g, ok as h, DEFAULT_RECOVERY_BUDGET_MS as i, MAX_LEASE_MS as l, fail as m, RUNTIME as n, serializeError as o, cancel as p, createOutbox as r, isTransientDbError as s, EnqueueRefusedError as t, defaultClassify as u };