@zucker-framework/task 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1881 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/task-state-machine.ts
5
+ var TASK_STATUSES = [
6
+ "QUEUED",
7
+ "PROCESSING",
8
+ "WAITING_PROVIDER",
9
+ "RETRYING",
10
+ "COMPLETED",
11
+ "FAILED",
12
+ "CANCELLED"
13
+ ];
14
+ var transitions = {
15
+ QUEUED: [
16
+ "PROCESSING",
17
+ "CANCELLED"
18
+ ],
19
+ PROCESSING: [
20
+ "WAITING_PROVIDER",
21
+ "RETRYING",
22
+ "COMPLETED",
23
+ "FAILED",
24
+ "CANCELLED"
25
+ ],
26
+ WAITING_PROVIDER: [
27
+ "PROCESSING",
28
+ "RETRYING",
29
+ "FAILED",
30
+ "CANCELLED"
31
+ ],
32
+ RETRYING: [
33
+ "QUEUED",
34
+ "FAILED",
35
+ "CANCELLED"
36
+ ],
37
+ COMPLETED: [],
38
+ FAILED: [],
39
+ CANCELLED: []
40
+ };
41
+ var InvalidTaskTransitionError = class extends Error {
42
+ static {
43
+ __name(this, "InvalidTaskTransitionError");
44
+ }
45
+ from;
46
+ to;
47
+ constructor(from, to) {
48
+ super(`Invalid task transition: ${from} -> ${to}`), this.from = from, this.to = to;
49
+ this.name = "InvalidTaskTransitionError";
50
+ }
51
+ };
52
+ function canTransitionTask(from, to) {
53
+ return transitions[from].includes(to);
54
+ }
55
+ __name(canTransitionTask, "canTransitionTask");
56
+ function assertTaskTransition(from, to) {
57
+ if (!canTransitionTask(from, to)) {
58
+ throw new InvalidTaskTransitionError(from, to);
59
+ }
60
+ }
61
+ __name(assertTaskTransition, "assertTaskTransition");
62
+ function isTerminalTaskStatus(status2) {
63
+ return status2 === "COMPLETED" || status2 === "FAILED" || status2 === "CANCELLED";
64
+ }
65
+ __name(isTerminalTaskStatus, "isTerminalTaskStatus");
66
+ function requiresTaskLease(status2) {
67
+ return status2 === "PROCESSING" || status2 === "WAITING_PROVIDER";
68
+ }
69
+ __name(requiresTaskLease, "requiresTaskLease");
70
+
71
+ // src/task-fingerprint.ts
72
+ import { createHash } from "crypto";
73
+ function serialize(value, seen) {
74
+ if (value === null) return "null";
75
+ switch (typeof value) {
76
+ case "string":
77
+ case "boolean":
78
+ return JSON.stringify(value);
79
+ case "number":
80
+ if (!Number.isFinite(value)) {
81
+ throw new TypeError("Task fingerprint input cannot contain non-finite numbers");
82
+ }
83
+ return JSON.stringify(value);
84
+ case "undefined":
85
+ return void 0;
86
+ case "bigint":
87
+ case "function":
88
+ case "symbol":
89
+ throw new TypeError(`Task fingerprint input cannot contain ${typeof value}`);
90
+ case "object":
91
+ break;
92
+ }
93
+ if (value instanceof Date) return JSON.stringify(value.toISOString());
94
+ if (seen.has(value)) throw new TypeError("Task fingerprint input cannot contain circular references");
95
+ seen.add(value);
96
+ try {
97
+ if (Array.isArray(value)) {
98
+ return `[${value.map((entry) => serialize(entry, seen) ?? "null").join(",")}]`;
99
+ }
100
+ const prototype = Object.getPrototypeOf(value);
101
+ if (prototype !== Object.prototype && prototype !== null) {
102
+ throw new TypeError("Task fingerprint input must contain only JSON-compatible objects");
103
+ }
104
+ const entries = Object.keys(value).sort().flatMap((key) => {
105
+ const serialized = serialize(value[key], seen);
106
+ return serialized === void 0 ? [] : [
107
+ `${JSON.stringify(key)}:${serialized}`
108
+ ];
109
+ });
110
+ return `{${entries.join(",")}}`;
111
+ } finally {
112
+ seen.delete(value);
113
+ }
114
+ }
115
+ __name(serialize, "serialize");
116
+ function createTaskFingerprint(value) {
117
+ const canonical = serialize(value, /* @__PURE__ */ new Set());
118
+ if (canonical === void 0) {
119
+ throw new TypeError("Task fingerprint input cannot be undefined");
120
+ }
121
+ return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
122
+ }
123
+ __name(createTaskFingerprint, "createTaskFingerprint");
124
+
125
+ // src/task-repository.ts
126
+ var TaskIdempotencyConflictError = class extends Error {
127
+ static {
128
+ __name(this, "TaskIdempotencyConflictError");
129
+ }
130
+ idempotencyKey;
131
+ constructor(idempotencyKey) {
132
+ super(`Idempotency key was reused with a different request: ${idempotencyKey}`), this.idempotencyKey = idempotencyKey;
133
+ this.name = "TaskIdempotencyConflictError";
134
+ }
135
+ };
136
+
137
+ // src/in-memory-task-repository.ts
138
+ function cloneTask(task) {
139
+ return {
140
+ ...task,
141
+ resource: {
142
+ ...task.resource
143
+ },
144
+ error: task.error ? {
145
+ ...task.error,
146
+ metadata: task.error.metadata ? {
147
+ ...task.error.metadata
148
+ } : void 0
149
+ } : void 0,
150
+ availableAt: new Date(task.availableAt),
151
+ createdAt: new Date(task.createdAt),
152
+ updatedAt: new Date(task.updatedAt),
153
+ startedAt: task.startedAt ? new Date(task.startedAt) : void 0,
154
+ completedAt: task.completedAt ? new Date(task.completedAt) : void 0,
155
+ leaseExpiresAt: task.leaseExpiresAt ? new Date(task.leaseExpiresAt) : void 0,
156
+ heartbeatAt: task.heartbeatAt ? new Date(task.heartbeatAt) : void 0
157
+ };
158
+ }
159
+ __name(cloneTask, "cloneTask");
160
+ function clampProgress(progress) {
161
+ return Math.max(0, Math.min(100, progress));
162
+ }
163
+ __name(clampProgress, "clampProgress");
164
+ var InMemoryTaskRepository = class {
165
+ static {
166
+ __name(this, "InMemoryTaskRepository");
167
+ }
168
+ tasks = /* @__PURE__ */ new Map();
169
+ idempotencyIndex = /* @__PURE__ */ new Map();
170
+ async create(input) {
171
+ const existingId = this.idempotencyIndex.get(input.idempotencyKey);
172
+ if (existingId) {
173
+ const existing = this.tasks.get(existingId);
174
+ if (!existing) throw new Error(`Task index is corrupted for key ${input.idempotencyKey}`);
175
+ if (existing.idempotencyFingerprint !== input.idempotencyFingerprint) {
176
+ throw new TaskIdempotencyConflictError(input.idempotencyKey);
177
+ }
178
+ return {
179
+ task: cloneTask(existing),
180
+ created: false
181
+ };
182
+ }
183
+ if (this.tasks.has(input.id)) throw new Error(`Task already exists: ${input.id}`);
184
+ if (input.maxAttempts < 1) throw new RangeError("maxAttempts must be at least 1");
185
+ const task = {
186
+ id: input.id,
187
+ kind: input.kind,
188
+ resource: {
189
+ ...input.resource
190
+ },
191
+ userId: input.userId,
192
+ orderId: input.orderId,
193
+ status: "QUEUED",
194
+ progress: 0,
195
+ attempt: 0,
196
+ maxAttempts: input.maxAttempts,
197
+ idempotencyKey: input.idempotencyKey,
198
+ idempotencyFingerprint: input.idempotencyFingerprint,
199
+ handlerVersion: input.handlerVersion,
200
+ payload: input.payload,
201
+ availableAt: new Date(input.availableAt ?? input.now),
202
+ createdAt: new Date(input.now),
203
+ updatedAt: new Date(input.now),
204
+ version: 0
205
+ };
206
+ this.tasks.set(task.id, task);
207
+ this.idempotencyIndex.set(task.idempotencyKey, task.id);
208
+ return {
209
+ task: cloneTask(task),
210
+ created: true
211
+ };
212
+ }
213
+ async findById(id) {
214
+ const task = this.tasks.get(id);
215
+ return task ? cloneTask(task) : null;
216
+ }
217
+ async findByIdempotencyKey(key) {
218
+ const id = this.idempotencyIndex.get(key);
219
+ return id ? this.findById(id) : null;
220
+ }
221
+ async claim(input) {
222
+ const task = this.tasks.get(input.id);
223
+ if (!task || task.status !== "QUEUED" || task.availableAt.getTime() > input.now.getTime() || task.attempt >= task.maxAttempts || input.leaseExpiresAt.getTime() <= input.now.getTime()) {
224
+ return null;
225
+ }
226
+ task.status = "PROCESSING";
227
+ task.workerId = input.workerId;
228
+ task.leaseToken = input.leaseToken;
229
+ task.leaseExpiresAt = new Date(input.leaseExpiresAt);
230
+ task.heartbeatAt = new Date(input.now);
231
+ task.startedAt ??= new Date(input.now);
232
+ task.attempt += 1;
233
+ task.updatedAt = new Date(input.now);
234
+ task.version += 1;
235
+ return cloneTask(task);
236
+ }
237
+ async heartbeat(input) {
238
+ const task = this.tasks.get(input.id);
239
+ if (!task || !requiresTaskLease(task.status) || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() <= input.now.getTime() || input.leaseExpiresAt.getTime() <= input.now.getTime()) {
240
+ return false;
241
+ }
242
+ task.heartbeatAt = new Date(input.now);
243
+ task.leaseExpiresAt = new Date(input.leaseExpiresAt);
244
+ task.updatedAt = new Date(input.now);
245
+ task.version += 1;
246
+ return true;
247
+ }
248
+ async reportProgress(input) {
249
+ const task = this.tasks.get(input.id);
250
+ if (!task || !requiresTaskLease(task.status) || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() <= input.now.getTime()) {
251
+ return null;
252
+ }
253
+ task.stage = input.stage ?? task.stage;
254
+ task.progress = Math.max(task.progress, clampProgress(input.progress));
255
+ task.updatedAt = new Date(input.now);
256
+ task.version += 1;
257
+ return cloneTask(task);
258
+ }
259
+ async transition(input) {
260
+ const task = this.tasks.get(input.id);
261
+ if (!task || task.status !== input.from) return null;
262
+ assertTaskTransition(input.from, input.to);
263
+ if (input.from === "QUEUED" && input.to === "PROCESSING") return null;
264
+ if (requiresTaskLease(input.from)) {
265
+ if (!input.leaseToken || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() <= input.now.getTime()) {
266
+ return null;
267
+ }
268
+ }
269
+ task.status = input.to;
270
+ task.stage = input.stage ?? task.stage;
271
+ task.progress = input.to === "COMPLETED" ? 100 : clampProgress(input.progress ?? task.progress);
272
+ task.result = input.result ?? task.result;
273
+ task.error = input.error;
274
+ task.providerRequestId = input.providerRequestId ?? task.providerRequestId;
275
+ task.availableAt = new Date(input.availableAt ?? task.availableAt);
276
+ task.updatedAt = new Date(input.now);
277
+ task.version += 1;
278
+ if (input.to === "QUEUED" || input.to === "RETRYING" || isTerminalTaskStatus(input.to)) {
279
+ task.workerId = void 0;
280
+ task.leaseToken = void 0;
281
+ task.leaseExpiresAt = void 0;
282
+ task.heartbeatAt = void 0;
283
+ }
284
+ if (isTerminalTaskStatus(input.to)) task.completedAt = new Date(input.now);
285
+ return cloneTask(task);
286
+ }
287
+ async findExpiredLeases(input) {
288
+ if (input.limit < 1) return [];
289
+ return [
290
+ ...this.tasks.values()
291
+ ].filter((task) => requiresTaskLease(task.status) && Boolean(task.leaseExpiresAt && task.leaseExpiresAt.getTime() <= input.now.getTime())).sort((a, b) => (a.leaseExpiresAt?.getTime() ?? 0) - (b.leaseExpiresAt?.getTime() ?? 0)).slice(0, input.limit).map(cloneTask);
292
+ }
293
+ async findReadyQueued(input) {
294
+ if (input.limit < 1) return [];
295
+ return [
296
+ ...this.tasks.values()
297
+ ].filter((task) => task.status === "QUEUED" && task.availableAt.getTime() <= input.now.getTime() && task.attempt < task.maxAttempts).sort((a, b) => a.availableAt.getTime() - b.availableAt.getTime()).slice(input.offset ?? 0, (input.offset ?? 0) + input.limit).map(cloneTask);
298
+ }
299
+ async recoverExpiredLease(input) {
300
+ const task = this.tasks.get(input.id);
301
+ if (!task || !requiresTaskLease(task.status) || task.leaseToken !== input.leaseToken || !task.leaseExpiresAt || task.leaseExpiresAt.getTime() > input.now.getTime()) {
302
+ return null;
303
+ }
304
+ const attemptsExhausted = task.attempt >= task.maxAttempts;
305
+ task.status = attemptsExhausted ? "FAILED" : "QUEUED";
306
+ task.error = input.error;
307
+ task.availableAt = new Date(input.availableAt ?? input.now);
308
+ task.workerId = void 0;
309
+ task.leaseToken = void 0;
310
+ task.leaseExpiresAt = void 0;
311
+ task.heartbeatAt = void 0;
312
+ task.updatedAt = new Date(input.now);
313
+ task.version += 1;
314
+ if (attemptsExhausted) task.completedAt = new Date(input.now);
315
+ return cloneTask(task);
316
+ }
317
+ async cancel(id, now) {
318
+ const task = this.tasks.get(id);
319
+ if (!task || isTerminalTaskStatus(task.status)) return null;
320
+ task.status = "CANCELLED";
321
+ task.workerId = void 0;
322
+ task.leaseToken = void 0;
323
+ task.leaseExpiresAt = void 0;
324
+ task.heartbeatAt = void 0;
325
+ task.completedAt = new Date(now);
326
+ task.updatedAt = new Date(now);
327
+ task.version += 1;
328
+ return cloneTask(task);
329
+ }
330
+ };
331
+
332
+ // src/postgres-task-repository.ts
333
+ var CREATE_TASK_SQL = `
334
+ WITH inserted AS (
335
+ INSERT INTO async_jobs (
336
+ "id", "kind", "resourceType", "resourceId", "userId", "orderId",
337
+ "status", "progress", "attempt", "maxAttempts", "idempotencyKey",
338
+ "idempotencyFingerprint", "handlerVersion", "payload", "availableAt",
339
+ "createdAt", "updatedAt", "version"
340
+ ) VALUES (
341
+ $1, $2, $3, $4, $5, $6,
342
+ 'QUEUED', 0, 0, $7, $8,
343
+ $9, $10, $11::jsonb, $12,
344
+ $13, $13, 0
345
+ )
346
+ ON CONFLICT ("idempotencyKey") DO NOTHING
347
+ RETURNING *
348
+ )
349
+ SELECT inserted.*, TRUE AS "__created"
350
+ FROM inserted
351
+ UNION ALL
352
+ SELECT existing.*, FALSE AS "__created"
353
+ FROM async_jobs AS existing
354
+ WHERE existing."idempotencyKey" = $8
355
+ AND NOT EXISTS (SELECT 1 FROM inserted)
356
+ LIMIT 1
357
+ `;
358
+ var CLAIM_TASK_SQL = `
359
+ UPDATE async_jobs
360
+ SET
361
+ "status" = 'PROCESSING',
362
+ "workerId" = $2,
363
+ "leaseToken" = $3,
364
+ "leaseExpiresAt" = $4,
365
+ "heartbeatAt" = $5,
366
+ "startedAt" = COALESCE("startedAt", $5),
367
+ "attempt" = "attempt" + 1,
368
+ "updatedAt" = $5,
369
+ "version" = "version" + 1
370
+ WHERE "id" = $1
371
+ AND "status" = 'QUEUED'
372
+ AND "availableAt" <= $5
373
+ AND "attempt" < "maxAttempts"
374
+ AND $4 > $5
375
+ RETURNING *
376
+ `;
377
+ var HEARTBEAT_TASK_SQL = `
378
+ UPDATE async_jobs
379
+ SET
380
+ "heartbeatAt" = $4,
381
+ "leaseExpiresAt" = $3,
382
+ "updatedAt" = $4,
383
+ "version" = "version" + 1
384
+ WHERE "id" = $1
385
+ AND "status" IN ('PROCESSING', 'WAITING_PROVIDER')
386
+ AND "leaseToken" = $2
387
+ AND "leaseExpiresAt" > $4
388
+ AND $3 > $4
389
+ RETURNING "id"
390
+ `;
391
+ var REPORT_TASK_PROGRESS_SQL = `
392
+ UPDATE async_jobs
393
+ SET
394
+ "stage" = COALESCE($3::text, "stage"),
395
+ "progress" = GREATEST("progress", GREATEST(0, LEAST(100, $4::integer))),
396
+ "updatedAt" = $5,
397
+ "version" = "version" + 1
398
+ WHERE "id" = $1
399
+ AND "status" IN ('PROCESSING', 'WAITING_PROVIDER')
400
+ AND "leaseToken" = $2
401
+ AND "leaseExpiresAt" > $5
402
+ RETURNING *
403
+ `;
404
+ var TRANSITION_TASK_SQL = `
405
+ UPDATE async_jobs
406
+ SET
407
+ "status" = $3::async_job_status,
408
+ "stage" = COALESCE($5::text, "stage"),
409
+ "progress" = CASE
410
+ WHEN $3 = 'COMPLETED' THEN 100
411
+ WHEN $6::integer IS NULL THEN "progress"
412
+ ELSE GREATEST(0, LEAST(100, $6::integer))
413
+ END,
414
+ "result" = COALESCE($7::jsonb, "result"),
415
+ "errorCode" = $8::text,
416
+ "errorCategory" = $9::text,
417
+ "errorSummary" = $10::text,
418
+ "errorRetryable" = $11::boolean,
419
+ "errorMetadata" = $12::jsonb,
420
+ "providerRequestId" = COALESCE($13::text, "providerRequestId"),
421
+ "availableAt" = COALESCE($14::timestamptz, "availableAt"),
422
+ "workerId" = CASE WHEN $16::boolean THEN NULL ELSE "workerId" END,
423
+ "leaseToken" = CASE WHEN $16::boolean THEN NULL ELSE "leaseToken" END,
424
+ "leaseExpiresAt" = CASE WHEN $16::boolean THEN NULL ELSE "leaseExpiresAt" END,
425
+ "heartbeatAt" = CASE WHEN $16::boolean THEN NULL ELSE "heartbeatAt" END,
426
+ "completedAt" = CASE WHEN $17::boolean THEN $15::timestamptz ELSE "completedAt" END,
427
+ "updatedAt" = $15::timestamptz,
428
+ "version" = "version" + 1
429
+ WHERE "id" = $1
430
+ AND "status" = $2::async_job_status
431
+ AND (
432
+ NOT $18::boolean
433
+ OR (
434
+ $4::text IS NOT NULL
435
+ AND "leaseToken" = $4::text
436
+ AND "leaseExpiresAt" > $15::timestamptz
437
+ )
438
+ )
439
+ RETURNING *
440
+ `;
441
+ var FIND_EXPIRED_LEASES_SQL = `
442
+ SELECT *
443
+ FROM async_jobs
444
+ WHERE "status" IN ('PROCESSING', 'WAITING_PROVIDER')
445
+ AND "leaseExpiresAt" <= $1
446
+ ORDER BY "leaseExpiresAt" ASC
447
+ LIMIT $2
448
+ `;
449
+ var FIND_READY_QUEUED_TASKS_SQL = `
450
+ SELECT *
451
+ FROM async_jobs
452
+ WHERE "status" = 'QUEUED'
453
+ AND "availableAt" <= $1
454
+ AND "attempt" < "maxAttempts"
455
+ ORDER BY "availableAt" ASC, "createdAt" ASC
456
+ LIMIT $2
457
+ OFFSET $3
458
+ `;
459
+ var RECOVER_EXPIRED_LEASE_SQL = `
460
+ UPDATE async_jobs
461
+ SET
462
+ "status" = CASE
463
+ WHEN "attempt" >= "maxAttempts" THEN 'FAILED'::async_job_status
464
+ ELSE 'QUEUED'::async_job_status
465
+ END,
466
+ "errorCode" = $4,
467
+ "errorCategory" = $5,
468
+ "errorSummary" = $6,
469
+ "errorRetryable" = $7,
470
+ "errorMetadata" = $8::jsonb,
471
+ "availableAt" = COALESCE($9, $3),
472
+ "workerId" = NULL,
473
+ "leaseToken" = NULL,
474
+ "leaseExpiresAt" = NULL,
475
+ "heartbeatAt" = NULL,
476
+ "completedAt" = CASE
477
+ WHEN "attempt" >= "maxAttempts" THEN $3
478
+ ELSE "completedAt"
479
+ END,
480
+ "updatedAt" = $3,
481
+ "version" = "version" + 1
482
+ WHERE "id" = $1
483
+ AND "status" IN ('PROCESSING', 'WAITING_PROVIDER')
484
+ AND "leaseToken" = $2
485
+ AND "leaseExpiresAt" <= $3
486
+ RETURNING *
487
+ `;
488
+ var CANCEL_TASK_SQL = `
489
+ UPDATE async_jobs
490
+ SET
491
+ "status" = 'CANCELLED',
492
+ "workerId" = NULL,
493
+ "leaseToken" = NULL,
494
+ "leaseExpiresAt" = NULL,
495
+ "heartbeatAt" = NULL,
496
+ "completedAt" = $2,
497
+ "updatedAt" = $2,
498
+ "version" = "version" + 1
499
+ WHERE "id" = $1
500
+ AND "status" NOT IN ('COMPLETED', 'FAILED', 'CANCELLED')
501
+ RETURNING *
502
+ `;
503
+ function jsonParameter(value) {
504
+ return value === void 0 ? null : JSON.stringify(value);
505
+ }
506
+ __name(jsonParameter, "jsonParameter");
507
+ function parseJson(value) {
508
+ if (typeof value !== "string") return value;
509
+ return JSON.parse(value);
510
+ }
511
+ __name(parseJson, "parseJson");
512
+ function optionalDate(value) {
513
+ return value === null ? void 0 : new Date(value);
514
+ }
515
+ __name(optionalDate, "optionalDate");
516
+ function taskStatus(value) {
517
+ if (TASK_STATUSES.includes(value)) return value;
518
+ throw new Error(`Unknown task status returned by PostgreSQL: ${value}`);
519
+ }
520
+ __name(taskStatus, "taskStatus");
521
+ function taskError(row) {
522
+ if (!row.errorCode) return void 0;
523
+ return {
524
+ code: row.errorCode,
525
+ category: row.errorCategory ?? "UNKNOWN",
526
+ summary: row.errorSummary ?? row.errorCode,
527
+ retryable: row.errorRetryable ?? false,
528
+ metadata: row.errorMetadata ? parseJson(row.errorMetadata) : void 0
529
+ };
530
+ }
531
+ __name(taskError, "taskError");
532
+ var PostgresTaskRepository = class {
533
+ static {
534
+ __name(this, "PostgresTaskRepository");
535
+ }
536
+ client;
537
+ constructor(client) {
538
+ this.client = client;
539
+ }
540
+ async create(input) {
541
+ if (input.maxAttempts < 1) throw new RangeError("maxAttempts must be at least 1");
542
+ const result = await this.client.query(CREATE_TASK_SQL, [
543
+ input.id,
544
+ input.kind,
545
+ input.resource.type,
546
+ input.resource.id,
547
+ input.userId ?? null,
548
+ input.orderId ?? null,
549
+ input.maxAttempts,
550
+ input.idempotencyKey,
551
+ input.idempotencyFingerprint,
552
+ input.handlerVersion,
553
+ jsonParameter(input.payload),
554
+ input.availableAt ?? input.now,
555
+ input.now
556
+ ]);
557
+ const row = result.rows[0];
558
+ if (!row) {
559
+ const existing = await this.findByIdempotencyKey(input.idempotencyKey);
560
+ if (!existing) throw new Error(`Task already exists with a different id: ${input.id}`);
561
+ if (existing.idempotencyFingerprint !== input.idempotencyFingerprint) {
562
+ throw new TaskIdempotencyConflictError(input.idempotencyKey);
563
+ }
564
+ return {
565
+ task: existing,
566
+ created: false
567
+ };
568
+ }
569
+ if (row.idempotencyFingerprint !== input.idempotencyFingerprint) {
570
+ throw new TaskIdempotencyConflictError(input.idempotencyKey);
571
+ }
572
+ return {
573
+ task: this.toTask(row),
574
+ created: row.__created
575
+ };
576
+ }
577
+ async findById(id) {
578
+ const result = await this.client.query('SELECT * FROM async_jobs WHERE "id" = $1 LIMIT 1', [
579
+ id
580
+ ]);
581
+ return result.rows[0] ? this.toTask(result.rows[0]) : null;
582
+ }
583
+ async findByIdempotencyKey(key) {
584
+ const result = await this.client.query('SELECT * FROM async_jobs WHERE "idempotencyKey" = $1 LIMIT 1', [
585
+ key
586
+ ]);
587
+ return result.rows[0] ? this.toTask(result.rows[0]) : null;
588
+ }
589
+ async claim(input) {
590
+ const result = await this.client.query(CLAIM_TASK_SQL, [
591
+ input.id,
592
+ input.workerId,
593
+ input.leaseToken,
594
+ input.leaseExpiresAt,
595
+ input.now
596
+ ]);
597
+ return result.rows[0] ? this.toTask(result.rows[0]) : null;
598
+ }
599
+ async heartbeat(input) {
600
+ const result = await this.client.query(HEARTBEAT_TASK_SQL, [
601
+ input.id,
602
+ input.leaseToken,
603
+ input.leaseExpiresAt,
604
+ input.now
605
+ ]);
606
+ return result.rows.length === 1;
607
+ }
608
+ async reportProgress(input) {
609
+ const result = await this.client.query(REPORT_TASK_PROGRESS_SQL, [
610
+ input.id,
611
+ input.leaseToken,
612
+ input.stage ?? null,
613
+ input.progress,
614
+ input.now
615
+ ]);
616
+ return result.rows[0] ? this.toTask(result.rows[0]) : null;
617
+ }
618
+ async transition(input) {
619
+ assertTaskTransition(input.from, input.to);
620
+ if (input.from === "QUEUED" && input.to === "PROCESSING") return null;
621
+ const clearsLease = input.to === "QUEUED" || input.to === "RETRYING" || isTerminalTaskStatus(input.to);
622
+ const error = input.error;
623
+ const result = await this.client.query(TRANSITION_TASK_SQL, [
624
+ input.id,
625
+ input.from,
626
+ input.to,
627
+ input.leaseToken ?? null,
628
+ input.stage ?? null,
629
+ input.progress ?? null,
630
+ jsonParameter(input.result),
631
+ error?.code ?? null,
632
+ error?.category ?? null,
633
+ error?.summary ?? null,
634
+ error?.retryable ?? null,
635
+ jsonParameter(error?.metadata),
636
+ input.providerRequestId ?? null,
637
+ input.availableAt ?? null,
638
+ input.now,
639
+ clearsLease,
640
+ isTerminalTaskStatus(input.to),
641
+ requiresTaskLease(input.from)
642
+ ]);
643
+ return result.rows[0] ? this.toTask(result.rows[0]) : null;
644
+ }
645
+ async findExpiredLeases(input) {
646
+ if (input.limit < 1) return [];
647
+ const result = await this.client.query(FIND_EXPIRED_LEASES_SQL, [
648
+ input.now,
649
+ input.limit
650
+ ]);
651
+ return result.rows.map((row) => this.toTask(row));
652
+ }
653
+ async findReadyQueued(input) {
654
+ if (input.limit < 1) return [];
655
+ const result = await this.client.query(FIND_READY_QUEUED_TASKS_SQL, [
656
+ input.now,
657
+ input.limit,
658
+ input.offset ?? 0
659
+ ]);
660
+ return result.rows.map((row) => this.toTask(row));
661
+ }
662
+ async recoverExpiredLease(input) {
663
+ const result = await this.client.query(RECOVER_EXPIRED_LEASE_SQL, [
664
+ input.id,
665
+ input.leaseToken,
666
+ input.now,
667
+ input.error.code,
668
+ input.error.category,
669
+ input.error.summary,
670
+ input.error.retryable,
671
+ jsonParameter(input.error.metadata),
672
+ input.availableAt ?? null
673
+ ]);
674
+ return result.rows[0] ? this.toTask(result.rows[0]) : null;
675
+ }
676
+ async cancel(id, now) {
677
+ const result = await this.client.query(CANCEL_TASK_SQL, [
678
+ id,
679
+ now
680
+ ]);
681
+ return result.rows[0] ? this.toTask(result.rows[0]) : null;
682
+ }
683
+ toTask(row) {
684
+ return {
685
+ id: row.id,
686
+ kind: row.kind,
687
+ resource: {
688
+ type: row.resourceType,
689
+ id: row.resourceId
690
+ },
691
+ userId: row.userId ?? void 0,
692
+ orderId: row.orderId ?? void 0,
693
+ status: taskStatus(row.status),
694
+ stage: row.stage ?? void 0,
695
+ progress: row.progress,
696
+ attempt: row.attempt,
697
+ maxAttempts: row.maxAttempts,
698
+ idempotencyKey: row.idempotencyKey,
699
+ idempotencyFingerprint: row.idempotencyFingerprint,
700
+ handlerVersion: row.handlerVersion,
701
+ payload: parseJson(row.payload),
702
+ result: row.result === null ? void 0 : parseJson(row.result),
703
+ error: taskError(row),
704
+ providerRequestId: row.providerRequestId ?? void 0,
705
+ workerId: row.workerId ?? void 0,
706
+ leaseToken: row.leaseToken ?? void 0,
707
+ leaseExpiresAt: optionalDate(row.leaseExpiresAt),
708
+ heartbeatAt: optionalDate(row.heartbeatAt),
709
+ availableAt: new Date(row.availableAt),
710
+ createdAt: new Date(row.createdAt),
711
+ startedAt: optionalDate(row.startedAt),
712
+ completedAt: optionalDate(row.completedAt),
713
+ updatedAt: new Date(row.updatedAt),
714
+ version: row.version
715
+ };
716
+ }
717
+ };
718
+
719
+ // src/postgres-task-summary-reader.ts
720
+ var SAFE_SUMMARY_COLUMNS = `
721
+ "id", "kind", "resourceType", "resourceId", "status"::text AS "status",
722
+ "stage", "progress", "attempt", "maxAttempts", "errorCode",
723
+ "errorCategory", "errorRetryable", "createdAt",
724
+ "startedAt", "completedAt", "updatedAt", "version"
725
+ `;
726
+ function optionalDate2(value) {
727
+ return value === null ? void 0 : new Date(value);
728
+ }
729
+ __name(optionalDate2, "optionalDate");
730
+ function taskStatus2(value) {
731
+ if (TASK_STATUSES.includes(value)) return value;
732
+ throw new Error(`Unknown task status returned by PostgreSQL: ${value}`);
733
+ }
734
+ __name(taskStatus2, "taskStatus");
735
+ function toSummary(row) {
736
+ const error = row.errorCode ? {
737
+ code: row.errorCode,
738
+ category: row.errorCategory ?? "UNKNOWN",
739
+ summary: row.errorCode,
740
+ retryable: row.errorRetryable ?? false
741
+ } : void 0;
742
+ return {
743
+ id: row.id,
744
+ kind: row.kind,
745
+ resource: {
746
+ type: row.resourceType,
747
+ id: row.resourceId
748
+ },
749
+ status: taskStatus2(row.status),
750
+ stage: row.stage ?? void 0,
751
+ progress: row.progress,
752
+ attempt: row.attempt,
753
+ maxAttempts: row.maxAttempts,
754
+ error,
755
+ createdAt: new Date(row.createdAt),
756
+ startedAt: optionalDate2(row.startedAt),
757
+ completedAt: optionalDate2(row.completedAt),
758
+ updatedAt: new Date(row.updatedAt),
759
+ version: row.version
760
+ };
761
+ }
762
+ __name(toSummary, "toSummary");
763
+ var PostgresTaskSummaryReader = class {
764
+ static {
765
+ __name(this, "PostgresTaskSummaryReader");
766
+ }
767
+ client;
768
+ constructor(client) {
769
+ this.client = client;
770
+ }
771
+ async findOwnedById(taskId, userId) {
772
+ const result = await this.client.query(`SELECT ${SAFE_SUMMARY_COLUMNS}
773
+ FROM async_jobs
774
+ WHERE "id" = $1 AND "userId" = $2
775
+ LIMIT 1`, [
776
+ taskId,
777
+ userId
778
+ ]);
779
+ return result.rows[0] ? toSummary(result.rows[0]) : null;
780
+ }
781
+ async listOwned(input) {
782
+ if (!input.userId) return [];
783
+ const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
784
+ const statuses = input.statuses?.length ? [
785
+ ...new Set(input.statuses)
786
+ ] : void 0;
787
+ if (statuses?.some((status2) => !TASK_STATUSES.includes(status2))) {
788
+ throw new RangeError("statuses contains an unknown task status");
789
+ }
790
+ const statusPlaceholders = statuses?.map((_, index) => `$${index + 2}::async_job_status`).join(", ");
791
+ const result = statuses ? await this.client.query(`SELECT ${SAFE_SUMMARY_COLUMNS}
792
+ FROM async_jobs
793
+ WHERE "userId" = $1
794
+ AND "status" IN (${statusPlaceholders})
795
+ ORDER BY "createdAt" DESC, "id" DESC
796
+ LIMIT $${statuses.length + 2}`, [
797
+ input.userId,
798
+ ...statuses,
799
+ limit
800
+ ]) : await this.client.query(`SELECT ${SAFE_SUMMARY_COLUMNS}
801
+ FROM async_jobs
802
+ WHERE "userId" = $1
803
+ ORDER BY "createdAt" DESC, "id" DESC
804
+ LIMIT $2`, [
805
+ input.userId,
806
+ limit
807
+ ]);
808
+ return result.rows.map(toSummary);
809
+ }
810
+ };
811
+
812
+ // src/task-timeline.ts
813
+ var TASK_TIMELINE_EVENT_TYPES = [
814
+ "SNAPSHOT",
815
+ "CREATED",
816
+ "CLAIMED",
817
+ "PROGRESS",
818
+ "RETRY_SCHEDULED",
819
+ "REQUEUED",
820
+ "LEASE_RECOVERED",
821
+ "MANUAL_REDRIVE",
822
+ "COMPLETED",
823
+ "FAILED",
824
+ "CANCELLED",
825
+ "STATUS_CHANGED"
826
+ ];
827
+ var PUBLIC_COLUMNS = `events."id", events."taskId", events."sequence",
828
+ events."eventType", events."fromStatus"::text AS "fromStatus",
829
+ events."toStatus"::text AS "toStatus", events."stage", events."progress",
830
+ events."attempt", events."maxAttempts", events."errorCode",
831
+ events."errorCategory", events."errorRetryable",
832
+ events."createdAt"`;
833
+ function status(value) {
834
+ if (value === null) return void 0;
835
+ if (TASK_STATUSES.includes(value)) return value;
836
+ throw new Error(`Unknown task status returned by PostgreSQL: ${value}`);
837
+ }
838
+ __name(status, "status");
839
+ function requiredStatus(value) {
840
+ const parsed = status(value);
841
+ if (parsed) return parsed;
842
+ throw new Error("Task timeline event is missing its destination status");
843
+ }
844
+ __name(requiredStatus, "requiredStatus");
845
+ function eventType(value) {
846
+ if (TASK_TIMELINE_EVENT_TYPES.includes(value)) {
847
+ return value;
848
+ }
849
+ throw new Error(`Unknown task timeline event type returned by PostgreSQL: ${value}`);
850
+ }
851
+ __name(eventType, "eventType");
852
+ function toOwnedEvent(row) {
853
+ const error = row.errorCode ? {
854
+ code: row.errorCode,
855
+ category: row.errorCategory ?? "UNKNOWN",
856
+ summary: row.errorCode,
857
+ retryable: row.errorRetryable ?? false
858
+ } : void 0;
859
+ return {
860
+ id: row.id,
861
+ taskId: row.taskId,
862
+ sequence: row.sequence,
863
+ type: eventType(row.eventType),
864
+ fromStatus: status(row.fromStatus),
865
+ toStatus: requiredStatus(row.toStatus),
866
+ stage: row.stage ?? void 0,
867
+ progress: row.progress,
868
+ attempt: row.attempt,
869
+ maxAttempts: row.maxAttempts,
870
+ error,
871
+ createdAt: new Date(row.createdAt)
872
+ };
873
+ }
874
+ __name(toOwnedEvent, "toOwnedEvent");
875
+ var PostgresTaskTimelineReader = class {
876
+ static {
877
+ __name(this, "PostgresTaskTimelineReader");
878
+ }
879
+ client;
880
+ constructor(client) {
881
+ this.client = client;
882
+ }
883
+ async listOwned(taskId, userId, limit = 200) {
884
+ if (!userId) return [];
885
+ const result = await this.client.query(`SELECT ${PUBLIC_COLUMNS}
886
+ FROM async_job_events AS events
887
+ INNER JOIN async_jobs AS tasks ON tasks."id" = events."taskId"
888
+ WHERE events."taskId" = $1 AND tasks."userId" = $2
889
+ ORDER BY events."sequence" ASC
890
+ LIMIT $3`, [
891
+ taskId,
892
+ userId,
893
+ this.normalizeLimit(limit)
894
+ ]);
895
+ return result.rows.map(toOwnedEvent);
896
+ }
897
+ async listByTaskId(taskId, limit = 500) {
898
+ const result = await this.client.query(`SELECT ${PUBLIC_COLUMNS}, events."workerId", events."providerRequestId"
899
+ FROM async_job_events AS events
900
+ WHERE events."taskId" = $1
901
+ ORDER BY events."sequence" ASC
902
+ LIMIT $2`, [
903
+ taskId,
904
+ this.normalizeLimit(limit)
905
+ ]);
906
+ return result.rows.map((row) => ({
907
+ ...toOwnedEvent(row),
908
+ workerId: row.workerId ?? void 0,
909
+ providerRequestId: row.providerRequestId ?? void 0
910
+ }));
911
+ }
912
+ normalizeLimit(limit) {
913
+ return Math.max(1, Math.min(Math.trunc(limit), 1e3));
914
+ }
915
+ };
916
+
917
+ // src/task-redriver.ts
918
+ var PostgresTaskRedriver = class {
919
+ static {
920
+ __name(this, "PostgresTaskRedriver");
921
+ }
922
+ client;
923
+ constructor(client) {
924
+ this.client = client;
925
+ }
926
+ async redriveFailed(input) {
927
+ const additionalAttempts = input.additionalAttempts ?? 1;
928
+ if (!Number.isInteger(additionalAttempts) || additionalAttempts < 1 || additionalAttempts > 10) {
929
+ throw new RangeError("additionalAttempts must be an integer between 1 and 10");
930
+ }
931
+ const result = await this.client.query(`UPDATE async_jobs
932
+ SET "status" = 'QUEUED'::async_job_status,
933
+ "stage" = $2,
934
+ "maxAttempts" = GREATEST("maxAttempts", "attempt" + $3),
935
+ "result" = NULL,
936
+ "errorCode" = NULL,
937
+ "errorCategory" = NULL,
938
+ "errorSummary" = NULL,
939
+ "errorRetryable" = NULL,
940
+ "errorMetadata" = NULL,
941
+ "providerRequestId" = NULL,
942
+ "workerId" = NULL,
943
+ "leaseToken" = NULL,
944
+ "leaseExpiresAt" = NULL,
945
+ "heartbeatAt" = NULL,
946
+ "availableAt" = $4,
947
+ "completedAt" = NULL,
948
+ "updatedAt" = $4,
949
+ "version" = "version" + 1
950
+ WHERE "id" = $1 AND "status" = 'FAILED'::async_job_status
951
+ RETURNING "id", "attempt", "maxAttempts", "version", "availableAt"`, [
952
+ input.taskId,
953
+ input.stage ?? "manual-redrive",
954
+ additionalAttempts,
955
+ input.now
956
+ ]);
957
+ const row = result.rows[0];
958
+ return row ? {
959
+ taskId: row.id,
960
+ attempt: row.attempt,
961
+ maxAttempts: row.maxAttempts,
962
+ version: row.version,
963
+ availableAt: new Date(row.availableAt)
964
+ } : null;
965
+ }
966
+ };
967
+
968
+ // src/postgres-task-outbox.ts
969
+ import { isDeepStrictEqual } from "util";
970
+
971
+ // src/task-outbox.ts
972
+ var TaskOutboxConflictError = class extends Error {
973
+ static {
974
+ __name(this, "TaskOutboxConflictError");
975
+ }
976
+ eventId;
977
+ constructor(eventId) {
978
+ super(`Outbox event id was reused with different content: ${eventId}`), this.eventId = eventId;
979
+ this.name = "TaskOutboxConflictError";
980
+ }
981
+ };
982
+
983
+ // src/postgres-task-outbox.ts
984
+ var APPEND_OUTBOX_SQL = `
985
+ WITH inserted AS (
986
+ INSERT INTO async_job_outbox (
987
+ "id", "taskId", "topic", "payload", "status", "attempt",
988
+ "availableAt", "createdAt", "updatedAt"
989
+ ) VALUES (
990
+ $1, $2, $3, $4::jsonb, 'PENDING', 0,
991
+ $5, $6, $6
992
+ )
993
+ ON CONFLICT ("id") DO NOTHING
994
+ RETURNING *
995
+ )
996
+ SELECT inserted.*, TRUE AS "__created"
997
+ FROM inserted
998
+ UNION ALL
999
+ SELECT existing.*, FALSE AS "__created"
1000
+ FROM async_job_outbox AS existing
1001
+ WHERE existing."id" = $1
1002
+ AND NOT EXISTS (SELECT 1 FROM inserted)
1003
+ LIMIT 1
1004
+ `;
1005
+ var CLAIM_OUTBOX_SQL = `
1006
+ WITH candidates AS (
1007
+ SELECT "id"
1008
+ FROM async_job_outbox
1009
+ WHERE "availableAt" <= $4
1010
+ AND (
1011
+ "status" = 'PENDING'
1012
+ OR (
1013
+ "status" = 'PUBLISHING'
1014
+ AND "leaseExpiresAt" <= $4
1015
+ )
1016
+ )
1017
+ ORDER BY "createdAt" ASC
1018
+ LIMIT $5
1019
+ FOR UPDATE SKIP LOCKED
1020
+ )
1021
+ UPDATE async_job_outbox AS events
1022
+ SET
1023
+ "status" = 'PUBLISHING',
1024
+ "workerId" = $1,
1025
+ "leaseToken" = $2,
1026
+ "leaseExpiresAt" = $3,
1027
+ "attempt" = events."attempt" + 1,
1028
+ "updatedAt" = $4
1029
+ FROM candidates
1030
+ WHERE events."id" = candidates."id"
1031
+ AND $3 > $4
1032
+ RETURNING events.*
1033
+ `;
1034
+ var MARK_OUTBOX_PUBLISHED_SQL = `
1035
+ UPDATE async_job_outbox
1036
+ SET
1037
+ "status" = 'PUBLISHED',
1038
+ "workerId" = NULL,
1039
+ "leaseToken" = NULL,
1040
+ "leaseExpiresAt" = NULL,
1041
+ "publishedAt" = $3,
1042
+ "updatedAt" = $3,
1043
+ "lastError" = NULL
1044
+ WHERE "id" = $1
1045
+ AND "status" = 'PUBLISHING'
1046
+ AND "leaseToken" = $2
1047
+ AND "leaseExpiresAt" > $3
1048
+ RETURNING "id"
1049
+ `;
1050
+ var RESCHEDULE_OUTBOX_SQL = `
1051
+ UPDATE async_job_outbox
1052
+ SET
1053
+ "status" = 'PENDING',
1054
+ "workerId" = NULL,
1055
+ "leaseToken" = NULL,
1056
+ "leaseExpiresAt" = NULL,
1057
+ "availableAt" = $3,
1058
+ "lastError" = $4,
1059
+ "updatedAt" = $5
1060
+ WHERE "id" = $1
1061
+ AND "status" = 'PUBLISHING'
1062
+ AND "leaseToken" = $2
1063
+ AND "leaseExpiresAt" > $5
1064
+ RETURNING "id"
1065
+ `;
1066
+ function parseJson2(value) {
1067
+ if (typeof value !== "string") return value;
1068
+ return JSON.parse(value);
1069
+ }
1070
+ __name(parseJson2, "parseJson");
1071
+ function optionalDate3(value) {
1072
+ return value === null ? void 0 : new Date(value);
1073
+ }
1074
+ __name(optionalDate3, "optionalDate");
1075
+ var PostgresTaskOutboxRepository = class {
1076
+ static {
1077
+ __name(this, "PostgresTaskOutboxRepository");
1078
+ }
1079
+ client;
1080
+ constructor(client) {
1081
+ this.client = client;
1082
+ }
1083
+ async append(input) {
1084
+ const result = await this.client.query(APPEND_OUTBOX_SQL, [
1085
+ input.id,
1086
+ input.taskId,
1087
+ input.topic,
1088
+ JSON.stringify(input.payload),
1089
+ input.availableAt ?? input.now,
1090
+ input.now
1091
+ ]);
1092
+ let row = result.rows[0];
1093
+ if (!row) {
1094
+ const existing = await this.client.query('SELECT * FROM async_job_outbox WHERE "id" = $1 LIMIT 1', [
1095
+ input.id
1096
+ ]);
1097
+ row = existing.rows[0];
1098
+ if (row) row.__created = false;
1099
+ }
1100
+ if (!row) throw new Error(`Outbox event could not be created: ${input.id}`);
1101
+ if (row.taskId !== input.taskId || row.topic !== input.topic || !isDeepStrictEqual(parseJson2(row.payload), input.payload)) {
1102
+ throw new TaskOutboxConflictError(input.id);
1103
+ }
1104
+ return {
1105
+ record: this.toRecord(row),
1106
+ created: row.__created
1107
+ };
1108
+ }
1109
+ async claimBatch(input) {
1110
+ if (input.limit < 1 || input.leaseExpiresAt.getTime() <= input.now.getTime()) return [];
1111
+ const result = await this.client.query(CLAIM_OUTBOX_SQL, [
1112
+ input.workerId,
1113
+ input.leaseToken,
1114
+ input.leaseExpiresAt,
1115
+ input.now,
1116
+ input.limit
1117
+ ]);
1118
+ return result.rows.map((row) => this.toRecord(row));
1119
+ }
1120
+ async markPublished(id, leaseToken, now) {
1121
+ const result = await this.client.query(MARK_OUTBOX_PUBLISHED_SQL, [
1122
+ id,
1123
+ leaseToken,
1124
+ now
1125
+ ]);
1126
+ return result.rows.length === 1;
1127
+ }
1128
+ async reschedule(id, leaseToken, availableAt, errorSummary, now) {
1129
+ const result = await this.client.query(RESCHEDULE_OUTBOX_SQL, [
1130
+ id,
1131
+ leaseToken,
1132
+ availableAt,
1133
+ errorSummary,
1134
+ now
1135
+ ]);
1136
+ return result.rows.length === 1;
1137
+ }
1138
+ toRecord(row) {
1139
+ return {
1140
+ id: row.id,
1141
+ taskId: row.taskId,
1142
+ topic: row.topic,
1143
+ payload: parseJson2(row.payload),
1144
+ status: row.status,
1145
+ attempt: row.attempt,
1146
+ availableAt: new Date(row.availableAt),
1147
+ workerId: row.workerId ?? void 0,
1148
+ leaseToken: row.leaseToken ?? void 0,
1149
+ leaseExpiresAt: optionalDate3(row.leaseExpiresAt),
1150
+ publishedAt: optionalDate3(row.publishedAt),
1151
+ lastError: row.lastError ?? void 0,
1152
+ createdAt: new Date(row.createdAt),
1153
+ updatedAt: new Date(row.updatedAt)
1154
+ };
1155
+ }
1156
+ };
1157
+
1158
+ // src/postgres-task-submitter.ts
1159
+ var PostgresTaskSubmitter = class {
1160
+ static {
1161
+ __name(this, "PostgresTaskSubmitter");
1162
+ }
1163
+ transactions;
1164
+ constructor(transactions) {
1165
+ this.transactions = transactions;
1166
+ }
1167
+ async submit(input, options = {}) {
1168
+ return this.transactions.runInTransaction(async (client) => {
1169
+ const result = await this.submitWithClient(client, input);
1170
+ await options.transactionalWork?.(client, result);
1171
+ return result;
1172
+ });
1173
+ }
1174
+ async submitBatch(inputs, options = {}) {
1175
+ return this.transactions.runInTransaction(async (client) => {
1176
+ const results = [];
1177
+ for (const input of inputs) {
1178
+ results.push(await this.submitWithClient(client, input));
1179
+ }
1180
+ await options.transactionalWork?.(client, results);
1181
+ return results;
1182
+ });
1183
+ }
1184
+ async submitWithClient(client, input) {
1185
+ const tasks = new PostgresTaskRepository(client);
1186
+ const outbox = new PostgresTaskOutboxRepository(client);
1187
+ const task = await tasks.create(input.task);
1188
+ const event = await outbox.append({
1189
+ ...input.outbox,
1190
+ taskId: task.task.id,
1191
+ now: input.task.now
1192
+ });
1193
+ return {
1194
+ task,
1195
+ outbox: event
1196
+ };
1197
+ }
1198
+ };
1199
+
1200
+ // src/task-lease-reconciler.ts
1201
+ var leaseExpiredError = {
1202
+ code: "WORKER_LEASE_EXPIRED",
1203
+ category: "INTERNAL",
1204
+ summary: "Worker heartbeat expired before the task reached a terminal state",
1205
+ retryable: true
1206
+ };
1207
+ var TaskLeaseReconciler = class {
1208
+ static {
1209
+ __name(this, "TaskLeaseReconciler");
1210
+ }
1211
+ repository;
1212
+ batchSize;
1213
+ retryDelayMs;
1214
+ now;
1215
+ constructor(repository, options = {}) {
1216
+ this.repository = repository;
1217
+ this.batchSize = options.batchSize ?? 100;
1218
+ this.retryDelayMs = options.retryDelayMs ?? 0;
1219
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1220
+ if (this.batchSize < 1) throw new RangeError("batchSize must be at least 1");
1221
+ if (this.retryDelayMs < 0) throw new RangeError("retryDelayMs cannot be negative");
1222
+ }
1223
+ async runOnce() {
1224
+ const now = this.now();
1225
+ const expired = await this.repository.findExpiredLeases({
1226
+ now,
1227
+ limit: this.batchSize
1228
+ });
1229
+ const result = {
1230
+ found: expired.length,
1231
+ requeued: 0,
1232
+ failed: 0,
1233
+ conflicts: 0
1234
+ };
1235
+ for (const task of expired) {
1236
+ if (!task.leaseToken) {
1237
+ result.conflicts += 1;
1238
+ continue;
1239
+ }
1240
+ const recovered = await this.repository.recoverExpiredLease({
1241
+ id: task.id,
1242
+ leaseToken: task.leaseToken,
1243
+ now,
1244
+ availableAt: new Date(now.getTime() + this.retryDelayMs),
1245
+ error: leaseExpiredError
1246
+ });
1247
+ if (!recovered) result.conflicts += 1;
1248
+ else if (recovered.status === "FAILED") result.failed += 1;
1249
+ else result.requeued += 1;
1250
+ }
1251
+ return result;
1252
+ }
1253
+ };
1254
+
1255
+ // src/task-queue-reconciler.ts
1256
+ var TaskQueueReconciler = class {
1257
+ static {
1258
+ __name(this, "TaskQueueReconciler");
1259
+ }
1260
+ repository;
1261
+ publisher;
1262
+ batchSize;
1263
+ now;
1264
+ offset = 0;
1265
+ constructor(repository, publisher, options = {}) {
1266
+ this.repository = repository;
1267
+ this.publisher = publisher;
1268
+ this.batchSize = options.batchSize ?? 100;
1269
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1270
+ if (this.batchSize < 1) throw new RangeError("batchSize must be at least 1");
1271
+ }
1272
+ async runOnce() {
1273
+ let tasks = await this.repository.findReadyQueued({
1274
+ now: this.now(),
1275
+ limit: this.batchSize,
1276
+ offset: this.offset
1277
+ });
1278
+ if (tasks.length === 0 && this.offset > 0) {
1279
+ this.offset = 0;
1280
+ tasks = await this.repository.findReadyQueued({
1281
+ now: this.now(),
1282
+ limit: this.batchSize,
1283
+ offset: 0
1284
+ });
1285
+ }
1286
+ this.offset = tasks.length < this.batchSize ? 0 : this.offset + tasks.length;
1287
+ const result = {
1288
+ found: tasks.length,
1289
+ ensured: 0,
1290
+ failed: 0
1291
+ };
1292
+ for (const task of tasks) {
1293
+ try {
1294
+ await this.publisher.ensure(task);
1295
+ result.ensured += 1;
1296
+ } catch {
1297
+ result.failed += 1;
1298
+ }
1299
+ }
1300
+ return result;
1301
+ }
1302
+ };
1303
+
1304
+ // src/retry-policy.ts
1305
+ var defaultRetryableCategories = [
1306
+ "RATE_LIMIT",
1307
+ "NETWORK",
1308
+ "PROVIDER_5XX",
1309
+ "PROVIDER_TIMEOUT",
1310
+ "INTERNAL"
1311
+ ];
1312
+ var ExponentialTaskRetryPolicy = class {
1313
+ static {
1314
+ __name(this, "ExponentialTaskRetryPolicy");
1315
+ }
1316
+ baseDelayMs;
1317
+ maxDelayMs;
1318
+ jitterRatio;
1319
+ retryableCategories;
1320
+ random;
1321
+ constructor(options = {}) {
1322
+ this.baseDelayMs = options.baseDelayMs ?? 1e3;
1323
+ this.maxDelayMs = options.maxDelayMs ?? 6e4;
1324
+ this.jitterRatio = options.jitterRatio ?? 0.2;
1325
+ this.retryableCategories = new Set(options.retryableCategories ?? defaultRetryableCategories);
1326
+ this.random = options.random ?? Math.random;
1327
+ if (this.baseDelayMs < 0) throw new RangeError("baseDelayMs cannot be negative");
1328
+ if (this.maxDelayMs < this.baseDelayMs) {
1329
+ throw new RangeError("maxDelayMs cannot be lower than baseDelayMs");
1330
+ }
1331
+ if (this.jitterRatio < 0 || this.jitterRatio > 1) {
1332
+ throw new RangeError("jitterRatio must be between 0 and 1");
1333
+ }
1334
+ }
1335
+ decide(context) {
1336
+ if (context.attempt >= context.maxAttempts) {
1337
+ return {
1338
+ retry: false,
1339
+ reason: "ATTEMPTS_EXHAUSTED"
1340
+ };
1341
+ }
1342
+ if (!context.error.retryable || !this.retryableCategories.has(context.error.category)) {
1343
+ return {
1344
+ retry: false,
1345
+ reason: "NON_RETRYABLE"
1346
+ };
1347
+ }
1348
+ const exponential = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** Math.max(0, context.attempt - 1));
1349
+ const retryAfter = Math.max(0, context.retryAfterMs ?? 0);
1350
+ const base = Math.max(exponential, retryAfter);
1351
+ const spread = Math.min(this.maxDelayMs, base) * this.jitterRatio;
1352
+ const jittered = base - spread + this.random() * spread * 2;
1353
+ return {
1354
+ retry: true,
1355
+ delayMs: Math.round(Math.min(this.maxDelayMs, jittered))
1356
+ };
1357
+ }
1358
+ };
1359
+
1360
+ // src/durable-worker-runtime.ts
1361
+ import { randomUUID } from "crypto";
1362
+ var DurableTaskNotFoundError = class extends Error {
1363
+ static {
1364
+ __name(this, "DurableTaskNotFoundError");
1365
+ }
1366
+ taskId;
1367
+ constructor(taskId) {
1368
+ super(`Durable task was not found: ${taskId}`), this.taskId = taskId;
1369
+ this.name = "DurableTaskNotFoundError";
1370
+ }
1371
+ };
1372
+ var UnsupportedDurableTaskKindError = class extends Error {
1373
+ static {
1374
+ __name(this, "UnsupportedDurableTaskKindError");
1375
+ }
1376
+ taskId;
1377
+ kind;
1378
+ constructor(taskId, kind) {
1379
+ super(`No durable task handler is registered for kind: ${kind}`), this.taskId = taskId, this.kind = kind;
1380
+ this.name = "UnsupportedDurableTaskKindError";
1381
+ }
1382
+ };
1383
+ var DurableTaskNotClaimableError = class extends Error {
1384
+ static {
1385
+ __name(this, "DurableTaskNotClaimableError");
1386
+ }
1387
+ taskId;
1388
+ status;
1389
+ constructor(taskId, status2) {
1390
+ super(`Durable task is not claimable: ${taskId} (${status2})`), this.taskId = taskId, this.status = status2;
1391
+ this.name = "DurableTaskNotClaimableError";
1392
+ }
1393
+ };
1394
+ var DurableTaskLeaseLostError = class extends Error {
1395
+ static {
1396
+ __name(this, "DurableTaskLeaseLostError");
1397
+ }
1398
+ taskId;
1399
+ operation;
1400
+ constructor(taskId, operation) {
1401
+ super(`Durable task lease no longer owns ${operation}: ${taskId}`), this.taskId = taskId, this.operation = operation;
1402
+ this.name = "DurableTaskLeaseLostError";
1403
+ }
1404
+ };
1405
+ var DurableTaskRetryScheduledError = class extends Error {
1406
+ static {
1407
+ __name(this, "DurableTaskRetryScheduledError");
1408
+ }
1409
+ taskId;
1410
+ taskError;
1411
+ retryAt;
1412
+ constructor(taskId, taskError2, retryAt) {
1413
+ super(`Durable task retry was scheduled: ${taskId} (${taskError2.code})`), this.taskId = taskId, this.taskError = taskError2, this.retryAt = retryAt;
1414
+ this.name = "DurableTaskRetryScheduledError";
1415
+ }
1416
+ };
1417
+ var defaultHeartbeatScheduler = {
1418
+ start(tick, intervalMs) {
1419
+ const handle = setInterval(() => void tick(), intervalMs);
1420
+ handle.unref?.();
1421
+ return handle;
1422
+ },
1423
+ stop(handle) {
1424
+ clearInterval(handle);
1425
+ }
1426
+ };
1427
+ function terminalResult(task) {
1428
+ return {
1429
+ taskId: task.id,
1430
+ kind: task.kind,
1431
+ status: task.status
1432
+ };
1433
+ }
1434
+ __name(terminalResult, "terminalResult");
1435
+ var DurableWorkerRuntime = class {
1436
+ static {
1437
+ __name(this, "DurableWorkerRuntime");
1438
+ }
1439
+ options;
1440
+ repository;
1441
+ retryPolicy;
1442
+ workerId;
1443
+ leaseDurationMs;
1444
+ heartbeatIntervalMs;
1445
+ maxConsecutiveHeartbeatFailures;
1446
+ now;
1447
+ createLeaseToken;
1448
+ heartbeatScheduler;
1449
+ constructor(options) {
1450
+ this.options = options;
1451
+ this.repository = options.repository;
1452
+ this.retryPolicy = options.retryPolicy;
1453
+ this.workerId = options.workerId;
1454
+ this.leaseDurationMs = options.leaseDurationMs ?? 9e4;
1455
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 2e4;
1456
+ this.maxConsecutiveHeartbeatFailures = options.maxConsecutiveHeartbeatFailures ?? 3;
1457
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1458
+ this.createLeaseToken = options.createLeaseToken ?? randomUUID;
1459
+ this.heartbeatScheduler = options.heartbeatScheduler ?? defaultHeartbeatScheduler;
1460
+ if (!this.workerId.trim()) throw new RangeError("workerId cannot be empty");
1461
+ if (this.leaseDurationMs < 1) throw new RangeError("leaseDurationMs must be positive");
1462
+ if (this.heartbeatIntervalMs < 1) {
1463
+ throw new RangeError("heartbeatIntervalMs must be positive");
1464
+ }
1465
+ if (this.heartbeatIntervalMs >= this.leaseDurationMs) {
1466
+ throw new RangeError("heartbeatIntervalMs must be lower than leaseDurationMs");
1467
+ }
1468
+ if (this.maxConsecutiveHeartbeatFailures < 1) {
1469
+ throw new RangeError("maxConsecutiveHeartbeatFailures must be at least 1");
1470
+ }
1471
+ }
1472
+ async run(taskId) {
1473
+ let task = await this.repository.findById(taskId);
1474
+ if (!task) throw new DurableTaskNotFoundError(taskId);
1475
+ if (isTerminalTaskStatus(task.status)) return terminalResult(task);
1476
+ if (task.status === "RETRYING") {
1477
+ const queued = await this.repository.transition({
1478
+ id: task.id,
1479
+ from: "RETRYING",
1480
+ to: "QUEUED",
1481
+ now: this.now()
1482
+ });
1483
+ if (!queued) {
1484
+ const latest = await this.repository.findById(task.id);
1485
+ if (latest && isTerminalTaskStatus(latest.status)) return terminalResult(latest);
1486
+ throw new DurableTaskNotClaimableError(task.id, latest?.status ?? task.status);
1487
+ }
1488
+ task = queued;
1489
+ }
1490
+ if (task.status !== "QUEUED") {
1491
+ throw new DurableTaskNotClaimableError(task.id, task.status);
1492
+ }
1493
+ const handler = this.options.resolveHandler(task);
1494
+ if (!handler) throw new UnsupportedDurableTaskKindError(task.id, task.kind);
1495
+ const leaseToken = this.createLeaseToken();
1496
+ const claimedAt = this.now();
1497
+ const claimed = await this.repository.claim({
1498
+ id: task.id,
1499
+ workerId: this.workerId,
1500
+ leaseToken,
1501
+ leaseExpiresAt: this.leaseExpiry(claimedAt),
1502
+ now: claimedAt
1503
+ });
1504
+ if (!claimed) throw new DurableTaskNotClaimableError(task.id, task.status);
1505
+ const controller = new AbortController();
1506
+ let abortNotified = false;
1507
+ let heartbeatFailures = 0;
1508
+ let heartbeatInFlight = false;
1509
+ const abortAttempt = /* @__PURE__ */ __name(async (operation) => {
1510
+ if (controller.signal.aborted) return;
1511
+ const reason = new DurableTaskLeaseLostError(claimed.id, operation);
1512
+ controller.abort(reason);
1513
+ if (abortNotified) return;
1514
+ abortNotified = true;
1515
+ try {
1516
+ await this.options.onAbort?.(claimed, reason);
1517
+ } catch {
1518
+ this.options.logger?.warn?.("durable_task_abort_callback_failed", {
1519
+ taskId: claimed.id,
1520
+ kind: claimed.kind
1521
+ });
1522
+ }
1523
+ }, "abortAttempt");
1524
+ const renewLease = /* @__PURE__ */ __name(async () => {
1525
+ const now = this.now();
1526
+ return this.repository.heartbeat({
1527
+ id: claimed.id,
1528
+ leaseToken,
1529
+ leaseExpiresAt: this.leaseExpiry(now),
1530
+ now
1531
+ });
1532
+ }, "renewLease");
1533
+ const heartbeat = /* @__PURE__ */ __name(async () => {
1534
+ if (heartbeatInFlight || controller.signal.aborted) return;
1535
+ heartbeatInFlight = true;
1536
+ try {
1537
+ const renewed = await renewLease();
1538
+ if (!renewed) {
1539
+ this.options.logger?.error?.("durable_task_lease_lost", {
1540
+ taskId: claimed.id,
1541
+ kind: claimed.kind
1542
+ });
1543
+ await abortAttempt("heartbeat");
1544
+ return;
1545
+ }
1546
+ heartbeatFailures = 0;
1547
+ } catch {
1548
+ heartbeatFailures += 1;
1549
+ this.options.logger?.warn?.("durable_task_heartbeat_failed", {
1550
+ taskId: claimed.id,
1551
+ kind: claimed.kind,
1552
+ consecutiveFailures: heartbeatFailures
1553
+ });
1554
+ if (heartbeatFailures >= this.maxConsecutiveHeartbeatFailures) {
1555
+ await abortAttempt("heartbeat");
1556
+ }
1557
+ } finally {
1558
+ heartbeatInFlight = false;
1559
+ }
1560
+ }, "heartbeat");
1561
+ const context = {
1562
+ task: claimed,
1563
+ leaseToken,
1564
+ signal: controller.signal,
1565
+ now: this.now,
1566
+ assertActive: /* @__PURE__ */ __name(async () => {
1567
+ let active;
1568
+ try {
1569
+ active = await renewLease();
1570
+ } catch (error) {
1571
+ await abortAttempt("active check");
1572
+ throw error;
1573
+ }
1574
+ if (active) return;
1575
+ await abortAttempt("active check");
1576
+ throw new DurableTaskLeaseLostError(claimed.id, "active check");
1577
+ }, "assertActive"),
1578
+ reportProgress: /* @__PURE__ */ __name(async (stage, progress) => {
1579
+ const updated = await this.repository.reportProgress({
1580
+ id: claimed.id,
1581
+ leaseToken,
1582
+ stage,
1583
+ progress,
1584
+ now: this.now()
1585
+ });
1586
+ if (updated) return updated;
1587
+ await abortAttempt("progress update");
1588
+ throw new DurableTaskLeaseLostError(claimed.id, "progress update");
1589
+ }, "reportProgress")
1590
+ };
1591
+ const heartbeatHandle = this.heartbeatScheduler.start(heartbeat, this.heartbeatIntervalMs);
1592
+ try {
1593
+ const result = await handler.execute(context);
1594
+ if (controller.signal.aborted) {
1595
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new DurableTaskLeaseLostError(claimed.id, "completion");
1596
+ }
1597
+ const completed = handler.complete ? await handler.complete(context, result) : Boolean(await this.repository.transition({
1598
+ id: claimed.id,
1599
+ from: "PROCESSING",
1600
+ to: "COMPLETED",
1601
+ leaseToken,
1602
+ result,
1603
+ now: this.now()
1604
+ }));
1605
+ if (!completed) return this.resolveRejectedCommit(claimed, "completion");
1606
+ return {
1607
+ taskId: claimed.id,
1608
+ kind: claimed.kind,
1609
+ status: "COMPLETED"
1610
+ };
1611
+ } catch (executionError) {
1612
+ const latest = await this.findLatestBestEffort(claimed);
1613
+ if (latest && isTerminalTaskStatus(latest.status)) {
1614
+ if (latest.status === "CANCELLED") await handler.onCancelled?.(context);
1615
+ return terminalResult(latest);
1616
+ }
1617
+ if (executionError instanceof DurableTaskRetryScheduledError) throw executionError;
1618
+ if (controller.signal.aborted) {
1619
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new DurableTaskLeaseLostError(claimed.id, "execution");
1620
+ }
1621
+ if (executionError instanceof DurableTaskLeaseLostError) throw executionError;
1622
+ const taskError2 = this.options.classifyError(executionError, claimed);
1623
+ const decision = this.retryPolicy.decide({
1624
+ error: taskError2,
1625
+ attempt: claimed.attempt,
1626
+ maxAttempts: claimed.maxAttempts,
1627
+ retryAfterMs: this.options.retryAfterMs?.(executionError, claimed)
1628
+ });
1629
+ if (decision.retry) {
1630
+ const retryAt = new Date(this.now().getTime() + decision.delayMs);
1631
+ const retried = handler.retry ? await handler.retry(context, taskError2, retryAt) : await this.retry(context, taskError2, retryAt);
1632
+ if (!retried) return this.resolveRejectedCommit(claimed, "retry");
1633
+ throw new DurableTaskRetryScheduledError(claimed.id, taskError2, retryAt);
1634
+ }
1635
+ const failed = handler.fail ? await handler.fail(context, taskError2) : Boolean(await this.repository.transition({
1636
+ id: claimed.id,
1637
+ from: "PROCESSING",
1638
+ to: "FAILED",
1639
+ leaseToken,
1640
+ error: taskError2,
1641
+ now: this.now()
1642
+ }));
1643
+ if (!failed) return this.resolveRejectedCommit(claimed, "failure");
1644
+ return {
1645
+ taskId: claimed.id,
1646
+ kind: claimed.kind,
1647
+ status: "FAILED"
1648
+ };
1649
+ } finally {
1650
+ this.heartbeatScheduler.stop(heartbeatHandle);
1651
+ try {
1652
+ await handler.cleanup?.(context);
1653
+ } catch {
1654
+ this.options.logger?.warn?.("durable_task_cleanup_failed", {
1655
+ taskId: claimed.id,
1656
+ kind: claimed.kind
1657
+ });
1658
+ }
1659
+ }
1660
+ }
1661
+ leaseExpiry(now) {
1662
+ return new Date(now.getTime() + this.leaseDurationMs);
1663
+ }
1664
+ async retry(context, error, retryAt) {
1665
+ const retrying = await this.repository.transition({
1666
+ id: context.task.id,
1667
+ from: "PROCESSING",
1668
+ to: "RETRYING",
1669
+ leaseToken: context.leaseToken,
1670
+ error,
1671
+ availableAt: retryAt,
1672
+ now: this.now()
1673
+ });
1674
+ if (!retrying) return false;
1675
+ return Boolean(await this.repository.transition({
1676
+ id: context.task.id,
1677
+ from: "RETRYING",
1678
+ to: "QUEUED",
1679
+ error,
1680
+ availableAt: retryAt,
1681
+ now: this.now()
1682
+ }));
1683
+ }
1684
+ async resolveRejectedCommit(claimed, operation) {
1685
+ const latest = await this.repository.findById(claimed.id);
1686
+ if (latest && isTerminalTaskStatus(latest.status)) return terminalResult(latest);
1687
+ throw new DurableTaskLeaseLostError(claimed.id, operation);
1688
+ }
1689
+ async findLatestBestEffort(claimed) {
1690
+ try {
1691
+ return await this.repository.findById(claimed.id);
1692
+ } catch {
1693
+ this.options.logger?.warn?.("durable_task_status_refresh_failed", {
1694
+ taskId: claimed.id,
1695
+ kind: claimed.kind
1696
+ });
1697
+ return null;
1698
+ }
1699
+ }
1700
+ };
1701
+
1702
+ // src/in-memory-task-outbox.ts
1703
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
1704
+ function cloneRecord(record) {
1705
+ return {
1706
+ ...record,
1707
+ availableAt: new Date(record.availableAt),
1708
+ createdAt: new Date(record.createdAt),
1709
+ updatedAt: new Date(record.updatedAt),
1710
+ leaseExpiresAt: record.leaseExpiresAt ? new Date(record.leaseExpiresAt) : void 0,
1711
+ publishedAt: record.publishedAt ? new Date(record.publishedAt) : void 0
1712
+ };
1713
+ }
1714
+ __name(cloneRecord, "cloneRecord");
1715
+ var InMemoryTaskOutboxRepository = class {
1716
+ static {
1717
+ __name(this, "InMemoryTaskOutboxRepository");
1718
+ }
1719
+ records = /* @__PURE__ */ new Map();
1720
+ async append(input) {
1721
+ const existing = this.records.get(input.id);
1722
+ if (existing) {
1723
+ if (existing.taskId !== input.taskId || existing.topic !== input.topic || !isDeepStrictEqual2(existing.payload, input.payload)) {
1724
+ throw new TaskOutboxConflictError(input.id);
1725
+ }
1726
+ return {
1727
+ record: cloneRecord(existing),
1728
+ created: false
1729
+ };
1730
+ }
1731
+ const record = {
1732
+ id: input.id,
1733
+ taskId: input.taskId,
1734
+ topic: input.topic,
1735
+ payload: input.payload,
1736
+ status: "PENDING",
1737
+ attempt: 0,
1738
+ availableAt: new Date(input.availableAt ?? input.now),
1739
+ createdAt: new Date(input.now),
1740
+ updatedAt: new Date(input.now)
1741
+ };
1742
+ this.records.set(record.id, record);
1743
+ return {
1744
+ record: cloneRecord(record),
1745
+ created: true
1746
+ };
1747
+ }
1748
+ async claimBatch(input) {
1749
+ if (input.limit < 1) return [];
1750
+ if (input.leaseExpiresAt.getTime() <= input.now.getTime()) return [];
1751
+ const claimable = [
1752
+ ...this.records.values()
1753
+ ].filter((record) => {
1754
+ if (record.availableAt.getTime() > input.now.getTime()) return false;
1755
+ if (record.status === "PENDING") return true;
1756
+ return record.status === "PUBLISHING" && Boolean(record.leaseExpiresAt && record.leaseExpiresAt.getTime() <= input.now.getTime());
1757
+ }).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, input.limit);
1758
+ return claimable.map((record) => {
1759
+ record.status = "PUBLISHING";
1760
+ record.workerId = input.workerId;
1761
+ record.leaseToken = input.leaseToken;
1762
+ record.leaseExpiresAt = new Date(input.leaseExpiresAt);
1763
+ record.attempt += 1;
1764
+ record.updatedAt = new Date(input.now);
1765
+ return cloneRecord(record);
1766
+ });
1767
+ }
1768
+ async markPublished(id, leaseToken, now) {
1769
+ const record = this.records.get(id);
1770
+ if (!record || record.status !== "PUBLISHING" || record.leaseToken !== leaseToken || !record.leaseExpiresAt || record.leaseExpiresAt.getTime() <= now.getTime()) return false;
1771
+ record.status = "PUBLISHED";
1772
+ record.workerId = void 0;
1773
+ record.leaseToken = void 0;
1774
+ record.leaseExpiresAt = void 0;
1775
+ record.publishedAt = new Date(now);
1776
+ record.updatedAt = new Date(now);
1777
+ record.lastError = void 0;
1778
+ return true;
1779
+ }
1780
+ async reschedule(id, leaseToken, availableAt, errorSummary, now) {
1781
+ const record = this.records.get(id);
1782
+ if (!record || record.status !== "PUBLISHING" || record.leaseToken !== leaseToken || !record.leaseExpiresAt || record.leaseExpiresAt.getTime() <= now.getTime()) return false;
1783
+ record.status = "PENDING";
1784
+ record.workerId = void 0;
1785
+ record.leaseToken = void 0;
1786
+ record.leaseExpiresAt = void 0;
1787
+ record.availableAt = new Date(availableAt);
1788
+ record.updatedAt = new Date(now);
1789
+ record.lastError = errorSummary;
1790
+ return true;
1791
+ }
1792
+ };
1793
+
1794
+ // src/task-outbox-dispatcher.ts
1795
+ var TaskOutboxDispatcher = class {
1796
+ static {
1797
+ __name(this, "TaskOutboxDispatcher");
1798
+ }
1799
+ repository;
1800
+ publisher;
1801
+ options;
1802
+ batchSize;
1803
+ leaseDurationMs;
1804
+ retryDelayMs;
1805
+ now;
1806
+ constructor(repository, publisher, options) {
1807
+ this.repository = repository;
1808
+ this.publisher = publisher;
1809
+ this.options = options;
1810
+ this.batchSize = options.batchSize ?? 100;
1811
+ this.leaseDurationMs = options.leaseDurationMs ?? 3e4;
1812
+ this.retryDelayMs = options.retryDelayMs ?? ((attempt) => Math.min(6e4, 1e3 * 2 ** (attempt - 1)));
1813
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1814
+ if (!options.workerId) throw new Error("workerId is required");
1815
+ if (this.batchSize < 1) throw new RangeError("batchSize must be at least 1");
1816
+ if (this.leaseDurationMs < 1) throw new RangeError("leaseDurationMs must be at least 1");
1817
+ }
1818
+ async runOnce() {
1819
+ const claimedAt = this.now();
1820
+ const leaseToken = this.options.createLeaseToken();
1821
+ const messages = await this.repository.claimBatch({
1822
+ workerId: this.options.workerId,
1823
+ leaseToken,
1824
+ leaseExpiresAt: new Date(claimedAt.getTime() + this.leaseDurationMs),
1825
+ limit: this.batchSize,
1826
+ now: claimedAt
1827
+ });
1828
+ const result = {
1829
+ claimed: messages.length,
1830
+ published: 0,
1831
+ rescheduled: 0,
1832
+ conflicts: 0
1833
+ };
1834
+ for (const message of messages) {
1835
+ try {
1836
+ await this.publisher.publish(message);
1837
+ const marked = await this.repository.markPublished(message.id, leaseToken, this.now());
1838
+ if (marked) result.published += 1;
1839
+ else result.conflicts += 1;
1840
+ } catch (error) {
1841
+ const now = this.now();
1842
+ const delayMs = Math.max(0, this.retryDelayMs(message.attempt));
1843
+ const summary = error instanceof Error ? error.message : String(error);
1844
+ const rescheduled = await this.repository.reschedule(message.id, leaseToken, new Date(now.getTime() + delayMs), summary.slice(0, 280), now);
1845
+ if (rescheduled) result.rescheduled += 1;
1846
+ else result.conflicts += 1;
1847
+ }
1848
+ }
1849
+ return result;
1850
+ }
1851
+ };
1852
+ export {
1853
+ DurableTaskLeaseLostError,
1854
+ DurableTaskNotClaimableError,
1855
+ DurableTaskNotFoundError,
1856
+ DurableTaskRetryScheduledError,
1857
+ DurableWorkerRuntime,
1858
+ ExponentialTaskRetryPolicy,
1859
+ InMemoryTaskOutboxRepository,
1860
+ InMemoryTaskRepository,
1861
+ InvalidTaskTransitionError,
1862
+ PostgresTaskOutboxRepository,
1863
+ PostgresTaskRedriver,
1864
+ PostgresTaskRepository,
1865
+ PostgresTaskSubmitter,
1866
+ PostgresTaskSummaryReader,
1867
+ PostgresTaskTimelineReader,
1868
+ TASK_STATUSES,
1869
+ TASK_TIMELINE_EVENT_TYPES,
1870
+ TaskIdempotencyConflictError,
1871
+ TaskLeaseReconciler,
1872
+ TaskOutboxConflictError,
1873
+ TaskOutboxDispatcher,
1874
+ TaskQueueReconciler,
1875
+ UnsupportedDurableTaskKindError,
1876
+ assertTaskTransition,
1877
+ canTransitionTask,
1878
+ createTaskFingerprint,
1879
+ isTerminalTaskStatus,
1880
+ requiresTaskLease
1881
+ };