pg-workflows 0.12.0 → 0.13.1

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 = {
@@ -1049,6 +1129,7 @@ class WorkflowEngine {
1049
1129
  for (const workflow2 of this.unregisteredWorkflows.values()) {
1050
1130
  await this.registerWorkflow(workflow2);
1051
1131
  }
1132
+ this.unregisteredWorkflows.clear();
1052
1133
  }
1053
1134
  const mainQueueOptions = {
1054
1135
  retryLimit: 0,
@@ -1066,10 +1147,41 @@ class WorkflowEngine {
1066
1147
  await this.boss.work(WORKFLOW_RUN_DLQ_QUEUE_NAME, { pollingIntervalSeconds: 0.5, batchSize: 1 }, (jobs) => this.handleWorkflowRunDlq(jobs));
1067
1148
  this.logger.log(`Worker started for queue ${WORKFLOW_RUN_DLQ_QUEUE_NAME}`);
1068
1149
  }
1150
+ if (asEngine) {
1151
+ const scheduled = Array.from(this.workflows.values()).flatMap((wf) => wf.schedule == null ? [] : [{ id: wf.id, resolved: resolveSchedule(wf.schedule, wf.timezone) }]);
1152
+ await Promise.allSettled(scheduled.map(({ id, resolved }) => this.registerWorkflowSchedule(id, resolved).catch((error) => {
1153
+ this.logger.error(`Failed to register schedule for "${id}", skipping`, error instanceof Error ? error : new Error(String(error)), { workflowId: id });
1154
+ })));
1155
+ }
1069
1156
  this._started = true;
1070
1157
  this.logger.log("Workflow engine started!");
1071
1158
  }
1159
+ async registerWorkflowSchedule(workflowId, resolvedSchedule) {
1160
+ const scheduleQueueName = scheduleQueueNameFor(workflowId);
1161
+ await this.boss.createQueue(scheduleQueueName);
1162
+ await this.boss.schedule(scheduleQueueName, resolvedSchedule.cron, null, {
1163
+ tz: resolvedSchedule.timezone
1164
+ });
1165
+ await this.boss.work(scheduleQueueName, { batchSize: 1, includeMetadata: true }, async (jobs) => {
1166
+ const scheduledAt = jobs[0]?.startAfter ?? new Date;
1167
+ try {
1168
+ await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
1169
+ } catch (error) {
1170
+ this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
1171
+ throw error;
1172
+ }
1173
+ });
1174
+ this.logger.log(`Schedule registered for workflow "${workflowId}": ${resolvedSchedule.cron} (${resolvedSchedule.timezone})`, { workflowId });
1175
+ }
1176
+ async unscheduleWorkflow(workflowId) {
1177
+ try {
1178
+ await this.boss.unschedule(scheduleQueueNameFor(workflowId));
1179
+ } catch (error) {
1180
+ this.logger.error(`Failed to unschedule "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
1181
+ }
1182
+ }
1072
1183
  async stop() {
1184
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
1073
1185
  await this.boss.stop();
1074
1186
  if (this._ownsPool) {
1075
1187
  await this.pool.end();
@@ -1082,10 +1194,14 @@ class WorkflowEngine {
1082
1194
  throw new WorkflowEngineError(`Workflow ${definition.id} is already registered`, definition.id);
1083
1195
  }
1084
1196
  const { steps } = parseWorkflowHandler(definition.handler);
1197
+ const resolvedSchedule = definition.schedule ? resolveSchedule(definition.schedule, definition.timezone) : undefined;
1085
1198
  this.workflows.set(definition.id, {
1086
1199
  ...definition,
1087
1200
  steps
1088
1201
  });
1202
+ if (this._started && resolvedSchedule) {
1203
+ await this.registerWorkflowSchedule(definition.id, resolvedSchedule);
1204
+ }
1089
1205
  this.logger.log(`Registered workflow "${definition.id}" with steps:`);
1090
1206
  for (const step of steps.values()) {
1091
1207
  const tags = [];
@@ -1100,10 +1216,17 @@ class WorkflowEngine {
1100
1216
  return this;
1101
1217
  }
1102
1218
  async unregisterWorkflow(workflowId) {
1219
+ const existing = this.workflows.get(workflowId);
1220
+ if (existing?.schedule != null && this._started) {
1221
+ await this.unscheduleWorkflow(workflowId);
1222
+ }
1103
1223
  this.workflows.delete(workflowId);
1104
1224
  return this;
1105
1225
  }
1106
1226
  async unregisterAllWorkflows() {
1227
+ if (this._started) {
1228
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
1229
+ }
1107
1230
  this.workflows.clear();
1108
1231
  return this;
1109
1232
  }
@@ -1153,6 +1276,7 @@ class WorkflowEngine {
1153
1276
  parentRunId,
1154
1277
  parentStepId,
1155
1278
  parentResourceId,
1279
+ scheduledAt,
1156
1280
  enqueue = true,
1157
1281
  db
1158
1282
  }) {
@@ -1186,7 +1310,8 @@ class WorkflowEngine {
1186
1310
  idempotencyKey,
1187
1311
  parentRunId,
1188
1312
  parentStepId,
1189
- parentResourceId
1313
+ parentResourceId,
1314
+ scheduledAt
1190
1315
  }, targetDb);
1191
1316
  const insertAndEnqueue = async (targetDb) => {
1192
1317
  const result = await insertRun(targetDb);
@@ -1369,6 +1494,14 @@ class WorkflowEngine {
1369
1494
  }
1370
1495
  return run;
1371
1496
  }
1497
+ async getWorkflowLastRun({
1498
+ workflowId,
1499
+ resourceId
1500
+ }) {
1501
+ validateWorkflowId(workflowId);
1502
+ validateResourceId(resourceId);
1503
+ return getWorkflowLastRun({ workflowId, resourceId }, this.db);
1504
+ }
1372
1505
  async updateRun({
1373
1506
  runId,
1374
1507
  resourceId,
@@ -1600,7 +1733,8 @@ class WorkflowEngine {
1600
1733
  return run?.timeline ?? {};
1601
1734
  },
1602
1735
  logger: this.logger,
1603
- step
1736
+ step,
1737
+ schedule: run.scheduledAt ? { timestamp: run.scheduledAt } : undefined
1604
1738
  };
1605
1739
  for (const plugin of plugins) {
1606
1740
  const extra = plugin.methods(step, context);
@@ -2319,5 +2453,5 @@ function otelPlugin(options = {}) {
2319
2453
  };
2320
2454
  }
2321
2455
 
2322
- //# debugId=BCF84547491115D464756E2164756E21
2456
+ //# debugId=5C1D7E8675006E9B64756E2164756E21
2323
2457
  //# 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 = {
@@ -191,6 +243,7 @@ class WorkflowEngine {
191
243
  for (const workflow2 of this.unregisteredWorkflows.values()) {
192
244
  await this.registerWorkflow(workflow2);
193
245
  }
246
+ this.unregisteredWorkflows.clear();
194
247
  }
195
248
  const mainQueueOptions = {
196
249
  retryLimit: 0,
@@ -208,10 +261,41 @@ class WorkflowEngine {
208
261
  await this.boss.work(WORKFLOW_RUN_DLQ_QUEUE_NAME, { pollingIntervalSeconds: 0.5, batchSize: 1 }, (jobs) => this.handleWorkflowRunDlq(jobs));
209
262
  this.logger.log(`Worker started for queue ${WORKFLOW_RUN_DLQ_QUEUE_NAME}`);
210
263
  }
264
+ if (asEngine) {
265
+ const scheduled = Array.from(this.workflows.values()).flatMap((wf) => wf.schedule == null ? [] : [{ id: wf.id, resolved: resolveSchedule(wf.schedule, wf.timezone) }]);
266
+ await Promise.allSettled(scheduled.map(({ id, resolved }) => this.registerWorkflowSchedule(id, resolved).catch((error) => {
267
+ this.logger.error(`Failed to register schedule for "${id}", skipping`, error instanceof Error ? error : new Error(String(error)), { workflowId: id });
268
+ })));
269
+ }
211
270
  this._started = true;
212
271
  this.logger.log("Workflow engine started!");
213
272
  }
273
+ async registerWorkflowSchedule(workflowId, resolvedSchedule) {
274
+ const scheduleQueueName = scheduleQueueNameFor(workflowId);
275
+ await this.boss.createQueue(scheduleQueueName);
276
+ await this.boss.schedule(scheduleQueueName, resolvedSchedule.cron, null, {
277
+ tz: resolvedSchedule.timezone
278
+ });
279
+ await this.boss.work(scheduleQueueName, { batchSize: 1, includeMetadata: true }, async (jobs) => {
280
+ const scheduledAt = jobs[0]?.startAfter ?? new Date;
281
+ try {
282
+ await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
283
+ } catch (error) {
284
+ this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
285
+ throw error;
286
+ }
287
+ });
288
+ this.logger.log(`Schedule registered for workflow "${workflowId}": ${resolvedSchedule.cron} (${resolvedSchedule.timezone})`, { workflowId });
289
+ }
290
+ async unscheduleWorkflow(workflowId) {
291
+ try {
292
+ await this.boss.unschedule(scheduleQueueNameFor(workflowId));
293
+ } catch (error) {
294
+ this.logger.error(`Failed to unschedule "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
295
+ }
296
+ }
214
297
  async stop() {
298
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
215
299
  await this.boss.stop();
216
300
  if (this._ownsPool) {
217
301
  await this.pool.end();
@@ -224,10 +308,14 @@ class WorkflowEngine {
224
308
  throw new WorkflowEngineError(`Workflow ${definition.id} is already registered`, definition.id);
225
309
  }
226
310
  const { steps } = parseWorkflowHandler(definition.handler);
311
+ const resolvedSchedule = definition.schedule ? resolveSchedule(definition.schedule, definition.timezone) : undefined;
227
312
  this.workflows.set(definition.id, {
228
313
  ...definition,
229
314
  steps
230
315
  });
316
+ if (this._started && resolvedSchedule) {
317
+ await this.registerWorkflowSchedule(definition.id, resolvedSchedule);
318
+ }
231
319
  this.logger.log(`Registered workflow "${definition.id}" with steps:`);
232
320
  for (const step of steps.values()) {
233
321
  const tags = [];
@@ -242,10 +330,17 @@ class WorkflowEngine {
242
330
  return this;
243
331
  }
244
332
  async unregisterWorkflow(workflowId) {
333
+ const existing = this.workflows.get(workflowId);
334
+ if (existing?.schedule != null && this._started) {
335
+ await this.unscheduleWorkflow(workflowId);
336
+ }
245
337
  this.workflows.delete(workflowId);
246
338
  return this;
247
339
  }
248
340
  async unregisterAllWorkflows() {
341
+ if (this._started) {
342
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
343
+ }
249
344
  this.workflows.clear();
250
345
  return this;
251
346
  }
@@ -295,6 +390,7 @@ class WorkflowEngine {
295
390
  parentRunId,
296
391
  parentStepId,
297
392
  parentResourceId,
393
+ scheduledAt,
298
394
  enqueue = true,
299
395
  db
300
396
  }) {
@@ -328,7 +424,8 @@ class WorkflowEngine {
328
424
  idempotencyKey,
329
425
  parentRunId,
330
426
  parentStepId,
331
- parentResourceId
427
+ parentResourceId,
428
+ scheduledAt
332
429
  }, targetDb);
333
430
  const insertAndEnqueue = async (targetDb) => {
334
431
  const result = await insertRun(targetDb);
@@ -511,6 +608,14 @@ class WorkflowEngine {
511
608
  }
512
609
  return run;
513
610
  }
611
+ async getWorkflowLastRun({
612
+ workflowId,
613
+ resourceId
614
+ }) {
615
+ validateWorkflowId(workflowId);
616
+ validateResourceId(resourceId);
617
+ return getWorkflowLastRun({ workflowId, resourceId }, this.db);
618
+ }
514
619
  async updateRun({
515
620
  runId,
516
621
  resourceId,
@@ -742,7 +847,8 @@ class WorkflowEngine {
742
847
  return run?.timeline ?? {};
743
848
  },
744
849
  logger: this.logger,
745
- step
850
+ step,
851
+ schedule: run.scheduledAt ? { timestamp: run.scheduledAt } : undefined
746
852
  };
747
853
  for (const plugin of plugins) {
748
854
  const extra = plugin.methods(step, context);
@@ -1475,5 +1581,5 @@ export {
1475
1581
  WorkflowClient
1476
1582
  };
1477
1583
 
1478
- //# debugId=3131CBB2B482181264756E2164756E21
1584
+ //# debugId=C2CB4B181900270764756E2164756E21
1479
1585
  //# sourceMappingURL=index.js.map