pg-workflows 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.cjs CHANGED
@@ -84,6 +84,8 @@ var import_pg_boss = require("pg-boss");
84
84
  var PAUSE_EVENT_NAME = "__internal_pause";
85
85
  var WORKFLOW_RUN_QUEUE_NAME = "workflow-run";
86
86
  var WORKFLOW_RUN_DLQ_QUEUE_NAME = "workflow_run_dlq";
87
+ var SCHEDULE_QUEUE_PREFIX = "__pgw_schedule_";
88
+ var scheduleQueueNameFor = (workflowId) => `${SCHEDULE_QUEUE_PREFIX}${workflowId}`;
87
89
  var DEFAULT_PGBOSS_SCHEMA = "pgboss_v12_pgworkflow";
88
90
  var MAX_WORKFLOW_ID_LENGTH = 256;
89
91
  var MAX_RESOURCE_ID_LENGTH = 256;
@@ -95,7 +97,7 @@ var isInvokeChildWorkflowTimelineEntry = (entry) => !!entry && typeof entry ===
95
97
 
96
98
  // src/db/migration.ts
97
99
  var MIGRATION_LOCK_ID = 738291645;
98
- var CURRENT_SCHEMA_VERSION = 4;
100
+ var CURRENT_SCHEMA_VERSION = 5;
99
101
  async function runMigrations(db) {
100
102
  if (await isSchemaUpToDate(db)) {
101
103
  return;
@@ -158,6 +160,9 @@ async function runMigrations(db) {
158
160
  commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS parent_step_id varchar(256)");
159
161
  commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS parent_resource_id varchar(256)");
160
162
  }
163
+ if (currentVersion < 5) {
164
+ commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS scheduled_at timestamp with time zone");
165
+ }
161
166
  if (currentVersion === 0) {
162
167
  commands.push(`INSERT INTO workflow_schema_version (version) VALUES (${CURRENT_SCHEMA_VERSION})`);
163
168
  } else {
@@ -223,7 +228,8 @@ function mapRowToWorkflowRun(row) {
223
228
  idempotencyKey: row.idempotency_key,
224
229
  parentRunId: row.parent_run_id,
225
230
  parentStepId: row.parent_step_id,
226
- parentResourceId: row.parent_resource_id
231
+ parentResourceId: row.parent_resource_id,
232
+ scheduledAt: row.scheduled_at ? new Date(row.scheduled_at) : null
227
233
  };
228
234
  }
229
235
  async function insertWorkflowRun({
@@ -237,7 +243,8 @@ async function insertWorkflowRun({
237
243
  idempotencyKey,
238
244
  parentRunId,
239
245
  parentStepId,
240
- parentResourceId
246
+ parentResourceId,
247
+ scheduledAt
241
248
  }, db) {
242
249
  const runId = generateKSUID("run");
243
250
  const now = new Date;
@@ -257,9 +264,10 @@ async function insertWorkflowRun({
257
264
  idempotency_key,
258
265
  parent_run_id,
259
266
  parent_step_id,
260
- parent_resource_id
267
+ parent_resource_id,
268
+ scheduled_at
261
269
  )
262
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
270
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
263
271
  ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
264
272
  RETURNING *`, [
265
273
  runId,
@@ -277,7 +285,8 @@ async function insertWorkflowRun({
277
285
  idempotencyKey ?? null,
278
286
  parentRunId ?? null,
279
287
  parentStepId ?? null,
280
- parentResourceId ?? null
288
+ parentResourceId ?? null,
289
+ scheduledAt ?? null
281
290
  ]);
282
291
  if (result.rows[0]) {
283
292
  return { run: mapRowToWorkflowRun(result.rows[0]), created: true };
@@ -306,6 +315,23 @@ async function getWorkflowRun({
306
315
  }
307
316
  return mapRowToWorkflowRun(run);
308
317
  }
318
+ async function getWorkflowLastRun({
319
+ workflowId,
320
+ resourceId
321
+ }, db) {
322
+ const result = resourceId ? await db.executeSql(`SELECT * FROM workflow_runs
323
+ WHERE workflow_id = $1 AND resource_id = $2
324
+ ORDER BY created_at DESC
325
+ LIMIT 1`, [workflowId, resourceId]) : await db.executeSql(`SELECT * FROM workflow_runs
326
+ WHERE workflow_id = $1
327
+ ORDER BY created_at DESC
328
+ LIMIT 1`, [workflowId]);
329
+ const run = result.rows[0];
330
+ if (!run) {
331
+ return null;
332
+ }
333
+ return mapRowToWorkflowRun(run);
334
+ }
309
335
  async function updateWorkflowRun({
310
336
  runId,
311
337
  resourceId,
@@ -853,7 +879,9 @@ function createWorkflowRef(id, options) {
853
879
  handler,
854
880
  inputSchema: options?.inputSchema,
855
881
  timeout: defineOptions?.timeout,
856
- retries: defineOptions?.retries
882
+ retries: defineOptions?.retries,
883
+ schedule: defineOptions?.schedule,
884
+ timezone: defineOptions?.timezone
857
885
  });
858
886
  Object.defineProperty(ref, "id", { value: id, enumerable: true });
859
887
  Object.defineProperty(ref, "inputSchema", {
@@ -863,12 +891,14 @@ function createWorkflowRef(id, options) {
863
891
  return ref;
864
892
  }
865
893
  function createWorkflowFactory(plugins = []) {
866
- const factory = (id, handler, { inputSchema, timeout, retries } = {}) => ({
894
+ const factory = (id, handler, { inputSchema, timeout, retries, schedule, timezone } = {}) => ({
867
895
  id,
868
896
  handler,
869
897
  inputSchema,
870
898
  timeout,
871
899
  retries,
900
+ schedule,
901
+ timezone,
872
902
  plugins: plugins.length > 0 ? plugins : undefined
873
903
  });
874
904
  factory.use = (plugin) => createWorkflowFactory([
@@ -980,6 +1010,56 @@ function parseDuration(duration) {
980
1010
  return ms;
981
1011
  }
982
1012
 
1013
+ // src/schedule.ts
1014
+ var import_cron_parser = require("cron-parser");
1015
+ var CRON_TOKEN = /^[0-9*/,?\-LW#]+$/;
1016
+ function looksLikeCronString(value) {
1017
+ const tokens = value.trim().split(/\s+/);
1018
+ if (tokens.length !== 5 && tokens.length !== 6)
1019
+ return false;
1020
+ return tokens.every((t) => CRON_TOKEN.test(t));
1021
+ }
1022
+ function validateCronExpression(expression, timezone) {
1023
+ try {
1024
+ import_cron_parser.CronExpressionParser.parse(expression, { tz: timezone });
1025
+ } catch (e) {
1026
+ throw new WorkflowEngineError(`Invalid cron expression "${expression}" (timezone: ${timezone}): ${e instanceof Error ? e.message : String(e)}`);
1027
+ }
1028
+ }
1029
+ function durationMsToCron(ms, original) {
1030
+ if (ms < MS_PER_MINUTE) {
1031
+ throw new WorkflowEngineError(`Schedule interval must be at least 1 minute; got ${ms}ms from ${JSON.stringify(original)}`);
1032
+ }
1033
+ if (ms % MS_PER_DAY === 0) {
1034
+ const days = ms / MS_PER_DAY;
1035
+ if (days === 1)
1036
+ return "0 0 * * *";
1037
+ throw cronStepError(original, `${days} days`);
1038
+ }
1039
+ if (ms % MS_PER_HOUR === 0) {
1040
+ const hours = ms / MS_PER_HOUR;
1041
+ if (24 % hours === 0)
1042
+ return `0 */${hours} * * *`;
1043
+ throw cronStepError(original, `${hours} hours`);
1044
+ }
1045
+ const minutes = ms / MS_PER_MINUTE;
1046
+ if (Number.isInteger(minutes) && 60 % minutes === 0)
1047
+ return `*/${minutes} * * * *`;
1048
+ throw cronStepError(original, `${minutes} minutes`);
1049
+ }
1050
+ function cronStepError(original, label) {
1051
+ return new WorkflowEngineError(`Schedule interval ${JSON.stringify(original)} (${label}) doesn't map cleanly to a recurring cron expression. Use a value that divides 60 minutes, 24 hours, or 1 day — or pass an explicit cron string.`);
1052
+ }
1053
+ function resolveSchedule(schedule, timezone) {
1054
+ const tz = timezone ?? "UTC";
1055
+ if (typeof schedule === "string" && looksLikeCronString(schedule)) {
1056
+ validateCronExpression(schedule, tz);
1057
+ return { cron: schedule, timezone: tz };
1058
+ }
1059
+ const ms = parseDuration(schedule);
1060
+ return { cron: durationMsToCron(ms, schedule), timezone: tz };
1061
+ }
1062
+
983
1063
  // src/engine.ts
984
1064
  var LOG_PREFIX2 = "[WorkflowEngine]";
985
1065
  var StepTypeToIcon = {
@@ -1066,10 +1146,41 @@ class WorkflowEngine {
1066
1146
  await this.boss.work(WORKFLOW_RUN_DLQ_QUEUE_NAME, { pollingIntervalSeconds: 0.5, batchSize: 1 }, (jobs) => this.handleWorkflowRunDlq(jobs));
1067
1147
  this.logger.log(`Worker started for queue ${WORKFLOW_RUN_DLQ_QUEUE_NAME}`);
1068
1148
  }
1149
+ if (asEngine) {
1150
+ const scheduled = Array.from(this.workflows.values()).flatMap((wf) => wf.schedule == null ? [] : [{ id: wf.id, resolved: resolveSchedule(wf.schedule, wf.timezone) }]);
1151
+ await Promise.allSettled(scheduled.map(({ id, resolved }) => this.registerWorkflowSchedule(id, resolved).catch((error) => {
1152
+ this.logger.error(`Failed to register schedule for "${id}", skipping`, error instanceof Error ? error : new Error(String(error)), { workflowId: id });
1153
+ })));
1154
+ }
1069
1155
  this._started = true;
1070
1156
  this.logger.log("Workflow engine started!");
1071
1157
  }
1158
+ async registerWorkflowSchedule(workflowId, resolvedSchedule) {
1159
+ const scheduleQueueName = scheduleQueueNameFor(workflowId);
1160
+ await this.boss.createQueue(scheduleQueueName);
1161
+ await this.boss.schedule(scheduleQueueName, resolvedSchedule.cron, null, {
1162
+ tz: resolvedSchedule.timezone
1163
+ });
1164
+ await this.boss.work(scheduleQueueName, { batchSize: 1, includeMetadata: true }, async (jobs) => {
1165
+ const scheduledAt = jobs[0]?.startAfter ?? new Date;
1166
+ try {
1167
+ await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
1168
+ } catch (error) {
1169
+ this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
1170
+ throw error;
1171
+ }
1172
+ });
1173
+ this.logger.log(`Schedule registered for workflow "${workflowId}": ${resolvedSchedule.cron} (${resolvedSchedule.timezone})`, { workflowId });
1174
+ }
1175
+ async unscheduleWorkflow(workflowId) {
1176
+ try {
1177
+ await this.boss.unschedule(scheduleQueueNameFor(workflowId));
1178
+ } catch (error) {
1179
+ this.logger.error(`Failed to unschedule "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
1180
+ }
1181
+ }
1072
1182
  async stop() {
1183
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
1073
1184
  await this.boss.stop();
1074
1185
  if (this._ownsPool) {
1075
1186
  await this.pool.end();
@@ -1082,10 +1193,14 @@ class WorkflowEngine {
1082
1193
  throw new WorkflowEngineError(`Workflow ${definition.id} is already registered`, definition.id);
1083
1194
  }
1084
1195
  const { steps } = parseWorkflowHandler(definition.handler);
1196
+ const resolvedSchedule = definition.schedule ? resolveSchedule(definition.schedule, definition.timezone) : undefined;
1085
1197
  this.workflows.set(definition.id, {
1086
1198
  ...definition,
1087
1199
  steps
1088
1200
  });
1201
+ if (this._started && resolvedSchedule) {
1202
+ await this.registerWorkflowSchedule(definition.id, resolvedSchedule);
1203
+ }
1089
1204
  this.logger.log(`Registered workflow "${definition.id}" with steps:`);
1090
1205
  for (const step of steps.values()) {
1091
1206
  const tags = [];
@@ -1100,10 +1215,17 @@ class WorkflowEngine {
1100
1215
  return this;
1101
1216
  }
1102
1217
  async unregisterWorkflow(workflowId) {
1218
+ const existing = this.workflows.get(workflowId);
1219
+ if (existing?.schedule != null && this._started) {
1220
+ await this.unscheduleWorkflow(workflowId);
1221
+ }
1103
1222
  this.workflows.delete(workflowId);
1104
1223
  return this;
1105
1224
  }
1106
1225
  async unregisterAllWorkflows() {
1226
+ if (this._started) {
1227
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
1228
+ }
1107
1229
  this.workflows.clear();
1108
1230
  return this;
1109
1231
  }
@@ -1153,6 +1275,7 @@ class WorkflowEngine {
1153
1275
  parentRunId,
1154
1276
  parentStepId,
1155
1277
  parentResourceId,
1278
+ scheduledAt,
1156
1279
  enqueue = true,
1157
1280
  db
1158
1281
  }) {
@@ -1186,7 +1309,8 @@ class WorkflowEngine {
1186
1309
  idempotencyKey,
1187
1310
  parentRunId,
1188
1311
  parentStepId,
1189
- parentResourceId
1312
+ parentResourceId,
1313
+ scheduledAt
1190
1314
  }, targetDb);
1191
1315
  const insertAndEnqueue = async (targetDb) => {
1192
1316
  const result = await insertRun(targetDb);
@@ -1369,6 +1493,14 @@ class WorkflowEngine {
1369
1493
  }
1370
1494
  return run;
1371
1495
  }
1496
+ async getWorkflowLastRun({
1497
+ workflowId,
1498
+ resourceId
1499
+ }) {
1500
+ validateWorkflowId(workflowId);
1501
+ validateResourceId(resourceId);
1502
+ return getWorkflowLastRun({ workflowId, resourceId }, this.db);
1503
+ }
1372
1504
  async updateRun({
1373
1505
  runId,
1374
1506
  resourceId,
@@ -1600,7 +1732,8 @@ class WorkflowEngine {
1600
1732
  return run?.timeline ?? {};
1601
1733
  },
1602
1734
  logger: this.logger,
1603
- step
1735
+ step,
1736
+ schedule: run.scheduledAt ? { timestamp: run.scheduledAt } : undefined
1604
1737
  };
1605
1738
  for (const plugin of plugins) {
1606
1739
  const extra = plugin.methods(step, context);
@@ -2319,5 +2452,5 @@ function otelPlugin(options = {}) {
2319
2452
  };
2320
2453
  }
2321
2454
 
2322
- //# debugId=BCF84547491115D464756E2164756E21
2455
+ //# debugId=12905C6BC12C3A1664756E2164756E21
2323
2456
  //# sourceMappingURL=index.js.map
package/dist/index.d.cts CHANGED
@@ -23,6 +23,8 @@ type WorkflowRun = {
23
23
  parentRunId: string | null;
24
24
  parentStepId: string | null;
25
25
  parentResourceId: string | null;
26
+ /** Set when the run was started by a recurring schedule; the timestamp the schedule fired. */
27
+ scheduledAt: Date | null;
26
28
  };
27
29
  import { StandardSchemaV1 } from "@standard-schema/spec";
28
30
  type DurationObject = {
@@ -33,6 +35,7 @@ type DurationObject = {
33
35
  seconds?: number;
34
36
  };
35
37
  type Duration = string | DurationObject;
38
+ type Schedule = string | Exclude<Duration, string>;
36
39
  declare enum WorkflowStatus {
37
40
  PENDING = "pending",
38
41
  RUNNING = "running",
@@ -63,6 +66,18 @@ type WorkflowOptions<I extends InputParameters> = {
63
66
  timeout?: number;
64
67
  retries?: number;
65
68
  inputSchema?: I;
69
+ /**
70
+ * Recurring schedule. Accepts a cron expression (`'0 9 * * 1-5'`),
71
+ * a duration string (`'5m'`, `'1 hour'`), or a `DurationObject`.
72
+ */
73
+ schedule?: Schedule;
74
+ /** IANA timezone for cron expressions. Defaults to UTC. Ignored for duration-based schedules. */
75
+ timezone?: string;
76
+ };
77
+ /** Metadata about a scheduled fire, exposed on `ctx.schedule` for runs triggered by a schedule. */
78
+ type ScheduleContext = {
79
+ /** Time the schedule fired this run. */
80
+ timestamp: Date;
66
81
  };
67
82
  type StepBaseContext = {
68
83
  run: <T>(stepId: string, handler: () => Promise<T>) => Promise<T>;
@@ -148,6 +163,8 @@ type WorkflowContext<
148
163
  attempt: number;
149
164
  timeline: Record<string, unknown>;
150
165
  logger: WorkflowLogger;
166
+ /** Set only for runs triggered by a recurring schedule. */
167
+ schedule?: ScheduleContext;
151
168
  };
152
169
  type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
153
170
  id: string;
@@ -156,6 +173,8 @@ type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
156
173
  inputSchema?: TInput;
157
174
  timeout?: number;
158
175
  retries?: number;
176
+ schedule?: Schedule;
177
+ timezone?: string;
159
178
  plugins?: WorkflowPlugin[];
160
179
  };
161
180
  type StepInternalDefinition = {
@@ -331,6 +350,8 @@ declare class WorkflowEngine {
331
350
  batchSize?: number;
332
351
  heartbeatSeconds?: number;
333
352
  }): Promise<void>;
353
+ private registerWorkflowSchedule;
354
+ private unscheduleWorkflow;
334
355
  stop(): Promise<void>;
335
356
  registerWorkflow(definition: WorkflowDefinition<InputParameters>): Promise<WorkflowEngine>;
336
357
  unregisterWorkflow(workflowId: string): Promise<WorkflowEngine>;
@@ -383,6 +404,15 @@ declare class WorkflowEngine {
383
404
  exclusiveLock?: boolean;
384
405
  db?: Db;
385
406
  }): Promise<WorkflowRun>;
407
+ /**
408
+ * Fetch the most recently created run for a workflow, optionally scoped to a
409
+ * `resourceId`. Useful for cron-style incremental syncs where the next run
410
+ * needs the previous run's completion timestamp as a cursor.
411
+ */
412
+ getWorkflowLastRun({ workflowId, resourceId }: {
413
+ workflowId: string;
414
+ resourceId?: string;
415
+ }): Promise<WorkflowRun | null>;
386
416
  updateRun({ runId, resourceId, data, expectedStatuses }: {
387
417
  runId: string;
388
418
  resourceId?: string;
@@ -472,4 +502,4 @@ type OtelPluginOptions = {
472
502
  attributes?: (context: WorkflowContext) => Record<string, AttributeValue>;
473
503
  };
474
504
  declare function otelPlugin(options?: OtelPluginOptions): WorkflowPlugin<StepBaseContext, object>;
475
- export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
505
+ export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
package/dist/index.d.ts CHANGED
@@ -23,6 +23,8 @@ type WorkflowRun = {
23
23
  parentRunId: string | null;
24
24
  parentStepId: string | null;
25
25
  parentResourceId: string | null;
26
+ /** Set when the run was started by a recurring schedule; the timestamp the schedule fired. */
27
+ scheduledAt: Date | null;
26
28
  };
27
29
  import { StandardSchemaV1 } from "@standard-schema/spec";
28
30
  type DurationObject = {
@@ -33,6 +35,7 @@ type DurationObject = {
33
35
  seconds?: number;
34
36
  };
35
37
  type Duration = string | DurationObject;
38
+ type Schedule = string | Exclude<Duration, string>;
36
39
  declare enum WorkflowStatus {
37
40
  PENDING = "pending",
38
41
  RUNNING = "running",
@@ -63,6 +66,18 @@ type WorkflowOptions<I extends InputParameters> = {
63
66
  timeout?: number;
64
67
  retries?: number;
65
68
  inputSchema?: I;
69
+ /**
70
+ * Recurring schedule. Accepts a cron expression (`'0 9 * * 1-5'`),
71
+ * a duration string (`'5m'`, `'1 hour'`), or a `DurationObject`.
72
+ */
73
+ schedule?: Schedule;
74
+ /** IANA timezone for cron expressions. Defaults to UTC. Ignored for duration-based schedules. */
75
+ timezone?: string;
76
+ };
77
+ /** Metadata about a scheduled fire, exposed on `ctx.schedule` for runs triggered by a schedule. */
78
+ type ScheduleContext = {
79
+ /** Time the schedule fired this run. */
80
+ timestamp: Date;
66
81
  };
67
82
  type StepBaseContext = {
68
83
  run: <T>(stepId: string, handler: () => Promise<T>) => Promise<T>;
@@ -148,6 +163,8 @@ type WorkflowContext<
148
163
  attempt: number;
149
164
  timeline: Record<string, unknown>;
150
165
  logger: WorkflowLogger;
166
+ /** Set only for runs triggered by a recurring schedule. */
167
+ schedule?: ScheduleContext;
151
168
  };
152
169
  type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
153
170
  id: string;
@@ -156,6 +173,8 @@ type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
156
173
  inputSchema?: TInput;
157
174
  timeout?: number;
158
175
  retries?: number;
176
+ schedule?: Schedule;
177
+ timezone?: string;
159
178
  plugins?: WorkflowPlugin[];
160
179
  };
161
180
  type StepInternalDefinition = {
@@ -331,6 +350,8 @@ declare class WorkflowEngine {
331
350
  batchSize?: number;
332
351
  heartbeatSeconds?: number;
333
352
  }): Promise<void>;
353
+ private registerWorkflowSchedule;
354
+ private unscheduleWorkflow;
334
355
  stop(): Promise<void>;
335
356
  registerWorkflow(definition: WorkflowDefinition<InputParameters>): Promise<WorkflowEngine>;
336
357
  unregisterWorkflow(workflowId: string): Promise<WorkflowEngine>;
@@ -383,6 +404,15 @@ declare class WorkflowEngine {
383
404
  exclusiveLock?: boolean;
384
405
  db?: Db;
385
406
  }): Promise<WorkflowRun>;
407
+ /**
408
+ * Fetch the most recently created run for a workflow, optionally scoped to a
409
+ * `resourceId`. Useful for cron-style incremental syncs where the next run
410
+ * needs the previous run's completion timestamp as a cursor.
411
+ */
412
+ getWorkflowLastRun({ workflowId, resourceId }: {
413
+ workflowId: string;
414
+ resourceId?: string;
415
+ }): Promise<WorkflowRun | null>;
386
416
  updateRun({ runId, resourceId, data, expectedStatuses }: {
387
417
  runId: string;
388
418
  resourceId?: string;
@@ -472,4 +502,4 @@ type OtelPluginOptions = {
472
502
  attributes?: (context: WorkflowContext) => Record<string, AttributeValue>;
473
503
  };
474
504
  declare function otelPlugin(options?: OtelPluginOptions): WorkflowPlugin<StepBaseContext, object>;
475
- export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
505
+ export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
package/dist/index.js CHANGED
@@ -8,19 +8,21 @@ import {
8
8
  WorkflowRunNotFoundError,
9
9
  WorkflowStatus,
10
10
  createWorkflowRef,
11
+ getWorkflowLastRun,
11
12
  getWorkflowRun,
12
13
  getWorkflowRuns,
13
14
  insertWorkflowRun,
14
15
  invokeChildWorkflowTimelineKey,
15
16
  isInvokeChildWorkflowTimelineEntry,
16
17
  runMigrations,
18
+ scheduleQueueNameFor,
17
19
  updateWorkflowRun,
18
20
  validateResourceId,
19
21
  validateWorkflowId,
20
22
  waitForTimelineKey,
21
23
  withPostgresTransaction,
22
24
  workflow
23
- } from "./shared/chunk-ahxqsytt.js";
25
+ } from "./shared/chunk-5xswmve7.js";
24
26
  // src/engine.ts
25
27
  import { merge } from "es-toolkit";
26
28
  import pg from "pg";
@@ -122,6 +124,56 @@ function parseDuration(duration) {
122
124
  return ms;
123
125
  }
124
126
 
127
+ // src/schedule.ts
128
+ import { CronExpressionParser } from "cron-parser";
129
+ var CRON_TOKEN = /^[0-9*/,?\-LW#]+$/;
130
+ function looksLikeCronString(value) {
131
+ const tokens = value.trim().split(/\s+/);
132
+ if (tokens.length !== 5 && tokens.length !== 6)
133
+ return false;
134
+ return tokens.every((t) => CRON_TOKEN.test(t));
135
+ }
136
+ function validateCronExpression(expression, timezone) {
137
+ try {
138
+ CronExpressionParser.parse(expression, { tz: timezone });
139
+ } catch (e) {
140
+ throw new WorkflowEngineError(`Invalid cron expression "${expression}" (timezone: ${timezone}): ${e instanceof Error ? e.message : String(e)}`);
141
+ }
142
+ }
143
+ function durationMsToCron(ms, original) {
144
+ if (ms < MS_PER_MINUTE) {
145
+ throw new WorkflowEngineError(`Schedule interval must be at least 1 minute; got ${ms}ms from ${JSON.stringify(original)}`);
146
+ }
147
+ if (ms % MS_PER_DAY === 0) {
148
+ const days = ms / MS_PER_DAY;
149
+ if (days === 1)
150
+ return "0 0 * * *";
151
+ throw cronStepError(original, `${days} days`);
152
+ }
153
+ if (ms % MS_PER_HOUR === 0) {
154
+ const hours = ms / MS_PER_HOUR;
155
+ if (24 % hours === 0)
156
+ return `0 */${hours} * * *`;
157
+ throw cronStepError(original, `${hours} hours`);
158
+ }
159
+ const minutes = ms / MS_PER_MINUTE;
160
+ if (Number.isInteger(minutes) && 60 % minutes === 0)
161
+ return `*/${minutes} * * * *`;
162
+ throw cronStepError(original, `${minutes} minutes`);
163
+ }
164
+ function cronStepError(original, label) {
165
+ return new WorkflowEngineError(`Schedule interval ${JSON.stringify(original)} (${label}) doesn't map cleanly to a recurring cron expression. Use a value that divides 60 minutes, 24 hours, or 1 day — or pass an explicit cron string.`);
166
+ }
167
+ function resolveSchedule(schedule, timezone) {
168
+ const tz = timezone ?? "UTC";
169
+ if (typeof schedule === "string" && looksLikeCronString(schedule)) {
170
+ validateCronExpression(schedule, tz);
171
+ return { cron: schedule, timezone: tz };
172
+ }
173
+ const ms = parseDuration(schedule);
174
+ return { cron: durationMsToCron(ms, schedule), timezone: tz };
175
+ }
176
+
125
177
  // src/engine.ts
126
178
  var LOG_PREFIX = "[WorkflowEngine]";
127
179
  var StepTypeToIcon = {
@@ -208,10 +260,41 @@ class WorkflowEngine {
208
260
  await this.boss.work(WORKFLOW_RUN_DLQ_QUEUE_NAME, { pollingIntervalSeconds: 0.5, batchSize: 1 }, (jobs) => this.handleWorkflowRunDlq(jobs));
209
261
  this.logger.log(`Worker started for queue ${WORKFLOW_RUN_DLQ_QUEUE_NAME}`);
210
262
  }
263
+ if (asEngine) {
264
+ const scheduled = Array.from(this.workflows.values()).flatMap((wf) => wf.schedule == null ? [] : [{ id: wf.id, resolved: resolveSchedule(wf.schedule, wf.timezone) }]);
265
+ await Promise.allSettled(scheduled.map(({ id, resolved }) => this.registerWorkflowSchedule(id, resolved).catch((error) => {
266
+ this.logger.error(`Failed to register schedule for "${id}", skipping`, error instanceof Error ? error : new Error(String(error)), { workflowId: id });
267
+ })));
268
+ }
211
269
  this._started = true;
212
270
  this.logger.log("Workflow engine started!");
213
271
  }
272
+ async registerWorkflowSchedule(workflowId, resolvedSchedule) {
273
+ const scheduleQueueName = scheduleQueueNameFor(workflowId);
274
+ await this.boss.createQueue(scheduleQueueName);
275
+ await this.boss.schedule(scheduleQueueName, resolvedSchedule.cron, null, {
276
+ tz: resolvedSchedule.timezone
277
+ });
278
+ await this.boss.work(scheduleQueueName, { batchSize: 1, includeMetadata: true }, async (jobs) => {
279
+ const scheduledAt = jobs[0]?.startAfter ?? new Date;
280
+ try {
281
+ await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
282
+ } catch (error) {
283
+ this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
284
+ throw error;
285
+ }
286
+ });
287
+ this.logger.log(`Schedule registered for workflow "${workflowId}": ${resolvedSchedule.cron} (${resolvedSchedule.timezone})`, { workflowId });
288
+ }
289
+ async unscheduleWorkflow(workflowId) {
290
+ try {
291
+ await this.boss.unschedule(scheduleQueueNameFor(workflowId));
292
+ } catch (error) {
293
+ this.logger.error(`Failed to unschedule "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
294
+ }
295
+ }
214
296
  async stop() {
297
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
215
298
  await this.boss.stop();
216
299
  if (this._ownsPool) {
217
300
  await this.pool.end();
@@ -224,10 +307,14 @@ class WorkflowEngine {
224
307
  throw new WorkflowEngineError(`Workflow ${definition.id} is already registered`, definition.id);
225
308
  }
226
309
  const { steps } = parseWorkflowHandler(definition.handler);
310
+ const resolvedSchedule = definition.schedule ? resolveSchedule(definition.schedule, definition.timezone) : undefined;
227
311
  this.workflows.set(definition.id, {
228
312
  ...definition,
229
313
  steps
230
314
  });
315
+ if (this._started && resolvedSchedule) {
316
+ await this.registerWorkflowSchedule(definition.id, resolvedSchedule);
317
+ }
231
318
  this.logger.log(`Registered workflow "${definition.id}" with steps:`);
232
319
  for (const step of steps.values()) {
233
320
  const tags = [];
@@ -242,10 +329,17 @@ class WorkflowEngine {
242
329
  return this;
243
330
  }
244
331
  async unregisterWorkflow(workflowId) {
332
+ const existing = this.workflows.get(workflowId);
333
+ if (existing?.schedule != null && this._started) {
334
+ await this.unscheduleWorkflow(workflowId);
335
+ }
245
336
  this.workflows.delete(workflowId);
246
337
  return this;
247
338
  }
248
339
  async unregisterAllWorkflows() {
340
+ if (this._started) {
341
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
342
+ }
249
343
  this.workflows.clear();
250
344
  return this;
251
345
  }
@@ -295,6 +389,7 @@ class WorkflowEngine {
295
389
  parentRunId,
296
390
  parentStepId,
297
391
  parentResourceId,
392
+ scheduledAt,
298
393
  enqueue = true,
299
394
  db
300
395
  }) {
@@ -328,7 +423,8 @@ class WorkflowEngine {
328
423
  idempotencyKey,
329
424
  parentRunId,
330
425
  parentStepId,
331
- parentResourceId
426
+ parentResourceId,
427
+ scheduledAt
332
428
  }, targetDb);
333
429
  const insertAndEnqueue = async (targetDb) => {
334
430
  const result = await insertRun(targetDb);
@@ -511,6 +607,14 @@ class WorkflowEngine {
511
607
  }
512
608
  return run;
513
609
  }
610
+ async getWorkflowLastRun({
611
+ workflowId,
612
+ resourceId
613
+ }) {
614
+ validateWorkflowId(workflowId);
615
+ validateResourceId(resourceId);
616
+ return getWorkflowLastRun({ workflowId, resourceId }, this.db);
617
+ }
514
618
  async updateRun({
515
619
  runId,
516
620
  resourceId,
@@ -742,7 +846,8 @@ class WorkflowEngine {
742
846
  return run?.timeline ?? {};
743
847
  },
744
848
  logger: this.logger,
745
- step
849
+ step,
850
+ schedule: run.scheduledAt ? { timestamp: run.scheduledAt } : undefined
746
851
  };
747
852
  for (const plugin of plugins) {
748
853
  const extra = plugin.methods(step, context);
@@ -1475,5 +1580,5 @@ export {
1475
1580
  WorkflowClient
1476
1581
  };
1477
1582
 
1478
- //# debugId=3131CBB2B482181264756E2164756E21
1583
+ //# debugId=5BC543336A07527964756E2164756E21
1479
1584
  //# sourceMappingURL=index.js.map