pg-workflows 0.11.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
@@ -65,6 +65,7 @@ var __export = (target, all) => {
65
65
  var exports_src = {};
66
66
  __export(exports_src, {
67
67
  workflow: () => workflow,
68
+ otelPlugin: () => otelPlugin,
68
69
  createWorkflowRef: () => createWorkflowRef,
69
70
  WorkflowStatus: () => WorkflowStatus,
70
71
  WorkflowRunNotFoundError: () => WorkflowRunNotFoundError,
@@ -83,6 +84,8 @@ var import_pg_boss = require("pg-boss");
83
84
  var PAUSE_EVENT_NAME = "__internal_pause";
84
85
  var WORKFLOW_RUN_QUEUE_NAME = "workflow-run";
85
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}`;
86
89
  var DEFAULT_PGBOSS_SCHEMA = "pgboss_v12_pgworkflow";
87
90
  var MAX_WORKFLOW_ID_LENGTH = 256;
88
91
  var MAX_RESOURCE_ID_LENGTH = 256;
@@ -94,7 +97,7 @@ var isInvokeChildWorkflowTimelineEntry = (entry) => !!entry && typeof entry ===
94
97
 
95
98
  // src/db/migration.ts
96
99
  var MIGRATION_LOCK_ID = 738291645;
97
- var CURRENT_SCHEMA_VERSION = 4;
100
+ var CURRENT_SCHEMA_VERSION = 5;
98
101
  async function runMigrations(db) {
99
102
  if (await isSchemaUpToDate(db)) {
100
103
  return;
@@ -157,6 +160,9 @@ async function runMigrations(db) {
157
160
  commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS parent_step_id varchar(256)");
158
161
  commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS parent_resource_id varchar(256)");
159
162
  }
163
+ if (currentVersion < 5) {
164
+ commands.push("ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS scheduled_at timestamp with time zone");
165
+ }
160
166
  if (currentVersion === 0) {
161
167
  commands.push(`INSERT INTO workflow_schema_version (version) VALUES (${CURRENT_SCHEMA_VERSION})`);
162
168
  } else {
@@ -222,7 +228,8 @@ function mapRowToWorkflowRun(row) {
222
228
  idempotencyKey: row.idempotency_key,
223
229
  parentRunId: row.parent_run_id,
224
230
  parentStepId: row.parent_step_id,
225
- parentResourceId: row.parent_resource_id
231
+ parentResourceId: row.parent_resource_id,
232
+ scheduledAt: row.scheduled_at ? new Date(row.scheduled_at) : null
226
233
  };
227
234
  }
228
235
  async function insertWorkflowRun({
@@ -236,7 +243,8 @@ async function insertWorkflowRun({
236
243
  idempotencyKey,
237
244
  parentRunId,
238
245
  parentStepId,
239
- parentResourceId
246
+ parentResourceId,
247
+ scheduledAt
240
248
  }, db) {
241
249
  const runId = generateKSUID("run");
242
250
  const now = new Date;
@@ -256,9 +264,10 @@ async function insertWorkflowRun({
256
264
  idempotency_key,
257
265
  parent_run_id,
258
266
  parent_step_id,
259
- parent_resource_id
267
+ parent_resource_id,
268
+ scheduled_at
260
269
  )
261
- 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)
262
271
  ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
263
272
  RETURNING *`, [
264
273
  runId,
@@ -276,7 +285,8 @@ async function insertWorkflowRun({
276
285
  idempotencyKey ?? null,
277
286
  parentRunId ?? null,
278
287
  parentStepId ?? null,
279
- parentResourceId ?? null
288
+ parentResourceId ?? null,
289
+ scheduledAt ?? null
280
290
  ]);
281
291
  if (result.rows[0]) {
282
292
  return { run: mapRowToWorkflowRun(result.rows[0]), created: true };
@@ -305,6 +315,23 @@ async function getWorkflowRun({
305
315
  }
306
316
  return mapRowToWorkflowRun(run);
307
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
+ }
308
335
  async function updateWorkflowRun({
309
336
  runId,
310
337
  resourceId,
@@ -852,7 +879,9 @@ function createWorkflowRef(id, options) {
852
879
  handler,
853
880
  inputSchema: options?.inputSchema,
854
881
  timeout: defineOptions?.timeout,
855
- retries: defineOptions?.retries
882
+ retries: defineOptions?.retries,
883
+ schedule: defineOptions?.schedule,
884
+ timezone: defineOptions?.timezone
856
885
  });
857
886
  Object.defineProperty(ref, "id", { value: id, enumerable: true });
858
887
  Object.defineProperty(ref, "inputSchema", {
@@ -862,12 +891,14 @@ function createWorkflowRef(id, options) {
862
891
  return ref;
863
892
  }
864
893
  function createWorkflowFactory(plugins = []) {
865
- const factory = (id, handler, { inputSchema, timeout, retries } = {}) => ({
894
+ const factory = (id, handler, { inputSchema, timeout, retries, schedule, timezone } = {}) => ({
866
895
  id,
867
896
  handler,
868
897
  inputSchema,
869
898
  timeout,
870
899
  retries,
900
+ schedule,
901
+ timezone,
871
902
  plugins: plugins.length > 0 ? plugins : undefined
872
903
  });
873
904
  factory.use = (plugin) => createWorkflowFactory([
@@ -979,6 +1010,56 @@ function parseDuration(duration) {
979
1010
  return ms;
980
1011
  }
981
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
+
982
1063
  // src/engine.ts
983
1064
  var LOG_PREFIX2 = "[WorkflowEngine]";
984
1065
  var StepTypeToIcon = {
@@ -1065,10 +1146,41 @@ class WorkflowEngine {
1065
1146
  await this.boss.work(WORKFLOW_RUN_DLQ_QUEUE_NAME, { pollingIntervalSeconds: 0.5, batchSize: 1 }, (jobs) => this.handleWorkflowRunDlq(jobs));
1066
1147
  this.logger.log(`Worker started for queue ${WORKFLOW_RUN_DLQ_QUEUE_NAME}`);
1067
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
+ }
1068
1155
  this._started = true;
1069
1156
  this.logger.log("Workflow engine started!");
1070
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
+ }
1071
1182
  async stop() {
1183
+ await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
1072
1184
  await this.boss.stop();
1073
1185
  if (this._ownsPool) {
1074
1186
  await this.pool.end();
@@ -1081,10 +1193,14 @@ class WorkflowEngine {
1081
1193
  throw new WorkflowEngineError(`Workflow ${definition.id} is already registered`, definition.id);
1082
1194
  }
1083
1195
  const { steps } = parseWorkflowHandler(definition.handler);
1196
+ const resolvedSchedule = definition.schedule ? resolveSchedule(definition.schedule, definition.timezone) : undefined;
1084
1197
  this.workflows.set(definition.id, {
1085
1198
  ...definition,
1086
1199
  steps
1087
1200
  });
1201
+ if (this._started && resolvedSchedule) {
1202
+ await this.registerWorkflowSchedule(definition.id, resolvedSchedule);
1203
+ }
1088
1204
  this.logger.log(`Registered workflow "${definition.id}" with steps:`);
1089
1205
  for (const step of steps.values()) {
1090
1206
  const tags = [];
@@ -1099,10 +1215,17 @@ class WorkflowEngine {
1099
1215
  return this;
1100
1216
  }
1101
1217
  async unregisterWorkflow(workflowId) {
1218
+ const existing = this.workflows.get(workflowId);
1219
+ if (existing?.schedule != null && this._started) {
1220
+ await this.unscheduleWorkflow(workflowId);
1221
+ }
1102
1222
  this.workflows.delete(workflowId);
1103
1223
  return this;
1104
1224
  }
1105
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
+ }
1106
1229
  this.workflows.clear();
1107
1230
  return this;
1108
1231
  }
@@ -1152,6 +1275,7 @@ class WorkflowEngine {
1152
1275
  parentRunId,
1153
1276
  parentStepId,
1154
1277
  parentResourceId,
1278
+ scheduledAt,
1155
1279
  enqueue = true,
1156
1280
  db
1157
1281
  }) {
@@ -1185,7 +1309,8 @@ class WorkflowEngine {
1185
1309
  idempotencyKey,
1186
1310
  parentRunId,
1187
1311
  parentStepId,
1188
- parentResourceId
1312
+ parentResourceId,
1313
+ scheduledAt
1189
1314
  }, targetDb);
1190
1315
  const insertAndEnqueue = async (targetDb) => {
1191
1316
  const result = await insertRun(targetDb);
@@ -1368,6 +1493,14 @@ class WorkflowEngine {
1368
1493
  }
1369
1494
  return run;
1370
1495
  }
1496
+ async getWorkflowLastRun({
1497
+ workflowId,
1498
+ resourceId
1499
+ }) {
1500
+ validateWorkflowId(workflowId);
1501
+ validateResourceId(resourceId);
1502
+ return getWorkflowLastRun({ workflowId, resourceId }, this.db);
1503
+ }
1371
1504
  async updateRun({
1372
1505
  runId,
1373
1506
  resourceId,
@@ -1589,21 +1722,33 @@ class WorkflowEngine {
1589
1722
  };
1590
1723
  let step = { ...baseStep };
1591
1724
  const plugins = workflow2.plugins ?? [];
1592
- for (const plugin of plugins) {
1593
- const extra = plugin.methods(step);
1594
- step = { ...step, ...extra };
1595
- }
1596
1725
  const context = {
1597
1726
  input: run.input,
1598
1727
  workflowId: run.workflowId,
1599
1728
  runId: run.id,
1729
+ resourceId: run.resourceId ?? undefined,
1730
+ attempt: run.retryCount,
1600
1731
  get timeline() {
1601
1732
  return run?.timeline ?? {};
1602
1733
  },
1603
1734
  logger: this.logger,
1604
- step
1735
+ step,
1736
+ schedule: run.scheduledAt ? { timestamp: run.scheduledAt } : undefined
1605
1737
  };
1606
- const result = await workflow2.handler(context);
1738
+ for (const plugin of plugins) {
1739
+ const extra = plugin.methods(step, context);
1740
+ step = { ...step, ...extra };
1741
+ context.step = step;
1742
+ }
1743
+ let next = () => workflow2.handler(context);
1744
+ for (const plugin of [...plugins].reverse()) {
1745
+ if (plugin.wrap) {
1746
+ const inner = next;
1747
+ const wrap = plugin.wrap;
1748
+ next = () => wrap(context, inner);
1749
+ }
1750
+ }
1751
+ const result = await next();
1607
1752
  run = await this.getRun({ runId, resourceId: scopedResourceId });
1608
1753
  const isLastParsedStep = run.currentStepId === workflow2.steps[workflow2.steps.length - 1]?.id;
1609
1754
  const hasPluginSteps = (workflow2.plugins?.length ?? 0) > 0;
@@ -2136,6 +2281,176 @@ ${error.stack}` : String(error)
2136
2281
  }, this.db);
2137
2282
  }
2138
2283
  }
2284
+ // src/plugins/otel.ts
2285
+ var import_api = require("@opentelemetry/api");
2286
+ var DEFAULT_PREFIX = "pg_workflows";
2287
+ function isCachedHit(timeline, stepId, kind) {
2288
+ const entry = timeline[stepId];
2289
+ if (entry && typeof entry === "object" && "output" in entry && entry.output !== undefined) {
2290
+ return true;
2291
+ }
2292
+ if (kind === "invokeChildWorkflow" && timeline[invokeChildWorkflowTimelineKey(stepId)]) {
2293
+ return true;
2294
+ }
2295
+ return false;
2296
+ }
2297
+ function otelPlugin(options = {}) {
2298
+ const tracer = options.tracer ?? import_api.trace.getTracer("pg-workflows");
2299
+ const prefix = options.spanNamePrefix ?? DEFAULT_PREFIX;
2300
+ const extraAttrs = options.attributes;
2301
+ return {
2302
+ name: "opentelemetry",
2303
+ methods: (step, context) => {
2304
+ const wrapVoidish = (kind, base) => {
2305
+ return async (stepId, ...args) => {
2306
+ if (isCachedHit(context.timeline, stepId, kind)) {
2307
+ return base(stepId, ...args);
2308
+ }
2309
+ const capturedCtx = import_api.context.active();
2310
+ const startTime = new Date;
2311
+ let result;
2312
+ let originalErr;
2313
+ let thrownError;
2314
+ try {
2315
+ result = await base(stepId, ...args);
2316
+ } catch (err) {
2317
+ originalErr = err;
2318
+ thrownError = err instanceof Error ? err : new Error(String(err));
2319
+ }
2320
+ const span = tracer.startSpan(`${prefix}.step.${kind}`, {
2321
+ startTime,
2322
+ attributes: { "step.id": stepId, "step.type": kind }
2323
+ }, capturedCtx);
2324
+ if (thrownError) {
2325
+ span.recordException(thrownError);
2326
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message: thrownError.message });
2327
+ span.end();
2328
+ throw originalErr;
2329
+ }
2330
+ span.setStatus({ code: import_api.SpanStatusCode.OK });
2331
+ span.end();
2332
+ return result;
2333
+ };
2334
+ };
2335
+ return {
2336
+ run: async (stepId, handler) => {
2337
+ if (isCachedHit(context.timeline, stepId, "run")) {
2338
+ return step.run(stepId, handler);
2339
+ }
2340
+ const capturedCtx = import_api.context.active();
2341
+ const startTime = new Date;
2342
+ let result;
2343
+ let originalErr;
2344
+ let thrownError;
2345
+ try {
2346
+ result = await step.run(stepId, handler);
2347
+ } catch (err) {
2348
+ originalErr = err;
2349
+ thrownError = err instanceof Error ? err : new Error(String(err));
2350
+ }
2351
+ if (result === undefined && !thrownError) {
2352
+ return;
2353
+ }
2354
+ const span = tracer.startSpan(`${prefix}.step.run`, {
2355
+ startTime,
2356
+ attributes: { "step.id": stepId, "step.type": "run" }
2357
+ }, capturedCtx);
2358
+ if (thrownError) {
2359
+ span.recordException(thrownError);
2360
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message: thrownError.message });
2361
+ span.end();
2362
+ throw originalErr;
2363
+ }
2364
+ span.setStatus({ code: import_api.SpanStatusCode.OK });
2365
+ span.end();
2366
+ return result;
2367
+ },
2368
+ waitFor: wrapVoidish("waitFor", step.waitFor),
2369
+ delay: wrapVoidish("delay", step.delay),
2370
+ sleep: wrapVoidish("delay", step.delay),
2371
+ waitUntil: wrapVoidish("waitUntil", step.waitUntil),
2372
+ pause: wrapVoidish("pause", step.pause),
2373
+ poll: async (stepId, conditionFn, pollOptions) => {
2374
+ const capturedCtx = import_api.context.active();
2375
+ const startTime = new Date;
2376
+ let result;
2377
+ let originalErr;
2378
+ let thrownError;
2379
+ try {
2380
+ result = await step.poll(stepId, conditionFn, pollOptions);
2381
+ } catch (err) {
2382
+ originalErr = err;
2383
+ thrownError = err instanceof Error ? err : new Error(String(err));
2384
+ }
2385
+ const span = tracer.startSpan(`${prefix}.step.poll`, {
2386
+ startTime,
2387
+ attributes: { "step.id": stepId, "step.type": "poll" }
2388
+ }, capturedCtx);
2389
+ if (thrownError) {
2390
+ span.recordException(thrownError);
2391
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message: thrownError.message });
2392
+ span.end();
2393
+ throw originalErr;
2394
+ }
2395
+ span.setStatus({ code: import_api.SpanStatusCode.OK });
2396
+ span.end();
2397
+ return result;
2398
+ },
2399
+ invokeChildWorkflow: async (stepId, refOrParams, inputArg, optionsArg) => {
2400
+ if (isCachedHit(context.timeline, stepId, "invokeChildWorkflow")) {
2401
+ return step.invokeChildWorkflow(stepId, refOrParams, inputArg, optionsArg);
2402
+ }
2403
+ const capturedCtx = import_api.context.active();
2404
+ const startTime = new Date;
2405
+ let result;
2406
+ let originalErr;
2407
+ let thrownError;
2408
+ try {
2409
+ result = await step.invokeChildWorkflow(stepId, refOrParams, inputArg, optionsArg);
2410
+ } catch (err) {
2411
+ originalErr = err;
2412
+ thrownError = err instanceof Error ? err : new Error(String(err));
2413
+ }
2414
+ const span = tracer.startSpan(`${prefix}.step.invokeChildWorkflow`, {
2415
+ startTime,
2416
+ attributes: { "step.id": stepId, "step.type": "invokeChildWorkflow" }
2417
+ }, capturedCtx);
2418
+ if (thrownError) {
2419
+ span.recordException(thrownError);
2420
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message: thrownError.message });
2421
+ span.end();
2422
+ throw originalErr;
2423
+ }
2424
+ span.setStatus({ code: import_api.SpanStatusCode.OK });
2425
+ span.end();
2426
+ return result;
2427
+ }
2428
+ };
2429
+ },
2430
+ wrap: (context, next) => tracer.startActiveSpan(`${prefix}.workflow.run`, {
2431
+ attributes: {
2432
+ "workflow.id": context.workflowId,
2433
+ "workflow.run_id": context.runId,
2434
+ "workflow.attempt": context.attempt,
2435
+ ...context.resourceId ? { "workflow.resource_id": context.resourceId } : {},
2436
+ ...extraAttrs ? extraAttrs(context) : {}
2437
+ }
2438
+ }, async (span) => {
2439
+ try {
2440
+ const result = await next();
2441
+ span.setStatus({ code: import_api.SpanStatusCode.OK });
2442
+ return result;
2443
+ } catch (err) {
2444
+ const error = err instanceof Error ? err : new Error(String(err));
2445
+ span.recordException(error);
2446
+ span.setStatus({ code: import_api.SpanStatusCode.ERROR, message: error.message });
2447
+ throw err;
2448
+ } finally {
2449
+ span.end();
2450
+ }
2451
+ })
2452
+ };
2453
+ }
2139
2454
 
2140
- //# debugId=B0870008EFFA04F064756E2164756E21
2455
+ //# debugId=12905C6BC12C3A1664756E2164756E21
2141
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>;
@@ -126,7 +141,13 @@ interface WorkflowPlugin<
126
141
  TStepExt = object
127
142
  > {
128
143
  name: string;
129
- methods: (step: TStepBase) => TStepExt;
144
+ methods: (step: TStepBase, context: WorkflowContext) => TStepExt;
145
+ /**
146
+ * Optional middleware around the workflow handler call. Composes in
147
+ * registration order — the first plugin passed to `.use()` wraps everything
148
+ * inside. Implementations MUST call `next()` exactly once.
149
+ */
150
+ wrap?: (context: WorkflowContext, next: () => Promise<unknown>) => Promise<unknown>;
130
151
  }
131
152
  type WorkflowContext<
132
153
  TInput extends InputParameters = InputParameters,
@@ -136,8 +157,14 @@ type WorkflowContext<
136
157
  step: TStep;
137
158
  workflowId: string;
138
159
  runId: string;
160
+ /** Tenant/scope identifier set when the run was started, if any. */
161
+ resourceId?: string;
162
+ /** Zero-based retry attempt number (= `run.retryCount`). */
163
+ attempt: number;
139
164
  timeline: Record<string, unknown>;
140
165
  logger: WorkflowLogger;
166
+ /** Set only for runs triggered by a recurring schedule. */
167
+ schedule?: ScheduleContext;
141
168
  };
142
169
  type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
143
170
  id: string;
@@ -146,6 +173,8 @@ type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
146
173
  inputSchema?: TInput;
147
174
  timeout?: number;
148
175
  retries?: number;
176
+ schedule?: Schedule;
177
+ timezone?: string;
149
178
  plugins?: WorkflowPlugin[];
150
179
  };
151
180
  type StepInternalDefinition = {
@@ -321,6 +350,8 @@ declare class WorkflowEngine {
321
350
  batchSize?: number;
322
351
  heartbeatSeconds?: number;
323
352
  }): Promise<void>;
353
+ private registerWorkflowSchedule;
354
+ private unscheduleWorkflow;
324
355
  stop(): Promise<void>;
325
356
  registerWorkflow(definition: WorkflowDefinition<InputParameters>): Promise<WorkflowEngine>;
326
357
  unregisterWorkflow(workflowId: string): Promise<WorkflowEngine>;
@@ -373,6 +404,15 @@ declare class WorkflowEngine {
373
404
  exclusiveLock?: boolean;
374
405
  db?: Db;
375
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>;
376
416
  updateRun({ runId, resourceId, data, expectedStatuses }: {
377
417
  runId: string;
378
418
  resourceId?: string;
@@ -452,4 +492,14 @@ declare class WorkflowEngineError extends Error {
452
492
  declare class WorkflowRunNotFoundError extends WorkflowEngineError {
453
493
  constructor(runId?: string, workflowId?: string);
454
494
  }
455
- export { workflow, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, InputParameters, InferInputParameters, Duration };
495
+ import { AttributeValue, Tracer } from "@opentelemetry/api";
496
+ type OtelPluginOptions = {
497
+ /** Tracer to use. Defaults to `trace.getTracer('pg-workflows')`. */
498
+ tracer?: Tracer;
499
+ /** Prefix for all span names. Defaults to `pg_workflows`. */
500
+ spanNamePrefix?: string;
501
+ /** Extra attributes merged onto the workflow.run span. */
502
+ attributes?: (context: WorkflowContext) => Record<string, AttributeValue>;
503
+ };
504
+ declare function otelPlugin(options?: OtelPluginOptions): WorkflowPlugin<StepBaseContext, object>;
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 };