pg-workflows 0.14.0 → 0.15.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.cjs CHANGED
@@ -69,6 +69,7 @@ __export(exports_src, {
69
69
  createWorkflowRef: () => createWorkflowRef,
70
70
  WorkflowStatus: () => WorkflowStatus,
71
71
  WorkflowRunNotFoundError: () => WorkflowRunNotFoundError,
72
+ WorkflowRunInProgressError: () => WorkflowRunInProgressError,
72
73
  WorkflowEngineError: () => WorkflowEngineError,
73
74
  WorkflowEngine: () => WorkflowEngine,
74
75
  WorkflowClient: () => WorkflowClient
@@ -97,7 +98,7 @@ var isInvokeChildWorkflowTimelineEntry = (entry) => !!entry && typeof entry ===
97
98
 
98
99
  // src/db/migration.ts
99
100
  var MIGRATION_LOCK_ID = 738291645;
100
- var CURRENT_SCHEMA_VERSION = 6;
101
+ var CURRENT_SCHEMA_VERSION = 7;
101
102
  async function runMigrations(db) {
102
103
  if (await isSchemaUpToDate(db)) {
103
104
  return;
@@ -176,6 +177,14 @@ async function runMigrations(db) {
176
177
  if (currentVersion < 6) {
177
178
  commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS priority integer DEFAULT 0 NOT NULL");
178
179
  }
180
+ if (currentVersion < 7) {
181
+ commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS singleton boolean DEFAULT false NOT NULL");
182
+ commands.push(`
183
+ CREATE UNIQUE INDEX IF NOT EXISTS workflow_runs_singleton_active_idx
184
+ ON workflow_runs (workflow_id)
185
+ WHERE singleton AND status IN ('pending', 'running')
186
+ `);
187
+ }
179
188
  if (currentVersion === 0) {
180
189
  commands.push(`INSERT INTO workflow_schema_version (version) VALUES (${CURRENT_SCHEMA_VERSION})`);
181
190
  } else {
@@ -215,9 +224,65 @@ async function getCurrentVersion(db) {
215
224
 
216
225
  // src/db/queries.ts
217
226
  var import_ksuid = __toESM(require("ksuid"));
227
+
228
+ // src/error.ts
229
+ function validateWorkflowId(workflowId) {
230
+ if (workflowId.length > MAX_WORKFLOW_ID_LENGTH) {
231
+ throw new WorkflowEngineError(`workflowId exceeds maximum length of ${MAX_WORKFLOW_ID_LENGTH} characters (got ${workflowId.length})`, workflowId);
232
+ }
233
+ }
234
+ function validateResourceId(resourceId) {
235
+ if (resourceId != null && resourceId.length > MAX_RESOURCE_ID_LENGTH) {
236
+ throw new WorkflowEngineError(`resourceId exceeds maximum length of ${MAX_RESOURCE_ID_LENGTH} characters (got ${resourceId.length})`);
237
+ }
238
+ }
239
+
240
+ class WorkflowEngineError extends Error {
241
+ workflowId;
242
+ runId;
243
+ cause;
244
+ issues;
245
+ constructor(message, workflowId, runId, cause = undefined, issues) {
246
+ super(message);
247
+ this.workflowId = workflowId;
248
+ this.runId = runId;
249
+ this.cause = cause;
250
+ this.issues = issues;
251
+ this.name = "WorkflowEngineError";
252
+ if (Error.captureStackTrace) {
253
+ Error.captureStackTrace(this, WorkflowEngineError);
254
+ }
255
+ }
256
+ }
257
+
258
+ class WorkflowRunNotFoundError extends WorkflowEngineError {
259
+ constructor(runId, workflowId) {
260
+ super("Workflow run not found", workflowId, runId);
261
+ this.name = "WorkflowRunNotFoundError";
262
+ }
263
+ }
264
+
265
+ class WorkflowRunInProgressError extends WorkflowEngineError {
266
+ constructor(workflowId, runId) {
267
+ super(`Workflow "${workflowId}" already has a running run; wait for it to pause, complete, fail, or be cancelled`, workflowId, runId);
268
+ this.name = "WorkflowRunInProgressError";
269
+ }
270
+ }
271
+
272
+ // src/db/queries.ts
218
273
  function generateKSUID(prefix) {
219
274
  return `${prefix ? `${prefix}_` : ""}${import_ksuid.default.randomSync().string}`;
220
275
  }
276
+ function isUniqueViolation(error) {
277
+ if (!error || typeof error !== "object") {
278
+ return false;
279
+ }
280
+ if ("code" in error && error.code === "23505") {
281
+ return true;
282
+ }
283
+ const cause = "cause" in error ? error.cause : undefined;
284
+ return Boolean(cause && typeof cause === "object" && "code" in cause && cause.code === "23505");
285
+ }
221
286
  function mapRowToWorkflowRun(row) {
222
287
  return {
223
288
  id: row.id,
@@ -243,7 +308,8 @@ function mapRowToWorkflowRun(row) {
243
308
  parentRunId: row.parent_run_id,
244
309
  parentStepId: row.parent_step_id,
245
310
  parentResourceId: row.parent_resource_id,
246
- scheduledAt: row.scheduled_at ? new Date(row.scheduled_at) : null
311
+ scheduledAt: row.scheduled_at ? new Date(row.scheduled_at) : null,
312
+ singleton: Boolean(row.singleton)
247
313
  };
248
314
  }
249
315
  async function insertWorkflowRun({
@@ -259,11 +325,20 @@ async function insertWorkflowRun({
259
325
  parentRunId,
260
326
  parentStepId,
261
327
  parentResourceId,
262
- scheduledAt
328
+ scheduledAt,
329
+ singleton = false
263
330
  }, db) {
264
331
  const runId = generateKSUID("run");
265
332
  const now = new Date;
266
- const result = await db.executeSql(`INSERT INTO workflow_runs (
333
+ if (singleton) {
334
+ const active = await getActiveSingletonRun(workflowId, db);
335
+ if (active) {
336
+ throw new WorkflowRunInProgressError(workflowId, active.id);
337
+ }
338
+ }
339
+ let result;
340
+ try {
341
+ result = await db.executeSql(`INSERT INTO workflow_runs (
267
342
  id,
268
343
  resource_id,
269
344
  workflow_id,
@@ -281,30 +356,39 @@ async function insertWorkflowRun({
281
356
  parent_run_id,
282
357
  parent_step_id,
283
358
  parent_resource_id,
284
- scheduled_at
359
+ scheduled_at,
360
+ singleton
285
361
  )
286
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
362
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
287
363
  ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
288
364
  RETURNING *`, [
289
- runId,
290
- resourceId ?? null,
291
- workflowId,
292
- currentStepId,
293
- status,
294
- JSON.stringify(input),
295
- maxRetries,
296
- priority,
297
- timeoutAt,
298
- now,
299
- now,
300
- "{}",
301
- 0,
302
- idempotencyKey ?? null,
303
- parentRunId ?? null,
304
- parentStepId ?? null,
305
- parentResourceId ?? null,
306
- scheduledAt ?? null
307
- ]);
365
+ runId,
366
+ resourceId ?? null,
367
+ workflowId,
368
+ currentStepId,
369
+ status,
370
+ JSON.stringify(input),
371
+ maxRetries,
372
+ priority,
373
+ timeoutAt,
374
+ now,
375
+ now,
376
+ "{}",
377
+ 0,
378
+ idempotencyKey ?? null,
379
+ parentRunId ?? null,
380
+ parentStepId ?? null,
381
+ parentResourceId ?? null,
382
+ scheduledAt ?? null,
383
+ singleton
384
+ ]);
385
+ } catch (error) {
386
+ if (singleton && isUniqueViolation(error)) {
387
+ const active = await getActiveSingletonRun(workflowId, db);
388
+ throw new WorkflowRunInProgressError(workflowId, active?.id);
389
+ }
390
+ throw error;
391
+ }
308
392
  if (result.rows[0]) {
309
393
  return { run: mapRowToWorkflowRun(result.rows[0]), created: true };
310
394
  }
@@ -316,6 +400,20 @@ async function insertWorkflowRun({
316
400
  }
317
401
  return { run: mapRowToWorkflowRun(existing.rows[0]), created: false };
318
402
  }
403
+ async function getActiveSingletonRun(workflowId, db) {
404
+ const result = await db.executeSql(`SELECT * FROM workflow_runs
405
+ WHERE workflow_id = $1
406
+ AND singleton
407
+ AND status IN ('pending', 'running')
408
+ LIMIT 1`, [workflowId]);
409
+ return result.rows[0] ? mapRowToWorkflowRun(result.rows[0]) : null;
410
+ }
411
+ async function assertSingletonSlotAvailable({ workflowId, exceptRunId }, db) {
412
+ const active = await getActiveSingletonRun(workflowId, db);
413
+ if (active && active.id !== exceptRunId) {
414
+ throw new WorkflowRunInProgressError(workflowId, active.id);
415
+ }
416
+ }
319
417
  async function getWorkflowRun({
320
418
  runId,
321
419
  resourceId
@@ -430,7 +528,15 @@ async function updateWorkflowRun({
430
528
  ${whereClause}
431
529
  RETURNING *
432
530
  `;
433
- const result = await db.executeSql(query, values);
531
+ let result;
532
+ try {
533
+ result = await db.executeSql(query, values);
534
+ } catch (error) {
535
+ if (data.status === "running" && isUniqueViolation(error)) {
536
+ throw new WorkflowRunInProgressError("unknown", runId);
537
+ }
538
+ throw error;
539
+ }
434
540
  const run = result.rows[0];
435
541
  if (!run) {
436
542
  return null;
@@ -536,43 +642,6 @@ async function withPostgresTransaction(db, callback, pool) {
536
642
  }
537
643
  }
538
644
 
539
- // src/error.ts
540
- function validateWorkflowId(workflowId) {
541
- if (workflowId.length > MAX_WORKFLOW_ID_LENGTH) {
542
- throw new WorkflowEngineError(`workflowId exceeds maximum length of ${MAX_WORKFLOW_ID_LENGTH} characters (got ${workflowId.length})`, workflowId);
543
- }
544
- }
545
- function validateResourceId(resourceId) {
546
- if (resourceId != null && resourceId.length > MAX_RESOURCE_ID_LENGTH) {
547
- throw new WorkflowEngineError(`resourceId exceeds maximum length of ${MAX_RESOURCE_ID_LENGTH} characters (got ${resourceId.length})`);
548
- }
549
- }
550
-
551
- class WorkflowEngineError extends Error {
552
- workflowId;
553
- runId;
554
- cause;
555
- issues;
556
- constructor(message, workflowId, runId, cause = undefined, issues) {
557
- super(message);
558
- this.workflowId = workflowId;
559
- this.runId = runId;
560
- this.cause = cause;
561
- this.issues = issues;
562
- this.name = "WorkflowEngineError";
563
- if (Error.captureStackTrace) {
564
- Error.captureStackTrace(this, WorkflowEngineError);
565
- }
566
- }
567
- }
568
-
569
- class WorkflowRunNotFoundError extends WorkflowEngineError {
570
- constructor(runId, workflowId) {
571
- super("Workflow run not found", workflowId, runId);
572
- this.name = "WorkflowRunNotFoundError";
573
- }
574
- }
575
-
576
645
  // src/priority.ts
577
646
  var PRIORITY_LEVELS = { high: 100, normal: 0, low: -100 };
578
647
  function priorityToInt(value) {
@@ -647,11 +716,10 @@ class WorkflowClient {
647
716
  const db = {
648
717
  executeSql: (text, values) => this.pool.query(text, values)
649
718
  };
650
- if (boss) {
651
- this.boss = boss;
652
- } else {
653
- this.boss = new import_pg_boss.PgBoss({ db, schema: DEFAULT_PGBOSS_SCHEMA });
654
- }
719
+ this.boss = boss ?? new import_pg_boss.PgBoss({ db, schema: DEFAULT_PGBOSS_SCHEMA });
720
+ this.boss.on("error", (error) => {
721
+ this.logger.error("pg-boss error", error);
722
+ });
655
723
  this.db = db;
656
724
  }
657
725
  async start() {
@@ -680,6 +748,7 @@ class WorkflowClient {
680
748
  let resourceId;
681
749
  let idempotencyKey;
682
750
  let options;
751
+ let singleton = false;
683
752
  if (typeof refOrParams === "function" && "id" in refOrParams) {
684
753
  const ref = refOrParams;
685
754
  workflowId = ref.id;
@@ -687,6 +756,7 @@ class WorkflowClient {
687
756
  options = optionsArg;
688
757
  resourceId = optionsArg?.resourceId;
689
758
  idempotencyKey = optionsArg?.idempotencyKey;
759
+ singleton = optionsArg?.singleton ?? ref.singleton === true;
690
760
  if (ref.inputSchema) {
691
761
  const result = await ref.inputSchema["~standard"].validate(input);
692
762
  if (result.issues) {
@@ -700,6 +770,7 @@ class WorkflowClient {
700
770
  resourceId = params.resourceId;
701
771
  idempotencyKey = params.idempotencyKey;
702
772
  options = params.options;
773
+ singleton = params.options?.singleton === true;
703
774
  }
704
775
  validateWorkflowId(workflowId);
705
776
  validateResourceId(resourceId);
@@ -714,7 +785,8 @@ class WorkflowClient {
714
785
  maxRetries: options?.retries ?? 0,
715
786
  priority: resolvePriority(options?.priority),
716
787
  timeoutAt,
717
- idempotencyKey
788
+ idempotencyKey,
789
+ singleton
718
790
  }, _db);
719
791
  if (created) {
720
792
  const job = {
@@ -744,6 +816,9 @@ class WorkflowClient {
744
816
  }) {
745
817
  await this.ensureStarted();
746
818
  const run = await this.getRun({ runId, resourceId });
819
+ if (run.singleton) {
820
+ await assertSingletonSlotAvailable({ workflowId: run.workflowId, exceptRunId: run.id }, this.db);
821
+ }
747
822
  const job = {
748
823
  runId: run.id,
749
824
  resourceId: resourceId ?? run.resourceId ?? undefined,
@@ -936,6 +1011,7 @@ function createWorkflowRef(id, options) {
936
1011
  timeout: defineOptions?.timeout,
937
1012
  retries: defineOptions?.retries,
938
1013
  priority: defineOptions?.priority,
1014
+ singleton: defineOptions?.singleton ?? options?.singleton,
939
1015
  schedule: defineOptions?.schedule,
940
1016
  timezone: defineOptions?.timezone
941
1017
  });
@@ -944,16 +1020,29 @@ function createWorkflowRef(id, options) {
944
1020
  value: options?.inputSchema,
945
1021
  enumerable: true
946
1022
  });
1023
+ Object.defineProperty(ref, "singleton", {
1024
+ value: options?.singleton,
1025
+ enumerable: true
1026
+ });
947
1027
  return ref;
948
1028
  }
949
1029
  function createWorkflowFactory(plugins = []) {
950
- const factory = (id, handler, { inputSchema, timeout, retries, priority, schedule, timezone } = {}) => ({
1030
+ const factory = (id, handler, {
1031
+ inputSchema,
1032
+ timeout,
1033
+ retries,
1034
+ priority,
1035
+ singleton,
1036
+ schedule,
1037
+ timezone
1038
+ } = {}) => ({
951
1039
  id,
952
1040
  handler,
953
1041
  inputSchema,
954
1042
  timeout,
955
1043
  retries,
956
1044
  priority,
1045
+ singleton,
957
1046
  schedule,
958
1047
  timezone,
959
1048
  plugins: plugins.length > 0 ? plugins : undefined
@@ -1166,11 +1255,10 @@ class WorkflowEngine {
1166
1255
  const db = {
1167
1256
  executeSql: (text, values) => this.pool.query(text, values)
1168
1257
  };
1169
- if (boss) {
1170
- this.boss = boss;
1171
- } else {
1172
- this.boss = new import_pg_boss2.PgBoss({ db, schema: DEFAULT_PGBOSS_SCHEMA });
1173
- }
1258
+ this.boss = boss ?? new import_pg_boss2.PgBoss({ db, schema: DEFAULT_PGBOSS_SCHEMA });
1259
+ this.boss.on("error", (error) => {
1260
+ this.logger.error("pg-boss error", error);
1261
+ });
1174
1262
  this.db = this.boss.getDb();
1175
1263
  }
1176
1264
  async start(asEngine = true, {
@@ -1224,6 +1312,10 @@ class WorkflowEngine {
1224
1312
  try {
1225
1313
  await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
1226
1314
  } catch (error) {
1315
+ if (error instanceof WorkflowRunInProgressError) {
1316
+ this.logger.log(`Skipping schedule fire for "${workflowId}": a run is already in progress`, { workflowId, runId: error.runId });
1317
+ return;
1318
+ }
1227
1319
  this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
1228
1320
  throw error;
1229
1321
  }
@@ -1373,7 +1465,8 @@ class WorkflowEngine {
1373
1465
  parentRunId,
1374
1466
  parentStepId,
1375
1467
  parentResourceId,
1376
- scheduledAt
1468
+ scheduledAt,
1469
+ singleton: workflow2.singleton === true || options?.singleton === true
1377
1470
  }, targetDb);
1378
1471
  const insertAndEnqueue = async (targetDb) => {
1379
1472
  const result = await insertRun(targetDb);
@@ -1532,6 +1625,9 @@ class WorkflowEngine {
1532
1625
  }) {
1533
1626
  await this.checkIfHasStarted();
1534
1627
  const run = await this.getRun({ runId, resourceId });
1628
+ if (run.singleton) {
1629
+ await assertSingletonSlotAvailable({ workflowId: run.workflowId, exceptRunId: run.id }, this.db);
1630
+ }
1535
1631
  const jobResourceId = resourceId ?? run.resourceId ?? undefined;
1536
1632
  const job = {
1537
1633
  runId: run.id,
@@ -2501,5 +2597,5 @@ function otelPlugin(options = {}) {
2501
2597
  };
2502
2598
  }
2503
2599
 
2504
- //# debugId=5FA47CDFF028114064756E2164756E21
2600
+ //# debugId=0BBC9686D69E329D64756E2164756E21
2505
2601
  //# sourceMappingURL=index.js.map
package/dist/index.d.cts CHANGED
@@ -20,6 +20,13 @@ type WorkflowRun = {
20
20
  maxRetries: number;
21
21
  /** Resolved scheduling priority (pg-boss integer; higher runs first). */
22
22
  priority: number;
23
+ /**
24
+ * When true, this run participates in the per-workflow-ID uniqueness
25
+ * constraint: no other pending or running singleton run of the same
26
+ * workflow ID may exist at the same time. Paused, completed, failed, and
27
+ * cancelled runs release the slot.
28
+ */
29
+ singleton: boolean;
23
30
  jobId: string | null;
24
31
  idempotencyKey: string | null;
25
32
  parentRunId: string | null;
@@ -76,6 +83,12 @@ type StartWorkflowOptions = {
76
83
  expireInSeconds?: number;
77
84
  idempotencyKey?: string;
78
85
  priority?: WorkflowPriority;
86
+ /**
87
+ * Client-side counterpart of `workflow(..., { singleton: true })`.
88
+ * The engine reads this from the registered definition; pass it here when
89
+ * starting from `WorkflowClient` so the uniqueness constraint applies.
90
+ */
91
+ singleton?: boolean;
79
92
  };
80
93
  type WorkflowOptions<I extends InputParameters> = {
81
94
  timeout?: number;
@@ -83,6 +96,12 @@ type WorkflowOptions<I extends InputParameters> = {
83
96
  inputSchema?: I;
84
97
  priority?: WorkflowPriority;
85
98
  /**
99
+ * When true, at most one pending or running run of this workflow ID may exist.
100
+ * A second `startWorkflow` throws `WorkflowRunInProgressError` until the
101
+ * current run pauses, completes, fails, or is cancelled.
102
+ */
103
+ singleton?: boolean;
104
+ /**
86
105
  * Recurring schedule. Accepts a cron expression (`'0 9 * * 1-5'`),
87
106
  * a duration string (`'5m'`, `'1 hour'`), or a `DurationObject`.
88
107
  */
@@ -190,6 +209,7 @@ type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
190
209
  timeout?: number;
191
210
  retries?: number;
192
211
  priority?: WorkflowPriority;
212
+ singleton?: boolean;
193
213
  schedule?: Schedule;
194
214
  timezone?: string;
195
215
  plugins?: WorkflowPlugin[];
@@ -216,6 +236,7 @@ interface WorkflowFactory<TStepExt = object> {
216
236
  TOutput = unknown
217
237
  >(id: string, options?: {
218
238
  inputSchema?: TInput;
239
+ singleton?: boolean;
219
240
  }): WorkflowRef<TInput, TOutput>;
220
241
  }
221
242
  /**
@@ -232,6 +253,7 @@ interface WorkflowRef<
232
253
  (handler: (context: WorkflowContext<TInput, StepBaseContext>) => Promise<unknown>, options?: Omit<WorkflowOptions<TInput>, "inputSchema">): WorkflowDefinition<TInput>;
233
254
  readonly id: string;
234
255
  readonly inputSchema?: TInput;
256
+ readonly singleton?: boolean;
235
257
  }
236
258
  type WorkflowRunProgress = WorkflowRun & {
237
259
  completionPercentage: number;
@@ -338,6 +360,7 @@ declare function createWorkflowRef<
338
360
  TInput extends InputParameters = InputParameters
339
361
  >(id: string, options?: {
340
362
  inputSchema?: TInput;
363
+ singleton?: boolean;
341
364
  }): WorkflowRef<TInput, TOutput>;
342
365
  declare const workflow: WorkflowFactory;
343
366
  import pg2 from "pg";
@@ -511,6 +534,9 @@ declare class WorkflowEngineError extends Error {
511
534
  declare class WorkflowRunNotFoundError extends WorkflowEngineError {
512
535
  constructor(runId?: string, workflowId?: string);
513
536
  }
537
+ declare class WorkflowRunInProgressError extends WorkflowEngineError {
538
+ constructor(workflowId: string, runId?: string);
539
+ }
514
540
  import { AttributeValue, Tracer } from "@opentelemetry/api";
515
541
  type OtelPluginOptions = {
516
542
  /** Tracer to use. Defaults to `trace.getTracer('pg-workflows')`. */
@@ -521,4 +547,4 @@ type OtelPluginOptions = {
521
547
  attributes?: (context: WorkflowContext) => Record<string, AttributeValue>;
522
548
  };
523
549
  declare function otelPlugin(options?: OtelPluginOptions): WorkflowPlugin<StepBaseContext, object>;
524
- export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPriority, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
550
+ export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRunInProgressError, WorkflowRun, WorkflowRef, WorkflowPriority, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
package/dist/index.d.ts CHANGED
@@ -20,6 +20,13 @@ type WorkflowRun = {
20
20
  maxRetries: number;
21
21
  /** Resolved scheduling priority (pg-boss integer; higher runs first). */
22
22
  priority: number;
23
+ /**
24
+ * When true, this run participates in the per-workflow-ID uniqueness
25
+ * constraint: no other pending or running singleton run of the same
26
+ * workflow ID may exist at the same time. Paused, completed, failed, and
27
+ * cancelled runs release the slot.
28
+ */
29
+ singleton: boolean;
23
30
  jobId: string | null;
24
31
  idempotencyKey: string | null;
25
32
  parentRunId: string | null;
@@ -76,6 +83,12 @@ type StartWorkflowOptions = {
76
83
  expireInSeconds?: number;
77
84
  idempotencyKey?: string;
78
85
  priority?: WorkflowPriority;
86
+ /**
87
+ * Client-side counterpart of `workflow(..., { singleton: true })`.
88
+ * The engine reads this from the registered definition; pass it here when
89
+ * starting from `WorkflowClient` so the uniqueness constraint applies.
90
+ */
91
+ singleton?: boolean;
79
92
  };
80
93
  type WorkflowOptions<I extends InputParameters> = {
81
94
  timeout?: number;
@@ -83,6 +96,12 @@ type WorkflowOptions<I extends InputParameters> = {
83
96
  inputSchema?: I;
84
97
  priority?: WorkflowPriority;
85
98
  /**
99
+ * When true, at most one pending or running run of this workflow ID may exist.
100
+ * A second `startWorkflow` throws `WorkflowRunInProgressError` until the
101
+ * current run pauses, completes, fails, or is cancelled.
102
+ */
103
+ singleton?: boolean;
104
+ /**
86
105
  * Recurring schedule. Accepts a cron expression (`'0 9 * * 1-5'`),
87
106
  * a duration string (`'5m'`, `'1 hour'`), or a `DurationObject`.
88
107
  */
@@ -190,6 +209,7 @@ type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
190
209
  timeout?: number;
191
210
  retries?: number;
192
211
  priority?: WorkflowPriority;
212
+ singleton?: boolean;
193
213
  schedule?: Schedule;
194
214
  timezone?: string;
195
215
  plugins?: WorkflowPlugin[];
@@ -216,6 +236,7 @@ interface WorkflowFactory<TStepExt = object> {
216
236
  TOutput = unknown
217
237
  >(id: string, options?: {
218
238
  inputSchema?: TInput;
239
+ singleton?: boolean;
219
240
  }): WorkflowRef<TInput, TOutput>;
220
241
  }
221
242
  /**
@@ -232,6 +253,7 @@ interface WorkflowRef<
232
253
  (handler: (context: WorkflowContext<TInput, StepBaseContext>) => Promise<unknown>, options?: Omit<WorkflowOptions<TInput>, "inputSchema">): WorkflowDefinition<TInput>;
233
254
  readonly id: string;
234
255
  readonly inputSchema?: TInput;
256
+ readonly singleton?: boolean;
235
257
  }
236
258
  type WorkflowRunProgress = WorkflowRun & {
237
259
  completionPercentage: number;
@@ -338,6 +360,7 @@ declare function createWorkflowRef<
338
360
  TInput extends InputParameters = InputParameters
339
361
  >(id: string, options?: {
340
362
  inputSchema?: TInput;
363
+ singleton?: boolean;
341
364
  }): WorkflowRef<TInput, TOutput>;
342
365
  declare const workflow: WorkflowFactory;
343
366
  import pg2 from "pg";
@@ -511,6 +534,9 @@ declare class WorkflowEngineError extends Error {
511
534
  declare class WorkflowRunNotFoundError extends WorkflowEngineError {
512
535
  constructor(runId?: string, workflowId?: string);
513
536
  }
537
+ declare class WorkflowRunInProgressError extends WorkflowEngineError {
538
+ constructor(workflowId: string, runId?: string);
539
+ }
514
540
  import { AttributeValue, Tracer } from "@opentelemetry/api";
515
541
  type OtelPluginOptions = {
516
542
  /** Tracer to use. Defaults to `trace.getTracer('pg-workflows')`. */
@@ -521,4 +547,4 @@ type OtelPluginOptions = {
521
547
  attributes?: (context: WorkflowContext) => Record<string, AttributeValue>;
522
548
  };
523
549
  declare function otelPlugin(options?: OtelPluginOptions): WorkflowPlugin<StepBaseContext, object>;
524
- export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPriority, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
550
+ export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRunInProgressError, WorkflowRun, WorkflowRef, WorkflowPriority, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
package/dist/index.js CHANGED
@@ -6,8 +6,10 @@ import {
6
6
  WORKFLOW_RUN_QUEUE_NAME,
7
7
  WorkflowClient,
8
8
  WorkflowEngineError,
9
+ WorkflowRunInProgressError,
9
10
  WorkflowRunNotFoundError,
10
11
  WorkflowStatus,
12
+ assertSingletonSlotAvailable,
11
13
  createWorkflowRef,
12
14
  getWorkflowLastRun,
13
15
  getWorkflowRun,
@@ -24,7 +26,7 @@ import {
24
26
  waitForTimelineKey,
25
27
  withPostgresTransaction,
26
28
  workflow
27
- } from "./shared/chunk-w3qcrrwx.js";
29
+ } from "./shared/chunk-n0f7hd40.js";
28
30
  // src/engine.ts
29
31
  import { merge } from "es-toolkit";
30
32
  import pg from "pg";
@@ -225,11 +227,10 @@ class WorkflowEngine {
225
227
  const db = {
226
228
  executeSql: (text, values) => this.pool.query(text, values)
227
229
  };
228
- if (boss) {
229
- this.boss = boss;
230
- } else {
231
- this.boss = new PgBoss({ db, schema: DEFAULT_PGBOSS_SCHEMA });
232
- }
230
+ this.boss = boss ?? new PgBoss({ db, schema: DEFAULT_PGBOSS_SCHEMA });
231
+ this.boss.on("error", (error) => {
232
+ this.logger.error("pg-boss error", error);
233
+ });
233
234
  this.db = this.boss.getDb();
234
235
  }
235
236
  async start(asEngine = true, {
@@ -283,6 +284,10 @@ class WorkflowEngine {
283
284
  try {
284
285
  await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
285
286
  } catch (error) {
287
+ if (error instanceof WorkflowRunInProgressError) {
288
+ this.logger.log(`Skipping schedule fire for "${workflowId}": a run is already in progress`, { workflowId, runId: error.runId });
289
+ return;
290
+ }
286
291
  this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
287
292
  throw error;
288
293
  }
@@ -432,7 +437,8 @@ class WorkflowEngine {
432
437
  parentRunId,
433
438
  parentStepId,
434
439
  parentResourceId,
435
- scheduledAt
440
+ scheduledAt,
441
+ singleton: workflow2.singleton === true || options?.singleton === true
436
442
  }, targetDb);
437
443
  const insertAndEnqueue = async (targetDb) => {
438
444
  const result = await insertRun(targetDb);
@@ -591,6 +597,9 @@ class WorkflowEngine {
591
597
  }) {
592
598
  await this.checkIfHasStarted();
593
599
  const run = await this.getRun({ runId, resourceId });
600
+ if (run.singleton) {
601
+ await assertSingletonSlotAvailable({ workflowId: run.workflowId, exceptRunId: run.id }, this.db);
602
+ }
594
603
  const jobResourceId = resourceId ?? run.resourceId ?? undefined;
595
604
  const job = {
596
605
  runId: run.id,
@@ -1569,10 +1578,11 @@ export {
1569
1578
  createWorkflowRef,
1570
1579
  WorkflowStatus,
1571
1580
  WorkflowRunNotFoundError,
1581
+ WorkflowRunInProgressError,
1572
1582
  WorkflowEngineError,
1573
1583
  WorkflowEngine,
1574
1584
  WorkflowClient
1575
1585
  };
1576
1586
 
1577
- //# debugId=A6C8F39FA22E601264756E2164756E21
1587
+ //# debugId=4EA8624FFA9819BE64756E2164756E21
1578
1588
  //# sourceMappingURL=index.js.map