pg-workflows 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +144 -11
- package/dist/index.d.cts +31 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +109 -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 = {
|
|
@@ -1066,10 +1146,41 @@ class WorkflowEngine {
|
|
|
1066
1146
|
await this.boss.work(WORKFLOW_RUN_DLQ_QUEUE_NAME, { pollingIntervalSeconds: 0.5, batchSize: 1 }, (jobs) => this.handleWorkflowRunDlq(jobs));
|
|
1067
1147
|
this.logger.log(`Worker started for queue ${WORKFLOW_RUN_DLQ_QUEUE_NAME}`);
|
|
1068
1148
|
}
|
|
1149
|
+
if (asEngine) {
|
|
1150
|
+
const scheduled = Array.from(this.workflows.values()).flatMap((wf) => wf.schedule == null ? [] : [{ id: wf.id, resolved: resolveSchedule(wf.schedule, wf.timezone) }]);
|
|
1151
|
+
await Promise.allSettled(scheduled.map(({ id, resolved }) => this.registerWorkflowSchedule(id, resolved).catch((error) => {
|
|
1152
|
+
this.logger.error(`Failed to register schedule for "${id}", skipping`, error instanceof Error ? error : new Error(String(error)), { workflowId: id });
|
|
1153
|
+
})));
|
|
1154
|
+
}
|
|
1069
1155
|
this._started = true;
|
|
1070
1156
|
this.logger.log("Workflow engine started!");
|
|
1071
1157
|
}
|
|
1158
|
+
async registerWorkflowSchedule(workflowId, resolvedSchedule) {
|
|
1159
|
+
const scheduleQueueName = scheduleQueueNameFor(workflowId);
|
|
1160
|
+
await this.boss.createQueue(scheduleQueueName);
|
|
1161
|
+
await this.boss.schedule(scheduleQueueName, resolvedSchedule.cron, null, {
|
|
1162
|
+
tz: resolvedSchedule.timezone
|
|
1163
|
+
});
|
|
1164
|
+
await this.boss.work(scheduleQueueName, { batchSize: 1, includeMetadata: true }, async (jobs) => {
|
|
1165
|
+
const scheduledAt = jobs[0]?.startAfter ?? new Date;
|
|
1166
|
+
try {
|
|
1167
|
+
await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
|
|
1170
|
+
throw error;
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
this.logger.log(`Schedule registered for workflow "${workflowId}": ${resolvedSchedule.cron} (${resolvedSchedule.timezone})`, { workflowId });
|
|
1174
|
+
}
|
|
1175
|
+
async unscheduleWorkflow(workflowId) {
|
|
1176
|
+
try {
|
|
1177
|
+
await this.boss.unschedule(scheduleQueueNameFor(workflowId));
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
this.logger.error(`Failed to unschedule "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1072
1182
|
async stop() {
|
|
1183
|
+
await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
|
|
1073
1184
|
await this.boss.stop();
|
|
1074
1185
|
if (this._ownsPool) {
|
|
1075
1186
|
await this.pool.end();
|
|
@@ -1082,10 +1193,14 @@ class WorkflowEngine {
|
|
|
1082
1193
|
throw new WorkflowEngineError(`Workflow ${definition.id} is already registered`, definition.id);
|
|
1083
1194
|
}
|
|
1084
1195
|
const { steps } = parseWorkflowHandler(definition.handler);
|
|
1196
|
+
const resolvedSchedule = definition.schedule ? resolveSchedule(definition.schedule, definition.timezone) : undefined;
|
|
1085
1197
|
this.workflows.set(definition.id, {
|
|
1086
1198
|
...definition,
|
|
1087
1199
|
steps
|
|
1088
1200
|
});
|
|
1201
|
+
if (this._started && resolvedSchedule) {
|
|
1202
|
+
await this.registerWorkflowSchedule(definition.id, resolvedSchedule);
|
|
1203
|
+
}
|
|
1089
1204
|
this.logger.log(`Registered workflow "${definition.id}" with steps:`);
|
|
1090
1205
|
for (const step of steps.values()) {
|
|
1091
1206
|
const tags = [];
|
|
@@ -1100,10 +1215,17 @@ class WorkflowEngine {
|
|
|
1100
1215
|
return this;
|
|
1101
1216
|
}
|
|
1102
1217
|
async unregisterWorkflow(workflowId) {
|
|
1218
|
+
const existing = this.workflows.get(workflowId);
|
|
1219
|
+
if (existing?.schedule != null && this._started) {
|
|
1220
|
+
await this.unscheduleWorkflow(workflowId);
|
|
1221
|
+
}
|
|
1103
1222
|
this.workflows.delete(workflowId);
|
|
1104
1223
|
return this;
|
|
1105
1224
|
}
|
|
1106
1225
|
async unregisterAllWorkflows() {
|
|
1226
|
+
if (this._started) {
|
|
1227
|
+
await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
|
|
1228
|
+
}
|
|
1107
1229
|
this.workflows.clear();
|
|
1108
1230
|
return this;
|
|
1109
1231
|
}
|
|
@@ -1153,6 +1275,7 @@ class WorkflowEngine {
|
|
|
1153
1275
|
parentRunId,
|
|
1154
1276
|
parentStepId,
|
|
1155
1277
|
parentResourceId,
|
|
1278
|
+
scheduledAt,
|
|
1156
1279
|
enqueue = true,
|
|
1157
1280
|
db
|
|
1158
1281
|
}) {
|
|
@@ -1186,7 +1309,8 @@ class WorkflowEngine {
|
|
|
1186
1309
|
idempotencyKey,
|
|
1187
1310
|
parentRunId,
|
|
1188
1311
|
parentStepId,
|
|
1189
|
-
parentResourceId
|
|
1312
|
+
parentResourceId,
|
|
1313
|
+
scheduledAt
|
|
1190
1314
|
}, targetDb);
|
|
1191
1315
|
const insertAndEnqueue = async (targetDb) => {
|
|
1192
1316
|
const result = await insertRun(targetDb);
|
|
@@ -1369,6 +1493,14 @@ class WorkflowEngine {
|
|
|
1369
1493
|
}
|
|
1370
1494
|
return run;
|
|
1371
1495
|
}
|
|
1496
|
+
async getWorkflowLastRun({
|
|
1497
|
+
workflowId,
|
|
1498
|
+
resourceId
|
|
1499
|
+
}) {
|
|
1500
|
+
validateWorkflowId(workflowId);
|
|
1501
|
+
validateResourceId(resourceId);
|
|
1502
|
+
return getWorkflowLastRun({ workflowId, resourceId }, this.db);
|
|
1503
|
+
}
|
|
1372
1504
|
async updateRun({
|
|
1373
1505
|
runId,
|
|
1374
1506
|
resourceId,
|
|
@@ -1600,7 +1732,8 @@ class WorkflowEngine {
|
|
|
1600
1732
|
return run?.timeline ?? {};
|
|
1601
1733
|
},
|
|
1602
1734
|
logger: this.logger,
|
|
1603
|
-
step
|
|
1735
|
+
step,
|
|
1736
|
+
schedule: run.scheduledAt ? { timestamp: run.scheduledAt } : undefined
|
|
1604
1737
|
};
|
|
1605
1738
|
for (const plugin of plugins) {
|
|
1606
1739
|
const extra = plugin.methods(step, context);
|
|
@@ -2319,5 +2452,5 @@ function otelPlugin(options = {}) {
|
|
|
2319
2452
|
};
|
|
2320
2453
|
}
|
|
2321
2454
|
|
|
2322
|
-
//# debugId=
|
|
2455
|
+
//# debugId=12905C6BC12C3A1664756E2164756E21
|
|
2323
2456
|
//# sourceMappingURL=index.js.map
|
package/dist/index.d.cts
CHANGED
|
@@ -23,6 +23,8 @@ type WorkflowRun = {
|
|
|
23
23
|
parentRunId: string | null;
|
|
24
24
|
parentStepId: string | null;
|
|
25
25
|
parentResourceId: string | null;
|
|
26
|
+
/** Set when the run was started by a recurring schedule; the timestamp the schedule fired. */
|
|
27
|
+
scheduledAt: Date | null;
|
|
26
28
|
};
|
|
27
29
|
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
28
30
|
type DurationObject = {
|
|
@@ -33,6 +35,7 @@ type DurationObject = {
|
|
|
33
35
|
seconds?: number;
|
|
34
36
|
};
|
|
35
37
|
type Duration = string | DurationObject;
|
|
38
|
+
type Schedule = string | Exclude<Duration, string>;
|
|
36
39
|
declare enum WorkflowStatus {
|
|
37
40
|
PENDING = "pending",
|
|
38
41
|
RUNNING = "running",
|
|
@@ -63,6 +66,18 @@ type WorkflowOptions<I extends InputParameters> = {
|
|
|
63
66
|
timeout?: number;
|
|
64
67
|
retries?: number;
|
|
65
68
|
inputSchema?: I;
|
|
69
|
+
/**
|
|
70
|
+
* Recurring schedule. Accepts a cron expression (`'0 9 * * 1-5'`),
|
|
71
|
+
* a duration string (`'5m'`, `'1 hour'`), or a `DurationObject`.
|
|
72
|
+
*/
|
|
73
|
+
schedule?: Schedule;
|
|
74
|
+
/** IANA timezone for cron expressions. Defaults to UTC. Ignored for duration-based schedules. */
|
|
75
|
+
timezone?: string;
|
|
76
|
+
};
|
|
77
|
+
/** Metadata about a scheduled fire, exposed on `ctx.schedule` for runs triggered by a schedule. */
|
|
78
|
+
type ScheduleContext = {
|
|
79
|
+
/** Time the schedule fired this run. */
|
|
80
|
+
timestamp: Date;
|
|
66
81
|
};
|
|
67
82
|
type StepBaseContext = {
|
|
68
83
|
run: <T>(stepId: string, handler: () => Promise<T>) => Promise<T>;
|
|
@@ -148,6 +163,8 @@ type WorkflowContext<
|
|
|
148
163
|
attempt: number;
|
|
149
164
|
timeline: Record<string, unknown>;
|
|
150
165
|
logger: WorkflowLogger;
|
|
166
|
+
/** Set only for runs triggered by a recurring schedule. */
|
|
167
|
+
schedule?: ScheduleContext;
|
|
151
168
|
};
|
|
152
169
|
type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
|
|
153
170
|
id: string;
|
|
@@ -156,6 +173,8 @@ type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
|
|
|
156
173
|
inputSchema?: TInput;
|
|
157
174
|
timeout?: number;
|
|
158
175
|
retries?: number;
|
|
176
|
+
schedule?: Schedule;
|
|
177
|
+
timezone?: string;
|
|
159
178
|
plugins?: WorkflowPlugin[];
|
|
160
179
|
};
|
|
161
180
|
type StepInternalDefinition = {
|
|
@@ -331,6 +350,8 @@ declare class WorkflowEngine {
|
|
|
331
350
|
batchSize?: number;
|
|
332
351
|
heartbeatSeconds?: number;
|
|
333
352
|
}): Promise<void>;
|
|
353
|
+
private registerWorkflowSchedule;
|
|
354
|
+
private unscheduleWorkflow;
|
|
334
355
|
stop(): Promise<void>;
|
|
335
356
|
registerWorkflow(definition: WorkflowDefinition<InputParameters>): Promise<WorkflowEngine>;
|
|
336
357
|
unregisterWorkflow(workflowId: string): Promise<WorkflowEngine>;
|
|
@@ -383,6 +404,15 @@ declare class WorkflowEngine {
|
|
|
383
404
|
exclusiveLock?: boolean;
|
|
384
405
|
db?: Db;
|
|
385
406
|
}): Promise<WorkflowRun>;
|
|
407
|
+
/**
|
|
408
|
+
* Fetch the most recently created run for a workflow, optionally scoped to a
|
|
409
|
+
* `resourceId`. Useful for cron-style incremental syncs where the next run
|
|
410
|
+
* needs the previous run's completion timestamp as a cursor.
|
|
411
|
+
*/
|
|
412
|
+
getWorkflowLastRun({ workflowId, resourceId }: {
|
|
413
|
+
workflowId: string;
|
|
414
|
+
resourceId?: string;
|
|
415
|
+
}): Promise<WorkflowRun | null>;
|
|
386
416
|
updateRun({ runId, resourceId, data, expectedStatuses }: {
|
|
387
417
|
runId: string;
|
|
388
418
|
resourceId?: string;
|
|
@@ -472,4 +502,4 @@ type OtelPluginOptions = {
|
|
|
472
502
|
attributes?: (context: WorkflowContext) => Record<string, AttributeValue>;
|
|
473
503
|
};
|
|
474
504
|
declare function otelPlugin(options?: OtelPluginOptions): WorkflowPlugin<StepBaseContext, object>;
|
|
475
|
-
export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
|
|
505
|
+
export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ type WorkflowRun = {
|
|
|
23
23
|
parentRunId: string | null;
|
|
24
24
|
parentStepId: string | null;
|
|
25
25
|
parentResourceId: string | null;
|
|
26
|
+
/** Set when the run was started by a recurring schedule; the timestamp the schedule fired. */
|
|
27
|
+
scheduledAt: Date | null;
|
|
26
28
|
};
|
|
27
29
|
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
28
30
|
type DurationObject = {
|
|
@@ -33,6 +35,7 @@ type DurationObject = {
|
|
|
33
35
|
seconds?: number;
|
|
34
36
|
};
|
|
35
37
|
type Duration = string | DurationObject;
|
|
38
|
+
type Schedule = string | Exclude<Duration, string>;
|
|
36
39
|
declare enum WorkflowStatus {
|
|
37
40
|
PENDING = "pending",
|
|
38
41
|
RUNNING = "running",
|
|
@@ -63,6 +66,18 @@ type WorkflowOptions<I extends InputParameters> = {
|
|
|
63
66
|
timeout?: number;
|
|
64
67
|
retries?: number;
|
|
65
68
|
inputSchema?: I;
|
|
69
|
+
/**
|
|
70
|
+
* Recurring schedule. Accepts a cron expression (`'0 9 * * 1-5'`),
|
|
71
|
+
* a duration string (`'5m'`, `'1 hour'`), or a `DurationObject`.
|
|
72
|
+
*/
|
|
73
|
+
schedule?: Schedule;
|
|
74
|
+
/** IANA timezone for cron expressions. Defaults to UTC. Ignored for duration-based schedules. */
|
|
75
|
+
timezone?: string;
|
|
76
|
+
};
|
|
77
|
+
/** Metadata about a scheduled fire, exposed on `ctx.schedule` for runs triggered by a schedule. */
|
|
78
|
+
type ScheduleContext = {
|
|
79
|
+
/** Time the schedule fired this run. */
|
|
80
|
+
timestamp: Date;
|
|
66
81
|
};
|
|
67
82
|
type StepBaseContext = {
|
|
68
83
|
run: <T>(stepId: string, handler: () => Promise<T>) => Promise<T>;
|
|
@@ -148,6 +163,8 @@ type WorkflowContext<
|
|
|
148
163
|
attempt: number;
|
|
149
164
|
timeline: Record<string, unknown>;
|
|
150
165
|
logger: WorkflowLogger;
|
|
166
|
+
/** Set only for runs triggered by a recurring schedule. */
|
|
167
|
+
schedule?: ScheduleContext;
|
|
151
168
|
};
|
|
152
169
|
type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
|
|
153
170
|
id: string;
|
|
@@ -156,6 +173,8 @@ type WorkflowDefinition<TInput extends InputParameters = InputParameters> = {
|
|
|
156
173
|
inputSchema?: TInput;
|
|
157
174
|
timeout?: number;
|
|
158
175
|
retries?: number;
|
|
176
|
+
schedule?: Schedule;
|
|
177
|
+
timezone?: string;
|
|
159
178
|
plugins?: WorkflowPlugin[];
|
|
160
179
|
};
|
|
161
180
|
type StepInternalDefinition = {
|
|
@@ -331,6 +350,8 @@ declare class WorkflowEngine {
|
|
|
331
350
|
batchSize?: number;
|
|
332
351
|
heartbeatSeconds?: number;
|
|
333
352
|
}): Promise<void>;
|
|
353
|
+
private registerWorkflowSchedule;
|
|
354
|
+
private unscheduleWorkflow;
|
|
334
355
|
stop(): Promise<void>;
|
|
335
356
|
registerWorkflow(definition: WorkflowDefinition<InputParameters>): Promise<WorkflowEngine>;
|
|
336
357
|
unregisterWorkflow(workflowId: string): Promise<WorkflowEngine>;
|
|
@@ -383,6 +404,15 @@ declare class WorkflowEngine {
|
|
|
383
404
|
exclusiveLock?: boolean;
|
|
384
405
|
db?: Db;
|
|
385
406
|
}): Promise<WorkflowRun>;
|
|
407
|
+
/**
|
|
408
|
+
* Fetch the most recently created run for a workflow, optionally scoped to a
|
|
409
|
+
* `resourceId`. Useful for cron-style incremental syncs where the next run
|
|
410
|
+
* needs the previous run's completion timestamp as a cursor.
|
|
411
|
+
*/
|
|
412
|
+
getWorkflowLastRun({ workflowId, resourceId }: {
|
|
413
|
+
workflowId: string;
|
|
414
|
+
resourceId?: string;
|
|
415
|
+
}): Promise<WorkflowRun | null>;
|
|
386
416
|
updateRun({ runId, resourceId, data, expectedStatuses }: {
|
|
387
417
|
runId: string;
|
|
388
418
|
resourceId?: string;
|
|
@@ -472,4 +502,4 @@ type OtelPluginOptions = {
|
|
|
472
502
|
attributes?: (context: WorkflowContext) => Record<string, AttributeValue>;
|
|
473
503
|
};
|
|
474
504
|
declare function otelPlugin(options?: OtelPluginOptions): WorkflowPlugin<StepBaseContext, object>;
|
|
475
|
-
export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
|
|
505
|
+
export { workflow, otelPlugin, createWorkflowRef, WorkflowStatus, WorkflowRunProgress, WorkflowRunNotFoundError, WorkflowRun, WorkflowRef, WorkflowPlugin, WorkflowOptions, WorkflowLogger, WorkflowEngineOptions, WorkflowEngineError, WorkflowEngine, WorkflowDefinition, WorkflowContext, WorkflowClientOptions, WorkflowClient, StepBaseContext, StartWorkflowOptions, ScheduleContext, Schedule, OtelPluginOptions, InputParameters, InferInputParameters, Duration };
|
package/dist/index.js
CHANGED
|
@@ -8,19 +8,21 @@ import {
|
|
|
8
8
|
WorkflowRunNotFoundError,
|
|
9
9
|
WorkflowStatus,
|
|
10
10
|
createWorkflowRef,
|
|
11
|
+
getWorkflowLastRun,
|
|
11
12
|
getWorkflowRun,
|
|
12
13
|
getWorkflowRuns,
|
|
13
14
|
insertWorkflowRun,
|
|
14
15
|
invokeChildWorkflowTimelineKey,
|
|
15
16
|
isInvokeChildWorkflowTimelineEntry,
|
|
16
17
|
runMigrations,
|
|
18
|
+
scheduleQueueNameFor,
|
|
17
19
|
updateWorkflowRun,
|
|
18
20
|
validateResourceId,
|
|
19
21
|
validateWorkflowId,
|
|
20
22
|
waitForTimelineKey,
|
|
21
23
|
withPostgresTransaction,
|
|
22
24
|
workflow
|
|
23
|
-
} from "./shared/chunk-
|
|
25
|
+
} from "./shared/chunk-5xswmve7.js";
|
|
24
26
|
// src/engine.ts
|
|
25
27
|
import { merge } from "es-toolkit";
|
|
26
28
|
import pg from "pg";
|
|
@@ -122,6 +124,56 @@ function parseDuration(duration) {
|
|
|
122
124
|
return ms;
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
// src/schedule.ts
|
|
128
|
+
import { CronExpressionParser } from "cron-parser";
|
|
129
|
+
var CRON_TOKEN = /^[0-9*/,?\-LW#]+$/;
|
|
130
|
+
function looksLikeCronString(value) {
|
|
131
|
+
const tokens = value.trim().split(/\s+/);
|
|
132
|
+
if (tokens.length !== 5 && tokens.length !== 6)
|
|
133
|
+
return false;
|
|
134
|
+
return tokens.every((t) => CRON_TOKEN.test(t));
|
|
135
|
+
}
|
|
136
|
+
function validateCronExpression(expression, timezone) {
|
|
137
|
+
try {
|
|
138
|
+
CronExpressionParser.parse(expression, { tz: timezone });
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw new WorkflowEngineError(`Invalid cron expression "${expression}" (timezone: ${timezone}): ${e instanceof Error ? e.message : String(e)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function durationMsToCron(ms, original) {
|
|
144
|
+
if (ms < MS_PER_MINUTE) {
|
|
145
|
+
throw new WorkflowEngineError(`Schedule interval must be at least 1 minute; got ${ms}ms from ${JSON.stringify(original)}`);
|
|
146
|
+
}
|
|
147
|
+
if (ms % MS_PER_DAY === 0) {
|
|
148
|
+
const days = ms / MS_PER_DAY;
|
|
149
|
+
if (days === 1)
|
|
150
|
+
return "0 0 * * *";
|
|
151
|
+
throw cronStepError(original, `${days} days`);
|
|
152
|
+
}
|
|
153
|
+
if (ms % MS_PER_HOUR === 0) {
|
|
154
|
+
const hours = ms / MS_PER_HOUR;
|
|
155
|
+
if (24 % hours === 0)
|
|
156
|
+
return `0 */${hours} * * *`;
|
|
157
|
+
throw cronStepError(original, `${hours} hours`);
|
|
158
|
+
}
|
|
159
|
+
const minutes = ms / MS_PER_MINUTE;
|
|
160
|
+
if (Number.isInteger(minutes) && 60 % minutes === 0)
|
|
161
|
+
return `*/${minutes} * * * *`;
|
|
162
|
+
throw cronStepError(original, `${minutes} minutes`);
|
|
163
|
+
}
|
|
164
|
+
function cronStepError(original, label) {
|
|
165
|
+
return new WorkflowEngineError(`Schedule interval ${JSON.stringify(original)} (${label}) doesn't map cleanly to a recurring cron expression. Use a value that divides 60 minutes, 24 hours, or 1 day — or pass an explicit cron string.`);
|
|
166
|
+
}
|
|
167
|
+
function resolveSchedule(schedule, timezone) {
|
|
168
|
+
const tz = timezone ?? "UTC";
|
|
169
|
+
if (typeof schedule === "string" && looksLikeCronString(schedule)) {
|
|
170
|
+
validateCronExpression(schedule, tz);
|
|
171
|
+
return { cron: schedule, timezone: tz };
|
|
172
|
+
}
|
|
173
|
+
const ms = parseDuration(schedule);
|
|
174
|
+
return { cron: durationMsToCron(ms, schedule), timezone: tz };
|
|
175
|
+
}
|
|
176
|
+
|
|
125
177
|
// src/engine.ts
|
|
126
178
|
var LOG_PREFIX = "[WorkflowEngine]";
|
|
127
179
|
var StepTypeToIcon = {
|
|
@@ -208,10 +260,41 @@ class WorkflowEngine {
|
|
|
208
260
|
await this.boss.work(WORKFLOW_RUN_DLQ_QUEUE_NAME, { pollingIntervalSeconds: 0.5, batchSize: 1 }, (jobs) => this.handleWorkflowRunDlq(jobs));
|
|
209
261
|
this.logger.log(`Worker started for queue ${WORKFLOW_RUN_DLQ_QUEUE_NAME}`);
|
|
210
262
|
}
|
|
263
|
+
if (asEngine) {
|
|
264
|
+
const scheduled = Array.from(this.workflows.values()).flatMap((wf) => wf.schedule == null ? [] : [{ id: wf.id, resolved: resolveSchedule(wf.schedule, wf.timezone) }]);
|
|
265
|
+
await Promise.allSettled(scheduled.map(({ id, resolved }) => this.registerWorkflowSchedule(id, resolved).catch((error) => {
|
|
266
|
+
this.logger.error(`Failed to register schedule for "${id}", skipping`, error instanceof Error ? error : new Error(String(error)), { workflowId: id });
|
|
267
|
+
})));
|
|
268
|
+
}
|
|
211
269
|
this._started = true;
|
|
212
270
|
this.logger.log("Workflow engine started!");
|
|
213
271
|
}
|
|
272
|
+
async registerWorkflowSchedule(workflowId, resolvedSchedule) {
|
|
273
|
+
const scheduleQueueName = scheduleQueueNameFor(workflowId);
|
|
274
|
+
await this.boss.createQueue(scheduleQueueName);
|
|
275
|
+
await this.boss.schedule(scheduleQueueName, resolvedSchedule.cron, null, {
|
|
276
|
+
tz: resolvedSchedule.timezone
|
|
277
|
+
});
|
|
278
|
+
await this.boss.work(scheduleQueueName, { batchSize: 1, includeMetadata: true }, async (jobs) => {
|
|
279
|
+
const scheduledAt = jobs[0]?.startAfter ?? new Date;
|
|
280
|
+
try {
|
|
281
|
+
await this.createWorkflowRun({ workflowId, input: {}, scheduledAt });
|
|
282
|
+
} catch (error) {
|
|
283
|
+
this.logger.error(`Schedule fire failed to start a run for workflow "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
this.logger.log(`Schedule registered for workflow "${workflowId}": ${resolvedSchedule.cron} (${resolvedSchedule.timezone})`, { workflowId });
|
|
288
|
+
}
|
|
289
|
+
async unscheduleWorkflow(workflowId) {
|
|
290
|
+
try {
|
|
291
|
+
await this.boss.unschedule(scheduleQueueNameFor(workflowId));
|
|
292
|
+
} catch (error) {
|
|
293
|
+
this.logger.error(`Failed to unschedule "${workflowId}"`, error instanceof Error ? error : new Error(String(error)), { workflowId });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
214
296
|
async stop() {
|
|
297
|
+
await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
|
|
215
298
|
await this.boss.stop();
|
|
216
299
|
if (this._ownsPool) {
|
|
217
300
|
await this.pool.end();
|
|
@@ -224,10 +307,14 @@ class WorkflowEngine {
|
|
|
224
307
|
throw new WorkflowEngineError(`Workflow ${definition.id} is already registered`, definition.id);
|
|
225
308
|
}
|
|
226
309
|
const { steps } = parseWorkflowHandler(definition.handler);
|
|
310
|
+
const resolvedSchedule = definition.schedule ? resolveSchedule(definition.schedule, definition.timezone) : undefined;
|
|
227
311
|
this.workflows.set(definition.id, {
|
|
228
312
|
...definition,
|
|
229
313
|
steps
|
|
230
314
|
});
|
|
315
|
+
if (this._started && resolvedSchedule) {
|
|
316
|
+
await this.registerWorkflowSchedule(definition.id, resolvedSchedule);
|
|
317
|
+
}
|
|
231
318
|
this.logger.log(`Registered workflow "${definition.id}" with steps:`);
|
|
232
319
|
for (const step of steps.values()) {
|
|
233
320
|
const tags = [];
|
|
@@ -242,10 +329,17 @@ class WorkflowEngine {
|
|
|
242
329
|
return this;
|
|
243
330
|
}
|
|
244
331
|
async unregisterWorkflow(workflowId) {
|
|
332
|
+
const existing = this.workflows.get(workflowId);
|
|
333
|
+
if (existing?.schedule != null && this._started) {
|
|
334
|
+
await this.unscheduleWorkflow(workflowId);
|
|
335
|
+
}
|
|
245
336
|
this.workflows.delete(workflowId);
|
|
246
337
|
return this;
|
|
247
338
|
}
|
|
248
339
|
async unregisterAllWorkflows() {
|
|
340
|
+
if (this._started) {
|
|
341
|
+
await Promise.allSettled(Array.from(this.workflows.values()).filter((wf) => wf.schedule != null).map((wf) => this.unscheduleWorkflow(wf.id)));
|
|
342
|
+
}
|
|
249
343
|
this.workflows.clear();
|
|
250
344
|
return this;
|
|
251
345
|
}
|
|
@@ -295,6 +389,7 @@ class WorkflowEngine {
|
|
|
295
389
|
parentRunId,
|
|
296
390
|
parentStepId,
|
|
297
391
|
parentResourceId,
|
|
392
|
+
scheduledAt,
|
|
298
393
|
enqueue = true,
|
|
299
394
|
db
|
|
300
395
|
}) {
|
|
@@ -328,7 +423,8 @@ class WorkflowEngine {
|
|
|
328
423
|
idempotencyKey,
|
|
329
424
|
parentRunId,
|
|
330
425
|
parentStepId,
|
|
331
|
-
parentResourceId
|
|
426
|
+
parentResourceId,
|
|
427
|
+
scheduledAt
|
|
332
428
|
}, targetDb);
|
|
333
429
|
const insertAndEnqueue = async (targetDb) => {
|
|
334
430
|
const result = await insertRun(targetDb);
|
|
@@ -511,6 +607,14 @@ class WorkflowEngine {
|
|
|
511
607
|
}
|
|
512
608
|
return run;
|
|
513
609
|
}
|
|
610
|
+
async getWorkflowLastRun({
|
|
611
|
+
workflowId,
|
|
612
|
+
resourceId
|
|
613
|
+
}) {
|
|
614
|
+
validateWorkflowId(workflowId);
|
|
615
|
+
validateResourceId(resourceId);
|
|
616
|
+
return getWorkflowLastRun({ workflowId, resourceId }, this.db);
|
|
617
|
+
}
|
|
514
618
|
async updateRun({
|
|
515
619
|
runId,
|
|
516
620
|
resourceId,
|
|
@@ -742,7 +846,8 @@ class WorkflowEngine {
|
|
|
742
846
|
return run?.timeline ?? {};
|
|
743
847
|
},
|
|
744
848
|
logger: this.logger,
|
|
745
|
-
step
|
|
849
|
+
step,
|
|
850
|
+
schedule: run.scheduledAt ? { timestamp: run.scheduledAt } : undefined
|
|
746
851
|
};
|
|
747
852
|
for (const plugin of plugins) {
|
|
748
853
|
const extra = plugin.methods(step, context);
|
|
@@ -1475,5 +1580,5 @@ export {
|
|
|
1475
1580
|
WorkflowClient
|
|
1476
1581
|
};
|
|
1477
1582
|
|
|
1478
|
-
//# debugId=
|
|
1583
|
+
//# debugId=5BC543336A07527964756E2164756E21
|
|
1479
1584
|
//# sourceMappingURL=index.js.map
|