@coji/durably 0.12.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,25 +931,82 @@ function createKyselyStorage(db) {
561
931
  if (!row) return null;
562
932
  return rowToRun({ ...row, step_count: 0 });
563
933
  },
564
- async createStep(input) {
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;
941
+ },
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);
950
+ },
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
+ });
957
+ },
958
+ async failRun(runId, leaseGeneration, error, completedAt) {
959
+ return terminateRun(runId, leaseGeneration, completedAt, {
960
+ status: "failed",
961
+ error
962
+ });
963
+ },
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;
973
+ },
974
+ async persistStep(runId, leaseGeneration, input) {
565
975
  const completedAt = (/* @__PURE__ */ new Date()).toISOString();
566
976
  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);
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();
993
+ }
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
+ };
1005
+ });
580
1006
  },
581
1007
  async deleteSteps(runId) {
582
1008
  await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
1009
+ await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
583
1010
  },
584
1011
  async getSteps(runId) {
585
1012
  const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
@@ -589,6 +1016,12 @@ function createKyselyStorage(db) {
589
1016
  const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
590
1017
  return row ? rowToStep(row) : null;
591
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
+ },
592
1025
  async createLog(input) {
593
1026
  const now = (/* @__PURE__ */ new Date()).toISOString();
594
1027
  const id = ulid();
@@ -609,328 +1042,54 @@ function createKyselyStorage(db) {
609
1042
  return rows.map(rowToLog);
610
1043
  }
611
1044
  };
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";
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));
622
1065
  }
623
- };
624
- function getErrorMessage(error) {
625
- return error instanceof Error ? error.message : String(error);
1066
+ return store;
626
1067
  }
627
1068
 
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();
1069
+ // src/worker.ts
1070
+ function createWorker(config, processOne) {
1071
+ let running = false;
1072
+ let pollingTimeout = null;
1073
+ let inFlight = null;
1074
+ let stopResolver = null;
1075
+ let activeWorkerId;
1076
+ async function poll() {
1077
+ if (!running) {
1078
+ return;
636
1079
  }
637
- });
638
- const step = {
639
- get runId() {
640
- return run.id;
641
- },
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
669
- });
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
- },
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
729
- });
730
- },
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
- }
768
- }
769
- };
770
- return { step, dispose: unsubscribe };
771
- }
772
-
773
- // src/worker.ts
774
- function createWorker(config, storage, eventEmitter, jobRegistry) {
775
- let running = false;
776
- let currentRunPromise = null;
777
- let pollingTimeout = null;
778
- 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") {
826
- return;
827
- }
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
1080
  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);
1081
+ inFlight = processOne({ workerId: activeWorkerId }).then(() => void 0);
1082
+ await inFlight;
882
1083
  } finally {
883
- if (config.cleanupSteps) {
884
- try {
885
- await storage.deleteSteps(run.id);
886
- } catch {
887
- }
888
- }
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;
1084
+ inFlight = null;
913
1085
  }
914
- await executeRun(run, job);
915
- return true;
916
- }
917
- async function poll() {
918
- if (!running) {
1086
+ if (running) {
1087
+ pollingTimeout = setTimeout(() => {
1088
+ void poll();
1089
+ }, config.pollingIntervalMs);
919
1090
  return;
920
1091
  }
921
- const doWork = async () => {
922
- await recoverStaleRuns();
923
- await processNextRun();
924
- };
925
- try {
926
- currentRunPromise = doWork();
927
- await currentRunPromise;
928
- } finally {
929
- currentRunPromise = null;
930
- }
931
- if (running) {
932
- pollingTimeout = setTimeout(() => poll(), config.pollingInterval);
933
- } else if (stopResolver) {
1092
+ if (stopResolver) {
934
1093
  stopResolver();
935
1094
  stopResolver = null;
936
1095
  }
@@ -939,12 +1098,13 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
939
1098
  get isRunning() {
940
1099
  return running;
941
1100
  },
942
- start() {
1101
+ start(options) {
943
1102
  if (running) {
944
1103
  return;
945
1104
  }
1105
+ activeWorkerId = options?.workerId;
946
1106
  running = true;
947
- poll();
1107
+ void poll();
948
1108
  },
949
1109
  async stop() {
950
1110
  if (!running) {
@@ -955,11 +1115,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
955
1115
  clearTimeout(pollingTimeout);
956
1116
  pollingTimeout = null;
957
1117
  }
958
- if (heartbeatInterval) {
959
- clearInterval(heartbeatInterval);
960
- heartbeatInterval = null;
961
- }
962
- if (currentRunPromise) {
1118
+ if (inFlight) {
963
1119
  return new Promise((resolve) => {
964
1120
  stopResolver = resolve;
965
1121
  });
@@ -970,13 +1126,90 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
970
1126
 
971
1127
  // src/durably.ts
972
1128
  var DEFAULTS = {
973
- pollingInterval: 1e3,
974
- heartbeatInterval: 5e3,
975
- staleThreshold: 3e4,
976
- cleanupSteps: true
1129
+ pollingIntervalMs: 1e3,
1130
+ leaseRenewIntervalMs: 5e3,
1131
+ leaseMs: 3e4,
1132
+ preserveSteps: false
977
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
+ }
978
1204
  function createDurablyInstance(state, jobs) {
979
- const { db, storage, eventEmitter, jobRegistry, worker } = state;
1205
+ const {
1206
+ db,
1207
+ storage,
1208
+ eventEmitter,
1209
+ jobRegistry,
1210
+ worker,
1211
+ releaseBrowserSingleton
1212
+ } = state;
980
1213
  async function getRunOrThrow(runId) {
981
1214
  const run = await storage.getRun(runId);
982
1215
  if (!run) {
@@ -984,6 +1217,158 @@ function createDurablyInstance(state, jobs) {
984
1217
  }
985
1218
  return run;
986
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
+ }
987
1372
  const durably = {
988
1373
  db,
989
1374
  storage,
@@ -992,7 +1377,10 @@ function createDurablyInstance(state, jobs) {
992
1377
  emit: eventEmitter.emit,
993
1378
  onError: eventEmitter.onError,
994
1379
  start: worker.start,
995
- stop: worker.stop,
1380
+ async stop() {
1381
+ releaseBrowserSingleton();
1382
+ await worker.stop();
1383
+ },
996
1384
  // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions
997
1385
  register(jobDefs) {
998
1386
  const newHandles = {};
@@ -1013,8 +1401,8 @@ function createDurablyInstance(state, jobs) {
1013
1401
  mergedJobs
1014
1402
  );
1015
1403
  },
1016
- getRun: storage.getRun,
1017
- getRuns: storage.getRuns,
1404
+ getRun: storage.getRun.bind(storage),
1405
+ getRuns: storage.getRuns.bind(storage),
1018
1406
  use(plugin) {
1019
1407
  plugin.install(durably);
1020
1408
  },
@@ -1028,9 +1416,14 @@ function createDurablyInstance(state, jobs) {
1028
1416
  subscribe(runId) {
1029
1417
  let closed = false;
1030
1418
  let cleanup = null;
1031
- 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
+ ]);
1032
1425
  const subscribedEvents = [
1033
- "run:start",
1426
+ "run:leased",
1034
1427
  "run:complete",
1035
1428
  "run:fail",
1036
1429
  "run:cancel",
@@ -1057,6 +1450,64 @@ function createDurablyInstance(state, jobs) {
1057
1450
  cleanup = () => {
1058
1451
  for (const unsub of unsubscribes) unsub();
1059
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
+ });
1060
1511
  },
1061
1512
  cancel: () => {
1062
1513
  if (!closed) {
@@ -1071,15 +1522,21 @@ function createDurablyInstance(state, jobs) {
1071
1522
  if (run.status === "pending") {
1072
1523
  throw new Error(`Cannot retrigger pending run: ${runId}`);
1073
1524
  }
1074
- if (run.status === "running") {
1075
- throw new Error(`Cannot retrigger running run: ${runId}`);
1525
+ if (run.status === "leased") {
1526
+ throw new Error(`Cannot retrigger leased run: ${runId}`);
1076
1527
  }
1077
- if (!jobRegistry.get(run.jobName)) {
1528
+ const job = jobRegistry.get(run.jobName);
1529
+ if (!job) {
1078
1530
  throw new Error(`Unknown job: ${run.jobName}`);
1079
1531
  }
1080
- const nextRun = await storage.createRun({
1532
+ const validatedInput = validateJobInputOrThrow(
1533
+ job.inputSchema,
1534
+ run.input,
1535
+ `Cannot retrigger run ${runId}`
1536
+ );
1537
+ const nextRun = await storage.enqueue({
1081
1538
  jobName: run.jobName,
1082
- input: run.input,
1539
+ input: validatedInput,
1083
1540
  concurrencyKey: run.concurrencyKey ?? void 0,
1084
1541
  labels: run.labels
1085
1542
  });
@@ -1087,7 +1544,7 @@ function createDurablyInstance(state, jobs) {
1087
1544
  type: "run:trigger",
1088
1545
  runId: nextRun.id,
1089
1546
  jobName: run.jobName,
1090
- input: run.input,
1547
+ input: validatedInput,
1091
1548
  labels: run.labels
1092
1549
  });
1093
1550
  return nextRun;
@@ -1104,11 +1561,14 @@ function createDurablyInstance(state, jobs) {
1104
1561
  throw new Error(`Cannot cancel already cancelled run: ${runId}`);
1105
1562
  }
1106
1563
  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) {
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) {
1112
1572
  await storage.deleteSteps(runId);
1113
1573
  }
1114
1574
  eventEmitter.emit({
@@ -1123,8 +1583,8 @@ function createDurablyInstance(state, jobs) {
1123
1583
  if (run.status === "pending") {
1124
1584
  throw new Error(`Cannot delete pending run: ${runId}`);
1125
1585
  }
1126
- if (run.status === "running") {
1127
- throw new Error(`Cannot delete running run: ${runId}`);
1586
+ if (run.status === "leased") {
1587
+ throw new Error(`Cannot delete leased run: ${runId}`);
1128
1588
  }
1129
1589
  await storage.deleteRun(runId);
1130
1590
  eventEmitter.emit({
@@ -1134,6 +1594,48 @@ function createDurablyInstance(state, jobs) {
1134
1594
  labels: run.labels
1135
1595
  });
1136
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
+ },
1137
1639
  async migrate() {
1138
1640
  if (state.migrated) {
1139
1641
  return;
@@ -1157,16 +1659,38 @@ function createDurablyInstance(state, jobs) {
1157
1659
  }
1158
1660
  function createDurably(options) {
1159
1661
  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
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
1164
1667
  };
1165
1668
  const db = new Kysely({ dialect: options.dialect });
1166
- 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
+ });
1167
1682
  const eventEmitter = createEventEmitter();
1168
1683
  const jobRegistry = createJobRegistry();
1169
- 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
+ );
1170
1694
  const state = {
1171
1695
  db,
1172
1696
  storage,
@@ -1174,14 +1698,20 @@ function createDurably(options) {
1174
1698
  jobRegistry,
1175
1699
  worker,
1176
1700
  labelsSchema: options.labels,
1177
- cleanupSteps: config.cleanupSteps,
1701
+ preserveSteps: config.preserveSteps,
1178
1702
  migrating: null,
1179
- migrated: false
1703
+ migrated: false,
1704
+ leaseMs: config.leaseMs,
1705
+ leaseRenewIntervalMs: config.leaseRenewIntervalMs,
1706
+ retainRunsMs: config.retainRunsMs,
1707
+ lastPurgeAt: 0,
1708
+ releaseBrowserSingleton
1180
1709
  };
1181
1710
  const instance = createDurablyInstance(
1182
1711
  state,
1183
1712
  {}
1184
1713
  );
1714
+ processOneImpl = instance.processOne;
1185
1715
  if (options.jobs) {
1186
1716
  return instance.register(options.jobs);
1187
1717
  }
@@ -1426,7 +1956,7 @@ function createThrottledSSEController(inner, throttleMs) {
1426
1956
  // src/server.ts
1427
1957
  var VALID_STATUSES = [
1428
1958
  "pending",
1429
- "running",
1959
+ "leased",
1430
1960
  "completed",
1431
1961
  "failed",
1432
1962
  "cancelled"
@@ -1656,10 +2186,10 @@ function createDurablyHandler(durably, options) {
1656
2186
  });
1657
2187
  }
1658
2188
  }),
1659
- durably.on("run:start", (event) => {
2189
+ durably.on("run:leased", (event) => {
1660
2190
  if (matchesFilter(event.jobName, event.labels)) {
1661
2191
  ctrl.enqueue({
1662
- type: "run:start",
2192
+ type: "run:leased",
1663
2193
  runId: event.runId,
1664
2194
  jobName: event.jobName,
1665
2195
  labels: event.labels
@@ -1825,8 +2355,10 @@ function createDurablyHandler(durably, options) {
1825
2355
  }
1826
2356
  export {
1827
2357
  CancelledError,
2358
+ LeaseLostError,
1828
2359
  createDurably,
1829
2360
  createDurablyHandler,
2361
+ createKyselyStore,
1830
2362
  defineJob,
1831
2363
  toClientRun,
1832
2364
  withLogPersistence