@coji/durably 0.8.1 → 0.10.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
@@ -93,15 +93,17 @@ function createJobHandle(jobDef, storage, eventEmitter, registry) {
93
93
  const validatedInput = validateJobInputOrThrow(inputSchema, input);
94
94
  const run = await storage.createRun({
95
95
  jobName: jobDef.name,
96
- payload: validatedInput,
96
+ input: validatedInput,
97
97
  idempotencyKey: options?.idempotencyKey,
98
- concurrencyKey: options?.concurrencyKey
98
+ concurrencyKey: options?.concurrencyKey,
99
+ labels: options?.labels
99
100
  });
100
101
  eventEmitter.emit({
101
102
  type: "run:trigger",
102
103
  runId: run.id,
103
104
  jobName: jobDef.name,
104
- payload: validatedInput
105
+ input: validatedInput,
106
+ labels: run.labels
105
107
  });
106
108
  return run;
107
109
  },
@@ -177,16 +179,17 @@ function createJobHandle(jobDef, storage, eventEmitter, registry) {
177
179
  `at index ${i}`
178
180
  );
179
181
  validated.push({
180
- payload: validatedInput,
182
+ input: validatedInput,
181
183
  options: normalized[i].options
182
184
  });
183
185
  }
184
186
  const runs = await storage.batchCreateRuns(
185
187
  validated.map((v) => ({
186
188
  jobName: jobDef.name,
187
- payload: v.payload,
189
+ input: v.input,
188
190
  idempotencyKey: v.options?.idempotencyKey,
189
- concurrencyKey: v.options?.concurrencyKey
191
+ concurrencyKey: v.options?.concurrencyKey,
192
+ labels: v.options?.labels
190
193
  }))
191
194
  );
192
195
  for (let i = 0; i < runs.length; i++) {
@@ -194,7 +197,8 @@ function createJobHandle(jobDef, storage, eventEmitter, registry) {
194
197
  type: "run:trigger",
195
198
  runId: runs[i].id,
196
199
  jobName: jobDef.name,
197
- payload: validated[i].payload
200
+ input: validated[i].input,
201
+ labels: runs[i].labels
198
202
  });
199
203
  }
200
204
  return runs;
@@ -230,11 +234,11 @@ var migrations = [
230
234
  {
231
235
  version: 1,
232
236
  up: async (db) => {
233
- await db.schema.createTable("durably_runs").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("job_name", "text", (col) => col.notNull()).addColumn("payload", "text", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull()).addColumn("idempotency_key", "text").addColumn("concurrency_key", "text").addColumn(
237
+ await db.schema.createTable("durably_runs").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("job_name", "text", (col) => col.notNull()).addColumn("input", "text", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull()).addColumn("idempotency_key", "text").addColumn("concurrency_key", "text").addColumn("labels", "text", (col) => col.notNull().defaultTo("{}")).addColumn(
234
238
  "current_step_index",
235
239
  "integer",
236
240
  (col) => col.notNull().defaultTo(0)
237
- ).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("heartbeat_at", "text", (col) => col.notNull()).addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
241
+ ).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();
238
242
  await db.schema.createIndex("idx_durably_runs_job_idempotency").ifNotExists().on("durably_runs").columns(["job_name", "idempotency_key"]).unique().execute();
239
243
  await db.schema.createIndex("idx_durably_runs_status_concurrency").ifNotExists().on("durably_runs").columns(["status", "concurrency_key"]).execute();
240
244
  await db.schema.createIndex("idx_durably_runs_status_created").ifNotExists().on("durably_runs").columns(["status", "created_at"]).execute();
@@ -258,22 +262,47 @@ async function runMigrations(db) {
258
262
  const currentVersion = await getCurrentVersion(db);
259
263
  for (const migration of migrations) {
260
264
  if (migration.version > currentVersion) {
261
- await migration.up(db);
262
- await db.insertInto("durably_schema_versions").values({
263
- version: migration.version,
264
- applied_at: (/* @__PURE__ */ new Date()).toISOString()
265
- }).execute();
265
+ await db.transaction().execute(async (trx) => {
266
+ await migration.up(trx);
267
+ await trx.insertInto("durably_schema_versions").values({
268
+ version: migration.version,
269
+ applied_at: (/* @__PURE__ */ new Date()).toISOString()
270
+ }).execute();
271
+ });
266
272
  }
267
273
  }
268
274
  }
269
275
 
270
276
  // src/storage.ts
271
- import { ulid } from "ulidx";
277
+ import { sql } from "kysely";
278
+ import { monotonicFactory } from "ulidx";
279
+ var ulid = monotonicFactory();
280
+ function toClientRun(run) {
281
+ const {
282
+ idempotencyKey,
283
+ concurrencyKey,
284
+ heartbeatAt,
285
+ updatedAt,
286
+ ...clientRun
287
+ } = run;
288
+ return clientRun;
289
+ }
290
+ var LABEL_KEY_PATTERN = /^[a-zA-Z0-9\-_./]+$/;
291
+ function validateLabels(labels) {
292
+ if (!labels) return;
293
+ for (const key of Object.keys(labels)) {
294
+ if (!LABEL_KEY_PATTERN.test(key)) {
295
+ throw new Error(
296
+ `Invalid label key "${key}": must contain only alphanumeric characters, dashes, underscores, dots, and slashes`
297
+ );
298
+ }
299
+ }
300
+ }
272
301
  function rowToRun(row) {
273
302
  return {
274
303
  id: row.id,
275
304
  jobName: row.job_name,
276
- payload: JSON.parse(row.payload),
305
+ input: JSON.parse(row.input),
277
306
  status: row.status,
278
307
  idempotencyKey: row.idempotency_key,
279
308
  concurrencyKey: row.concurrency_key,
@@ -282,7 +311,10 @@ function rowToRun(row) {
282
311
  progress: row.progress ? JSON.parse(row.progress) : null,
283
312
  output: row.output ? JSON.parse(row.output) : null,
284
313
  error: row.error,
314
+ labels: JSON.parse(row.labels),
285
315
  heartbeatAt: row.heartbeat_at,
316
+ startedAt: row.started_at,
317
+ completedAt: row.completed_at,
286
318
  createdAt: row.created_at,
287
319
  updatedAt: row.updated_at
288
320
  };
@@ -321,11 +353,12 @@ function createKyselyStorage(db) {
321
353
  return rowToRun(existing);
322
354
  }
323
355
  }
356
+ validateLabels(input.labels);
324
357
  const id = ulid();
325
358
  const run = {
326
359
  id,
327
360
  job_name: input.jobName,
328
- payload: JSON.stringify(input.payload),
361
+ input: JSON.stringify(input.input),
329
362
  status: "pending",
330
363
  idempotency_key: input.idempotencyKey ?? null,
331
364
  concurrency_key: input.concurrencyKey ?? null,
@@ -333,7 +366,10 @@ function createKyselyStorage(db) {
333
366
  progress: null,
334
367
  output: null,
335
368
  error: null,
369
+ labels: JSON.stringify(input.labels ?? {}),
336
370
  heartbeat_at: now,
371
+ started_at: null,
372
+ completed_at: null,
337
373
  created_at: now,
338
374
  updated_at: now
339
375
  };
@@ -347,6 +383,9 @@ function createKyselyStorage(db) {
347
383
  return await db.transaction().execute(async (trx) => {
348
384
  const now = (/* @__PURE__ */ new Date()).toISOString();
349
385
  const runs = [];
386
+ for (const input of inputs) {
387
+ validateLabels(input.labels);
388
+ }
350
389
  for (const input of inputs) {
351
390
  if (input.idempotencyKey) {
352
391
  const existing = await trx.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
@@ -359,7 +398,7 @@ function createKyselyStorage(db) {
359
398
  runs.push({
360
399
  id,
361
400
  job_name: input.jobName,
362
- payload: JSON.stringify(input.payload),
401
+ input: JSON.stringify(input.input),
363
402
  status: "pending",
364
403
  idempotency_key: input.idempotencyKey ?? null,
365
404
  concurrency_key: input.concurrencyKey ?? null,
@@ -367,7 +406,10 @@ function createKyselyStorage(db) {
367
406
  progress: null,
368
407
  output: null,
369
408
  error: null,
409
+ labels: JSON.stringify(input.labels ?? {}),
370
410
  heartbeat_at: now,
411
+ started_at: null,
412
+ completed_at: null,
371
413
  created_at: now,
372
414
  updated_at: now
373
415
  });
@@ -394,6 +436,9 @@ function createKyselyStorage(db) {
394
436
  if (data.error !== void 0) updates.error = data.error;
395
437
  if (data.heartbeatAt !== void 0)
396
438
  updates.heartbeat_at = data.heartbeatAt;
439
+ if (data.startedAt !== void 0) updates.started_at = data.startedAt;
440
+ if (data.completedAt !== void 0)
441
+ updates.completed_at = data.completedAt;
397
442
  await db.updateTable("durably_runs").set(updates).where("id", "=", runId).execute();
398
443
  },
399
444
  async deleteRun(runId) {
@@ -417,6 +462,16 @@ function createKyselyStorage(db) {
417
462
  if (filter?.jobName) {
418
463
  query = query.where("durably_runs.job_name", "=", filter.jobName);
419
464
  }
465
+ if (filter?.labels) {
466
+ validateLabels(filter.labels);
467
+ for (const [key, value] of Object.entries(filter.labels)) {
468
+ query = query.where(
469
+ sql`json_extract(durably_runs.labels, ${`$."${key}"`})`,
470
+ "=",
471
+ value
472
+ );
473
+ }
474
+ }
420
475
  query = query.orderBy("durably_runs.created_at", "desc");
421
476
  if (filter?.limit !== void 0) {
422
477
  query = query.limit(filter.limit);
@@ -430,24 +485,29 @@ function createKyselyStorage(db) {
430
485
  const rows = await query.execute();
431
486
  return rows.map(rowToRun);
432
487
  },
433
- async getNextPendingRun(excludeConcurrencyKeys) {
434
- let query = db.selectFrom("durably_runs").leftJoin("durably_steps", "durably_runs.id", "durably_steps.run_id").selectAll("durably_runs").select(
435
- (eb) => eb.fn.count("durably_steps.id").as("step_count")
436
- ).where("durably_runs.status", "=", "pending").groupBy("durably_runs.id").orderBy("durably_runs.created_at", "asc").orderBy("durably_runs.id", "asc").limit(1);
488
+ async claimNextPendingRun(excludeConcurrencyKeys) {
489
+ const now = (/* @__PURE__ */ new Date()).toISOString();
490
+ let subquery = db.selectFrom("durably_runs").select("id").where("status", "=", "pending").orderBy("created_at", "asc").orderBy("id", "asc").limit(1);
437
491
  if (excludeConcurrencyKeys.length > 0) {
438
- query = query.where(
492
+ subquery = subquery.where(
439
493
  (eb) => eb.or([
440
- eb("durably_runs.concurrency_key", "is", null),
441
- eb(
442
- "durably_runs.concurrency_key",
443
- "not in",
444
- excludeConcurrencyKeys
445
- )
494
+ eb("concurrency_key", "is", null),
495
+ eb("concurrency_key", "not in", excludeConcurrencyKeys)
446
496
  ])
447
497
  );
448
498
  }
449
- const row = await query.executeTakeFirst();
450
- return row ? rowToRun(row) : null;
499
+ const row = await db.updateTable("durably_runs").set({
500
+ status: "running",
501
+ heartbeat_at: now,
502
+ started_at: sql`COALESCE(started_at, ${now})`,
503
+ updated_at: now
504
+ }).where(
505
+ "id",
506
+ "=",
507
+ (eb) => eb.selectFrom(subquery.as("sub")).select("id")
508
+ ).returningAll().executeTakeFirst();
509
+ if (!row) return null;
510
+ return rowToRun({ ...row, step_count: 0 });
451
511
  },
452
512
  async createStep(input) {
453
513
  const completedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -514,13 +574,26 @@ function getErrorMessage(error) {
514
574
  function createStepContext(run, jobName, storage, eventEmitter) {
515
575
  let stepIndex = run.currentStepIndex;
516
576
  let currentStepName = null;
517
- return {
577
+ const controller = new AbortController();
578
+ const unsubscribe = eventEmitter.on("run:cancel", (event) => {
579
+ if (event.runId === run.id) {
580
+ controller.abort();
581
+ }
582
+ });
583
+ const step = {
518
584
  get runId() {
519
585
  return run.id;
520
586
  },
521
587
  async run(name, fn) {
588
+ if (controller.signal.aborted) {
589
+ throw new CancelledError(run.id);
590
+ }
522
591
  const currentRun = await storage.getRun(run.id);
523
592
  if (currentRun?.status === "cancelled") {
593
+ controller.abort();
594
+ throw new CancelledError(run.id);
595
+ }
596
+ if (controller.signal.aborted) {
524
597
  throw new CancelledError(run.id);
525
598
  }
526
599
  const existingStep = await storage.getCompletedStep(run.id, name);
@@ -536,10 +609,11 @@ function createStepContext(run, jobName, storage, eventEmitter) {
536
609
  runId: run.id,
537
610
  jobName,
538
611
  stepName: name,
539
- stepIndex
612
+ stepIndex,
613
+ labels: run.labels
540
614
  });
541
615
  try {
542
- const result = await fn();
616
+ const result = await fn(controller.signal);
543
617
  await storage.createStep({
544
618
  runId: run.id,
545
619
  name,
@@ -557,27 +631,32 @@ function createStepContext(run, jobName, storage, eventEmitter) {
557
631
  stepName: name,
558
632
  stepIndex: stepIndex - 1,
559
633
  output: result,
560
- duration: Date.now() - startTime
634
+ duration: Date.now() - startTime,
635
+ labels: run.labels
561
636
  });
562
637
  return result;
563
638
  } catch (error) {
639
+ const isCancelled = controller.signal.aborted;
564
640
  const errorMessage = error instanceof Error ? error.message : String(error);
565
641
  await storage.createStep({
566
642
  runId: run.id,
567
643
  name,
568
644
  index: stepIndex,
569
- status: "failed",
645
+ status: isCancelled ? "cancelled" : "failed",
570
646
  error: errorMessage,
571
647
  startedAt
572
648
  });
573
649
  eventEmitter.emit({
574
- type: "step:fail",
650
+ ...isCancelled ? { type: "step:cancel" } : { type: "step:fail", error: errorMessage },
575
651
  runId: run.id,
576
652
  jobName,
577
653
  stepName: name,
578
654
  stepIndex,
579
- error: errorMessage
655
+ labels: run.labels
580
656
  });
657
+ if (isCancelled) {
658
+ throw new CancelledError(run.id);
659
+ }
581
660
  throw error;
582
661
  } finally {
583
662
  currentStepName = null;
@@ -590,7 +669,8 @@ function createStepContext(run, jobName, storage, eventEmitter) {
590
669
  type: "run:progress",
591
670
  runId: run.id,
592
671
  jobName,
593
- progress: progressData
672
+ progress: progressData,
673
+ labels: run.labels
594
674
  });
595
675
  },
596
676
  log: {
@@ -598,6 +678,8 @@ function createStepContext(run, jobName, storage, eventEmitter) {
598
678
  eventEmitter.emit({
599
679
  type: "log:write",
600
680
  runId: run.id,
681
+ jobName,
682
+ labels: run.labels,
601
683
  stepName: currentStepName,
602
684
  level: "info",
603
685
  message,
@@ -608,6 +690,8 @@ function createStepContext(run, jobName, storage, eventEmitter) {
608
690
  eventEmitter.emit({
609
691
  type: "log:write",
610
692
  runId: run.id,
693
+ jobName,
694
+ labels: run.labels,
611
695
  stepName: currentStepName,
612
696
  level: "warn",
613
697
  message,
@@ -618,6 +702,8 @@ function createStepContext(run, jobName, storage, eventEmitter) {
618
702
  eventEmitter.emit({
619
703
  type: "log:write",
620
704
  runId: run.id,
705
+ jobName,
706
+ labels: run.labels,
621
707
  stepName: currentStepName,
622
708
  level: "error",
623
709
  message,
@@ -626,6 +712,7 @@ function createStepContext(run, jobName, storage, eventEmitter) {
626
712
  }
627
713
  }
628
714
  };
715
+ return { step, dispose: unsubscribe };
629
716
  }
630
717
 
631
718
  // src/worker.ts
@@ -658,19 +745,21 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
658
745
  }
659
746
  async function handleRunSuccess(runId, jobName, output, startTime) {
660
747
  const currentRun = await storage.getRun(runId);
661
- if (currentRun?.status === "cancelled") {
748
+ if (!currentRun || currentRun.status === "cancelled") {
662
749
  return;
663
750
  }
664
751
  await storage.updateRun(runId, {
665
752
  status: "completed",
666
- output
753
+ output,
754
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
667
755
  });
668
756
  eventEmitter.emit({
669
757
  type: "run:complete",
670
758
  runId,
671
759
  jobName,
672
760
  output,
673
- duration: Date.now() - startTime
761
+ duration: Date.now() - startTime,
762
+ labels: currentRun.labels
674
763
  });
675
764
  }
676
765
  async function handleRunFailure(runId, jobName, error) {
@@ -678,7 +767,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
678
767
  return;
679
768
  }
680
769
  const currentRun = await storage.getRun(runId);
681
- if (currentRun?.status === "cancelled") {
770
+ if (!currentRun || currentRun.status === "cancelled") {
682
771
  return;
683
772
  }
684
773
  const errorMessage = getErrorMessage(error);
@@ -686,14 +775,16 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
686
775
  const failedStep = steps.find((s) => s.status === "failed");
687
776
  await storage.updateRun(runId, {
688
777
  status: "failed",
689
- error: errorMessage
778
+ error: errorMessage,
779
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
690
780
  });
691
781
  eventEmitter.emit({
692
782
  type: "run:fail",
693
783
  runId,
694
784
  jobName,
695
785
  error: errorMessage,
696
- failedStepName: failedStep?.name ?? "unknown"
786
+ failedStepName: failedStep?.name ?? "unknown",
787
+ labels: currentRun.labels
697
788
  });
698
789
  }
699
790
  async function executeRun(run, job) {
@@ -712,12 +803,18 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
712
803
  type: "run:start",
713
804
  runId: run.id,
714
805
  jobName: run.jobName,
715
- payload: run.payload
806
+ input: run.input,
807
+ labels: run.labels
716
808
  });
717
809
  const startTime = Date.now();
810
+ const { step, dispose } = createStepContext(
811
+ run,
812
+ run.jobName,
813
+ storage,
814
+ eventEmitter
815
+ );
718
816
  try {
719
- const step = createStepContext(run, run.jobName, storage, eventEmitter);
720
- const output = await job.fn(step, run.payload);
817
+ const output = await job.fn(step, run.input);
721
818
  if (job.outputSchema) {
722
819
  const parseResult = job.outputSchema.safeParse(output);
723
820
  if (!parseResult.success) {
@@ -728,6 +825,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
728
825
  } catch (error) {
729
826
  await handleRunFailure(run.id, run.jobName, error);
730
827
  } finally {
828
+ dispose();
731
829
  if (heartbeatInterval) {
732
830
  clearInterval(heartbeatInterval);
733
831
  heartbeatInterval = null;
@@ -740,7 +838,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
740
838
  const excludeConcurrencyKeys = runningRuns.filter(
741
839
  (r) => r.concurrencyKey !== null
742
840
  ).map((r) => r.concurrencyKey);
743
- const run = await storage.getNextPendingRun(excludeConcurrencyKeys);
841
+ const run = await storage.claimNextPendingRun(excludeConcurrencyKeys);
744
842
  if (!run) {
745
843
  return false;
746
844
  }
@@ -752,10 +850,6 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
752
850
  });
753
851
  return true;
754
852
  }
755
- await storage.updateRun(run.id, {
756
- status: "running",
757
- heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
758
- });
759
853
  await executeRun(run, job);
760
854
  return true;
761
855
  }
@@ -889,6 +983,14 @@ function createDurablyInstance(state, jobs) {
889
983
  controller.enqueue(event);
890
984
  }
891
985
  });
986
+ const unsubscribeDelete = eventEmitter.on("run:delete", (event) => {
987
+ if (!closed && event.runId === runId) {
988
+ controller.enqueue(event);
989
+ closed = true;
990
+ cleanup?.();
991
+ controller.close();
992
+ }
993
+ });
892
994
  const unsubscribeRetry = eventEmitter.on("run:retry", (event) => {
893
995
  if (!closed && event.runId === runId) {
894
996
  controller.enqueue(event);
@@ -933,6 +1035,7 @@ function createDurablyInstance(state, jobs) {
933
1035
  unsubscribeComplete();
934
1036
  unsubscribeFail();
935
1037
  unsubscribeCancel();
1038
+ unsubscribeDelete();
936
1039
  unsubscribeRetry();
937
1040
  unsubscribeProgress();
938
1041
  unsubscribeStepStart();
@@ -970,7 +1073,8 @@ function createDurablyInstance(state, jobs) {
970
1073
  eventEmitter.emit({
971
1074
  type: "run:retry",
972
1075
  runId,
973
- jobName: run.jobName
1076
+ jobName: run.jobName,
1077
+ labels: run.labels
974
1078
  });
975
1079
  },
976
1080
  async cancel(runId) {
@@ -993,7 +1097,8 @@ function createDurablyInstance(state, jobs) {
993
1097
  eventEmitter.emit({
994
1098
  type: "run:cancel",
995
1099
  runId,
996
- jobName: run.jobName
1100
+ jobName: run.jobName,
1101
+ labels: run.labels
997
1102
  });
998
1103
  },
999
1104
  async deleteRun(runId) {
@@ -1008,6 +1113,12 @@ function createDurablyInstance(state, jobs) {
1008
1113
  throw new Error(`Cannot delete running run: ${runId}`);
1009
1114
  }
1010
1115
  await storage.deleteRun(runId);
1116
+ eventEmitter.emit({
1117
+ type: "run:delete",
1118
+ runId,
1119
+ jobName: run.jobName,
1120
+ labels: run.labels
1121
+ });
1011
1122
  },
1012
1123
  async migrate() {
1013
1124
  if (state.migrated) {
@@ -1132,6 +1243,48 @@ function createSSEStreamFromReader(reader) {
1132
1243
  }
1133
1244
  });
1134
1245
  }
1246
+ function createThrottledSSEStreamFromReader(reader, throttleMs) {
1247
+ if (throttleMs <= 0) {
1248
+ return createSSEStreamFromReader(reader);
1249
+ }
1250
+ const encoder = createSSEEncoder();
1251
+ let closed = false;
1252
+ let throttle = null;
1253
+ return new ReadableStream({
1254
+ async start(controller) {
1255
+ const innerCtrl = {
1256
+ enqueue: (data) => controller.enqueue(encodeSSE(encoder, data)),
1257
+ close: () => {
1258
+ closed = true;
1259
+ controller.close();
1260
+ },
1261
+ get closed() {
1262
+ return closed;
1263
+ }
1264
+ };
1265
+ throttle = createThrottledSSEController(innerCtrl, throttleMs);
1266
+ try {
1267
+ while (true) {
1268
+ const { done, value } = await reader.read();
1269
+ if (done) {
1270
+ throttle.controller.close();
1271
+ break;
1272
+ }
1273
+ throttle.controller.enqueue(value);
1274
+ }
1275
+ } catch (error) {
1276
+ throttle.dispose();
1277
+ reader.releaseLock();
1278
+ controller.error(error);
1279
+ }
1280
+ },
1281
+ cancel() {
1282
+ closed = true;
1283
+ throttle?.dispose();
1284
+ reader.releaseLock();
1285
+ }
1286
+ });
1287
+ }
1135
1288
  function createSSEStreamFromSubscriptions(setup) {
1136
1289
  const encoder = createSSEEncoder();
1137
1290
  let closed = false;
@@ -1162,9 +1315,108 @@ function createSSEStreamFromSubscriptions(setup) {
1162
1315
  }
1163
1316
  });
1164
1317
  }
1318
+ var TERMINAL_EVENT_TYPES = /* @__PURE__ */ new Set([
1319
+ "run:complete",
1320
+ "run:fail",
1321
+ "run:cancel",
1322
+ "run:delete"
1323
+ ]);
1324
+ function createThrottledSSEController(inner, throttleMs) {
1325
+ if (throttleMs <= 0) {
1326
+ return { controller: inner, dispose: () => {
1327
+ } };
1328
+ }
1329
+ const pending = /* @__PURE__ */ new Map();
1330
+ const lastSent = /* @__PURE__ */ new Map();
1331
+ const controller = {
1332
+ enqueue(data) {
1333
+ if (inner.closed) return;
1334
+ const event = typeof data === "object" && data !== null ? data : null;
1335
+ if (event?.runId && TERMINAL_EVENT_TYPES.has(event.type ?? "")) {
1336
+ lastSent.delete(event.runId);
1337
+ const entry = pending.get(event.runId);
1338
+ if (entry) {
1339
+ clearTimeout(entry.timer);
1340
+ if (!inner.closed) inner.enqueue(entry.data);
1341
+ pending.delete(event.runId);
1342
+ }
1343
+ }
1344
+ if (event?.type !== "run:progress" || !event?.runId) {
1345
+ inner.enqueue(data);
1346
+ return;
1347
+ }
1348
+ const runId = event.runId;
1349
+ const now = Date.now();
1350
+ const last = lastSent.get(runId) ?? 0;
1351
+ if (now - last >= throttleMs) {
1352
+ lastSent.set(runId, now);
1353
+ const entry = pending.get(runId);
1354
+ if (entry) {
1355
+ clearTimeout(entry.timer);
1356
+ pending.delete(runId);
1357
+ }
1358
+ inner.enqueue(data);
1359
+ return;
1360
+ }
1361
+ const existing = pending.get(runId);
1362
+ if (existing) {
1363
+ clearTimeout(existing.timer);
1364
+ }
1365
+ const delay = Math.max(0, throttleMs - (now - last));
1366
+ const timer = setTimeout(() => {
1367
+ const current = pending.get(runId);
1368
+ if (!current || current.timer !== timer) return;
1369
+ pending.delete(runId);
1370
+ if (!inner.closed) {
1371
+ lastSent.set(runId, Date.now());
1372
+ inner.enqueue(current.data);
1373
+ }
1374
+ }, delay);
1375
+ pending.set(runId, { data, timer });
1376
+ },
1377
+ close() {
1378
+ for (const [, entry] of pending) {
1379
+ clearTimeout(entry.timer);
1380
+ if (!inner.closed) {
1381
+ inner.enqueue(entry.data);
1382
+ }
1383
+ }
1384
+ pending.clear();
1385
+ lastSent.clear();
1386
+ inner.close();
1387
+ },
1388
+ get closed() {
1389
+ return inner.closed;
1390
+ }
1391
+ };
1392
+ const dispose = () => {
1393
+ for (const [, entry] of pending) {
1394
+ clearTimeout(entry.timer);
1395
+ }
1396
+ pending.clear();
1397
+ lastSent.clear();
1398
+ };
1399
+ return { controller, dispose };
1400
+ }
1165
1401
 
1166
1402
  // src/server.ts
1403
+ function parseLabelsFromParams(searchParams) {
1404
+ const labels = {};
1405
+ for (const [key, value] of searchParams.entries()) {
1406
+ if (key.startsWith("label.")) {
1407
+ labels[key.slice(6)] = value;
1408
+ }
1409
+ }
1410
+ return Object.keys(labels).length > 0 ? labels : void 0;
1411
+ }
1412
+ function matchesLabels(eventLabels, filterLabels) {
1413
+ for (const [key, value] of Object.entries(filterLabels)) {
1414
+ if (eventLabels[key] !== value) return false;
1415
+ }
1416
+ return true;
1417
+ }
1167
1418
  function createDurablyHandler(durably, options) {
1419
+ const throttleMs = options?.sseThrottleMs ?? 100;
1168
1420
  const handler = {
1169
1421
  async handle(request, basePath) {
1170
1422
  if (options?.onRequest) {
@@ -1202,7 +1454,8 @@ function createDurablyHandler(durably, options) {
1202
1454
  }
1203
1455
  const run = await job.trigger(body.input ?? {}, {
1204
1456
  idempotencyKey: body.idempotencyKey,
1205
- concurrencyKey: body.concurrencyKey
1457
+ concurrencyKey: body.concurrencyKey,
1458
+ labels: body.labels
1206
1459
  });
1207
1460
  const response = { runId: run.id };
1208
1461
  return jsonResponse(response);
@@ -1215,8 +1468,9 @@ function createDurablyHandler(durably, options) {
1215
1468
  const runId = getRequiredQueryParam(url, "runId");
1216
1469
  if (runId instanceof Response) return runId;
1217
1470
  const stream = durably.subscribe(runId);
1218
- const sseStream = createSSEStreamFromReader(
1219
- stream.getReader()
1471
+ const sseStream = createThrottledSSEStreamFromReader(
1472
+ stream.getReader(),
1473
+ throttleMs
1220
1474
  );
1221
1475
  return createSSEResponse(sseStream);
1222
1476
  },
@@ -1227,13 +1481,15 @@ function createDurablyHandler(durably, options) {
1227
1481
  const status = url.searchParams.get("status");
1228
1482
  const limit = url.searchParams.get("limit");
1229
1483
  const offset = url.searchParams.get("offset");
1484
+ const labels = parseLabelsFromParams(url.searchParams);
1230
1485
  const runs = await durably.getRuns({
1231
1486
  jobName,
1232
1487
  status,
1488
+ labels,
1233
1489
  limit: limit ? Number.parseInt(limit, 10) : void 0,
1234
1490
  offset: offset ? Number.parseInt(offset, 10) : void 0
1235
1491
  });
1236
- return jsonResponse(runs);
1492
+ return jsonResponse(runs.map(toClientRun));
1237
1493
  } catch (error) {
1238
1494
  return errorResponse(getErrorMessage(error), 500);
1239
1495
  }
@@ -1247,7 +1503,7 @@ function createDurablyHandler(durably, options) {
1247
1503
  if (!run) {
1248
1504
  return errorResponse("Run not found", 404);
1249
1505
  }
1250
- return jsonResponse(run);
1506
+ return jsonResponse(toClientRun(run));
1251
1507
  } catch (error) {
1252
1508
  return errorResponse(getErrorMessage(error), 500);
1253
1509
  }
@@ -1299,120 +1555,167 @@ function createDurablyHandler(durably, options) {
1299
1555
  runsSubscribe(request) {
1300
1556
  const url = new URL(request.url);
1301
1557
  const jobNameFilter = url.searchParams.get("jobName");
1302
- const matchesFilter = (jobName) => !jobNameFilter || jobName === jobNameFilter;
1558
+ const labelsFilter = parseLabelsFromParams(url.searchParams);
1559
+ const matchesFilter = (jobName, labels) => {
1560
+ if (jobNameFilter && jobName !== jobNameFilter) return false;
1561
+ if (labelsFilter && (!labels || !matchesLabels(labels, labelsFilter)))
1562
+ return false;
1563
+ return true;
1564
+ };
1303
1565
  const sseStream = createSSEStreamFromSubscriptions(
1304
- (ctrl) => [
1305
- durably.on("run:trigger", (event) => {
1306
- if (matchesFilter(event.jobName)) {
1307
- ctrl.enqueue({
1308
- type: "run:trigger",
1309
- runId: event.runId,
1310
- jobName: event.jobName
1311
- });
1312
- }
1313
- }),
1314
- durably.on("run:start", (event) => {
1315
- if (matchesFilter(event.jobName)) {
1316
- ctrl.enqueue({
1317
- type: "run:start",
1318
- runId: event.runId,
1319
- jobName: event.jobName
1320
- });
1321
- }
1322
- }),
1323
- durably.on("run:complete", (event) => {
1324
- if (matchesFilter(event.jobName)) {
1325
- ctrl.enqueue({
1326
- type: "run:complete",
1327
- runId: event.runId,
1328
- jobName: event.jobName
1329
- });
1330
- }
1331
- }),
1332
- durably.on("run:fail", (event) => {
1333
- if (matchesFilter(event.jobName)) {
1334
- ctrl.enqueue({
1335
- type: "run:fail",
1336
- runId: event.runId,
1337
- jobName: event.jobName
1338
- });
1339
- }
1340
- }),
1341
- durably.on("run:cancel", (event) => {
1342
- if (matchesFilter(event.jobName)) {
1343
- ctrl.enqueue({
1344
- type: "run:cancel",
1345
- runId: event.runId,
1346
- jobName: event.jobName
1347
- });
1348
- }
1349
- }),
1350
- durably.on("run:retry", (event) => {
1351
- if (matchesFilter(event.jobName)) {
1352
- ctrl.enqueue({
1353
- type: "run:retry",
1354
- runId: event.runId,
1355
- jobName: event.jobName
1356
- });
1357
- }
1358
- }),
1359
- durably.on("run:progress", (event) => {
1360
- if (matchesFilter(event.jobName)) {
1361
- ctrl.enqueue({
1362
- type: "run:progress",
1363
- runId: event.runId,
1364
- jobName: event.jobName,
1365
- progress: event.progress
1366
- });
1367
- }
1368
- }),
1369
- durably.on("step:start", (event) => {
1370
- if (matchesFilter(event.jobName)) {
1371
- ctrl.enqueue({
1372
- type: "step:start",
1373
- runId: event.runId,
1374
- jobName: event.jobName,
1375
- stepName: event.stepName,
1376
- stepIndex: event.stepIndex
1377
- });
1378
- }
1379
- }),
1380
- durably.on("step:complete", (event) => {
1381
- if (matchesFilter(event.jobName)) {
1382
- ctrl.enqueue({
1383
- type: "step:complete",
1384
- runId: event.runId,
1385
- jobName: event.jobName,
1386
- stepName: event.stepName,
1387
- stepIndex: event.stepIndex
1388
- });
1389
- }
1390
- }),
1391
- durably.on("step:fail", (event) => {
1392
- if (matchesFilter(event.jobName)) {
1393
- ctrl.enqueue({
1394
- type: "step:fail",
1395
- runId: event.runId,
1396
- jobName: event.jobName,
1397
- stepName: event.stepName,
1398
- stepIndex: event.stepIndex,
1399
- error: event.error
1400
- });
1401
- }
1402
- }),
1403
- durably.on("log:write", (event) => {
1404
- if (!jobNameFilter) {
1405
- ctrl.enqueue({
1406
- type: "log:write",
1407
- runId: event.runId,
1408
- stepName: event.stepName,
1409
- level: event.level,
1410
- message: event.message,
1411
- data: event.data
1412
- });
1413
- }
1414
- })
1415
- ]
1566
+ (innerCtrl) => {
1567
+ const { controller: ctrl, dispose } = createThrottledSSEController(
1568
+ innerCtrl,
1569
+ throttleMs
1570
+ );
1571
+ const unsubscribes = [
1572
+ durably.on("run:trigger", (event) => {
1573
+ if (matchesFilter(event.jobName, event.labels)) {
1574
+ ctrl.enqueue({
1575
+ type: "run:trigger",
1576
+ runId: event.runId,
1577
+ jobName: event.jobName,
1578
+ labels: event.labels
1579
+ });
1580
+ }
1581
+ }),
1582
+ durably.on("run:start", (event) => {
1583
+ if (matchesFilter(event.jobName, event.labels)) {
1584
+ ctrl.enqueue({
1585
+ type: "run:start",
1586
+ runId: event.runId,
1587
+ jobName: event.jobName,
1588
+ labels: event.labels
1589
+ });
1590
+ }
1591
+ }),
1592
+ durably.on("run:complete", (event) => {
1593
+ if (matchesFilter(event.jobName, event.labels)) {
1594
+ ctrl.enqueue({
1595
+ type: "run:complete",
1596
+ runId: event.runId,
1597
+ jobName: event.jobName,
1598
+ labels: event.labels
1599
+ });
1600
+ }
1601
+ }),
1602
+ durably.on("run:fail", (event) => {
1603
+ if (matchesFilter(event.jobName, event.labels)) {
1604
+ ctrl.enqueue({
1605
+ type: "run:fail",
1606
+ runId: event.runId,
1607
+ jobName: event.jobName,
1608
+ labels: event.labels
1609
+ });
1610
+ }
1611
+ }),
1612
+ durably.on("run:cancel", (event) => {
1613
+ if (matchesFilter(event.jobName, event.labels)) {
1614
+ ctrl.enqueue({
1615
+ type: "run:cancel",
1616
+ runId: event.runId,
1617
+ jobName: event.jobName,
1618
+ labels: event.labels
1619
+ });
1620
+ }
1621
+ }),
1622
+ durably.on("run:delete", (event) => {
1623
+ if (matchesFilter(event.jobName, event.labels)) {
1624
+ ctrl.enqueue({
1625
+ type: "run:delete",
1626
+ runId: event.runId,
1627
+ jobName: event.jobName,
1628
+ labels: event.labels
1629
+ });
1630
+ }
1631
+ }),
1632
+ durably.on("run:retry", (event) => {
1633
+ if (matchesFilter(event.jobName, event.labels)) {
1634
+ ctrl.enqueue({
1635
+ type: "run:retry",
1636
+ runId: event.runId,
1637
+ jobName: event.jobName,
1638
+ labels: event.labels
1639
+ });
1640
+ }
1641
+ }),
1642
+ durably.on("run:progress", (event) => {
1643
+ if (matchesFilter(event.jobName, event.labels)) {
1644
+ ctrl.enqueue({
1645
+ type: "run:progress",
1646
+ runId: event.runId,
1647
+ jobName: event.jobName,
1648
+ progress: event.progress,
1649
+ labels: event.labels
1650
+ });
1651
+ }
1652
+ }),
1653
+ durably.on("step:start", (event) => {
1654
+ if (matchesFilter(event.jobName, event.labels)) {
1655
+ ctrl.enqueue({
1656
+ type: "step:start",
1657
+ runId: event.runId,
1658
+ jobName: event.jobName,
1659
+ stepName: event.stepName,
1660
+ stepIndex: event.stepIndex,
1661
+ labels: event.labels
1662
+ });
1663
+ }
1664
+ }),
1665
+ durably.on("step:complete", (event) => {
1666
+ if (matchesFilter(event.jobName, event.labels)) {
1667
+ ctrl.enqueue({
1668
+ type: "step:complete",
1669
+ runId: event.runId,
1670
+ jobName: event.jobName,
1671
+ stepName: event.stepName,
1672
+ stepIndex: event.stepIndex,
1673
+ labels: event.labels
1674
+ });
1675
+ }
1676
+ }),
1677
+ durably.on("step:fail", (event) => {
1678
+ if (matchesFilter(event.jobName, event.labels)) {
1679
+ ctrl.enqueue({
1680
+ type: "step:fail",
1681
+ runId: event.runId,
1682
+ jobName: event.jobName,
1683
+ stepName: event.stepName,
1684
+ stepIndex: event.stepIndex,
1685
+ error: event.error,
1686
+ labels: event.labels
1687
+ });
1688
+ }
1689
+ }),
1690
+ durably.on("step:cancel", (event) => {
1691
+ if (matchesFilter(event.jobName, event.labels)) {
1692
+ ctrl.enqueue({
1693
+ type: "step:cancel",
1694
+ runId: event.runId,
1695
+ jobName: event.jobName,
1696
+ stepName: event.stepName,
1697
+ stepIndex: event.stepIndex,
1698
+ labels: event.labels
1699
+ });
1700
+ }
1701
+ }),
1702
+ durably.on("log:write", (event) => {
1703
+ if (matchesFilter(event.jobName, event.labels)) {
1704
+ ctrl.enqueue({
1705
+ type: "log:write",
1706
+ runId: event.runId,
1707
+ jobName: event.jobName,
1708
+ labels: event.labels,
1709
+ stepName: event.stepName,
1710
+ level: event.level,
1711
+ message: event.message,
1712
+ data: event.data
1713
+ });
1714
+ }
1715
+ })
1716
+ ];
1717
+ return [...unsubscribes, dispose];
1718
+ }
1416
1719
  );
1417
1720
  return createSSEResponse(sseStream);
1418
1721
  }
@@ -1424,6 +1727,7 @@ export {
1424
1727
  createDurably,
1425
1728
  createDurablyHandler,
1426
1729
  defineJob,
1730
+ toClientRun,
1427
1731
  withLogPersistence
1428
1732
  };
1429
1733
  //# sourceMappingURL=index.js.map