@coji/durably 0.12.0 → 0.14.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.js CHANGED
@@ -1,9 +1,254 @@
1
1
  import {
2
2
  withLogPersistence
3
- } from "./chunk-UCUP6NMJ.js";
3
+ } from "./chunk-L42OCQEV.js";
4
4
 
5
5
  // src/durably.ts
6
6
  import { Kysely } from "kysely";
7
+ import { monotonicFactory as monotonicFactory2 } from "ulidx";
8
+
9
+ // src/errors.ts
10
+ var CancelledError = class extends Error {
11
+ constructor(runId) {
12
+ super(`Run was cancelled: ${runId}`);
13
+ this.name = "CancelledError";
14
+ }
15
+ };
16
+ var LeaseLostError = class extends Error {
17
+ constructor(runId) {
18
+ super(`Lease ownership was lost: ${runId}`);
19
+ this.name = "LeaseLostError";
20
+ }
21
+ };
22
+ var DurablyError = class extends Error {
23
+ statusCode;
24
+ constructor(message, statusCode) {
25
+ super(message);
26
+ this.name = "DurablyError";
27
+ this.statusCode = statusCode;
28
+ }
29
+ };
30
+ var NotFoundError = class extends DurablyError {
31
+ constructor(message) {
32
+ super(message, 404);
33
+ this.name = "NotFoundError";
34
+ }
35
+ };
36
+ var ValidationError = class extends DurablyError {
37
+ constructor(message) {
38
+ super(message, 400);
39
+ this.name = "ValidationError";
40
+ }
41
+ };
42
+ var ConflictError = class extends DurablyError {
43
+ constructor(message) {
44
+ super(message, 409);
45
+ this.name = "ConflictError";
46
+ }
47
+ };
48
+ function getErrorMessage(error) {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
51
+ function toError(error) {
52
+ return error instanceof Error ? error : new Error(String(error));
53
+ }
54
+
55
+ // src/context.ts
56
+ var LEASE_LOST = "lease-lost";
57
+ function createStepContext(run, jobName, leaseGeneration, storage, eventEmitter) {
58
+ let stepIndex = run.currentStepIndex;
59
+ let currentStepName = null;
60
+ const controller = new AbortController();
61
+ function abortForLeaseLoss() {
62
+ if (!controller.signal.aborted) {
63
+ controller.abort(LEASE_LOST);
64
+ }
65
+ }
66
+ function throwIfAborted() {
67
+ if (!controller.signal.aborted) {
68
+ return;
69
+ }
70
+ if (controller.signal.reason === LEASE_LOST) {
71
+ throw new LeaseLostError(run.id);
72
+ }
73
+ throw new CancelledError(run.id);
74
+ }
75
+ async function throwForRefusedStep(stepName, stepIndex2) {
76
+ const latestRun = await storage.getRun(run.id);
77
+ if (latestRun?.status === "cancelled") {
78
+ eventEmitter.emit({
79
+ type: "step:cancel",
80
+ runId: run.id,
81
+ jobName,
82
+ stepName,
83
+ stepIndex: stepIndex2,
84
+ labels: run.labels
85
+ });
86
+ throw new CancelledError(run.id);
87
+ }
88
+ abortForLeaseLoss();
89
+ throw new LeaseLostError(run.id);
90
+ }
91
+ const unsubscribe = eventEmitter.on("run:cancel", (event) => {
92
+ if (event.runId === run.id) {
93
+ controller.abort();
94
+ }
95
+ });
96
+ const step = {
97
+ get runId() {
98
+ return run.id;
99
+ },
100
+ get signal() {
101
+ return controller.signal;
102
+ },
103
+ isAborted() {
104
+ return controller.signal.aborted;
105
+ },
106
+ throwIfAborted() {
107
+ throwIfAborted();
108
+ },
109
+ async run(name, fn) {
110
+ throwIfAborted();
111
+ const currentRun = await storage.getRun(run.id);
112
+ if (currentRun?.status === "cancelled") {
113
+ controller.abort();
114
+ throwIfAborted();
115
+ }
116
+ if (currentRun && (currentRun.status === "leased" && currentRun.leaseGeneration !== leaseGeneration || currentRun.status === "completed" || currentRun.status === "failed")) {
117
+ abortForLeaseLoss();
118
+ throwIfAborted();
119
+ }
120
+ throwIfAborted();
121
+ const existingStep = await storage.getCompletedStep(run.id, name);
122
+ if (existingStep) {
123
+ stepIndex++;
124
+ return existingStep.output;
125
+ }
126
+ currentStepName = name;
127
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
128
+ const startTime = Date.now();
129
+ eventEmitter.emit({
130
+ type: "step:start",
131
+ runId: run.id,
132
+ jobName,
133
+ stepName: name,
134
+ stepIndex,
135
+ labels: run.labels
136
+ });
137
+ try {
138
+ const result = await fn(controller.signal);
139
+ throwIfAborted();
140
+ const savedStep = await storage.persistStep(run.id, leaseGeneration, {
141
+ name,
142
+ index: stepIndex,
143
+ status: "completed",
144
+ output: result,
145
+ startedAt
146
+ });
147
+ if (!savedStep) {
148
+ await throwForRefusedStep(name, stepIndex);
149
+ }
150
+ stepIndex++;
151
+ eventEmitter.emit({
152
+ type: "step:complete",
153
+ runId: run.id,
154
+ jobName,
155
+ stepName: name,
156
+ stepIndex: stepIndex - 1,
157
+ output: result,
158
+ duration: Date.now() - startTime,
159
+ labels: run.labels
160
+ });
161
+ return result;
162
+ } catch (error) {
163
+ if (error instanceof LeaseLostError) {
164
+ throw error;
165
+ }
166
+ const isLeaseLost = controller.signal.aborted && controller.signal.reason === LEASE_LOST;
167
+ if (isLeaseLost) {
168
+ throw new LeaseLostError(run.id);
169
+ }
170
+ const isCancelled = controller.signal.aborted;
171
+ const errorMessage = getErrorMessage(error);
172
+ const savedStep = await storage.persistStep(run.id, leaseGeneration, {
173
+ name,
174
+ index: stepIndex,
175
+ status: isCancelled ? "cancelled" : "failed",
176
+ error: errorMessage,
177
+ startedAt
178
+ });
179
+ if (!savedStep) {
180
+ await throwForRefusedStep(name, stepIndex);
181
+ }
182
+ eventEmitter.emit({
183
+ type: "step:fail",
184
+ error: errorMessage,
185
+ runId: run.id,
186
+ jobName,
187
+ stepName: name,
188
+ stepIndex,
189
+ labels: run.labels
190
+ });
191
+ throw error;
192
+ } finally {
193
+ currentStepName = null;
194
+ }
195
+ },
196
+ progress(current, total, message) {
197
+ const progressData = { current, total, message };
198
+ storage.updateProgress(run.id, leaseGeneration, progressData);
199
+ eventEmitter.emit({
200
+ type: "run:progress",
201
+ runId: run.id,
202
+ jobName,
203
+ progress: progressData,
204
+ labels: run.labels
205
+ });
206
+ },
207
+ log: {
208
+ info(message, data) {
209
+ eventEmitter.emit({
210
+ type: "log:write",
211
+ runId: run.id,
212
+ jobName,
213
+ labels: run.labels,
214
+ stepName: currentStepName,
215
+ level: "info",
216
+ message,
217
+ data
218
+ });
219
+ },
220
+ warn(message, data) {
221
+ eventEmitter.emit({
222
+ type: "log:write",
223
+ runId: run.id,
224
+ jobName,
225
+ labels: run.labels,
226
+ stepName: currentStepName,
227
+ level: "warn",
228
+ message,
229
+ data
230
+ });
231
+ },
232
+ error(message, data) {
233
+ eventEmitter.emit({
234
+ type: "log:write",
235
+ runId: run.id,
236
+ jobName,
237
+ labels: run.labels,
238
+ stepName: currentStepName,
239
+ level: "error",
240
+ message,
241
+ data
242
+ });
243
+ }
244
+ }
245
+ };
246
+ return {
247
+ step,
248
+ abortLeaseOwnership: abortForLeaseLoss,
249
+ dispose: unsubscribe
250
+ };
251
+ }
7
252
 
8
253
  // src/events.ts
9
254
  function createEventEmitter() {
@@ -35,16 +280,16 @@ function createEventEmitter() {
35
280
  if (!typeListeners) {
36
281
  return;
37
282
  }
283
+ const reportError = (error) => errorHandler?.(toError(error), fullEvent);
38
284
  for (const listener of typeListeners) {
39
285
  try {
40
- listener(fullEvent);
41
- } catch (error) {
42
- if (errorHandler) {
43
- errorHandler(
44
- error instanceof Error ? error : new Error(String(error)),
45
- fullEvent
46
- );
286
+ const result = listener(fullEvent);
287
+ if (result != null && typeof result.then === "function") {
288
+ ;
289
+ result.catch(reportError);
47
290
  }
291
+ } catch (error) {
292
+ reportError(error);
48
293
  }
49
294
  }
50
295
  }
@@ -59,7 +304,9 @@ function validateJobInputOrThrow(schema, input, context) {
59
304
  const result = schema.safeParse(input);
60
305
  if (!result.success) {
61
306
  const prefix = context ? `${context}: ` : "";
62
- throw new Error(`${prefix}Invalid input: ${prettifyError(result.error)}`);
307
+ throw new ValidationError(
308
+ `${prefix}Invalid input: ${prettifyError(result.error)}`
309
+ );
63
310
  }
64
311
  return result.data;
65
312
  }
@@ -96,7 +343,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
96
343
  if (labelsSchema && options?.labels) {
97
344
  validateJobInputOrThrow(labelsSchema, options.labels, "labels");
98
345
  }
99
- const run = await storage.createRun({
346
+ const run = await storage.enqueue({
100
347
  jobName: jobDef.name,
101
348
  input: validatedInput,
102
349
  idempotencyKey: options?.idempotencyKey,
@@ -183,7 +430,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
183
430
  }).catch((error) => {
184
431
  if (resolved) return;
185
432
  cleanup();
186
- reject(error instanceof Error ? error : new Error(String(error)));
433
+ reject(toError(error));
187
434
  });
188
435
  if (options?.timeout !== void 0) {
189
436
  timeoutId = setTimeout(() => {
@@ -226,7 +473,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
226
473
  options: normalized[i].options
227
474
  });
228
475
  }
229
- const runs = await storage.batchCreateRuns(
476
+ const runs = await storage.enqueueMany(
230
477
  validated.map((v) => ({
231
478
  jobName: jobDef.name,
232
479
  input: v.input,
@@ -274,6 +521,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
274
521
  }
275
522
 
276
523
  // src/migrations.ts
524
+ import { sql } from "kysely";
277
525
  var migrations = [
278
526
  {
279
527
  version: 1,
@@ -282,12 +530,30 @@ var migrations = [
282
530
  "current_step_index",
283
531
  "integer",
284
532
  (col) => col.notNull().defaultTo(0)
285
- ).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("heartbeat_at", "text", (col) => col.notNull()).addColumn("started_at", "text").addColumn("completed_at", "text").addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
533
+ ).addColumn(
534
+ "completed_step_count",
535
+ "integer",
536
+ (col) => col.notNull().defaultTo(0)
537
+ ).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("lease_owner", "text").addColumn("lease_expires_at", "text").addColumn(
538
+ "lease_generation",
539
+ "integer",
540
+ (col) => col.notNull().defaultTo(0)
541
+ ).addColumn("started_at", "text").addColumn("completed_at", "text").addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
286
542
  await db.schema.createIndex("idx_durably_runs_job_idempotency").ifNotExists().on("durably_runs").columns(["job_name", "idempotency_key"]).unique().execute();
287
543
  await db.schema.createIndex("idx_durably_runs_status_concurrency").ifNotExists().on("durably_runs").columns(["status", "concurrency_key"]).execute();
288
544
  await db.schema.createIndex("idx_durably_runs_status_created").ifNotExists().on("durably_runs").columns(["status", "created_at"]).execute();
545
+ await db.schema.createIndex("idx_durably_runs_status_lease_expires").ifNotExists().on("durably_runs").columns(["status", "lease_expires_at"]).execute();
546
+ await db.schema.createIndex("idx_durably_runs_job_created").ifNotExists().on("durably_runs").columns(["job_name", "created_at"]).execute();
547
+ await db.schema.createIndex("idx_durably_runs_status_completed").ifNotExists().on("durably_runs").columns(["status", "completed_at"]).execute();
548
+ await db.schema.createTable("durably_run_labels").ifNotExists().addColumn("run_id", "text", (col) => col.notNull()).addColumn("key", "text", (col) => col.notNull()).addColumn("value", "text", (col) => col.notNull()).execute();
549
+ await db.schema.createIndex("idx_durably_run_labels_pk").ifNotExists().on("durably_run_labels").columns(["run_id", "key"]).unique().execute();
550
+ await db.schema.createIndex("idx_durably_run_labels_key_value").ifNotExists().on("durably_run_labels").columns(["key", "value"]).execute();
289
551
  await db.schema.createTable("durably_steps").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("run_id", "text", (col) => col.notNull()).addColumn("name", "text", (col) => col.notNull()).addColumn("index", "integer", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull()).addColumn("output", "text").addColumn("error", "text").addColumn("started_at", "text", (col) => col.notNull()).addColumn("completed_at", "text").execute();
290
552
  await db.schema.createIndex("idx_durably_steps_run_index").ifNotExists().on("durably_steps").columns(["run_id", "index"]).execute();
553
+ await sql`
554
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_durably_steps_completed_unique
555
+ ON durably_steps(run_id, name) WHERE status = 'completed'
556
+ `.execute(db);
291
557
  await db.schema.createTable("durably_logs").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("run_id", "text", (col) => col.notNull()).addColumn("step_name", "text").addColumn("level", "text", (col) => col.notNull()).addColumn("message", "text", (col) => col.notNull()).addColumn("data", "text").addColumn("created_at", "text", (col) => col.notNull()).execute();
292
558
  await db.schema.createIndex("idx_durably_logs_run_created").ifNotExists().on("durably_logs").columns(["run_id", "created_at"]).execute();
293
559
  await db.schema.createTable("durably_schema_versions").ifNotExists().addColumn("version", "integer", (col) => col.primaryKey()).addColumn("applied_at", "text", (col) => col.notNull()).execute();
@@ -318,30 +584,13 @@ async function runMigrations(db) {
318
584
  }
319
585
 
320
586
  // src/storage.ts
321
- import { sql } from "kysely";
587
+ import { sql as sql4 } from "kysely";
322
588
  import { monotonicFactory } from "ulidx";
323
- var ulid = monotonicFactory();
324
- function toClientRun(run) {
325
- const {
326
- idempotencyKey,
327
- concurrencyKey,
328
- heartbeatAt,
329
- updatedAt,
330
- ...clientRun
331
- } = run;
332
- return clientRun;
333
- }
334
- var LABEL_KEY_PATTERN = /^[a-zA-Z0-9\-_./]+$/;
335
- function validateLabels(labels) {
336
- if (!labels) return;
337
- for (const key of Object.keys(labels)) {
338
- if (!LABEL_KEY_PATTERN.test(key)) {
339
- throw new Error(
340
- `Invalid label key "${key}": must contain only alphanumeric characters, dashes, underscores, dots, and slashes`
341
- );
342
- }
343
- }
344
- }
589
+
590
+ // src/claim-postgres.ts
591
+ import { sql as sql2 } from "kysely";
592
+
593
+ // src/transformers.ts
345
594
  function rowToRun(row) {
346
595
  return {
347
596
  id: row.id,
@@ -351,12 +600,14 @@ function rowToRun(row) {
351
600
  idempotencyKey: row.idempotency_key,
352
601
  concurrencyKey: row.concurrency_key,
353
602
  currentStepIndex: row.current_step_index,
354
- stepCount: Number(row.step_count ?? 0),
603
+ completedStepCount: row.completed_step_count,
355
604
  progress: row.progress ? JSON.parse(row.progress) : null,
356
605
  output: row.output ? JSON.parse(row.output) : null,
357
606
  error: row.error,
358
607
  labels: JSON.parse(row.labels),
359
- heartbeatAt: row.heartbeat_at,
608
+ leaseOwner: row.lease_owner,
609
+ leaseExpiresAt: row.lease_expires_at,
610
+ leaseGeneration: row.lease_generation,
360
611
  startedAt: row.started_at,
361
612
  completedAt: row.completed_at,
362
613
  createdAt: row.created_at,
@@ -387,9 +638,166 @@ function rowToLog(row) {
387
638
  createdAt: row.created_at
388
639
  };
389
640
  }
390
- function createKyselyStorage(db) {
391
- return {
392
- async createRun(input) {
641
+ var LABEL_KEY_PATTERN = /^[a-zA-Z0-9\-_./]+$/;
642
+ function validateLabels(labels) {
643
+ if (!labels) return;
644
+ for (const key of Object.keys(labels)) {
645
+ if (!LABEL_KEY_PATTERN.test(key)) {
646
+ throw new Error(
647
+ `Invalid label key "${key}": must contain only alphanumeric characters, dashes, underscores, dots, and slashes`
648
+ );
649
+ }
650
+ }
651
+ }
652
+
653
+ // src/claim-postgres.ts
654
+ async function claimNextPostgres(db, workerId, now, leaseExpiresAt, activeLeaseGuard) {
655
+ return await db.transaction().execute(async (trx) => {
656
+ const skipKeys = [];
657
+ for (; ; ) {
658
+ const concurrencyCondition = skipKeys.length > 0 ? sql2`
659
+ AND (
660
+ concurrency_key IS NULL
661
+ OR concurrency_key NOT IN (${sql2.join(skipKeys)})
662
+ )
663
+ ` : sql2``;
664
+ const candidateResult = await sql2`
665
+ SELECT id, concurrency_key
666
+ FROM durably_runs
667
+ WHERE
668
+ (
669
+ status = 'pending'
670
+ OR (status = 'leased' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ${now})
671
+ )
672
+ AND ${activeLeaseGuard}
673
+ ${concurrencyCondition}
674
+ ORDER BY created_at ASC, id ASC
675
+ FOR UPDATE SKIP LOCKED
676
+ LIMIT 1
677
+ `.execute(trx);
678
+ const candidate = candidateResult.rows[0];
679
+ if (!candidate) return null;
680
+ if (candidate.concurrency_key) {
681
+ await sql2`SELECT pg_advisory_xact_lock(hashtext(${candidate.concurrency_key}))`.execute(
682
+ trx
683
+ );
684
+ const conflict = await sql2`
685
+ SELECT 1 FROM durably_runs
686
+ WHERE concurrency_key = ${candidate.concurrency_key}
687
+ AND id <> ${candidate.id}
688
+ AND status = 'leased'
689
+ AND lease_expires_at IS NOT NULL
690
+ AND lease_expires_at > ${now}
691
+ LIMIT 1
692
+ `.execute(trx);
693
+ if (conflict.rows.length > 0) {
694
+ skipKeys.push(candidate.concurrency_key);
695
+ continue;
696
+ }
697
+ }
698
+ const result = await sql2`
699
+ UPDATE durably_runs
700
+ SET
701
+ status = 'leased',
702
+ lease_owner = ${workerId},
703
+ lease_expires_at = ${leaseExpiresAt},
704
+ lease_generation = lease_generation + 1,
705
+ started_at = COALESCE(started_at, ${now}),
706
+ updated_at = ${now}
707
+ WHERE id = ${candidate.id}
708
+ RETURNING *
709
+ `.execute(trx);
710
+ const row = result.rows[0];
711
+ if (!row) return null;
712
+ return rowToRun(row);
713
+ }
714
+ });
715
+ }
716
+
717
+ // src/claim-sqlite.ts
718
+ import { sql as sql3 } from "kysely";
719
+ async function claimNextSqlite(db, workerId, now, leaseExpiresAt, activeLeaseGuard) {
720
+ const subquery = db.selectFrom("durably_runs").select("durably_runs.id").where(
721
+ (eb) => eb.or([
722
+ eb("status", "=", "pending"),
723
+ eb.and([
724
+ eb("status", "=", "leased"),
725
+ eb("lease_expires_at", "is not", null),
726
+ eb("lease_expires_at", "<=", now)
727
+ ])
728
+ ])
729
+ ).where(activeLeaseGuard).orderBy("created_at", "asc").orderBy("id", "asc").limit(1);
730
+ const row = await db.updateTable("durably_runs").set({
731
+ status: "leased",
732
+ lease_owner: workerId,
733
+ lease_expires_at: leaseExpiresAt,
734
+ lease_generation: sql3`lease_generation + 1`,
735
+ started_at: sql3`COALESCE(started_at, ${now})`,
736
+ updated_at: now
737
+ }).where("id", "=", (eb) => eb.selectFrom(subquery.as("sub")).select("id")).returningAll().executeTakeFirst();
738
+ if (!row) return null;
739
+ return rowToRun(row);
740
+ }
741
+
742
+ // src/storage.ts
743
+ var ulid = monotonicFactory();
744
+ var TERMINAL_STATUSES = ["completed", "failed", "cancelled"];
745
+ function toClientRun(run) {
746
+ const {
747
+ idempotencyKey,
748
+ concurrencyKey,
749
+ leaseOwner,
750
+ leaseExpiresAt,
751
+ leaseGeneration,
752
+ updatedAt,
753
+ ...clientRun
754
+ } = run;
755
+ return clientRun;
756
+ }
757
+ function createWriteMutex() {
758
+ let queue = Promise.resolve();
759
+ return async function withWriteLock(fn) {
760
+ let release;
761
+ const next = new Promise((resolve) => {
762
+ release = resolve;
763
+ });
764
+ const prev = queue;
765
+ queue = next;
766
+ await prev;
767
+ try {
768
+ return await fn();
769
+ } finally {
770
+ release();
771
+ }
772
+ };
773
+ }
774
+ function createKyselyStore(db, backend = "generic") {
775
+ const withWriteLock = createWriteMutex();
776
+ async function cascadeDeleteRuns(trx, ids) {
777
+ if (ids.length === 0) return;
778
+ await trx.deleteFrom("durably_steps").where("run_id", "in", ids).execute();
779
+ await trx.deleteFrom("durably_logs").where("run_id", "in", ids).execute();
780
+ await trx.deleteFrom("durably_run_labels").where("run_id", "in", ids).execute();
781
+ await trx.deleteFrom("durably_runs").where("id", "in", ids).execute();
782
+ }
783
+ async function insertLabelRows(executor, runId, labels) {
784
+ const entries = Object.entries(labels ?? {});
785
+ if (entries.length > 0) {
786
+ await executor.insertInto("durably_run_labels").values(entries.map(([key, value]) => ({ run_id: runId, key, value }))).execute();
787
+ }
788
+ }
789
+ async function terminateRun(runId, leaseGeneration, completedAt, fields) {
790
+ const result = await db.updateTable("durably_runs").set({
791
+ ...fields,
792
+ lease_owner: null,
793
+ lease_expires_at: null,
794
+ completed_at: completedAt,
795
+ updated_at: completedAt
796
+ }).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).executeTakeFirst();
797
+ return Number(result.numUpdatedRows) > 0;
798
+ }
799
+ const store = {
800
+ async enqueue(input) {
393
801
  const now = (/* @__PURE__ */ new Date()).toISOString();
394
802
  if (input.idempotencyKey) {
395
803
  const existing = await db.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
@@ -407,20 +815,26 @@ function createKyselyStorage(db) {
407
815
  idempotency_key: input.idempotencyKey ?? null,
408
816
  concurrency_key: input.concurrencyKey ?? null,
409
817
  current_step_index: 0,
818
+ completed_step_count: 0,
410
819
  progress: null,
411
820
  output: null,
412
821
  error: null,
413
822
  labels: JSON.stringify(input.labels ?? {}),
414
- heartbeat_at: now,
823
+ lease_owner: null,
824
+ lease_expires_at: null,
825
+ lease_generation: 0,
415
826
  started_at: null,
416
827
  completed_at: null,
417
828
  created_at: now,
418
829
  updated_at: now
419
830
  };
420
- await db.insertInto("durably_runs").values(run).execute();
831
+ await db.transaction().execute(async (trx) => {
832
+ await trx.insertInto("durably_runs").values(run).execute();
833
+ await insertLabelRows(trx, id, input.labels);
834
+ });
421
835
  return rowToRun(run);
422
836
  },
423
- async batchCreateRuns(inputs) {
837
+ async enqueueMany(inputs) {
424
838
  if (inputs.length === 0) {
425
839
  return [];
426
840
  }
@@ -430,6 +844,7 @@ function createKyselyStorage(db) {
430
844
  for (const input of inputs) {
431
845
  validateLabels(input.labels);
432
846
  }
847
+ const allLabelRows = [];
433
848
  for (const input of inputs) {
434
849
  if (input.idempotencyKey) {
435
850
  const existing = await trx.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
@@ -439,6 +854,11 @@ function createKyselyStorage(db) {
439
854
  }
440
855
  }
441
856
  const id = ulid();
857
+ if (input.labels) {
858
+ for (const [key, value] of Object.entries(input.labels)) {
859
+ allLabelRows.push({ run_id: id, key, value });
860
+ }
861
+ }
442
862
  runs.push({
443
863
  id,
444
864
  job_name: input.jobName,
@@ -447,11 +867,14 @@ function createKyselyStorage(db) {
447
867
  idempotency_key: input.idempotencyKey ?? null,
448
868
  concurrency_key: input.concurrencyKey ?? null,
449
869
  current_step_index: 0,
870
+ completed_step_count: 0,
450
871
  progress: null,
451
872
  output: null,
452
873
  error: null,
453
874
  labels: JSON.stringify(input.labels ?? {}),
454
- heartbeat_at: now,
875
+ lease_owner: null,
876
+ lease_expires_at: null,
877
+ lease_generation: 0,
455
878
  started_at: null,
456
879
  completed_at: null,
457
880
  created_at: now,
@@ -461,55 +884,29 @@ function createKyselyStorage(db) {
461
884
  const newRuns = runs.filter((r) => r.created_at === now);
462
885
  if (newRuns.length > 0) {
463
886
  await trx.insertInto("durably_runs").values(newRuns).execute();
887
+ if (allLabelRows.length > 0) {
888
+ await trx.insertInto("durably_run_labels").values(allLabelRows).execute();
889
+ }
464
890
  }
465
891
  return runs.map(rowToRun);
466
892
  });
467
893
  },
468
- async updateRun(runId, data) {
469
- const now = (/* @__PURE__ */ new Date()).toISOString();
470
- const updates = {
471
- updated_at: now
472
- };
473
- if (data.status !== void 0) updates.status = data.status;
474
- if (data.currentStepIndex !== void 0)
475
- updates.current_step_index = data.currentStepIndex;
476
- if (data.progress !== void 0)
477
- updates.progress = data.progress ? JSON.stringify(data.progress) : null;
478
- if (data.output !== void 0)
479
- updates.output = JSON.stringify(data.output);
480
- if (data.error !== void 0) updates.error = data.error;
481
- if (data.heartbeatAt !== void 0)
482
- updates.heartbeat_at = data.heartbeatAt;
483
- if (data.startedAt !== void 0) updates.started_at = data.startedAt;
484
- if (data.completedAt !== void 0)
485
- updates.completed_at = data.completedAt;
486
- await db.updateTable("durably_runs").set(updates).where("id", "=", runId).execute();
487
- },
488
- async deleteRun(runId) {
489
- await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
490
- await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
491
- await db.deleteFrom("durably_runs").where("id", "=", runId).execute();
492
- },
493
894
  async getRun(runId) {
494
- const row = await db.selectFrom("durably_runs").leftJoin("durably_steps", "durably_runs.id", "durably_steps.run_id").selectAll("durably_runs").select(
495
- (eb) => eb.fn.count("durably_steps.id").as("step_count")
496
- ).where("durably_runs.id", "=", runId).groupBy("durably_runs.id").executeTakeFirst();
895
+ const row = await db.selectFrom("durably_runs").selectAll().where("id", "=", runId).executeTakeFirst();
497
896
  return row ? rowToRun(row) : null;
498
897
  },
499
898
  async getRuns(filter) {
500
- let query = db.selectFrom("durably_runs").leftJoin("durably_steps", "durably_runs.id", "durably_steps.run_id").selectAll("durably_runs").select(
501
- (eb) => eb.fn.count("durably_steps.id").as("step_count")
502
- ).groupBy("durably_runs.id");
899
+ let query = db.selectFrom("durably_runs").selectAll();
503
900
  if (filter?.status) {
504
- query = query.where("durably_runs.status", "=", filter.status);
901
+ query = query.where("status", "=", filter.status);
505
902
  }
506
903
  if (filter?.jobName) {
507
904
  if (Array.isArray(filter.jobName)) {
508
905
  if (filter.jobName.length > 0) {
509
- query = query.where("durably_runs.job_name", "in", filter.jobName);
906
+ query = query.where("job_name", "in", filter.jobName);
510
907
  }
511
908
  } else {
512
- query = query.where("durably_runs.job_name", "=", filter.jobName);
909
+ query = query.where("job_name", "=", filter.jobName);
513
910
  }
514
911
  }
515
912
  if (filter?.labels) {
@@ -518,13 +915,13 @@ function createKyselyStorage(db) {
518
915
  for (const [key, value] of Object.entries(labels)) {
519
916
  if (value === void 0) continue;
520
917
  query = query.where(
521
- sql`json_extract(durably_runs.labels, ${`$."${key}"`})`,
522
- "=",
523
- value
918
+ (eb) => eb.exists(
919
+ eb.selectFrom("durably_run_labels").select(sql4.lit(1).as("one")).whereRef("durably_run_labels.run_id", "=", "durably_runs.id").where("durably_run_labels.key", "=", key).where("durably_run_labels.value", "=", value)
920
+ )
524
921
  );
525
922
  }
526
923
  }
527
- query = query.orderBy("durably_runs.created_at", "desc");
924
+ query = query.orderBy("created_at", "desc");
528
925
  if (filter?.limit !== void 0) {
529
926
  query = query.limit(filter.limit);
530
927
  }
@@ -537,400 +934,226 @@ function createKyselyStorage(db) {
537
934
  const rows = await query.execute();
538
935
  return rows.map(rowToRun);
539
936
  },
540
- async claimNextPendingRun(excludeConcurrencyKeys) {
937
+ async updateRun(runId, data) {
541
938
  const now = (/* @__PURE__ */ new Date()).toISOString();
542
- let subquery = db.selectFrom("durably_runs").select("id").where("status", "=", "pending").orderBy("created_at", "asc").orderBy("id", "asc").limit(1);
543
- if (excludeConcurrencyKeys.length > 0) {
544
- subquery = subquery.where(
545
- (eb) => eb.or([
546
- eb("concurrency_key", "is", null),
547
- eb("concurrency_key", "not in", excludeConcurrencyKeys)
548
- ])
549
- );
550
- }
551
- const row = await db.updateTable("durably_runs").set({
552
- status: "running",
553
- heartbeat_at: now,
554
- started_at: sql`COALESCE(started_at, ${now})`,
939
+ const status = data.status;
940
+ await db.updateTable("durably_runs").set({
941
+ status,
942
+ current_step_index: data.currentStepIndex,
943
+ progress: data.progress !== void 0 ? data.progress ? JSON.stringify(data.progress) : null : void 0,
944
+ output: data.output !== void 0 ? JSON.stringify(data.output) : void 0,
945
+ error: data.error,
946
+ lease_owner: data.leaseOwner !== void 0 ? data.leaseOwner : void 0,
947
+ lease_expires_at: data.leaseExpiresAt !== void 0 ? data.leaseExpiresAt : void 0,
948
+ started_at: data.startedAt,
949
+ completed_at: data.completedAt,
555
950
  updated_at: now
556
- }).where(
557
- "id",
558
- "=",
559
- (eb) => eb.selectFrom(subquery.as("sub")).select("id")
560
- ).returningAll().executeTakeFirst();
561
- if (!row) return null;
562
- return rowToRun({ ...row, step_count: 0 });
951
+ }).where("id", "=", runId).execute();
563
952
  },
564
- async createStep(input) {
565
- const completedAt = (/* @__PURE__ */ new Date()).toISOString();
566
- const id = ulid();
567
- const step = {
568
- id,
569
- run_id: input.runId,
570
- name: input.name,
571
- index: input.index,
572
- status: input.status,
573
- output: input.output !== void 0 ? JSON.stringify(input.output) : null,
574
- error: input.error ?? null,
575
- started_at: input.startedAt,
576
- completed_at: completedAt
577
- };
578
- await db.insertInto("durably_steps").values(step).execute();
579
- return rowToStep(step);
953
+ async deleteRun(runId) {
954
+ await db.transaction().execute(async (trx) => {
955
+ await cascadeDeleteRuns(trx, [runId]);
956
+ });
580
957
  },
581
- async deleteSteps(runId) {
582
- await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
958
+ async purgeRuns(options) {
959
+ const limit = options.limit ?? 500;
960
+ return await db.transaction().execute(async (trx) => {
961
+ const rows = await trx.selectFrom("durably_runs").select("id").where("status", "in", TERMINAL_STATUSES).where("completed_at", "<", options.olderThan).orderBy("completed_at", "asc").limit(limit).execute();
962
+ if (rows.length === 0) return 0;
963
+ const ids = rows.map((r) => r.id);
964
+ await cascadeDeleteRuns(trx, ids);
965
+ return ids.length;
966
+ });
583
967
  },
584
- async getSteps(runId) {
585
- const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
586
- return rows.map(rowToStep);
968
+ async claimNext(workerId, now, leaseMs) {
969
+ const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
970
+ const activeLeaseGuard = sql4`
971
+ (
972
+ concurrency_key IS NULL
973
+ OR NOT EXISTS (
974
+ SELECT 1
975
+ FROM durably_runs AS active
976
+ WHERE active.concurrency_key = durably_runs.concurrency_key
977
+ AND active.id <> durably_runs.id
978
+ AND active.status = 'leased'
979
+ AND active.lease_expires_at IS NOT NULL
980
+ AND active.lease_expires_at > ${now}
981
+ )
982
+ )
983
+ `;
984
+ return backend === "postgres" ? claimNextPostgres(db, workerId, now, leaseExpiresAt, activeLeaseGuard) : claimNextSqlite(db, workerId, now, leaseExpiresAt, activeLeaseGuard);
587
985
  },
588
- async getCompletedStep(runId, name) {
589
- const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
590
- return row ? rowToStep(row) : null;
591
- },
592
- async createLog(input) {
593
- const now = (/* @__PURE__ */ new Date()).toISOString();
594
- const id = ulid();
595
- const log = {
596
- id,
597
- run_id: input.runId,
598
- step_name: input.stepName,
599
- level: input.level,
600
- message: input.message,
601
- data: input.data !== void 0 ? JSON.stringify(input.data) : null,
602
- created_at: now
603
- };
604
- await db.insertInto("durably_logs").values(log).execute();
605
- return rowToLog(log);
986
+ async renewLease(runId, leaseGeneration, now, leaseMs) {
987
+ const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
988
+ const result = await db.updateTable("durably_runs").set({
989
+ lease_expires_at: leaseExpiresAt,
990
+ updated_at: now
991
+ }).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).where("lease_expires_at", ">", now).executeTakeFirst();
992
+ return Number(result.numUpdatedRows) > 0;
606
993
  },
607
- async getLogs(runId) {
608
- const rows = await db.selectFrom("durably_logs").selectAll().where("run_id", "=", runId).orderBy("created_at", "asc").execute();
609
- return rows.map(rowToLog);
610
- }
611
- };
612
- }
613
-
614
- // src/worker.ts
615
- import { prettifyError as prettifyError2 } from "zod";
616
-
617
- // src/errors.ts
618
- var CancelledError = class extends Error {
619
- constructor(runId) {
620
- super(`Run was cancelled: ${runId}`);
621
- this.name = "CancelledError";
622
- }
623
- };
624
- function getErrorMessage(error) {
625
- return error instanceof Error ? error.message : String(error);
626
- }
627
-
628
- // src/context.ts
629
- function createStepContext(run, jobName, storage, eventEmitter) {
630
- let stepIndex = run.currentStepIndex;
631
- let currentStepName = null;
632
- const controller = new AbortController();
633
- const unsubscribe = eventEmitter.on("run:cancel", (event) => {
634
- if (event.runId === run.id) {
635
- controller.abort();
636
- }
637
- });
638
- const step = {
639
- get runId() {
640
- return run.id;
994
+ async releaseExpiredLeases(now) {
995
+ const result = await db.updateTable("durably_runs").set({
996
+ status: "pending",
997
+ lease_owner: null,
998
+ lease_expires_at: null,
999
+ updated_at: now
1000
+ }).where("status", "=", "leased").where("lease_expires_at", "is not", null).where("lease_expires_at", "<=", now).executeTakeFirst();
1001
+ return Number(result.numUpdatedRows);
641
1002
  },
642
- async run(name, fn) {
643
- if (controller.signal.aborted) {
644
- throw new CancelledError(run.id);
645
- }
646
- const currentRun = await storage.getRun(run.id);
647
- if (currentRun?.status === "cancelled") {
648
- controller.abort();
649
- throw new CancelledError(run.id);
650
- }
651
- if (controller.signal.aborted) {
652
- throw new CancelledError(run.id);
653
- }
654
- const existingStep = await storage.getCompletedStep(run.id, name);
655
- if (existingStep) {
656
- stepIndex++;
657
- return existingStep.output;
658
- }
659
- currentStepName = name;
660
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
661
- const startTime = Date.now();
662
- eventEmitter.emit({
663
- type: "step:start",
664
- runId: run.id,
665
- jobName,
666
- stepName: name,
667
- stepIndex,
668
- labels: run.labels
1003
+ async completeRun(runId, leaseGeneration, output, completedAt) {
1004
+ return terminateRun(runId, leaseGeneration, completedAt, {
1005
+ status: "completed",
1006
+ output: JSON.stringify(output),
1007
+ error: null
669
1008
  });
670
- try {
671
- const result = await fn(controller.signal);
672
- await storage.createStep({
673
- runId: run.id,
674
- name,
675
- index: stepIndex,
676
- status: "completed",
677
- output: result,
678
- startedAt
679
- });
680
- stepIndex++;
681
- await storage.updateRun(run.id, { currentStepIndex: stepIndex });
682
- eventEmitter.emit({
683
- type: "step:complete",
684
- runId: run.id,
685
- jobName,
686
- stepName: name,
687
- stepIndex: stepIndex - 1,
688
- output: result,
689
- duration: Date.now() - startTime,
690
- labels: run.labels
691
- });
692
- return result;
693
- } catch (error) {
694
- const isCancelled = controller.signal.aborted;
695
- const errorMessage = error instanceof Error ? error.message : String(error);
696
- await storage.createStep({
697
- runId: run.id,
698
- name,
699
- index: stepIndex,
700
- status: isCancelled ? "cancelled" : "failed",
701
- error: errorMessage,
702
- startedAt
703
- });
704
- eventEmitter.emit({
705
- ...isCancelled ? { type: "step:cancel" } : { type: "step:fail", error: errorMessage },
706
- runId: run.id,
707
- jobName,
708
- stepName: name,
709
- stepIndex,
710
- labels: run.labels
711
- });
712
- if (isCancelled) {
713
- throw new CancelledError(run.id);
714
- }
715
- throw error;
716
- } finally {
717
- currentStepName = null;
718
- }
719
1009
  },
720
- progress(current, total, message) {
721
- const progressData = { current, total, message };
722
- storage.updateRun(run.id, { progress: progressData });
723
- eventEmitter.emit({
724
- type: "run:progress",
725
- runId: run.id,
726
- jobName,
727
- progress: progressData,
728
- labels: run.labels
1010
+ async failRun(runId, leaseGeneration, error, completedAt) {
1011
+ return terminateRun(runId, leaseGeneration, completedAt, {
1012
+ status: "failed",
1013
+ error
729
1014
  });
730
1015
  },
731
- log: {
732
- info(message, data) {
733
- eventEmitter.emit({
734
- type: "log:write",
735
- runId: run.id,
736
- jobName,
737
- labels: run.labels,
738
- stepName: currentStepName,
739
- level: "info",
740
- message,
741
- data
742
- });
743
- },
744
- warn(message, data) {
745
- eventEmitter.emit({
746
- type: "log:write",
747
- runId: run.id,
748
- jobName,
749
- labels: run.labels,
750
- stepName: currentStepName,
751
- level: "warn",
752
- message,
753
- data
754
- });
755
- },
756
- error(message, data) {
757
- eventEmitter.emit({
758
- type: "log:write",
759
- runId: run.id,
760
- jobName,
761
- labels: run.labels,
762
- stepName: currentStepName,
763
- level: "error",
764
- message,
765
- data
766
- });
767
- }
1016
+ async cancelRun(runId, now) {
1017
+ const result = await db.updateTable("durably_runs").set({
1018
+ status: "cancelled",
1019
+ lease_owner: null,
1020
+ lease_expires_at: null,
1021
+ completed_at: now,
1022
+ updated_at: now
1023
+ }).where("id", "=", runId).where("status", "in", ["pending", "leased"]).executeTakeFirst();
1024
+ return Number(result.numUpdatedRows) > 0;
1025
+ },
1026
+ async persistStep(runId, leaseGeneration, input) {
1027
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
1028
+ const id = ulid();
1029
+ const outputJson = input.output !== void 0 ? JSON.stringify(input.output) : null;
1030
+ const errorValue = input.error ?? null;
1031
+ return await db.transaction().execute(async (trx) => {
1032
+ const insertResult = await sql4`
1033
+ INSERT INTO durably_steps (id, run_id, name, "index", status, output, error, started_at, completed_at)
1034
+ SELECT ${id}, ${runId}, ${input.name}, ${input.index}, ${input.status},
1035
+ ${outputJson}, ${errorValue}, ${input.startedAt}, ${completedAt}
1036
+ FROM durably_runs
1037
+ WHERE id = ${runId} AND status = 'leased' AND lease_generation = ${leaseGeneration}
1038
+ `.execute(trx);
1039
+ if (Number(insertResult.numAffectedRows) === 0) return null;
1040
+ if (input.status === "completed") {
1041
+ await trx.updateTable("durably_runs").set({
1042
+ current_step_index: input.index + 1,
1043
+ completed_step_count: sql4`completed_step_count + 1`,
1044
+ updated_at: completedAt
1045
+ }).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).execute();
1046
+ }
1047
+ return {
1048
+ id,
1049
+ runId,
1050
+ name: input.name,
1051
+ index: input.index,
1052
+ status: input.status,
1053
+ output: input.output !== void 0 ? input.output : null,
1054
+ error: errorValue,
1055
+ startedAt: input.startedAt,
1056
+ completedAt
1057
+ };
1058
+ });
1059
+ },
1060
+ async deleteSteps(runId) {
1061
+ await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
1062
+ await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
1063
+ },
1064
+ async getSteps(runId) {
1065
+ const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
1066
+ return rows.map(rowToStep);
1067
+ },
1068
+ async getCompletedStep(runId, name) {
1069
+ const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
1070
+ return row ? rowToStep(row) : null;
1071
+ },
1072
+ async updateProgress(runId, leaseGeneration, progress) {
1073
+ await db.updateTable("durably_runs").set({
1074
+ progress: progress ? JSON.stringify(progress) : null,
1075
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1076
+ }).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).execute();
1077
+ },
1078
+ async createLog(input) {
1079
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1080
+ const id = ulid();
1081
+ const log = {
1082
+ id,
1083
+ run_id: input.runId,
1084
+ step_name: input.stepName,
1085
+ level: input.level,
1086
+ message: input.message,
1087
+ data: input.data !== void 0 ? JSON.stringify(input.data) : null,
1088
+ created_at: now
1089
+ };
1090
+ await db.insertInto("durably_logs").values(log).execute();
1091
+ return rowToLog(log);
1092
+ },
1093
+ async getLogs(runId) {
1094
+ const rows = await db.selectFrom("durably_logs").selectAll().where("run_id", "=", runId).orderBy("created_at", "asc").execute();
1095
+ return rows.map(rowToLog);
768
1096
  }
769
1097
  };
770
- return { step, dispose: unsubscribe };
1098
+ if (backend !== "postgres") {
1099
+ const mutatingKeys = [
1100
+ "enqueue",
1101
+ "enqueueMany",
1102
+ "updateRun",
1103
+ "deleteRun",
1104
+ "purgeRuns",
1105
+ "claimNext",
1106
+ "renewLease",
1107
+ "releaseExpiredLeases",
1108
+ "completeRun",
1109
+ "failRun",
1110
+ "cancelRun",
1111
+ "persistStep",
1112
+ "deleteSteps",
1113
+ "updateProgress",
1114
+ "createLog"
1115
+ ];
1116
+ for (const key of mutatingKeys) {
1117
+ const original = store[key];
1118
+ store[key] = (...args) => withWriteLock(() => original.apply(store, args));
1119
+ }
1120
+ }
1121
+ return store;
771
1122
  }
772
1123
 
773
1124
  // src/worker.ts
774
- function createWorker(config, storage, eventEmitter, jobRegistry) {
1125
+ function createWorker(config, processOne, onIdle) {
775
1126
  let running = false;
776
- let currentRunPromise = null;
777
1127
  let pollingTimeout = null;
1128
+ let inFlight = null;
778
1129
  let stopResolver = null;
779
- let heartbeatInterval = null;
780
- let currentRunId = null;
781
- async function recoverStaleRuns() {
782
- const staleThreshold = new Date(
783
- Date.now() - config.staleThreshold
784
- ).toISOString();
785
- const runningRuns = await storage.getRuns({ status: "running" });
786
- for (const run of runningRuns) {
787
- if (run.heartbeatAt < staleThreshold) {
788
- await storage.updateRun(run.id, {
789
- status: "pending"
790
- });
791
- }
792
- }
793
- }
794
- async function updateHeartbeat() {
795
- if (currentRunId) {
796
- await storage.updateRun(currentRunId, {
797
- heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
798
- });
799
- }
800
- }
801
- async function handleRunSuccess(runId, jobName, output, startTime) {
802
- const currentRun = await storage.getRun(runId);
803
- if (!currentRun || currentRun.status === "cancelled") {
804
- return;
805
- }
806
- await storage.updateRun(runId, {
807
- status: "completed",
808
- output,
809
- completedAt: (/* @__PURE__ */ new Date()).toISOString()
810
- });
811
- eventEmitter.emit({
812
- type: "run:complete",
813
- runId,
814
- jobName,
815
- output,
816
- duration: Date.now() - startTime,
817
- labels: currentRun.labels
818
- });
819
- }
820
- async function handleRunFailure(runId, jobName, error) {
821
- if (error instanceof CancelledError) {
822
- return;
823
- }
824
- const currentRun = await storage.getRun(runId);
825
- if (!currentRun || currentRun.status === "cancelled") {
1130
+ let activeWorkerId;
1131
+ async function poll() {
1132
+ if (!running) {
826
1133
  return;
827
1134
  }
828
- const errorMessage = getErrorMessage(error);
829
- const steps = await storage.getSteps(runId);
830
- const failedStep = steps.find((s) => s.status === "failed");
831
- await storage.updateRun(runId, {
832
- status: "failed",
833
- error: errorMessage,
834
- completedAt: (/* @__PURE__ */ new Date()).toISOString()
835
- });
836
- eventEmitter.emit({
837
- type: "run:fail",
838
- runId,
839
- jobName,
840
- error: errorMessage,
841
- failedStepName: failedStep?.name ?? "unknown",
842
- labels: currentRun.labels
843
- });
844
- }
845
- async function executeRun(run, job) {
846
- currentRunId = run.id;
847
- heartbeatInterval = setInterval(() => {
848
- updateHeartbeat().catch((error) => {
849
- eventEmitter.emit({
850
- type: "worker:error",
851
- error: getErrorMessage(error),
852
- context: "heartbeat",
853
- runId: run.id
854
- });
855
- });
856
- }, config.heartbeatInterval);
857
- eventEmitter.emit({
858
- type: "run:start",
859
- runId: run.id,
860
- jobName: run.jobName,
861
- input: run.input,
862
- labels: run.labels
863
- });
864
- const startTime = Date.now();
865
- const { step, dispose } = createStepContext(
866
- run,
867
- run.jobName,
868
- storage,
869
- eventEmitter
870
- );
871
- try {
872
- const output = await job.fn(step, run.input);
873
- if (job.outputSchema) {
874
- const parseResult = job.outputSchema.safeParse(output);
875
- if (!parseResult.success) {
876
- throw new Error(`Invalid output: ${prettifyError2(parseResult.error)}`);
877
- }
878
- }
879
- await handleRunSuccess(run.id, run.jobName, output, startTime);
880
- } catch (error) {
881
- await handleRunFailure(run.id, run.jobName, error);
882
- } finally {
883
- if (config.cleanupSteps) {
1135
+ const cycle = (async () => {
1136
+ const didProcess = await processOne({ workerId: activeWorkerId });
1137
+ if (!didProcess && onIdle && running) {
884
1138
  try {
885
- await storage.deleteSteps(run.id);
1139
+ await onIdle();
886
1140
  } catch {
887
1141
  }
888
1142
  }
889
- dispose();
890
- if (heartbeatInterval) {
891
- clearInterval(heartbeatInterval);
892
- heartbeatInterval = null;
893
- }
894
- currentRunId = null;
895
- }
896
- }
897
- async function processNextRun() {
898
- const runningRuns = await storage.getRuns({ status: "running" });
899
- const excludeConcurrencyKeys = runningRuns.filter(
900
- (r) => r.concurrencyKey !== null
901
- ).map((r) => r.concurrencyKey);
902
- const run = await storage.claimNextPendingRun(excludeConcurrencyKeys);
903
- if (!run) {
904
- return false;
905
- }
906
- const job = jobRegistry.get(run.jobName);
907
- if (!job) {
908
- await storage.updateRun(run.id, {
909
- status: "failed",
910
- error: `Unknown job: ${run.jobName}`
911
- });
912
- return true;
913
- }
914
- await executeRun(run, job);
915
- return true;
916
- }
917
- async function poll() {
918
- if (!running) {
919
- return;
920
- }
921
- const doWork = async () => {
922
- await recoverStaleRuns();
923
- await processNextRun();
924
- };
1143
+ })();
1144
+ inFlight = cycle;
925
1145
  try {
926
- currentRunPromise = doWork();
927
- await currentRunPromise;
1146
+ await cycle;
928
1147
  } finally {
929
- currentRunPromise = null;
1148
+ inFlight = null;
930
1149
  }
931
1150
  if (running) {
932
- pollingTimeout = setTimeout(() => poll(), config.pollingInterval);
933
- } else if (stopResolver) {
1151
+ pollingTimeout = setTimeout(() => {
1152
+ void poll();
1153
+ }, config.pollingIntervalMs);
1154
+ return;
1155
+ }
1156
+ if (stopResolver) {
934
1157
  stopResolver();
935
1158
  stopResolver = null;
936
1159
  }
@@ -939,12 +1162,13 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
939
1162
  get isRunning() {
940
1163
  return running;
941
1164
  },
942
- start() {
1165
+ start(options) {
943
1166
  if (running) {
944
1167
  return;
945
1168
  }
1169
+ activeWorkerId = options?.workerId;
946
1170
  running = true;
947
- poll();
1171
+ void poll();
948
1172
  },
949
1173
  async stop() {
950
1174
  if (!running) {
@@ -955,11 +1179,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
955
1179
  clearTimeout(pollingTimeout);
956
1180
  pollingTimeout = null;
957
1181
  }
958
- if (heartbeatInterval) {
959
- clearInterval(heartbeatInterval);
960
- heartbeatInterval = null;
961
- }
962
- if (currentRunPromise) {
1182
+ if (inFlight) {
963
1183
  return new Promise((resolve) => {
964
1184
  stopResolver = resolve;
965
1185
  });
@@ -970,20 +1190,249 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
970
1190
 
971
1191
  // src/durably.ts
972
1192
  var DEFAULTS = {
973
- pollingInterval: 1e3,
974
- heartbeatInterval: 5e3,
975
- staleThreshold: 3e4,
976
- cleanupSteps: true
1193
+ pollingIntervalMs: 1e3,
1194
+ leaseRenewIntervalMs: 5e3,
1195
+ leaseMs: 3e4,
1196
+ preserveSteps: false
977
1197
  };
1198
+ function parseDuration(value) {
1199
+ const match = value.match(/^(\d+)(d|h|m)$/);
1200
+ if (!match) {
1201
+ throw new Error(
1202
+ `Invalid duration format: "${value}". Use e.g. '30d', '24h', '60m'`
1203
+ );
1204
+ }
1205
+ const num = Number.parseInt(match[1], 10);
1206
+ const unit = match[2];
1207
+ const multipliers = {
1208
+ d: 864e5,
1209
+ h: 36e5,
1210
+ m: 6e4
1211
+ };
1212
+ return num * multipliers[unit];
1213
+ }
1214
+ var PURGE_INTERVAL_MS = 6e4;
1215
+ var ulid2 = monotonicFactory2();
1216
+ var BROWSER_SINGLETON_REGISTRY_KEY = "__durablyBrowserSingletonRegistry";
1217
+ var BROWSER_LOCAL_DIALECT_KEY = "__durablyBrowserLocalKey";
1218
+ function defaultWorkerId() {
1219
+ return `worker_${ulid2()}`;
1220
+ }
1221
+ function detectBackend(dialect) {
1222
+ return dialect.constructor.name === "PostgresDialect" ? "postgres" : "generic";
1223
+ }
1224
+ function isBrowserLikeEnvironment() {
1225
+ return typeof globalThis.window !== "undefined" || typeof globalThis.document !== "undefined";
1226
+ }
1227
+ function getBrowserSingletonKey(dialect, explicitKey) {
1228
+ if (!isBrowserLikeEnvironment()) {
1229
+ return null;
1230
+ }
1231
+ if (explicitKey) {
1232
+ return explicitKey;
1233
+ }
1234
+ const taggedDialect = dialect;
1235
+ const taggedKey = taggedDialect[BROWSER_LOCAL_DIALECT_KEY];
1236
+ return typeof taggedKey === "string" ? taggedKey : null;
1237
+ }
1238
+ function registerBrowserSingletonWarning(singletonKey) {
1239
+ const globalRegistry = globalThis;
1240
+ const registry = globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] ?? /* @__PURE__ */ new Map();
1241
+ globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] = registry;
1242
+ const instanceId = ulid2();
1243
+ const instances = registry.get(singletonKey) ?? /* @__PURE__ */ new Set();
1244
+ const hadExistingInstance = instances.size > 0;
1245
+ instances.add(instanceId);
1246
+ registry.set(singletonKey, instances);
1247
+ if (hadExistingInstance && (typeof process === "undefined" || process.env.NODE_ENV !== "production")) {
1248
+ console.warn(
1249
+ `[durably] Multiple runtimes were created for browser-local store "${singletonKey}" in one tab. Prefer a single shared instance per tab.`
1250
+ );
1251
+ }
1252
+ let released = false;
1253
+ return () => {
1254
+ if (released) {
1255
+ return;
1256
+ }
1257
+ released = true;
1258
+ const activeInstances = registry.get(singletonKey);
1259
+ if (!activeInstances) {
1260
+ return;
1261
+ }
1262
+ activeInstances.delete(instanceId);
1263
+ if (activeInstances.size === 0) {
1264
+ registry.delete(singletonKey);
1265
+ }
1266
+ };
1267
+ }
978
1268
  function createDurablyInstance(state, jobs) {
979
- const { db, storage, eventEmitter, jobRegistry, worker } = state;
1269
+ const {
1270
+ db,
1271
+ storage,
1272
+ eventEmitter,
1273
+ jobRegistry,
1274
+ worker,
1275
+ releaseBrowserSingleton
1276
+ } = state;
980
1277
  async function getRunOrThrow(runId) {
981
1278
  const run = await storage.getRun(runId);
982
1279
  if (!run) {
983
- throw new Error(`Run not found: ${runId}`);
1280
+ throw new NotFoundError(`Run not found: ${runId}`);
984
1281
  }
985
1282
  return run;
986
1283
  }
1284
+ async function executeRun(run, workerId) {
1285
+ const job = jobRegistry.get(run.jobName);
1286
+ if (!job) {
1287
+ await storage.failRun(
1288
+ run.id,
1289
+ run.leaseGeneration,
1290
+ `Unknown job: ${run.jobName}`,
1291
+ (/* @__PURE__ */ new Date()).toISOString()
1292
+ );
1293
+ return;
1294
+ }
1295
+ const { step, abortLeaseOwnership, dispose } = createStepContext(
1296
+ run,
1297
+ run.jobName,
1298
+ run.leaseGeneration,
1299
+ storage,
1300
+ eventEmitter
1301
+ );
1302
+ let leaseDeadlineTimer = null;
1303
+ const scheduleLeaseDeadline = (leaseExpiresAt) => {
1304
+ if (leaseDeadlineTimer) {
1305
+ clearTimeout(leaseDeadlineTimer);
1306
+ leaseDeadlineTimer = null;
1307
+ }
1308
+ if (!leaseExpiresAt) {
1309
+ return;
1310
+ }
1311
+ const delay = Math.max(0, Date.parse(leaseExpiresAt) - Date.now());
1312
+ leaseDeadlineTimer = setTimeout(() => {
1313
+ abortLeaseOwnership();
1314
+ }, delay);
1315
+ };
1316
+ scheduleLeaseDeadline(run.leaseExpiresAt);
1317
+ const leaseTimer = setInterval(() => {
1318
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1319
+ storage.renewLease(run.id, run.leaseGeneration, now, state.leaseMs).then((renewed) => {
1320
+ if (!renewed) {
1321
+ abortLeaseOwnership();
1322
+ eventEmitter.emit({
1323
+ type: "worker:error",
1324
+ error: `Lease renewal lost ownership for run ${run.id}`,
1325
+ context: "lease-renewal",
1326
+ runId: run.id
1327
+ });
1328
+ return;
1329
+ }
1330
+ const renewedLeaseExpiresAt = new Date(
1331
+ Date.parse(now) + state.leaseMs
1332
+ ).toISOString();
1333
+ scheduleLeaseDeadline(renewedLeaseExpiresAt);
1334
+ eventEmitter.emit({
1335
+ type: "run:lease-renewed",
1336
+ runId: run.id,
1337
+ jobName: run.jobName,
1338
+ leaseOwner: workerId,
1339
+ leaseExpiresAt: renewedLeaseExpiresAt,
1340
+ labels: run.labels
1341
+ });
1342
+ }).catch((error) => {
1343
+ eventEmitter.emit({
1344
+ type: "worker:error",
1345
+ error: getErrorMessage(error),
1346
+ context: "lease-renewal",
1347
+ runId: run.id
1348
+ });
1349
+ });
1350
+ }, state.leaseRenewIntervalMs);
1351
+ const started = Date.now();
1352
+ let reachedTerminalState = false;
1353
+ try {
1354
+ eventEmitter.emit({
1355
+ type: "run:leased",
1356
+ runId: run.id,
1357
+ jobName: run.jobName,
1358
+ input: run.input,
1359
+ leaseOwner: workerId,
1360
+ leaseExpiresAt: run.leaseExpiresAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1361
+ labels: run.labels
1362
+ });
1363
+ const output = await job.fn(step, run.input);
1364
+ if (job.outputSchema) {
1365
+ const parseResult = job.outputSchema.safeParse(output);
1366
+ if (!parseResult.success) {
1367
+ throw new Error(`Invalid output: ${parseResult.error.message}`);
1368
+ }
1369
+ }
1370
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
1371
+ const completed = await storage.completeRun(
1372
+ run.id,
1373
+ run.leaseGeneration,
1374
+ output,
1375
+ completedAt
1376
+ );
1377
+ if (completed) {
1378
+ reachedTerminalState = true;
1379
+ eventEmitter.emit({
1380
+ type: "run:complete",
1381
+ runId: run.id,
1382
+ jobName: run.jobName,
1383
+ output,
1384
+ duration: Date.now() - started,
1385
+ labels: run.labels
1386
+ });
1387
+ } else {
1388
+ eventEmitter.emit({
1389
+ type: "worker:error",
1390
+ error: `Lease lost before completing run ${run.id}`,
1391
+ context: "run-completion"
1392
+ });
1393
+ }
1394
+ } catch (error) {
1395
+ if (error instanceof LeaseLostError || error instanceof CancelledError) {
1396
+ return;
1397
+ }
1398
+ const errorMessage = getErrorMessage(error);
1399
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
1400
+ const failed = await storage.failRun(
1401
+ run.id,
1402
+ run.leaseGeneration,
1403
+ errorMessage,
1404
+ completedAt
1405
+ );
1406
+ if (failed) {
1407
+ reachedTerminalState = true;
1408
+ const steps = await storage.getSteps(run.id);
1409
+ const failedStep = steps.find((entry) => entry.status === "failed");
1410
+ eventEmitter.emit({
1411
+ type: "run:fail",
1412
+ runId: run.id,
1413
+ jobName: run.jobName,
1414
+ error: errorMessage,
1415
+ failedStepName: failedStep?.name ?? "unknown",
1416
+ labels: run.labels
1417
+ });
1418
+ } else {
1419
+ eventEmitter.emit({
1420
+ type: "worker:error",
1421
+ error: `Lease lost before recording failure for run ${run.id}`,
1422
+ context: "run-failure"
1423
+ });
1424
+ }
1425
+ } finally {
1426
+ clearInterval(leaseTimer);
1427
+ if (leaseDeadlineTimer) {
1428
+ clearTimeout(leaseDeadlineTimer);
1429
+ }
1430
+ dispose();
1431
+ if (!state.preserveSteps && reachedTerminalState) {
1432
+ await storage.deleteSteps(run.id);
1433
+ }
1434
+ }
1435
+ }
987
1436
  const durably = {
988
1437
  db,
989
1438
  storage,
@@ -992,7 +1441,10 @@ function createDurablyInstance(state, jobs) {
992
1441
  emit: eventEmitter.emit,
993
1442
  onError: eventEmitter.onError,
994
1443
  start: worker.start,
995
- stop: worker.stop,
1444
+ async stop() {
1445
+ releaseBrowserSingleton();
1446
+ await worker.stop();
1447
+ },
996
1448
  // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions
997
1449
  register(jobDefs) {
998
1450
  const newHandles = {};
@@ -1013,8 +1465,8 @@ function createDurablyInstance(state, jobs) {
1013
1465
  mergedJobs
1014
1466
  );
1015
1467
  },
1016
- getRun: storage.getRun,
1017
- getRuns: storage.getRuns,
1468
+ getRun: storage.getRun.bind(storage),
1469
+ getRuns: storage.getRuns.bind(storage),
1018
1470
  use(plugin) {
1019
1471
  plugin.install(durably);
1020
1472
  },
@@ -1028,9 +1480,14 @@ function createDurablyInstance(state, jobs) {
1028
1480
  subscribe(runId) {
1029
1481
  let closed = false;
1030
1482
  let cleanup = null;
1031
- const closeEvents = /* @__PURE__ */ new Set(["run:complete", "run:delete"]);
1483
+ const closeEvents = /* @__PURE__ */ new Set([
1484
+ "run:complete",
1485
+ "run:fail",
1486
+ "run:cancel",
1487
+ "run:delete"
1488
+ ]);
1032
1489
  const subscribedEvents = [
1033
- "run:start",
1490
+ "run:leased",
1034
1491
  "run:complete",
1035
1492
  "run:fail",
1036
1493
  "run:cancel",
@@ -1057,6 +1514,64 @@ function createDurablyInstance(state, jobs) {
1057
1514
  cleanup = () => {
1058
1515
  for (const unsub of unsubscribes) unsub();
1059
1516
  };
1517
+ const closeStream = () => {
1518
+ closed = true;
1519
+ cleanup?.();
1520
+ controller.close();
1521
+ };
1522
+ storage.getRun(runId).then((run) => {
1523
+ if (closed || !run) return;
1524
+ const base = {
1525
+ runId,
1526
+ jobName: run.jobName,
1527
+ labels: run.labels,
1528
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1529
+ sequence: 0
1530
+ };
1531
+ if (run.status === "leased") {
1532
+ controller.enqueue({
1533
+ ...base,
1534
+ type: "run:leased",
1535
+ input: run.input,
1536
+ leaseOwner: run.leaseOwner ?? "",
1537
+ leaseExpiresAt: run.leaseExpiresAt ?? ""
1538
+ });
1539
+ if (run.progress != null) {
1540
+ controller.enqueue({
1541
+ ...base,
1542
+ type: "run:progress",
1543
+ progress: run.progress
1544
+ });
1545
+ }
1546
+ } else if (run.status === "completed") {
1547
+ controller.enqueue({
1548
+ ...base,
1549
+ type: "run:complete",
1550
+ output: run.output,
1551
+ duration: 0
1552
+ });
1553
+ closeStream();
1554
+ } else if (run.status === "failed") {
1555
+ controller.enqueue({
1556
+ ...base,
1557
+ type: "run:fail",
1558
+ error: run.error ?? "Unknown error",
1559
+ failedStepName: ""
1560
+ });
1561
+ closeStream();
1562
+ } else if (run.status === "cancelled") {
1563
+ controller.enqueue({
1564
+ ...base,
1565
+ type: "run:cancel"
1566
+ });
1567
+ closeStream();
1568
+ }
1569
+ }).catch((error) => {
1570
+ if (closed) return;
1571
+ closed = true;
1572
+ cleanup?.();
1573
+ controller.error(error);
1574
+ });
1060
1575
  },
1061
1576
  cancel: () => {
1062
1577
  if (!closed) {
@@ -1069,17 +1584,23 @@ function createDurablyInstance(state, jobs) {
1069
1584
  async retrigger(runId) {
1070
1585
  const run = await getRunOrThrow(runId);
1071
1586
  if (run.status === "pending") {
1072
- throw new Error(`Cannot retrigger pending run: ${runId}`);
1587
+ throw new ConflictError(`Cannot retrigger pending run: ${runId}`);
1073
1588
  }
1074
- if (run.status === "running") {
1075
- throw new Error(`Cannot retrigger running run: ${runId}`);
1589
+ if (run.status === "leased") {
1590
+ throw new ConflictError(`Cannot retrigger leased run: ${runId}`);
1076
1591
  }
1077
- if (!jobRegistry.get(run.jobName)) {
1078
- throw new Error(`Unknown job: ${run.jobName}`);
1592
+ const job = jobRegistry.get(run.jobName);
1593
+ if (!job) {
1594
+ throw new NotFoundError(`Unknown job: ${run.jobName}`);
1079
1595
  }
1080
- const nextRun = await storage.createRun({
1596
+ const validatedInput = validateJobInputOrThrow(
1597
+ job.inputSchema,
1598
+ run.input,
1599
+ `Cannot retrigger run ${runId}`
1600
+ );
1601
+ const nextRun = await storage.enqueue({
1081
1602
  jobName: run.jobName,
1082
- input: run.input,
1603
+ input: validatedInput,
1083
1604
  concurrencyKey: run.concurrencyKey ?? void 0,
1084
1605
  labels: run.labels
1085
1606
  });
@@ -1087,7 +1608,7 @@ function createDurablyInstance(state, jobs) {
1087
1608
  type: "run:trigger",
1088
1609
  runId: nextRun.id,
1089
1610
  jobName: run.jobName,
1090
- input: run.input,
1611
+ input: validatedInput,
1091
1612
  labels: run.labels
1092
1613
  });
1093
1614
  return nextRun;
@@ -1095,20 +1616,23 @@ function createDurablyInstance(state, jobs) {
1095
1616
  async cancel(runId) {
1096
1617
  const run = await getRunOrThrow(runId);
1097
1618
  if (run.status === "completed") {
1098
- throw new Error(`Cannot cancel completed run: ${runId}`);
1619
+ throw new ConflictError(`Cannot cancel completed run: ${runId}`);
1099
1620
  }
1100
1621
  if (run.status === "failed") {
1101
- throw new Error(`Cannot cancel failed run: ${runId}`);
1622
+ throw new ConflictError(`Cannot cancel failed run: ${runId}`);
1102
1623
  }
1103
1624
  if (run.status === "cancelled") {
1104
- throw new Error(`Cannot cancel already cancelled run: ${runId}`);
1625
+ throw new ConflictError(`Cannot cancel already cancelled run: ${runId}`);
1105
1626
  }
1106
1627
  const wasPending = run.status === "pending";
1107
- await storage.updateRun(runId, {
1108
- status: "cancelled",
1109
- completedAt: (/* @__PURE__ */ new Date()).toISOString()
1110
- });
1111
- if (wasPending && state.cleanupSteps) {
1628
+ const cancelled = await storage.cancelRun(runId, (/* @__PURE__ */ new Date()).toISOString());
1629
+ if (!cancelled) {
1630
+ const current = await getRunOrThrow(runId);
1631
+ throw new ConflictError(
1632
+ `Cannot cancel run ${runId}: status changed to ${current.status}`
1633
+ );
1634
+ }
1635
+ if (wasPending && !state.preserveSteps) {
1112
1636
  await storage.deleteSteps(runId);
1113
1637
  }
1114
1638
  eventEmitter.emit({
@@ -1121,10 +1645,10 @@ function createDurablyInstance(state, jobs) {
1121
1645
  async deleteRun(runId) {
1122
1646
  const run = await getRunOrThrow(runId);
1123
1647
  if (run.status === "pending") {
1124
- throw new Error(`Cannot delete pending run: ${runId}`);
1648
+ throw new ConflictError(`Cannot delete pending run: ${runId}`);
1125
1649
  }
1126
- if (run.status === "running") {
1127
- throw new Error(`Cannot delete running run: ${runId}`);
1650
+ if (run.status === "leased") {
1651
+ throw new ConflictError(`Cannot delete leased run: ${runId}`);
1128
1652
  }
1129
1653
  await storage.deleteRun(runId);
1130
1654
  eventEmitter.emit({
@@ -1134,6 +1658,40 @@ function createDurablyInstance(state, jobs) {
1134
1658
  labels: run.labels
1135
1659
  });
1136
1660
  },
1661
+ async purgeRuns(options) {
1662
+ return storage.purgeRuns({
1663
+ olderThan: options.olderThan.toISOString(),
1664
+ limit: options.limit
1665
+ });
1666
+ },
1667
+ async processOne(options) {
1668
+ const workerId = options?.workerId ?? defaultWorkerId();
1669
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1670
+ const run = await storage.claimNext(workerId, now, state.leaseMs);
1671
+ if (!run) {
1672
+ return false;
1673
+ }
1674
+ await executeRun(run, workerId);
1675
+ return true;
1676
+ },
1677
+ async processUntilIdle(options) {
1678
+ const workerId = options?.workerId ?? defaultWorkerId();
1679
+ const maxRuns = options?.maxRuns ?? Number.POSITIVE_INFINITY;
1680
+ let processed = 0;
1681
+ let reachedIdle = false;
1682
+ while (processed < maxRuns) {
1683
+ const didProcess = await this.processOne({ workerId });
1684
+ if (!didProcess) {
1685
+ reachedIdle = true;
1686
+ break;
1687
+ }
1688
+ processed++;
1689
+ }
1690
+ if (reachedIdle) {
1691
+ await state.runIdleMaintenance();
1692
+ }
1693
+ return processed;
1694
+ },
1137
1695
  async migrate() {
1138
1696
  if (state.migrated) {
1139
1697
  return;
@@ -1157,16 +1715,60 @@ function createDurablyInstance(state, jobs) {
1157
1715
  }
1158
1716
  function createDurably(options) {
1159
1717
  const config = {
1160
- pollingInterval: options.pollingInterval ?? DEFAULTS.pollingInterval,
1161
- heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
1162
- staleThreshold: options.staleThreshold ?? DEFAULTS.staleThreshold,
1163
- cleanupSteps: options.cleanupSteps ?? DEFAULTS.cleanupSteps
1718
+ pollingIntervalMs: options.pollingIntervalMs ?? DEFAULTS.pollingIntervalMs,
1719
+ leaseRenewIntervalMs: options.leaseRenewIntervalMs ?? DEFAULTS.leaseRenewIntervalMs,
1720
+ leaseMs: options.leaseMs ?? DEFAULTS.leaseMs,
1721
+ preserveSteps: options.preserveSteps ?? DEFAULTS.preserveSteps,
1722
+ retainRunsMs: options.retainRuns ? parseDuration(options.retainRuns) : null
1164
1723
  };
1165
1724
  const db = new Kysely({ dialect: options.dialect });
1166
- const storage = createKyselyStorage(db);
1725
+ const singletonKey = getBrowserSingletonKey(
1726
+ options.dialect,
1727
+ options.singletonKey
1728
+ );
1729
+ const releaseBrowserSingleton = singletonKey !== null ? registerBrowserSingletonWarning(singletonKey) : () => {
1730
+ };
1731
+ const backend = detectBackend(options.dialect);
1732
+ const storage = createKyselyStore(db, backend);
1733
+ const originalDestroy = db.destroy.bind(db);
1734
+ db.destroy = (async () => {
1735
+ releaseBrowserSingleton();
1736
+ return originalDestroy();
1737
+ });
1167
1738
  const eventEmitter = createEventEmitter();
1168
1739
  const jobRegistry = createJobRegistry();
1169
- const worker = createWorker(config, storage, eventEmitter, jobRegistry);
1740
+ let lastPurgeAt = 0;
1741
+ const runIdleMaintenance = async () => {
1742
+ try {
1743
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1744
+ await storage.releaseExpiredLeases(now);
1745
+ if (config.retainRunsMs !== null) {
1746
+ const purgeNow = Date.now();
1747
+ if (purgeNow - lastPurgeAt >= PURGE_INTERVAL_MS) {
1748
+ lastPurgeAt = purgeNow;
1749
+ const cutoff = new Date(purgeNow - config.retainRunsMs).toISOString();
1750
+ await storage.purgeRuns({ olderThan: cutoff, limit: 100 });
1751
+ }
1752
+ }
1753
+ } catch (error) {
1754
+ eventEmitter.emit({
1755
+ type: "worker:error",
1756
+ error: getErrorMessage(error),
1757
+ context: "idle-maintenance"
1758
+ });
1759
+ }
1760
+ };
1761
+ let processOneImpl = null;
1762
+ const worker = createWorker(
1763
+ { pollingIntervalMs: config.pollingIntervalMs },
1764
+ (runtimeOptions) => {
1765
+ if (!processOneImpl) {
1766
+ throw new Error("Durably runtime is not initialized");
1767
+ }
1768
+ return processOneImpl(runtimeOptions);
1769
+ },
1770
+ runIdleMaintenance
1771
+ );
1170
1772
  const state = {
1171
1773
  db,
1172
1774
  storage,
@@ -1174,14 +1776,20 @@ function createDurably(options) {
1174
1776
  jobRegistry,
1175
1777
  worker,
1176
1778
  labelsSchema: options.labels,
1177
- cleanupSteps: config.cleanupSteps,
1779
+ preserveSteps: config.preserveSteps,
1178
1780
  migrating: null,
1179
- migrated: false
1781
+ migrated: false,
1782
+ leaseMs: config.leaseMs,
1783
+ leaseRenewIntervalMs: config.leaseRenewIntervalMs,
1784
+ retainRunsMs: config.retainRunsMs,
1785
+ releaseBrowserSingleton,
1786
+ runIdleMaintenance
1180
1787
  };
1181
1788
  const instance = createDurablyInstance(
1182
1789
  state,
1183
1790
  {}
1184
1791
  );
1792
+ processOneImpl = instance.processOne;
1185
1793
  if (options.jobs) {
1186
1794
  return instance.register(options.jobs);
1187
1795
  }
@@ -1426,7 +2034,7 @@ function createThrottledSSEController(inner, throttleMs) {
1426
2034
  // src/server.ts
1427
2035
  var VALID_STATUSES = [
1428
2036
  "pending",
1429
- "running",
2037
+ "leased",
1430
2038
  "completed",
1431
2039
  "failed",
1432
2040
  "cancelled"
@@ -1505,6 +2113,12 @@ function createDurablyHandler(durably, options) {
1505
2113
  return await fn();
1506
2114
  } catch (error) {
1507
2115
  if (error instanceof Response) throw error;
2116
+ if (error instanceof DurablyError) {
2117
+ return errorResponse(
2118
+ error.message,
2119
+ error.statusCode
2120
+ );
2121
+ }
1508
2122
  return errorResponse(getErrorMessage(error), 500);
1509
2123
  }
1510
2124
  }
@@ -1533,14 +2147,11 @@ function createDurablyHandler(durably, options) {
1533
2147
  if (auth?.onTrigger && ctx !== void 0) {
1534
2148
  await auth.onTrigger(ctx, body);
1535
2149
  }
1536
- const run = await job.trigger(
1537
- body.input ?? {},
1538
- {
1539
- idempotencyKey: body.idempotencyKey,
1540
- concurrencyKey: body.concurrencyKey,
1541
- labels: body.labels
1542
- }
1543
- );
2150
+ const run = await job.trigger(body.input, {
2151
+ idempotencyKey: body.idempotencyKey,
2152
+ concurrencyKey: body.concurrencyKey,
2153
+ labels: body.labels
2154
+ });
1544
2155
  const response = { runId: run.id };
1545
2156
  return jsonResponse(response);
1546
2157
  });
@@ -1656,10 +2267,10 @@ function createDurablyHandler(durably, options) {
1656
2267
  });
1657
2268
  }
1658
2269
  }),
1659
- durably.on("run:start", (event) => {
2270
+ durably.on("run:leased", (event) => {
1660
2271
  if (matchesFilter(event.jobName, event.labels)) {
1661
2272
  ctrl.enqueue({
1662
- type: "run:start",
2273
+ type: "run:leased",
1663
2274
  runId: event.runId,
1664
2275
  jobName: event.jobName,
1665
2276
  labels: event.labels
@@ -1825,8 +2436,14 @@ function createDurablyHandler(durably, options) {
1825
2436
  }
1826
2437
  export {
1827
2438
  CancelledError,
2439
+ ConflictError,
2440
+ DurablyError,
2441
+ LeaseLostError,
2442
+ NotFoundError,
2443
+ ValidationError,
1828
2444
  createDurably,
1829
2445
  createDurablyHandler,
2446
+ createKyselyStore,
1830
2447
  defineJob,
1831
2448
  toClientRun,
1832
2449
  withLogPersistence