@coji/durably 0.11.0 → 0.13.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
@@ -4,6 +4,210 @@ import {
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
+ function getErrorMessage(error) {
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+
26
+ // src/context.ts
27
+ var LEASE_LOST = "lease-lost";
28
+ function createStepContext(run, jobName, leaseGeneration, storage, eventEmitter) {
29
+ let stepIndex = run.currentStepIndex;
30
+ let currentStepName = null;
31
+ const controller = new AbortController();
32
+ function abortForLeaseLoss() {
33
+ if (!controller.signal.aborted) {
34
+ controller.abort(LEASE_LOST);
35
+ }
36
+ }
37
+ function throwIfAborted() {
38
+ if (!controller.signal.aborted) {
39
+ return;
40
+ }
41
+ if (controller.signal.reason === LEASE_LOST) {
42
+ throw new LeaseLostError(run.id);
43
+ }
44
+ throw new CancelledError(run.id);
45
+ }
46
+ const unsubscribe = eventEmitter.on("run:cancel", (event) => {
47
+ if (event.runId === run.id) {
48
+ controller.abort();
49
+ }
50
+ });
51
+ const step = {
52
+ get runId() {
53
+ return run.id;
54
+ },
55
+ get signal() {
56
+ return controller.signal;
57
+ },
58
+ isAborted() {
59
+ return controller.signal.aborted;
60
+ },
61
+ throwIfAborted() {
62
+ throwIfAborted();
63
+ },
64
+ async run(name, fn) {
65
+ throwIfAborted();
66
+ const currentRun = await storage.getRun(run.id);
67
+ if (currentRun?.status === "cancelled") {
68
+ controller.abort();
69
+ throwIfAborted();
70
+ }
71
+ if (currentRun && (currentRun.status === "leased" && currentRun.leaseGeneration !== leaseGeneration || currentRun.status === "completed" || currentRun.status === "failed")) {
72
+ abortForLeaseLoss();
73
+ throwIfAborted();
74
+ }
75
+ throwIfAborted();
76
+ const existingStep = await storage.getCompletedStep(run.id, name);
77
+ if (existingStep) {
78
+ stepIndex++;
79
+ return existingStep.output;
80
+ }
81
+ currentStepName = name;
82
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
83
+ const startTime = Date.now();
84
+ eventEmitter.emit({
85
+ type: "step:start",
86
+ runId: run.id,
87
+ jobName,
88
+ stepName: name,
89
+ stepIndex,
90
+ labels: run.labels
91
+ });
92
+ try {
93
+ const result = await fn(controller.signal);
94
+ throwIfAborted();
95
+ const savedStep = await storage.persistStep(run.id, leaseGeneration, {
96
+ name,
97
+ index: stepIndex,
98
+ status: "completed",
99
+ output: result,
100
+ startedAt
101
+ });
102
+ if (!savedStep) {
103
+ abortForLeaseLoss();
104
+ throwIfAborted();
105
+ }
106
+ stepIndex++;
107
+ eventEmitter.emit({
108
+ type: "step:complete",
109
+ runId: run.id,
110
+ jobName,
111
+ stepName: name,
112
+ stepIndex: stepIndex - 1,
113
+ output: result,
114
+ duration: Date.now() - startTime,
115
+ labels: run.labels
116
+ });
117
+ return result;
118
+ } catch (error) {
119
+ if (error instanceof LeaseLostError) {
120
+ throw error;
121
+ }
122
+ const isLeaseLost = controller.signal.aborted && controller.signal.reason === LEASE_LOST;
123
+ if (isLeaseLost) {
124
+ throw new LeaseLostError(run.id);
125
+ }
126
+ const isCancelled = controller.signal.aborted;
127
+ const errorMessage = getErrorMessage(error);
128
+ const savedStep = await storage.persistStep(run.id, leaseGeneration, {
129
+ name,
130
+ index: stepIndex,
131
+ status: isCancelled ? "cancelled" : "failed",
132
+ error: errorMessage,
133
+ startedAt
134
+ });
135
+ if (!savedStep) {
136
+ abortForLeaseLoss();
137
+ throw new LeaseLostError(run.id);
138
+ }
139
+ eventEmitter.emit({
140
+ ...isCancelled ? { type: "step:cancel" } : { type: "step:fail", error: errorMessage },
141
+ runId: run.id,
142
+ jobName,
143
+ stepName: name,
144
+ stepIndex,
145
+ labels: run.labels
146
+ });
147
+ if (isCancelled) {
148
+ throwIfAborted();
149
+ }
150
+ throw error;
151
+ } finally {
152
+ currentStepName = null;
153
+ }
154
+ },
155
+ progress(current, total, message) {
156
+ const progressData = { current, total, message };
157
+ storage.updateProgress(run.id, leaseGeneration, progressData);
158
+ eventEmitter.emit({
159
+ type: "run:progress",
160
+ runId: run.id,
161
+ jobName,
162
+ progress: progressData,
163
+ labels: run.labels
164
+ });
165
+ },
166
+ log: {
167
+ info(message, data) {
168
+ eventEmitter.emit({
169
+ type: "log:write",
170
+ runId: run.id,
171
+ jobName,
172
+ labels: run.labels,
173
+ stepName: currentStepName,
174
+ level: "info",
175
+ message,
176
+ data
177
+ });
178
+ },
179
+ warn(message, data) {
180
+ eventEmitter.emit({
181
+ type: "log:write",
182
+ runId: run.id,
183
+ jobName,
184
+ labels: run.labels,
185
+ stepName: currentStepName,
186
+ level: "warn",
187
+ message,
188
+ data
189
+ });
190
+ },
191
+ error(message, data) {
192
+ eventEmitter.emit({
193
+ type: "log:write",
194
+ runId: run.id,
195
+ jobName,
196
+ labels: run.labels,
197
+ stepName: currentStepName,
198
+ level: "error",
199
+ message,
200
+ data
201
+ });
202
+ }
203
+ }
204
+ };
205
+ return {
206
+ step,
207
+ abortLeaseOwnership: abortForLeaseLoss,
208
+ dispose: unsubscribe
209
+ };
210
+ }
7
211
 
8
212
  // src/events.ts
9
213
  function createEventEmitter() {
@@ -96,7 +300,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
96
300
  if (labelsSchema && options?.labels) {
97
301
  validateJobInputOrThrow(labelsSchema, options.labels, "labels");
98
302
  }
99
- const run = await storage.createRun({
303
+ const run = await storage.enqueue({
100
304
  jobName: jobDef.name,
101
305
  input: validatedInput,
102
306
  idempotencyKey: options?.idempotencyKey,
@@ -226,7 +430,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
226
430
  options: normalized[i].options
227
431
  });
228
432
  }
229
- const runs = await storage.batchCreateRuns(
433
+ const runs = await storage.enqueueMany(
230
434
  validated.map((v) => ({
231
435
  jobName: jobDef.name,
232
436
  input: v.input,
@@ -274,6 +478,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
274
478
  }
275
479
 
276
480
  // src/migrations.ts
481
+ import { sql } from "kysely";
277
482
  var migrations = [
278
483
  {
279
484
  version: 1,
@@ -282,12 +487,26 @@ var migrations = [
282
487
  "current_step_index",
283
488
  "integer",
284
489
  (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();
490
+ ).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("lease_owner", "text").addColumn("lease_expires_at", "text").addColumn(
491
+ "lease_generation",
492
+ "integer",
493
+ (col) => col.notNull().defaultTo(0)
494
+ ).addColumn("started_at", "text").addColumn("completed_at", "text").addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
286
495
  await db.schema.createIndex("idx_durably_runs_job_idempotency").ifNotExists().on("durably_runs").columns(["job_name", "idempotency_key"]).unique().execute();
287
496
  await db.schema.createIndex("idx_durably_runs_status_concurrency").ifNotExists().on("durably_runs").columns(["status", "concurrency_key"]).execute();
288
497
  await db.schema.createIndex("idx_durably_runs_status_created").ifNotExists().on("durably_runs").columns(["status", "created_at"]).execute();
498
+ await db.schema.createIndex("idx_durably_runs_status_lease_expires").ifNotExists().on("durably_runs").columns(["status", "lease_expires_at"]).execute();
499
+ await db.schema.createIndex("idx_durably_runs_job_created").ifNotExists().on("durably_runs").columns(["job_name", "created_at"]).execute();
500
+ await db.schema.createIndex("idx_durably_runs_status_completed").ifNotExists().on("durably_runs").columns(["status", "completed_at"]).execute();
501
+ 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();
502
+ await db.schema.createIndex("idx_durably_run_labels_pk").ifNotExists().on("durably_run_labels").columns(["run_id", "key"]).unique().execute();
503
+ await db.schema.createIndex("idx_durably_run_labels_key_value").ifNotExists().on("durably_run_labels").columns(["key", "value"]).execute();
289
504
  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
505
  await db.schema.createIndex("idx_durably_steps_run_index").ifNotExists().on("durably_steps").columns(["run_id", "index"]).execute();
506
+ await sql`
507
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_durably_steps_completed_unique
508
+ ON durably_steps(run_id, name) WHERE status = 'completed'
509
+ `.execute(db);
291
510
  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
511
  await db.schema.createIndex("idx_durably_logs_run_created").ifNotExists().on("durably_logs").columns(["run_id", "created_at"]).execute();
293
512
  await db.schema.createTable("durably_schema_versions").ifNotExists().addColumn("version", "integer", (col) => col.primaryKey()).addColumn("applied_at", "text", (col) => col.notNull()).execute();
@@ -318,14 +537,17 @@ async function runMigrations(db) {
318
537
  }
319
538
 
320
539
  // src/storage.ts
321
- import { sql } from "kysely";
540
+ import { sql as sql2 } from "kysely";
322
541
  import { monotonicFactory } from "ulidx";
323
542
  var ulid = monotonicFactory();
543
+ var TERMINAL_STATUSES = ["completed", "failed", "cancelled"];
324
544
  function toClientRun(run) {
325
545
  const {
326
546
  idempotencyKey,
327
547
  concurrencyKey,
328
- heartbeatAt,
548
+ leaseOwner,
549
+ leaseExpiresAt,
550
+ leaseGeneration,
329
551
  updatedAt,
330
552
  ...clientRun
331
553
  } = run;
@@ -356,7 +578,9 @@ function rowToRun(row) {
356
578
  output: row.output ? JSON.parse(row.output) : null,
357
579
  error: row.error,
358
580
  labels: JSON.parse(row.labels),
359
- heartbeatAt: row.heartbeat_at,
581
+ leaseOwner: row.lease_owner,
582
+ leaseExpiresAt: row.lease_expires_at,
583
+ leaseGeneration: row.lease_generation,
360
584
  startedAt: row.started_at,
361
585
  completedAt: row.completed_at,
362
586
  createdAt: row.created_at,
@@ -387,9 +611,50 @@ function rowToLog(row) {
387
611
  createdAt: row.created_at
388
612
  };
389
613
  }
390
- function createKyselyStorage(db) {
391
- return {
392
- async createRun(input) {
614
+ function createWriteMutex() {
615
+ let queue = Promise.resolve();
616
+ return async function withWriteLock(fn) {
617
+ let release;
618
+ const next = new Promise((resolve) => {
619
+ release = resolve;
620
+ });
621
+ const prev = queue;
622
+ queue = next;
623
+ await prev;
624
+ try {
625
+ return await fn();
626
+ } finally {
627
+ release();
628
+ }
629
+ };
630
+ }
631
+ function createKyselyStore(db, backend = "generic") {
632
+ const withWriteLock = createWriteMutex();
633
+ async function cascadeDeleteRuns(trx, ids) {
634
+ if (ids.length === 0) return;
635
+ await trx.deleteFrom("durably_steps").where("run_id", "in", ids).execute();
636
+ await trx.deleteFrom("durably_logs").where("run_id", "in", ids).execute();
637
+ await trx.deleteFrom("durably_run_labels").where("run_id", "in", ids).execute();
638
+ await trx.deleteFrom("durably_runs").where("id", "in", ids).execute();
639
+ }
640
+ async function insertLabelRows(executor, runId, labels) {
641
+ const entries = Object.entries(labels ?? {});
642
+ if (entries.length > 0) {
643
+ await executor.insertInto("durably_run_labels").values(entries.map(([key, value]) => ({ run_id: runId, key, value }))).execute();
644
+ }
645
+ }
646
+ async function terminateRun(runId, leaseGeneration, completedAt, fields) {
647
+ const result = await db.updateTable("durably_runs").set({
648
+ ...fields,
649
+ lease_owner: null,
650
+ lease_expires_at: null,
651
+ completed_at: completedAt,
652
+ updated_at: completedAt
653
+ }).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).executeTakeFirst();
654
+ return Number(result.numUpdatedRows) > 0;
655
+ }
656
+ const store = {
657
+ async enqueue(input) {
393
658
  const now = (/* @__PURE__ */ new Date()).toISOString();
394
659
  if (input.idempotencyKey) {
395
660
  const existing = await db.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
@@ -411,16 +676,21 @@ function createKyselyStorage(db) {
411
676
  output: null,
412
677
  error: null,
413
678
  labels: JSON.stringify(input.labels ?? {}),
414
- heartbeat_at: now,
679
+ lease_owner: null,
680
+ lease_expires_at: null,
681
+ lease_generation: 0,
415
682
  started_at: null,
416
683
  completed_at: null,
417
684
  created_at: now,
418
685
  updated_at: now
419
686
  };
420
- await db.insertInto("durably_runs").values(run).execute();
687
+ await db.transaction().execute(async (trx) => {
688
+ await trx.insertInto("durably_runs").values(run).execute();
689
+ await insertLabelRows(trx, id, input.labels);
690
+ });
421
691
  return rowToRun(run);
422
692
  },
423
- async batchCreateRuns(inputs) {
693
+ async enqueueMany(inputs) {
424
694
  if (inputs.length === 0) {
425
695
  return [];
426
696
  }
@@ -430,6 +700,7 @@ function createKyselyStorage(db) {
430
700
  for (const input of inputs) {
431
701
  validateLabels(input.labels);
432
702
  }
703
+ const allLabelRows = [];
433
704
  for (const input of inputs) {
434
705
  if (input.idempotencyKey) {
435
706
  const existing = await trx.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
@@ -439,6 +710,11 @@ function createKyselyStorage(db) {
439
710
  }
440
711
  }
441
712
  const id = ulid();
713
+ if (input.labels) {
714
+ for (const [key, value] of Object.entries(input.labels)) {
715
+ allLabelRows.push({ run_id: id, key, value });
716
+ }
717
+ }
442
718
  runs.push({
443
719
  id,
444
720
  job_name: input.jobName,
@@ -451,7 +727,9 @@ function createKyselyStorage(db) {
451
727
  output: null,
452
728
  error: null,
453
729
  labels: JSON.stringify(input.labels ?? {}),
454
- heartbeat_at: now,
730
+ lease_owner: null,
731
+ lease_expires_at: null,
732
+ lease_generation: 0,
455
733
  started_at: null,
456
734
  completed_at: null,
457
735
  created_at: now,
@@ -461,35 +739,13 @@ function createKyselyStorage(db) {
461
739
  const newRuns = runs.filter((r) => r.created_at === now);
462
740
  if (newRuns.length > 0) {
463
741
  await trx.insertInto("durably_runs").values(newRuns).execute();
742
+ if (allLabelRows.length > 0) {
743
+ await trx.insertInto("durably_run_labels").values(allLabelRows).execute();
744
+ }
464
745
  }
465
746
  return runs.map(rowToRun);
466
747
  });
467
748
  },
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
749
  async getRun(runId) {
494
750
  const row = await db.selectFrom("durably_runs").leftJoin("durably_steps", "durably_runs.id", "durably_steps.run_id").selectAll("durably_runs").select(
495
751
  (eb) => eb.fn.count("durably_steps.id").as("step_count")
@@ -517,10 +773,14 @@ function createKyselyStorage(db) {
517
773
  validateLabels(labels);
518
774
  for (const [key, value] of Object.entries(labels)) {
519
775
  if (value === void 0) continue;
776
+ const jsonFallback = backend === "postgres" ? sql2`durably_runs.labels ->> ${key} = ${value}` : sql2`json_extract(durably_runs.labels, ${`$.${key}`}) = ${value}`;
520
777
  query = query.where(
521
- sql`json_extract(durably_runs.labels, ${`$."${key}"`})`,
522
- "=",
523
- value
778
+ (eb) => eb.or([
779
+ eb.exists(
780
+ eb.selectFrom("durably_run_labels").select(sql2.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)
781
+ ),
782
+ jsonFallback
783
+ ])
524
784
  );
525
785
  }
526
786
  }
@@ -537,21 +797,131 @@ function createKyselyStorage(db) {
537
797
  const rows = await query.execute();
538
798
  return rows.map(rowToRun);
539
799
  },
540
- async claimNextPendingRun(excludeConcurrencyKeys) {
800
+ async updateRun(runId, data) {
541
801
  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
- );
802
+ const status = data.status;
803
+ await db.updateTable("durably_runs").set({
804
+ status,
805
+ current_step_index: data.currentStepIndex,
806
+ progress: data.progress !== void 0 ? data.progress ? JSON.stringify(data.progress) : null : void 0,
807
+ output: data.output !== void 0 ? JSON.stringify(data.output) : void 0,
808
+ error: data.error,
809
+ lease_owner: data.leaseOwner !== void 0 ? data.leaseOwner : void 0,
810
+ lease_expires_at: data.leaseExpiresAt !== void 0 ? data.leaseExpiresAt : void 0,
811
+ started_at: data.startedAt,
812
+ completed_at: data.completedAt,
813
+ updated_at: now
814
+ }).where("id", "=", runId).execute();
815
+ },
816
+ async deleteRun(runId) {
817
+ await db.transaction().execute(async (trx) => {
818
+ await cascadeDeleteRuns(trx, [runId]);
819
+ });
820
+ },
821
+ async purgeRuns(options) {
822
+ const limit = options.limit ?? 500;
823
+ return await db.transaction().execute(async (trx) => {
824
+ 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();
825
+ if (rows.length === 0) return 0;
826
+ const ids = rows.map((r) => r.id);
827
+ await cascadeDeleteRuns(trx, ids);
828
+ return ids.length;
829
+ });
830
+ },
831
+ async claimNext(workerId, now, leaseMs) {
832
+ const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
833
+ const activeLeaseGuard = sql2`
834
+ (
835
+ concurrency_key IS NULL
836
+ OR NOT EXISTS (
837
+ SELECT 1
838
+ FROM durably_runs AS active
839
+ WHERE active.concurrency_key = durably_runs.concurrency_key
840
+ AND active.id <> durably_runs.id
841
+ AND active.status = 'leased'
842
+ AND active.lease_expires_at IS NOT NULL
843
+ AND active.lease_expires_at > ${now}
844
+ )
845
+ )
846
+ `;
847
+ if (backend === "postgres") {
848
+ return await db.transaction().execute(async (trx) => {
849
+ const skipKeys = [];
850
+ for (; ; ) {
851
+ const concurrencyCondition = skipKeys.length > 0 ? sql2`
852
+ AND (
853
+ concurrency_key IS NULL
854
+ OR concurrency_key NOT IN (${sql2.join(skipKeys)})
855
+ )
856
+ ` : sql2``;
857
+ const candidateResult = await sql2`
858
+ SELECT id, concurrency_key
859
+ FROM durably_runs
860
+ WHERE
861
+ (
862
+ status = 'pending'
863
+ OR (status = 'leased' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ${now})
864
+ )
865
+ AND ${activeLeaseGuard}
866
+ ${concurrencyCondition}
867
+ ORDER BY created_at ASC, id ASC
868
+ FOR UPDATE SKIP LOCKED
869
+ LIMIT 1
870
+ `.execute(trx);
871
+ const candidate = candidateResult.rows[0];
872
+ if (!candidate) return null;
873
+ if (candidate.concurrency_key) {
874
+ await sql2`SELECT pg_advisory_xact_lock(hashtext(${candidate.concurrency_key}))`.execute(
875
+ trx
876
+ );
877
+ const conflict = await sql2`
878
+ SELECT 1 FROM durably_runs
879
+ WHERE concurrency_key = ${candidate.concurrency_key}
880
+ AND id <> ${candidate.id}
881
+ AND status = 'leased'
882
+ AND lease_expires_at IS NOT NULL
883
+ AND lease_expires_at > ${now}
884
+ LIMIT 1
885
+ `.execute(trx);
886
+ if (conflict.rows.length > 0) {
887
+ skipKeys.push(candidate.concurrency_key);
888
+ continue;
889
+ }
890
+ }
891
+ const result = await sql2`
892
+ UPDATE durably_runs
893
+ SET
894
+ status = 'leased',
895
+ lease_owner = ${workerId},
896
+ lease_expires_at = ${leaseExpiresAt},
897
+ lease_generation = lease_generation + 1,
898
+ started_at = COALESCE(started_at, ${now}),
899
+ updated_at = ${now}
900
+ WHERE id = ${candidate.id}
901
+ RETURNING *
902
+ `.execute(trx);
903
+ const row2 = result.rows[0];
904
+ if (!row2) return null;
905
+ return rowToRun({ ...row2, step_count: 0 });
906
+ }
907
+ });
550
908
  }
909
+ let subquery = db.selectFrom("durably_runs").select("durably_runs.id").where(
910
+ (eb) => eb.or([
911
+ eb("status", "=", "pending"),
912
+ eb.and([
913
+ eb("status", "=", "leased"),
914
+ eb("lease_expires_at", "is not", null),
915
+ eb("lease_expires_at", "<=", now)
916
+ ])
917
+ ])
918
+ ).where(activeLeaseGuard).orderBy("created_at", "asc").orderBy("id", "asc").limit(1);
551
919
  const row = await db.updateTable("durably_runs").set({
552
- status: "running",
553
- heartbeat_at: now,
554
- started_at: sql`COALESCE(started_at, ${now})`,
920
+ status: "leased",
921
+ lease_owner: workerId,
922
+ lease_expires_at: leaseExpiresAt,
923
+ lease_generation: sql2`lease_generation + 1`,
924
+ started_at: sql2`COALESCE(started_at, ${now})`,
555
925
  updated_at: now
556
926
  }).where(
557
927
  "id",
@@ -561,367 +931,165 @@ function createKyselyStorage(db) {
561
931
  if (!row) return null;
562
932
  return rowToRun({ ...row, step_count: 0 });
563
933
  },
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);
934
+ async renewLease(runId, leaseGeneration, now, leaseMs) {
935
+ const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
936
+ const result = await db.updateTable("durably_runs").set({
937
+ lease_expires_at: leaseExpiresAt,
938
+ updated_at: now
939
+ }).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).where("lease_expires_at", ">", now).executeTakeFirst();
940
+ return Number(result.numUpdatedRows) > 0;
580
941
  },
581
- async getSteps(runId) {
582
- const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
583
- return rows.map(rowToStep);
942
+ async releaseExpiredLeases(now) {
943
+ const result = await db.updateTable("durably_runs").set({
944
+ status: "pending",
945
+ lease_owner: null,
946
+ lease_expires_at: null,
947
+ updated_at: now
948
+ }).where("status", "=", "leased").where("lease_expires_at", "is not", null).where("lease_expires_at", "<=", now).executeTakeFirst();
949
+ return Number(result.numUpdatedRows);
584
950
  },
585
- async getCompletedStep(runId, name) {
586
- const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
587
- return row ? rowToStep(row) : null;
951
+ async completeRun(runId, leaseGeneration, output, completedAt) {
952
+ return terminateRun(runId, leaseGeneration, completedAt, {
953
+ status: "completed",
954
+ output: JSON.stringify(output),
955
+ error: null
956
+ });
588
957
  },
589
- async createLog(input) {
590
- const now = (/* @__PURE__ */ new Date()).toISOString();
591
- const id = ulid();
592
- const log = {
593
- id,
594
- run_id: input.runId,
595
- step_name: input.stepName,
596
- level: input.level,
597
- message: input.message,
598
- data: input.data !== void 0 ? JSON.stringify(input.data) : null,
599
- created_at: now
600
- };
601
- await db.insertInto("durably_logs").values(log).execute();
602
- return rowToLog(log);
958
+ async failRun(runId, leaseGeneration, error, completedAt) {
959
+ return terminateRun(runId, leaseGeneration, completedAt, {
960
+ status: "failed",
961
+ error
962
+ });
603
963
  },
604
- async getLogs(runId) {
605
- const rows = await db.selectFrom("durably_logs").selectAll().where("run_id", "=", runId).orderBy("created_at", "asc").execute();
606
- return rows.map(rowToLog);
607
- }
608
- };
609
- }
610
-
611
- // src/worker.ts
612
- import { prettifyError as prettifyError2 } from "zod";
613
-
614
- // src/errors.ts
615
- var CancelledError = class extends Error {
616
- constructor(runId) {
617
- super(`Run was cancelled: ${runId}`);
618
- this.name = "CancelledError";
619
- }
620
- };
621
- function getErrorMessage(error) {
622
- return error instanceof Error ? error.message : String(error);
623
- }
624
-
625
- // src/context.ts
626
- function createStepContext(run, jobName, storage, eventEmitter) {
627
- let stepIndex = run.currentStepIndex;
628
- let currentStepName = null;
629
- const controller = new AbortController();
630
- const unsubscribe = eventEmitter.on("run:cancel", (event) => {
631
- if (event.runId === run.id) {
632
- controller.abort();
633
- }
634
- });
635
- const step = {
636
- get runId() {
637
- return run.id;
964
+ async cancelRun(runId, now) {
965
+ const result = await db.updateTable("durably_runs").set({
966
+ status: "cancelled",
967
+ lease_owner: null,
968
+ lease_expires_at: null,
969
+ completed_at: now,
970
+ updated_at: now
971
+ }).where("id", "=", runId).where("status", "in", ["pending", "leased"]).executeTakeFirst();
972
+ return Number(result.numUpdatedRows) > 0;
638
973
  },
639
- async run(name, fn) {
640
- if (controller.signal.aborted) {
641
- throw new CancelledError(run.id);
642
- }
643
- const currentRun = await storage.getRun(run.id);
644
- if (currentRun?.status === "cancelled") {
645
- controller.abort();
646
- throw new CancelledError(run.id);
647
- }
648
- if (controller.signal.aborted) {
649
- throw new CancelledError(run.id);
650
- }
651
- const existingStep = await storage.getCompletedStep(run.id, name);
652
- if (existingStep) {
653
- stepIndex++;
654
- return existingStep.output;
655
- }
656
- currentStepName = name;
657
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
658
- const startTime = Date.now();
659
- eventEmitter.emit({
660
- type: "step:start",
661
- runId: run.id,
662
- jobName,
663
- stepName: name,
664
- stepIndex,
665
- labels: run.labels
666
- });
667
- try {
668
- const result = await fn(controller.signal);
669
- await storage.createStep({
670
- runId: run.id,
671
- name,
672
- index: stepIndex,
673
- status: "completed",
674
- output: result,
675
- startedAt
676
- });
677
- stepIndex++;
678
- await storage.updateRun(run.id, { currentStepIndex: stepIndex });
679
- eventEmitter.emit({
680
- type: "step:complete",
681
- runId: run.id,
682
- jobName,
683
- stepName: name,
684
- stepIndex: stepIndex - 1,
685
- output: result,
686
- duration: Date.now() - startTime,
687
- labels: run.labels
688
- });
689
- return result;
690
- } catch (error) {
691
- const isCancelled = controller.signal.aborted;
692
- const errorMessage = error instanceof Error ? error.message : String(error);
693
- await storage.createStep({
694
- runId: run.id,
695
- name,
696
- index: stepIndex,
697
- status: isCancelled ? "cancelled" : "failed",
698
- error: errorMessage,
699
- startedAt
700
- });
701
- eventEmitter.emit({
702
- ...isCancelled ? { type: "step:cancel" } : { type: "step:fail", error: errorMessage },
703
- runId: run.id,
704
- jobName,
705
- stepName: name,
706
- stepIndex,
707
- labels: run.labels
708
- });
709
- if (isCancelled) {
710
- throw new CancelledError(run.id);
974
+ async persistStep(runId, leaseGeneration, input) {
975
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
976
+ const id = ulid();
977
+ const outputJson = input.output !== void 0 ? JSON.stringify(input.output) : null;
978
+ const errorValue = input.error ?? null;
979
+ return await db.transaction().execute(async (trx) => {
980
+ const insertResult = await sql2`
981
+ INSERT INTO durably_steps (id, run_id, name, "index", status, output, error, started_at, completed_at)
982
+ SELECT ${id}, ${runId}, ${input.name}, ${input.index}, ${input.status},
983
+ ${outputJson}, ${errorValue}, ${input.startedAt}, ${completedAt}
984
+ FROM durably_runs
985
+ WHERE id = ${runId} AND lease_generation = ${leaseGeneration}
986
+ `.execute(trx);
987
+ if (Number(insertResult.numAffectedRows) === 0) return null;
988
+ if (input.status === "completed") {
989
+ await trx.updateTable("durably_runs").set({
990
+ current_step_index: input.index + 1,
991
+ updated_at: completedAt
992
+ }).where("id", "=", runId).where("lease_generation", "=", leaseGeneration).execute();
711
993
  }
712
- throw error;
713
- } finally {
714
- currentStepName = null;
715
- }
716
- },
717
- progress(current, total, message) {
718
- const progressData = { current, total, message };
719
- storage.updateRun(run.id, { progress: progressData });
720
- eventEmitter.emit({
721
- type: "run:progress",
722
- runId: run.id,
723
- jobName,
724
- progress: progressData,
725
- labels: run.labels
994
+ return {
995
+ id,
996
+ runId,
997
+ name: input.name,
998
+ index: input.index,
999
+ status: input.status,
1000
+ output: input.output !== void 0 ? input.output : null,
1001
+ error: errorValue,
1002
+ startedAt: input.startedAt,
1003
+ completedAt
1004
+ };
726
1005
  });
727
1006
  },
728
- log: {
729
- info(message, data) {
730
- eventEmitter.emit({
731
- type: "log:write",
732
- runId: run.id,
733
- jobName,
734
- labels: run.labels,
735
- stepName: currentStepName,
736
- level: "info",
737
- message,
738
- data
739
- });
740
- },
741
- warn(message, data) {
742
- eventEmitter.emit({
743
- type: "log:write",
744
- runId: run.id,
745
- jobName,
746
- labels: run.labels,
747
- stepName: currentStepName,
748
- level: "warn",
749
- message,
750
- data
751
- });
752
- },
753
- error(message, data) {
754
- eventEmitter.emit({
755
- type: "log:write",
756
- runId: run.id,
757
- jobName,
758
- labels: run.labels,
759
- stepName: currentStepName,
760
- level: "error",
761
- message,
762
- data
763
- });
764
- }
1007
+ async deleteSteps(runId) {
1008
+ await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
1009
+ await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
1010
+ },
1011
+ async getSteps(runId) {
1012
+ const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
1013
+ return rows.map(rowToStep);
1014
+ },
1015
+ async getCompletedStep(runId, name) {
1016
+ const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
1017
+ return row ? rowToStep(row) : null;
1018
+ },
1019
+ async updateProgress(runId, leaseGeneration, progress) {
1020
+ await db.updateTable("durably_runs").set({
1021
+ progress: progress ? JSON.stringify(progress) : null,
1022
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1023
+ }).where("id", "=", runId).where("lease_generation", "=", leaseGeneration).execute();
1024
+ },
1025
+ async createLog(input) {
1026
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1027
+ const id = ulid();
1028
+ const log = {
1029
+ id,
1030
+ run_id: input.runId,
1031
+ step_name: input.stepName,
1032
+ level: input.level,
1033
+ message: input.message,
1034
+ data: input.data !== void 0 ? JSON.stringify(input.data) : null,
1035
+ created_at: now
1036
+ };
1037
+ await db.insertInto("durably_logs").values(log).execute();
1038
+ return rowToLog(log);
1039
+ },
1040
+ async getLogs(runId) {
1041
+ const rows = await db.selectFrom("durably_logs").selectAll().where("run_id", "=", runId).orderBy("created_at", "asc").execute();
1042
+ return rows.map(rowToLog);
765
1043
  }
766
1044
  };
767
- return { step, dispose: unsubscribe };
1045
+ const mutatingKeys = [
1046
+ "enqueue",
1047
+ "enqueueMany",
1048
+ "updateRun",
1049
+ "deleteRun",
1050
+ "purgeRuns",
1051
+ "claimNext",
1052
+ "renewLease",
1053
+ "releaseExpiredLeases",
1054
+ "completeRun",
1055
+ "failRun",
1056
+ "cancelRun",
1057
+ "persistStep",
1058
+ "deleteSteps",
1059
+ "updateProgress",
1060
+ "createLog"
1061
+ ];
1062
+ for (const key of mutatingKeys) {
1063
+ const original = store[key];
1064
+ store[key] = (...args) => withWriteLock(() => original.apply(store, args));
1065
+ }
1066
+ return store;
768
1067
  }
769
1068
 
770
1069
  // src/worker.ts
771
- function createWorker(config, storage, eventEmitter, jobRegistry) {
1070
+ function createWorker(config, processOne) {
772
1071
  let running = false;
773
- let currentRunPromise = null;
774
1072
  let pollingTimeout = null;
1073
+ let inFlight = null;
775
1074
  let stopResolver = null;
776
- let heartbeatInterval = null;
777
- let currentRunId = null;
778
- async function recoverStaleRuns() {
779
- const staleThreshold = new Date(
780
- Date.now() - config.staleThreshold
781
- ).toISOString();
782
- const runningRuns = await storage.getRuns({ status: "running" });
783
- for (const run of runningRuns) {
784
- if (run.heartbeatAt < staleThreshold) {
785
- await storage.updateRun(run.id, {
786
- status: "pending"
787
- });
788
- }
789
- }
790
- }
791
- async function updateHeartbeat() {
792
- if (currentRunId) {
793
- await storage.updateRun(currentRunId, {
794
- heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
795
- });
796
- }
797
- }
798
- async function handleRunSuccess(runId, jobName, output, startTime) {
799
- const currentRun = await storage.getRun(runId);
800
- if (!currentRun || currentRun.status === "cancelled") {
801
- return;
802
- }
803
- await storage.updateRun(runId, {
804
- status: "completed",
805
- output,
806
- completedAt: (/* @__PURE__ */ new Date()).toISOString()
807
- });
808
- eventEmitter.emit({
809
- type: "run:complete",
810
- runId,
811
- jobName,
812
- output,
813
- duration: Date.now() - startTime,
814
- labels: currentRun.labels
815
- });
816
- }
817
- async function handleRunFailure(runId, jobName, error) {
818
- if (error instanceof CancelledError) {
819
- return;
820
- }
821
- const currentRun = await storage.getRun(runId);
822
- if (!currentRun || currentRun.status === "cancelled") {
823
- return;
824
- }
825
- const errorMessage = getErrorMessage(error);
826
- const steps = await storage.getSteps(runId);
827
- const failedStep = steps.find((s) => s.status === "failed");
828
- await storage.updateRun(runId, {
829
- status: "failed",
830
- error: errorMessage,
831
- completedAt: (/* @__PURE__ */ new Date()).toISOString()
832
- });
833
- eventEmitter.emit({
834
- type: "run:fail",
835
- runId,
836
- jobName,
837
- error: errorMessage,
838
- failedStepName: failedStep?.name ?? "unknown",
839
- labels: currentRun.labels
840
- });
841
- }
842
- async function executeRun(run, job) {
843
- currentRunId = run.id;
844
- heartbeatInterval = setInterval(() => {
845
- updateHeartbeat().catch((error) => {
846
- eventEmitter.emit({
847
- type: "worker:error",
848
- error: getErrorMessage(error),
849
- context: "heartbeat",
850
- runId: run.id
851
- });
852
- });
853
- }, config.heartbeatInterval);
854
- eventEmitter.emit({
855
- type: "run:start",
856
- runId: run.id,
857
- jobName: run.jobName,
858
- input: run.input,
859
- labels: run.labels
860
- });
861
- const startTime = Date.now();
862
- const { step, dispose } = createStepContext(
863
- run,
864
- run.jobName,
865
- storage,
866
- eventEmitter
867
- );
868
- try {
869
- const output = await job.fn(step, run.input);
870
- if (job.outputSchema) {
871
- const parseResult = job.outputSchema.safeParse(output);
872
- if (!parseResult.success) {
873
- throw new Error(`Invalid output: ${prettifyError2(parseResult.error)}`);
874
- }
875
- }
876
- await handleRunSuccess(run.id, run.jobName, output, startTime);
877
- } catch (error) {
878
- await handleRunFailure(run.id, run.jobName, error);
879
- } finally {
880
- dispose();
881
- if (heartbeatInterval) {
882
- clearInterval(heartbeatInterval);
883
- heartbeatInterval = null;
884
- }
885
- currentRunId = null;
886
- }
887
- }
888
- async function processNextRun() {
889
- const runningRuns = await storage.getRuns({ status: "running" });
890
- const excludeConcurrencyKeys = runningRuns.filter(
891
- (r) => r.concurrencyKey !== null
892
- ).map((r) => r.concurrencyKey);
893
- const run = await storage.claimNextPendingRun(excludeConcurrencyKeys);
894
- if (!run) {
895
- return false;
896
- }
897
- const job = jobRegistry.get(run.jobName);
898
- if (!job) {
899
- await storage.updateRun(run.id, {
900
- status: "failed",
901
- error: `Unknown job: ${run.jobName}`
902
- });
903
- return true;
904
- }
905
- await executeRun(run, job);
906
- return true;
907
- }
1075
+ let activeWorkerId;
908
1076
  async function poll() {
909
1077
  if (!running) {
910
1078
  return;
911
1079
  }
912
- const doWork = async () => {
913
- await recoverStaleRuns();
914
- await processNextRun();
915
- };
916
1080
  try {
917
- currentRunPromise = doWork();
918
- await currentRunPromise;
1081
+ inFlight = processOne({ workerId: activeWorkerId }).then(() => void 0);
1082
+ await inFlight;
919
1083
  } finally {
920
- currentRunPromise = null;
1084
+ inFlight = null;
921
1085
  }
922
1086
  if (running) {
923
- pollingTimeout = setTimeout(() => poll(), config.pollingInterval);
924
- } else if (stopResolver) {
1087
+ pollingTimeout = setTimeout(() => {
1088
+ void poll();
1089
+ }, config.pollingIntervalMs);
1090
+ return;
1091
+ }
1092
+ if (stopResolver) {
925
1093
  stopResolver();
926
1094
  stopResolver = null;
927
1095
  }
@@ -930,12 +1098,13 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
930
1098
  get isRunning() {
931
1099
  return running;
932
1100
  },
933
- start() {
1101
+ start(options) {
934
1102
  if (running) {
935
1103
  return;
936
1104
  }
1105
+ activeWorkerId = options?.workerId;
937
1106
  running = true;
938
- poll();
1107
+ void poll();
939
1108
  },
940
1109
  async stop() {
941
1110
  if (!running) {
@@ -946,11 +1115,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
946
1115
  clearTimeout(pollingTimeout);
947
1116
  pollingTimeout = null;
948
1117
  }
949
- if (heartbeatInterval) {
950
- clearInterval(heartbeatInterval);
951
- heartbeatInterval = null;
952
- }
953
- if (currentRunPromise) {
1118
+ if (inFlight) {
954
1119
  return new Promise((resolve) => {
955
1120
  stopResolver = resolve;
956
1121
  });
@@ -961,12 +1126,249 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
961
1126
 
962
1127
  // src/durably.ts
963
1128
  var DEFAULTS = {
964
- pollingInterval: 1e3,
965
- heartbeatInterval: 5e3,
966
- staleThreshold: 3e4
1129
+ pollingIntervalMs: 1e3,
1130
+ leaseRenewIntervalMs: 5e3,
1131
+ leaseMs: 3e4,
1132
+ preserveSteps: false
967
1133
  };
1134
+ function parseDuration(value) {
1135
+ const match = value.match(/^(\d+)(d|h|m)$/);
1136
+ if (!match) {
1137
+ throw new Error(
1138
+ `Invalid duration format: "${value}". Use e.g. '30d', '24h', '60m'`
1139
+ );
1140
+ }
1141
+ const num = Number.parseInt(match[1], 10);
1142
+ const unit = match[2];
1143
+ const multipliers = {
1144
+ d: 864e5,
1145
+ h: 36e5,
1146
+ m: 6e4
1147
+ };
1148
+ return num * multipliers[unit];
1149
+ }
1150
+ var PURGE_INTERVAL_MS = 6e4;
1151
+ var ulid2 = monotonicFactory2();
1152
+ var BROWSER_SINGLETON_REGISTRY_KEY = "__durablyBrowserSingletonRegistry";
1153
+ var BROWSER_LOCAL_DIALECT_KEY = "__durablyBrowserLocalKey";
1154
+ function defaultWorkerId() {
1155
+ return `worker_${ulid2()}`;
1156
+ }
1157
+ function detectBackend(dialect) {
1158
+ return dialect.constructor.name === "PostgresDialect" ? "postgres" : "generic";
1159
+ }
1160
+ function isBrowserLikeEnvironment() {
1161
+ return typeof globalThis.window !== "undefined" || typeof globalThis.document !== "undefined";
1162
+ }
1163
+ function getBrowserSingletonKey(dialect, explicitKey) {
1164
+ if (!isBrowserLikeEnvironment()) {
1165
+ return null;
1166
+ }
1167
+ if (explicitKey) {
1168
+ return explicitKey;
1169
+ }
1170
+ const taggedDialect = dialect;
1171
+ const taggedKey = taggedDialect[BROWSER_LOCAL_DIALECT_KEY];
1172
+ return typeof taggedKey === "string" ? taggedKey : null;
1173
+ }
1174
+ function registerBrowserSingletonWarning(singletonKey) {
1175
+ const globalRegistry = globalThis;
1176
+ const registry = globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] ?? /* @__PURE__ */ new Map();
1177
+ globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] = registry;
1178
+ const instanceId = ulid2();
1179
+ const instances = registry.get(singletonKey) ?? /* @__PURE__ */ new Set();
1180
+ const hadExistingInstance = instances.size > 0;
1181
+ instances.add(instanceId);
1182
+ registry.set(singletonKey, instances);
1183
+ if (hadExistingInstance && (typeof process === "undefined" || process.env.NODE_ENV !== "production")) {
1184
+ console.warn(
1185
+ `[durably] Multiple runtimes were created for browser-local store "${singletonKey}" in one tab. Prefer a single shared instance per tab.`
1186
+ );
1187
+ }
1188
+ let released = false;
1189
+ return () => {
1190
+ if (released) {
1191
+ return;
1192
+ }
1193
+ released = true;
1194
+ const activeInstances = registry.get(singletonKey);
1195
+ if (!activeInstances) {
1196
+ return;
1197
+ }
1198
+ activeInstances.delete(instanceId);
1199
+ if (activeInstances.size === 0) {
1200
+ registry.delete(singletonKey);
1201
+ }
1202
+ };
1203
+ }
968
1204
  function createDurablyInstance(state, jobs) {
969
- const { db, storage, eventEmitter, jobRegistry, worker } = state;
1205
+ const {
1206
+ db,
1207
+ storage,
1208
+ eventEmitter,
1209
+ jobRegistry,
1210
+ worker,
1211
+ releaseBrowserSingleton
1212
+ } = state;
1213
+ async function getRunOrThrow(runId) {
1214
+ const run = await storage.getRun(runId);
1215
+ if (!run) {
1216
+ throw new Error(`Run not found: ${runId}`);
1217
+ }
1218
+ return run;
1219
+ }
1220
+ async function executeRun(run, workerId) {
1221
+ const job = jobRegistry.get(run.jobName);
1222
+ if (!job) {
1223
+ await storage.failRun(
1224
+ run.id,
1225
+ run.leaseGeneration,
1226
+ `Unknown job: ${run.jobName}`,
1227
+ (/* @__PURE__ */ new Date()).toISOString()
1228
+ );
1229
+ return;
1230
+ }
1231
+ const { step, abortLeaseOwnership, dispose } = createStepContext(
1232
+ run,
1233
+ run.jobName,
1234
+ run.leaseGeneration,
1235
+ storage,
1236
+ eventEmitter
1237
+ );
1238
+ let leaseDeadlineTimer = null;
1239
+ const scheduleLeaseDeadline = (leaseExpiresAt) => {
1240
+ if (leaseDeadlineTimer) {
1241
+ clearTimeout(leaseDeadlineTimer);
1242
+ leaseDeadlineTimer = null;
1243
+ }
1244
+ if (!leaseExpiresAt) {
1245
+ return;
1246
+ }
1247
+ const delay = Math.max(0, Date.parse(leaseExpiresAt) - Date.now());
1248
+ leaseDeadlineTimer = setTimeout(() => {
1249
+ abortLeaseOwnership();
1250
+ }, delay);
1251
+ };
1252
+ scheduleLeaseDeadline(run.leaseExpiresAt);
1253
+ const leaseTimer = setInterval(() => {
1254
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1255
+ storage.renewLease(run.id, run.leaseGeneration, now, state.leaseMs).then((renewed) => {
1256
+ if (!renewed) {
1257
+ abortLeaseOwnership();
1258
+ eventEmitter.emit({
1259
+ type: "worker:error",
1260
+ error: `Lease renewal lost ownership for run ${run.id}`,
1261
+ context: "lease-renewal",
1262
+ runId: run.id
1263
+ });
1264
+ return;
1265
+ }
1266
+ const renewedLeaseExpiresAt = new Date(
1267
+ Date.parse(now) + state.leaseMs
1268
+ ).toISOString();
1269
+ scheduleLeaseDeadline(renewedLeaseExpiresAt);
1270
+ eventEmitter.emit({
1271
+ type: "run:lease-renewed",
1272
+ runId: run.id,
1273
+ jobName: run.jobName,
1274
+ leaseOwner: workerId,
1275
+ leaseExpiresAt: renewedLeaseExpiresAt,
1276
+ labels: run.labels
1277
+ });
1278
+ }).catch((error) => {
1279
+ eventEmitter.emit({
1280
+ type: "worker:error",
1281
+ error: getErrorMessage(error),
1282
+ context: "lease-renewal",
1283
+ runId: run.id
1284
+ });
1285
+ });
1286
+ }, state.leaseRenewIntervalMs);
1287
+ const started = Date.now();
1288
+ let reachedTerminalState = false;
1289
+ try {
1290
+ eventEmitter.emit({
1291
+ type: "run:leased",
1292
+ runId: run.id,
1293
+ jobName: run.jobName,
1294
+ input: run.input,
1295
+ leaseOwner: workerId,
1296
+ leaseExpiresAt: run.leaseExpiresAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1297
+ labels: run.labels
1298
+ });
1299
+ const output = await job.fn(step, run.input);
1300
+ if (job.outputSchema) {
1301
+ const parseResult = job.outputSchema.safeParse(output);
1302
+ if (!parseResult.success) {
1303
+ throw new Error(`Invalid output: ${parseResult.error.message}`);
1304
+ }
1305
+ }
1306
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
1307
+ const completed = await storage.completeRun(
1308
+ run.id,
1309
+ run.leaseGeneration,
1310
+ output,
1311
+ completedAt
1312
+ );
1313
+ if (completed) {
1314
+ reachedTerminalState = true;
1315
+ eventEmitter.emit({
1316
+ type: "run:complete",
1317
+ runId: run.id,
1318
+ jobName: run.jobName,
1319
+ output,
1320
+ duration: Date.now() - started,
1321
+ labels: run.labels
1322
+ });
1323
+ } else {
1324
+ eventEmitter.emit({
1325
+ type: "worker:error",
1326
+ error: `Lease lost before completing run ${run.id}`,
1327
+ context: "run-completion"
1328
+ });
1329
+ }
1330
+ } catch (error) {
1331
+ if (error instanceof LeaseLostError || error instanceof CancelledError) {
1332
+ return;
1333
+ }
1334
+ const errorMessage = getErrorMessage(error);
1335
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
1336
+ const failed = await storage.failRun(
1337
+ run.id,
1338
+ run.leaseGeneration,
1339
+ errorMessage,
1340
+ completedAt
1341
+ );
1342
+ if (failed) {
1343
+ reachedTerminalState = true;
1344
+ const steps = await storage.getSteps(run.id);
1345
+ const failedStep = steps.find((entry) => entry.status === "failed");
1346
+ eventEmitter.emit({
1347
+ type: "run:fail",
1348
+ runId: run.id,
1349
+ jobName: run.jobName,
1350
+ error: errorMessage,
1351
+ failedStepName: failedStep?.name ?? "unknown",
1352
+ labels: run.labels
1353
+ });
1354
+ } else {
1355
+ eventEmitter.emit({
1356
+ type: "worker:error",
1357
+ error: `Lease lost before recording failure for run ${run.id}`,
1358
+ context: "run-failure"
1359
+ });
1360
+ }
1361
+ } finally {
1362
+ clearInterval(leaseTimer);
1363
+ if (leaseDeadlineTimer) {
1364
+ clearTimeout(leaseDeadlineTimer);
1365
+ }
1366
+ dispose();
1367
+ if (!state.preserveSteps && reachedTerminalState) {
1368
+ await storage.deleteSteps(run.id);
1369
+ }
1370
+ }
1371
+ }
970
1372
  const durably = {
971
1373
  db,
972
1374
  storage,
@@ -975,7 +1377,10 @@ function createDurablyInstance(state, jobs) {
975
1377
  emit: eventEmitter.emit,
976
1378
  onError: eventEmitter.onError,
977
1379
  start: worker.start,
978
- stop: worker.stop,
1380
+ async stop() {
1381
+ releaseBrowserSingleton();
1382
+ await worker.stop();
1383
+ },
979
1384
  // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions
980
1385
  register(jobDefs) {
981
1386
  const newHandles = {};
@@ -996,8 +1401,8 @@ function createDurablyInstance(state, jobs) {
996
1401
  mergedJobs
997
1402
  );
998
1403
  },
999
- getRun: storage.getRun,
1000
- getRuns: storage.getRuns,
1404
+ getRun: storage.getRun.bind(storage),
1405
+ getRuns: storage.getRuns.bind(storage),
1001
1406
  use(plugin) {
1002
1407
  plugin.install(durably);
1003
1408
  },
@@ -1011,14 +1416,18 @@ function createDurablyInstance(state, jobs) {
1011
1416
  subscribe(runId) {
1012
1417
  let closed = false;
1013
1418
  let cleanup = null;
1014
- const closeEvents = /* @__PURE__ */ new Set(["run:complete", "run:delete"]);
1419
+ const closeEvents = /* @__PURE__ */ new Set([
1420
+ "run:complete",
1421
+ "run:fail",
1422
+ "run:cancel",
1423
+ "run:delete"
1424
+ ]);
1015
1425
  const subscribedEvents = [
1016
- "run:start",
1426
+ "run:leased",
1017
1427
  "run:complete",
1018
1428
  "run:fail",
1019
1429
  "run:cancel",
1020
1430
  "run:delete",
1021
- "run:retry",
1022
1431
  "run:progress",
1023
1432
  "step:start",
1024
1433
  "step:complete",
@@ -1041,6 +1450,64 @@ function createDurablyInstance(state, jobs) {
1041
1450
  cleanup = () => {
1042
1451
  for (const unsub of unsubscribes) unsub();
1043
1452
  };
1453
+ const closeStream = () => {
1454
+ closed = true;
1455
+ cleanup?.();
1456
+ controller.close();
1457
+ };
1458
+ storage.getRun(runId).then((run) => {
1459
+ if (closed || !run) return;
1460
+ const base = {
1461
+ runId,
1462
+ jobName: run.jobName,
1463
+ labels: run.labels,
1464
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1465
+ sequence: 0
1466
+ };
1467
+ if (run.status === "leased") {
1468
+ controller.enqueue({
1469
+ ...base,
1470
+ type: "run:leased",
1471
+ input: run.input,
1472
+ leaseOwner: run.leaseOwner ?? "",
1473
+ leaseExpiresAt: run.leaseExpiresAt ?? ""
1474
+ });
1475
+ if (run.progress != null) {
1476
+ controller.enqueue({
1477
+ ...base,
1478
+ type: "run:progress",
1479
+ progress: run.progress
1480
+ });
1481
+ }
1482
+ } else if (run.status === "completed") {
1483
+ controller.enqueue({
1484
+ ...base,
1485
+ type: "run:complete",
1486
+ output: run.output,
1487
+ duration: 0
1488
+ });
1489
+ closeStream();
1490
+ } else if (run.status === "failed") {
1491
+ controller.enqueue({
1492
+ ...base,
1493
+ type: "run:fail",
1494
+ error: run.error ?? "Unknown error",
1495
+ failedStepName: ""
1496
+ });
1497
+ closeStream();
1498
+ } else if (run.status === "cancelled") {
1499
+ controller.enqueue({
1500
+ ...base,
1501
+ type: "run:cancel"
1502
+ });
1503
+ closeStream();
1504
+ }
1505
+ }).catch((error) => {
1506
+ if (closed) return;
1507
+ closed = true;
1508
+ cleanup?.();
1509
+ controller.error(error);
1510
+ });
1044
1511
  },
1045
1512
  cancel: () => {
1046
1513
  if (!closed) {
@@ -1050,36 +1517,40 @@ function createDurablyInstance(state, jobs) {
1050
1517
  }
1051
1518
  });
1052
1519
  },
1053
- async retry(runId) {
1054
- const run = await storage.getRun(runId);
1055
- if (!run) {
1056
- throw new Error(`Run not found: ${runId}`);
1057
- }
1058
- if (run.status === "completed") {
1059
- throw new Error(`Cannot retry completed run: ${runId}`);
1060
- }
1520
+ async retrigger(runId) {
1521
+ const run = await getRunOrThrow(runId);
1061
1522
  if (run.status === "pending") {
1062
- throw new Error(`Cannot retry pending run: ${runId}`);
1523
+ throw new Error(`Cannot retrigger pending run: ${runId}`);
1063
1524
  }
1064
- if (run.status === "running") {
1065
- throw new Error(`Cannot retry running run: ${runId}`);
1525
+ if (run.status === "leased") {
1526
+ throw new Error(`Cannot retrigger leased run: ${runId}`);
1066
1527
  }
1067
- await storage.updateRun(runId, {
1068
- status: "pending",
1069
- error: null
1528
+ const job = jobRegistry.get(run.jobName);
1529
+ if (!job) {
1530
+ throw new Error(`Unknown job: ${run.jobName}`);
1531
+ }
1532
+ const validatedInput = validateJobInputOrThrow(
1533
+ job.inputSchema,
1534
+ run.input,
1535
+ `Cannot retrigger run ${runId}`
1536
+ );
1537
+ const nextRun = await storage.enqueue({
1538
+ jobName: run.jobName,
1539
+ input: validatedInput,
1540
+ concurrencyKey: run.concurrencyKey ?? void 0,
1541
+ labels: run.labels
1070
1542
  });
1071
1543
  eventEmitter.emit({
1072
- type: "run:retry",
1073
- runId,
1544
+ type: "run:trigger",
1545
+ runId: nextRun.id,
1074
1546
  jobName: run.jobName,
1547
+ input: validatedInput,
1075
1548
  labels: run.labels
1076
1549
  });
1550
+ return nextRun;
1077
1551
  },
1078
1552
  async cancel(runId) {
1079
- const run = await storage.getRun(runId);
1080
- if (!run) {
1081
- throw new Error(`Run not found: ${runId}`);
1082
- }
1553
+ const run = await getRunOrThrow(runId);
1083
1554
  if (run.status === "completed") {
1084
1555
  throw new Error(`Cannot cancel completed run: ${runId}`);
1085
1556
  }
@@ -1089,9 +1560,17 @@ function createDurablyInstance(state, jobs) {
1089
1560
  if (run.status === "cancelled") {
1090
1561
  throw new Error(`Cannot cancel already cancelled run: ${runId}`);
1091
1562
  }
1092
- await storage.updateRun(runId, {
1093
- status: "cancelled"
1094
- });
1563
+ const wasPending = run.status === "pending";
1564
+ const cancelled = await storage.cancelRun(runId, (/* @__PURE__ */ new Date()).toISOString());
1565
+ if (!cancelled) {
1566
+ const current = await getRunOrThrow(runId);
1567
+ throw new Error(
1568
+ `Cannot cancel run ${runId}: status changed to ${current.status}`
1569
+ );
1570
+ }
1571
+ if (wasPending && !state.preserveSteps) {
1572
+ await storage.deleteSteps(runId);
1573
+ }
1095
1574
  eventEmitter.emit({
1096
1575
  type: "run:cancel",
1097
1576
  runId,
@@ -1100,15 +1579,12 @@ function createDurablyInstance(state, jobs) {
1100
1579
  });
1101
1580
  },
1102
1581
  async deleteRun(runId) {
1103
- const run = await storage.getRun(runId);
1104
- if (!run) {
1105
- throw new Error(`Run not found: ${runId}`);
1106
- }
1582
+ const run = await getRunOrThrow(runId);
1107
1583
  if (run.status === "pending") {
1108
1584
  throw new Error(`Cannot delete pending run: ${runId}`);
1109
1585
  }
1110
- if (run.status === "running") {
1111
- throw new Error(`Cannot delete running run: ${runId}`);
1586
+ if (run.status === "leased") {
1587
+ throw new Error(`Cannot delete leased run: ${runId}`);
1112
1588
  }
1113
1589
  await storage.deleteRun(runId);
1114
1590
  eventEmitter.emit({
@@ -1118,6 +1594,48 @@ function createDurablyInstance(state, jobs) {
1118
1594
  labels: run.labels
1119
1595
  });
1120
1596
  },
1597
+ async purgeRuns(options) {
1598
+ return storage.purgeRuns({
1599
+ olderThan: options.olderThan.toISOString(),
1600
+ limit: options.limit
1601
+ });
1602
+ },
1603
+ async processOne(options) {
1604
+ const workerId = options?.workerId ?? defaultWorkerId();
1605
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1606
+ await storage.releaseExpiredLeases(now);
1607
+ const run = await storage.claimNext(workerId, now, state.leaseMs);
1608
+ if (!run) {
1609
+ if (state.retainRunsMs !== null && Date.now() - state.lastPurgeAt >= PURGE_INTERVAL_MS) {
1610
+ const purgeNow = Date.now();
1611
+ state.lastPurgeAt = purgeNow;
1612
+ const cutoff = new Date(purgeNow - state.retainRunsMs).toISOString();
1613
+ storage.purgeRuns({ olderThan: cutoff, limit: 100 }).catch((error) => {
1614
+ eventEmitter.emit({
1615
+ type: "worker:error",
1616
+ error: getErrorMessage(error),
1617
+ context: "auto-purge"
1618
+ });
1619
+ });
1620
+ }
1621
+ return false;
1622
+ }
1623
+ await executeRun(run, workerId);
1624
+ return true;
1625
+ },
1626
+ async processUntilIdle(options) {
1627
+ const workerId = options?.workerId ?? defaultWorkerId();
1628
+ const maxRuns = options?.maxRuns ?? Number.POSITIVE_INFINITY;
1629
+ let processed = 0;
1630
+ while (processed < maxRuns) {
1631
+ const didProcess = await this.processOne({ workerId });
1632
+ if (!didProcess) {
1633
+ break;
1634
+ }
1635
+ processed++;
1636
+ }
1637
+ return processed;
1638
+ },
1121
1639
  async migrate() {
1122
1640
  if (state.migrated) {
1123
1641
  return;
@@ -1141,15 +1659,38 @@ function createDurablyInstance(state, jobs) {
1141
1659
  }
1142
1660
  function createDurably(options) {
1143
1661
  const config = {
1144
- pollingInterval: options.pollingInterval ?? DEFAULTS.pollingInterval,
1145
- heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
1146
- staleThreshold: options.staleThreshold ?? DEFAULTS.staleThreshold
1662
+ pollingIntervalMs: options.pollingIntervalMs ?? DEFAULTS.pollingIntervalMs,
1663
+ leaseRenewIntervalMs: options.leaseRenewIntervalMs ?? DEFAULTS.leaseRenewIntervalMs,
1664
+ leaseMs: options.leaseMs ?? DEFAULTS.leaseMs,
1665
+ preserveSteps: options.preserveSteps ?? DEFAULTS.preserveSteps,
1666
+ retainRunsMs: options.retainRuns ? parseDuration(options.retainRuns) : null
1147
1667
  };
1148
1668
  const db = new Kysely({ dialect: options.dialect });
1149
- const storage = createKyselyStorage(db);
1669
+ const singletonKey = getBrowserSingletonKey(
1670
+ options.dialect,
1671
+ options.singletonKey
1672
+ );
1673
+ const releaseBrowserSingleton = singletonKey !== null ? registerBrowserSingletonWarning(singletonKey) : () => {
1674
+ };
1675
+ const backend = detectBackend(options.dialect);
1676
+ const storage = createKyselyStore(db, backend);
1677
+ const originalDestroy = db.destroy.bind(db);
1678
+ db.destroy = (async () => {
1679
+ releaseBrowserSingleton();
1680
+ return originalDestroy();
1681
+ });
1150
1682
  const eventEmitter = createEventEmitter();
1151
1683
  const jobRegistry = createJobRegistry();
1152
- const worker = createWorker(config, storage, eventEmitter, jobRegistry);
1684
+ let processOneImpl = null;
1685
+ const worker = createWorker(
1686
+ { pollingIntervalMs: config.pollingIntervalMs },
1687
+ (runtimeOptions) => {
1688
+ if (!processOneImpl) {
1689
+ throw new Error("Durably runtime is not initialized");
1690
+ }
1691
+ return processOneImpl(runtimeOptions);
1692
+ }
1693
+ );
1153
1694
  const state = {
1154
1695
  db,
1155
1696
  storage,
@@ -1157,13 +1698,20 @@ function createDurably(options) {
1157
1698
  jobRegistry,
1158
1699
  worker,
1159
1700
  labelsSchema: options.labels,
1701
+ preserveSteps: config.preserveSteps,
1160
1702
  migrating: null,
1161
- migrated: false
1703
+ migrated: false,
1704
+ leaseMs: config.leaseMs,
1705
+ leaseRenewIntervalMs: config.leaseRenewIntervalMs,
1706
+ retainRunsMs: config.retainRunsMs,
1707
+ lastPurgeAt: 0,
1708
+ releaseBrowserSingleton
1162
1709
  };
1163
1710
  const instance = createDurablyInstance(
1164
1711
  state,
1165
1712
  {}
1166
1713
  );
1714
+ processOneImpl = instance.processOne;
1167
1715
  if (options.jobs) {
1168
1716
  return instance.register(options.jobs);
1169
1717
  }
@@ -1408,7 +1956,7 @@ function createThrottledSSEController(inner, throttleMs) {
1408
1956
  // src/server.ts
1409
1957
  var VALID_STATUSES = [
1410
1958
  "pending",
1411
- "running",
1959
+ "leased",
1412
1960
  "completed",
1413
1961
  "failed",
1414
1962
  "cancelled"
@@ -1564,12 +2112,12 @@ function createDurablyHandler(durably, options) {
1564
2112
  return jsonResponse(steps);
1565
2113
  });
1566
2114
  }
1567
- async function handleRetry(url, ctx) {
2115
+ async function handleRetrigger(url, ctx) {
1568
2116
  return withErrorHandling(async () => {
1569
- const result = await requireRunAccess(url, ctx, "retry");
2117
+ const result = await requireRunAccess(url, ctx, "retrigger");
1570
2118
  if (result instanceof Response) return result;
1571
- await durably.retry(result.runId);
1572
- return successResponse();
2119
+ const run = await durably.retrigger(result.runId);
2120
+ return jsonResponse({ success: true, runId: run.id });
1573
2121
  });
1574
2122
  }
1575
2123
  async function handleCancel(url, ctx) {
@@ -1638,10 +2186,10 @@ function createDurablyHandler(durably, options) {
1638
2186
  });
1639
2187
  }
1640
2188
  }),
1641
- durably.on("run:start", (event) => {
2189
+ durably.on("run:leased", (event) => {
1642
2190
  if (matchesFilter(event.jobName, event.labels)) {
1643
2191
  ctrl.enqueue({
1644
- type: "run:start",
2192
+ type: "run:leased",
1645
2193
  runId: event.runId,
1646
2194
  jobName: event.jobName,
1647
2195
  labels: event.labels
@@ -1688,16 +2236,6 @@ function createDurablyHandler(durably, options) {
1688
2236
  });
1689
2237
  }
1690
2238
  }),
1691
- durably.on("run:retry", (event) => {
1692
- if (matchesFilter(event.jobName, event.labels)) {
1693
- ctrl.enqueue({
1694
- type: "run:retry",
1695
- runId: event.runId,
1696
- jobName: event.jobName,
1697
- labels: event.labels
1698
- });
1699
- }
1700
- }),
1701
2239
  durably.on("run:progress", (event) => {
1702
2240
  if (matchesFilter(event.jobName, event.labels)) {
1703
2241
  ctrl.enqueue({
@@ -1801,7 +2339,7 @@ function createDurablyHandler(durably, options) {
1801
2339
  }
1802
2340
  if (method === "POST") {
1803
2341
  if (path === "/trigger") return await handleTrigger(request, ctx);
1804
- if (path === "/retry") return await handleRetry(url, ctx);
2342
+ if (path === "/retrigger") return await handleRetrigger(url, ctx);
1805
2343
  if (path === "/cancel") return await handleCancel(url, ctx);
1806
2344
  }
1807
2345
  if (method === "DELETE") {
@@ -1817,8 +2355,10 @@ function createDurablyHandler(durably, options) {
1817
2355
  }
1818
2356
  export {
1819
2357
  CancelledError,
2358
+ LeaseLostError,
1820
2359
  createDurably,
1821
2360
  createDurablyHandler,
2361
+ createKyselyStore,
1822
2362
  defineJob,
1823
2363
  toClientRun,
1824
2364
  withLogPersistence