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/client.entry.cjs +39 -9
- package/dist/client.entry.d.cts +19 -0
- package/dist/client.entry.d.ts +19 -0
- package/dist/client.entry.js +1 -1
- package/dist/client.entry.js.map +7 -7
- package/dist/index.cjs +145 -11
- package/dist/index.d.cts +31 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +110 -4
- package/dist/index.js.map +11 -10
- package/dist/shared/{chunk-ahxqsytt.js → chunk-5xswmve7.js} +41 -11
- package/dist/shared/chunk-5xswmve7.js.map +16 -0
- package/package.json +2 -1
- package/dist/shared/chunk-ahxqsytt.js.map +0 -16
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 =
|
|
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=
|
|
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-
|
|
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=
|
|
1584
|
+
//# debugId=C2CB4B181900270764756E2164756E21
|
|
1479
1585
|
//# sourceMappingURL=index.js.map
|