@classytic/streamline 1.0.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/LICENSE +21 -0
- package/README.md +740 -0
- package/dist/container-BzpIMrrj.mjs +2697 -0
- package/dist/errors-BqunvWPz.mjs +129 -0
- package/dist/events-B5aTz7kD.mjs +28 -0
- package/dist/events-C0sZINZq.d.mts +92 -0
- package/dist/index.d.mts +1589 -0
- package/dist/index.mjs +1102 -0
- package/dist/integrations/fastify.d.mts +12 -0
- package/dist/integrations/fastify.mjs +23 -0
- package/dist/telemetry/index.d.mts +18 -0
- package/dist/telemetry/index.mjs +102 -0
- package/dist/types-DG85_LzF.d.mts +275 -0
- package/package.json +104 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import { a as StepNotFoundError, c as WorkflowNotFoundError, i as MaxRetriesExceededError, n as ErrorCode, o as StepTimeoutError, r as InvalidStateError, s as WorkflowError, t as DataCorruptionError } from "./errors-BqunvWPz.mjs";
|
|
2
|
+
import { A as isValidStepTransition, C as RUN_STATUS_VALUES, D as isStepStatus, E as isRunStatus, M as logger, O as isTerminalState, S as shouldSkipStep, T as deriveRunStatus, _ as COMPUTED, a as singleTenantPlugin, b as createCondition, c as RUN_STATUS, d as WorkflowRunModel, f as WorkflowCache, g as validateRetryConfig, h as validateId, i as workflowRunRepository, j as WaitSignal, k as isValidRunTransition, l as STEP_STATUS, m as hookRegistry, n as isStreamlineContainer, o as tenantFilterPlugin, p as WorkflowEngine, r as createWorkflowRepository, s as CommonQueries, t as createContainer, u as WorkflowQueryBuilder, v as SCHEDULING, w as STEP_STATUS_VALUES, x as isConditionalStep, y as conditions } from "./container-BzpIMrrj.mjs";
|
|
3
|
+
import { n as globalEventBus, t as WorkflowEventBus } from "./events-B5aTz7kD.mjs";
|
|
4
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import mongoose, { Schema } from "mongoose";
|
|
6
|
+
import semver from "semver";
|
|
7
|
+
import { DateTime } from "luxon";
|
|
8
|
+
|
|
9
|
+
//#region src/workflow/define.ts
|
|
10
|
+
/**
|
|
11
|
+
* Simplified Workflow Definition API
|
|
12
|
+
*
|
|
13
|
+
* Inspired by Vercel's workflow - cleaner, function-based syntax.
|
|
14
|
+
* No compiler needed - just cleaner ergonomics.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { createWorkflow } from '@classytic/streamline';
|
|
19
|
+
*
|
|
20
|
+
* const userSignup = createWorkflow('user-signup', {
|
|
21
|
+
* steps: {
|
|
22
|
+
* createUser: async (ctx) => {
|
|
23
|
+
* return { id: crypto.randomUUID(), email: ctx.input.email };
|
|
24
|
+
* },
|
|
25
|
+
* sendWelcome: async (ctx) => {
|
|
26
|
+
* const user = ctx.getOutput('createUser');
|
|
27
|
+
* await sendEmail(user.email, 'Welcome!');
|
|
28
|
+
* },
|
|
29
|
+
* onboard: async (ctx) => {
|
|
30
|
+
* await ctx.sleep(5000);
|
|
31
|
+
* await sendOnboardingEmail(ctx.getOutput('createUser'));
|
|
32
|
+
* },
|
|
33
|
+
* },
|
|
34
|
+
* context: (input) => ({ email: input.email }),
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* // Start workflow
|
|
38
|
+
* const run = await userSignup.start({ email: 'test@example.com' });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
const toName = (id) => id.replace(/-/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
42
|
+
/**
|
|
43
|
+
* Create a workflow with inline step handlers
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* const orderProcess = createWorkflow('order-process', {
|
|
48
|
+
* steps: {
|
|
49
|
+
* validate: async (ctx) => validateOrder(ctx.input),
|
|
50
|
+
* charge: async (ctx) => chargeCard(ctx.getOutput('validate')),
|
|
51
|
+
* fulfill: async (ctx) => shipOrder(ctx.getOutput('charge')),
|
|
52
|
+
* notify: async (ctx) => sendConfirmation(ctx.context.email),
|
|
53
|
+
* },
|
|
54
|
+
* context: (input) => ({ orderId: input.id, email: input.email }),
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* await orderProcess.start({ id: '123', email: 'user@example.com' });
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @example Custom container for testing
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const container = createContainer();
|
|
63
|
+
* const workflow = createWorkflow('test-workflow', {
|
|
64
|
+
* steps: { ... },
|
|
65
|
+
* container
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
function createWorkflow(id, config) {
|
|
70
|
+
validateId(id, "workflow");
|
|
71
|
+
const stepIds = Object.keys(config.steps);
|
|
72
|
+
if (stepIds.length === 0) throw new Error("Workflow must have at least one step");
|
|
73
|
+
if (config.defaults) validateRetryConfig(config.defaults.retries, config.defaults.timeout);
|
|
74
|
+
const definition = {
|
|
75
|
+
id,
|
|
76
|
+
name: toName(id),
|
|
77
|
+
version: config.version ?? "1.0.0",
|
|
78
|
+
steps: stepIds.map((stepId) => ({
|
|
79
|
+
id: stepId,
|
|
80
|
+
name: toName(stepId)
|
|
81
|
+
})),
|
|
82
|
+
createContext: config.context ?? ((input) => input),
|
|
83
|
+
defaults: config.defaults
|
|
84
|
+
};
|
|
85
|
+
const container = config.container ?? createContainer();
|
|
86
|
+
const engine = new WorkflowEngine(definition, config.steps, container, { ...config.autoExecute !== void 0 && { autoExecute: config.autoExecute } });
|
|
87
|
+
const waitFor = async (runId, options = {}) => {
|
|
88
|
+
const { pollInterval = 1e3, timeout } = options;
|
|
89
|
+
const startTime = Date.now();
|
|
90
|
+
while (true) {
|
|
91
|
+
const run = await engine.get(runId);
|
|
92
|
+
if (!run) throw new WorkflowNotFoundError(runId);
|
|
93
|
+
if (isTerminalState(run.status)) return run;
|
|
94
|
+
if (timeout && Date.now() - startTime >= timeout) throw new Error(`Timeout waiting for workflow "${runId}" to complete after ${timeout}ms. Current status: ${run.status}`);
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
start: (input, meta) => engine.start(input, meta),
|
|
100
|
+
get: (runId) => engine.get(runId),
|
|
101
|
+
execute: (runId) => engine.execute(runId),
|
|
102
|
+
resume: (runId, payload) => engine.resume(runId, payload),
|
|
103
|
+
cancel: (runId) => engine.cancel(runId),
|
|
104
|
+
pause: (runId) => engine.pause(runId),
|
|
105
|
+
rewindTo: (runId, stepId) => engine.rewindTo(runId, stepId),
|
|
106
|
+
waitFor,
|
|
107
|
+
shutdown: () => engine.shutdown(),
|
|
108
|
+
definition,
|
|
109
|
+
engine,
|
|
110
|
+
container
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/features/hooks.ts
|
|
116
|
+
/**
|
|
117
|
+
* Hooks & Webhooks
|
|
118
|
+
*
|
|
119
|
+
* Inspired by Vercel's workflow hooks - pause execution and wait for external input.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```typescript
|
|
123
|
+
* const approval = createWorkflow('doc-approval', {
|
|
124
|
+
* steps: {
|
|
125
|
+
* request: async (ctx) => {
|
|
126
|
+
* await sendApprovalEmail(ctx.input.docId);
|
|
127
|
+
* return createHook(ctx, 'approval');
|
|
128
|
+
* },
|
|
129
|
+
* process: async (ctx) => {
|
|
130
|
+
* const { approved } = ctx.getOutput<{ approved: boolean }>('request');
|
|
131
|
+
* if (approved) await publishDoc(ctx.input.docId);
|
|
132
|
+
* },
|
|
133
|
+
* },
|
|
134
|
+
* });
|
|
135
|
+
*
|
|
136
|
+
* // Resume hook from API route
|
|
137
|
+
* const result = await resumeHook(token, { approved: true });
|
|
138
|
+
* console.log(result.run); // The resumed workflow run
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
/**
|
|
142
|
+
* Create a hook that pauses workflow until external input.
|
|
143
|
+
* The token includes a crypto-random suffix for security.
|
|
144
|
+
*
|
|
145
|
+
* IMPORTANT: Pass the returned token to ctx.wait() to enable token validation:
|
|
146
|
+
* ```typescript
|
|
147
|
+
* const hook = createHook(ctx, 'approval');
|
|
148
|
+
* return ctx.wait(hook.token, { hookToken: hook.token }); // Token stored for validation
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```typescript
|
|
153
|
+
* // In step handler
|
|
154
|
+
* async function waitForApproval(ctx) {
|
|
155
|
+
* const hook = createHook(ctx, 'waiting-for-approval');
|
|
156
|
+
* console.log('Resume with token:', hook.token);
|
|
157
|
+
* return ctx.wait(hook.token, { hookToken: hook.token }); // Token validated on resume
|
|
158
|
+
* }
|
|
159
|
+
*
|
|
160
|
+
* // From API route
|
|
161
|
+
* await resumeHook('token-123', { approved: true });
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
function createHook(ctx, reason, options) {
|
|
165
|
+
const randomSuffix = randomBytes(16).toString("hex");
|
|
166
|
+
const token = options?.token ?? `${ctx.runId}:${ctx.stepId}:${randomSuffix}`;
|
|
167
|
+
return {
|
|
168
|
+
token,
|
|
169
|
+
path: `/hooks/${token}`
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Resume a paused workflow by hook token.
|
|
174
|
+
*
|
|
175
|
+
* Security: If the workflow was paused with a hookToken in waitingFor.data,
|
|
176
|
+
* this function validates the token before resuming.
|
|
177
|
+
*
|
|
178
|
+
* Multi-worker support: Falls back to DB lookup if engine not in local registry.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```typescript
|
|
182
|
+
* // API route handler
|
|
183
|
+
* app.post('/hooks/:token', async (req, res) => {
|
|
184
|
+
* const result = await resumeHook(req.params.token, req.body);
|
|
185
|
+
* res.json({ success: true, runId: result.runId, status: result.run.status });
|
|
186
|
+
* });
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
async function resumeHook(token, payload) {
|
|
190
|
+
const [runId] = token.split(":");
|
|
191
|
+
if (!runId) throw new Error(`Invalid hook token: ${token}`);
|
|
192
|
+
const engine = hookRegistry.getEngine(runId);
|
|
193
|
+
if (!engine) throw new Error(`No engine registered for workflow ${runId}. Ensure the workflow was started with createWorkflow() and the engine is still running. For multi-worker deployments, ensure all workers use shared state or implement a custom resume endpoint.`);
|
|
194
|
+
const run = await engine.container.repository.getById(runId);
|
|
195
|
+
if (!run) throw new Error(`Workflow not found for token: ${token}`);
|
|
196
|
+
if (run.status !== "waiting") throw new Error(`Workflow ${runId} is not waiting (status: ${run.status})`);
|
|
197
|
+
const storedToken = (run.steps.find((s) => s.status === "waiting")?.waitingFor?.data)?.hookToken;
|
|
198
|
+
if (storedToken && storedToken !== token) throw new Error(`Invalid hook token for workflow ${runId}`);
|
|
199
|
+
const resumedRun = await engine.resume(runId, payload);
|
|
200
|
+
return {
|
|
201
|
+
runId: run._id,
|
|
202
|
+
run: resumedRun
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Generate a deterministic token for idempotent hooks
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```typescript
|
|
210
|
+
* // Slack bot - same channel always gets same token
|
|
211
|
+
* const token = hookToken('slack', channelId);
|
|
212
|
+
* const hook = createHook(ctx, 'slack-message', { token });
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
function hookToken(...parts) {
|
|
216
|
+
return parts.join(":");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/storage/definition.model.ts
|
|
221
|
+
/**
|
|
222
|
+
* Workflow Definition Model (Optional)
|
|
223
|
+
*
|
|
224
|
+
* Stores workflow definitions in MongoDB for:
|
|
225
|
+
* - Versioning: Track workflow changes over time
|
|
226
|
+
* - Auditing: Who created/modified workflows
|
|
227
|
+
* - Dynamic loading: Load workflows from DB instead of code
|
|
228
|
+
* - Collaboration: Teams can share workflow definitions
|
|
229
|
+
*
|
|
230
|
+
* Note: This is OPTIONAL. You can define workflows in code without this model.
|
|
231
|
+
*/
|
|
232
|
+
const WorkflowDefinitionSchema = new Schema({
|
|
233
|
+
workflowId: {
|
|
234
|
+
type: String,
|
|
235
|
+
required: true,
|
|
236
|
+
index: true
|
|
237
|
+
},
|
|
238
|
+
name: {
|
|
239
|
+
type: String,
|
|
240
|
+
required: true
|
|
241
|
+
},
|
|
242
|
+
description: String,
|
|
243
|
+
version: {
|
|
244
|
+
type: String,
|
|
245
|
+
required: true,
|
|
246
|
+
default: "1.0.0"
|
|
247
|
+
},
|
|
248
|
+
versionMajor: {
|
|
249
|
+
type: Number,
|
|
250
|
+
required: true
|
|
251
|
+
},
|
|
252
|
+
versionMinor: {
|
|
253
|
+
type: Number,
|
|
254
|
+
required: true
|
|
255
|
+
},
|
|
256
|
+
versionPatch: {
|
|
257
|
+
type: Number,
|
|
258
|
+
required: true
|
|
259
|
+
},
|
|
260
|
+
steps: [{
|
|
261
|
+
id: {
|
|
262
|
+
type: String,
|
|
263
|
+
required: true
|
|
264
|
+
},
|
|
265
|
+
name: {
|
|
266
|
+
type: String,
|
|
267
|
+
required: true
|
|
268
|
+
},
|
|
269
|
+
retries: Number,
|
|
270
|
+
timeout: Number,
|
|
271
|
+
condition: String
|
|
272
|
+
}],
|
|
273
|
+
defaults: {
|
|
274
|
+
retries: Number,
|
|
275
|
+
timeout: Number
|
|
276
|
+
},
|
|
277
|
+
createdBy: String,
|
|
278
|
+
updatedBy: String,
|
|
279
|
+
isActive: {
|
|
280
|
+
type: Boolean,
|
|
281
|
+
default: true
|
|
282
|
+
},
|
|
283
|
+
tags: [String],
|
|
284
|
+
metadata: Schema.Types.Mixed
|
|
285
|
+
}, {
|
|
286
|
+
collection: "workflow_definitions",
|
|
287
|
+
timestamps: true
|
|
288
|
+
});
|
|
289
|
+
WorkflowDefinitionSchema.pre("save", function() {
|
|
290
|
+
if (this.isModified("version")) {
|
|
291
|
+
const parsed = semver.parse(this.version);
|
|
292
|
+
if (!parsed) throw new Error(`Invalid semver version: ${this.version}`);
|
|
293
|
+
this.versionMajor = parsed.major;
|
|
294
|
+
this.versionMinor = parsed.minor;
|
|
295
|
+
this.versionPatch = parsed.patch;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
WorkflowDefinitionSchema.index({ name: 1 });
|
|
299
|
+
WorkflowDefinitionSchema.index({ isActive: 1 });
|
|
300
|
+
WorkflowDefinitionSchema.index({ tags: 1 });
|
|
301
|
+
WorkflowDefinitionSchema.index({
|
|
302
|
+
workflowId: 1,
|
|
303
|
+
version: 1
|
|
304
|
+
}, { unique: true });
|
|
305
|
+
WorkflowDefinitionSchema.index({
|
|
306
|
+
workflowId: 1,
|
|
307
|
+
isActive: 1,
|
|
308
|
+
versionMajor: -1,
|
|
309
|
+
versionMinor: -1,
|
|
310
|
+
versionPatch: -1
|
|
311
|
+
});
|
|
312
|
+
/**
|
|
313
|
+
* MULTI-TENANCY & CUSTOM INDEXES
|
|
314
|
+
*
|
|
315
|
+
* This model is intentionally unopinionated about multi-tenancy.
|
|
316
|
+
* Different apps have different needs (tenantId, orgId, workspaceId, etc.)
|
|
317
|
+
*
|
|
318
|
+
* To add custom indexes for your app:
|
|
319
|
+
*
|
|
320
|
+
* import { WorkflowDefinitionModel } from '@classytic/streamline';
|
|
321
|
+
*
|
|
322
|
+
* // Add your custom indexes
|
|
323
|
+
* WorkflowDefinitionModel.collection.createIndex({ tenantId: 1, isActive: 1 });
|
|
324
|
+
* WorkflowDefinitionModel.collection.createIndex({ orgId: 1, createdAt: -1 });
|
|
325
|
+
*
|
|
326
|
+
* OR extend the schema:
|
|
327
|
+
*
|
|
328
|
+
* import { WorkflowDefinitionModel } from '@classytic/streamline';
|
|
329
|
+
* WorkflowDefinitionModel.schema.add({ tenantId: String });
|
|
330
|
+
* WorkflowDefinitionModel.schema.index({ tenantId: 1, isActive: 1 });
|
|
331
|
+
*/
|
|
332
|
+
/**
|
|
333
|
+
* Export WorkflowDefinitionModel with hot-reload safety
|
|
334
|
+
*
|
|
335
|
+
* The pattern checks if the model already exists before creating a new one.
|
|
336
|
+
* This prevents "OverwriteModelError" in development with hot module replacement.
|
|
337
|
+
*/
|
|
338
|
+
let WorkflowDefinitionModel;
|
|
339
|
+
if (mongoose.models.WorkflowDefinition) WorkflowDefinitionModel = mongoose.models.WorkflowDefinition;
|
|
340
|
+
else WorkflowDefinitionModel = mongoose.model("WorkflowDefinition", WorkflowDefinitionSchema);
|
|
341
|
+
/**
|
|
342
|
+
* Repository for WorkflowDefinition
|
|
343
|
+
* Optional: Use if you want to store workflows in MongoDB
|
|
344
|
+
*/
|
|
345
|
+
const workflowDefinitionRepository = {
|
|
346
|
+
async create(definition) {
|
|
347
|
+
if (definition.version) {
|
|
348
|
+
const parsed = semver.parse(definition.version);
|
|
349
|
+
if (!parsed) throw new Error(`Invalid semver version: ${definition.version}`);
|
|
350
|
+
definition.versionMajor = parsed.major;
|
|
351
|
+
definition.versionMinor = parsed.minor;
|
|
352
|
+
definition.versionPatch = parsed.patch;
|
|
353
|
+
}
|
|
354
|
+
return (await WorkflowDefinitionModel.create(definition)).toObject();
|
|
355
|
+
},
|
|
356
|
+
async getLatestVersion(workflowId) {
|
|
357
|
+
return await WorkflowDefinitionModel.findOne({
|
|
358
|
+
workflowId,
|
|
359
|
+
isActive: true
|
|
360
|
+
}).sort({
|
|
361
|
+
versionMajor: -1,
|
|
362
|
+
versionMinor: -1,
|
|
363
|
+
versionPatch: -1
|
|
364
|
+
}).lean();
|
|
365
|
+
},
|
|
366
|
+
async getByVersion(workflowId, version) {
|
|
367
|
+
return await WorkflowDefinitionModel.findOne({
|
|
368
|
+
workflowId,
|
|
369
|
+
version
|
|
370
|
+
}).lean();
|
|
371
|
+
},
|
|
372
|
+
async getActiveDefinitions() {
|
|
373
|
+
return await WorkflowDefinitionModel.aggregate([
|
|
374
|
+
{ $match: { isActive: true } },
|
|
375
|
+
{ $sort: {
|
|
376
|
+
versionMajor: -1,
|
|
377
|
+
versionMinor: -1,
|
|
378
|
+
versionPatch: -1
|
|
379
|
+
} },
|
|
380
|
+
{ $group: {
|
|
381
|
+
_id: "$workflowId",
|
|
382
|
+
doc: { $first: "$$ROOT" }
|
|
383
|
+
} },
|
|
384
|
+
{ $replaceRoot: { newRoot: "$doc" } },
|
|
385
|
+
{ $sort: { createdAt: -1 } }
|
|
386
|
+
]);
|
|
387
|
+
},
|
|
388
|
+
async getVersionHistory(workflowId) {
|
|
389
|
+
return await WorkflowDefinitionModel.find({ workflowId }).sort({
|
|
390
|
+
versionMajor: -1,
|
|
391
|
+
versionMinor: -1,
|
|
392
|
+
versionPatch: -1
|
|
393
|
+
}).lean();
|
|
394
|
+
},
|
|
395
|
+
async update(workflowId, version, updates) {
|
|
396
|
+
if (updates.version) {
|
|
397
|
+
const parsed = semver.parse(updates.version);
|
|
398
|
+
if (!parsed) throw new Error(`Invalid semver version: ${updates.version}`);
|
|
399
|
+
updates.versionMajor = parsed.major;
|
|
400
|
+
updates.versionMinor = parsed.minor;
|
|
401
|
+
updates.versionPatch = parsed.patch;
|
|
402
|
+
}
|
|
403
|
+
return await WorkflowDefinitionModel.findOneAndUpdate({
|
|
404
|
+
workflowId,
|
|
405
|
+
version
|
|
406
|
+
}, updates, { returnDocument: "after" }).lean();
|
|
407
|
+
},
|
|
408
|
+
async deactivate(workflowId, version) {
|
|
409
|
+
const filter = { workflowId };
|
|
410
|
+
if (version) filter.version = version;
|
|
411
|
+
await WorkflowDefinitionModel.updateMany(filter, { isActive: false });
|
|
412
|
+
},
|
|
413
|
+
async deactivateOldVersions(workflowId, keepVersion) {
|
|
414
|
+
await WorkflowDefinitionModel.updateMany({
|
|
415
|
+
workflowId,
|
|
416
|
+
version: { $ne: keepVersion }
|
|
417
|
+
}, { isActive: false });
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/utils/visualization.ts
|
|
423
|
+
function getStepTimeline(run) {
|
|
424
|
+
return run.steps.map((step) => ({
|
|
425
|
+
id: step.stepId,
|
|
426
|
+
status: step.status,
|
|
427
|
+
duration: step.startedAt && step.endedAt ? step.endedAt.getTime() - step.startedAt.getTime() : null,
|
|
428
|
+
startedAt: step.startedAt,
|
|
429
|
+
endedAt: step.endedAt
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
function getWorkflowProgress(run) {
|
|
433
|
+
const total = run.steps.length;
|
|
434
|
+
const completed = run.steps.filter((s) => s.status === "done").length;
|
|
435
|
+
return {
|
|
436
|
+
completed,
|
|
437
|
+
total,
|
|
438
|
+
percentage: total > 0 ? Math.round(completed / total * 100) : 0
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function getStepUIStates(run) {
|
|
442
|
+
const currentIndex = run.steps.findIndex((s) => s.stepId === run.currentStepId);
|
|
443
|
+
return run.steps.map((step, index) => ({
|
|
444
|
+
...step,
|
|
445
|
+
isCurrentStep: step.stepId === run.currentStepId,
|
|
446
|
+
isPastStep: index < currentIndex,
|
|
447
|
+
isFutureStep: index > currentIndex,
|
|
448
|
+
canRewindTo: index <= currentIndex && step.status === "done"
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
function getWaitingInfo(run) {
|
|
452
|
+
const waitingStep = run.steps.find((s) => s.status === "waiting");
|
|
453
|
+
if (!waitingStep?.waitingFor) return null;
|
|
454
|
+
return {
|
|
455
|
+
stepId: waitingStep.stepId,
|
|
456
|
+
type: waitingStep.waitingFor.type,
|
|
457
|
+
reason: waitingStep.waitingFor.reason,
|
|
458
|
+
resumeAt: waitingStep.waitingFor.resumeAt,
|
|
459
|
+
eventName: waitingStep.waitingFor.eventName,
|
|
460
|
+
data: waitingStep.waitingFor.data
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function canRewindTo(run, stepId) {
|
|
464
|
+
const step = run.steps.find((s) => s.stepId === stepId);
|
|
465
|
+
if (!step) return false;
|
|
466
|
+
return run.steps.findIndex((s) => s.stepId === stepId) <= run.steps.findIndex((s) => s.stepId === run.currentStepId) && step.status === "done";
|
|
467
|
+
}
|
|
468
|
+
function getExecutionPath(run) {
|
|
469
|
+
return run.steps.filter((s) => s.status === "done").map((s) => s.stepId);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/scheduling/timezone-handler.ts
|
|
474
|
+
/**
|
|
475
|
+
* TimezoneHandler - Industry-standard timezone conversion with DST edge case handling
|
|
476
|
+
*
|
|
477
|
+
* Handles two critical DST edge cases:
|
|
478
|
+
* 1. Spring Forward (non-existent time): When clocks jump forward, e.g., 2:30 AM doesn't exist
|
|
479
|
+
* 2. Fall Back (ambiguous time): When clocks fall back, e.g., 1:30 AM occurs twice
|
|
480
|
+
*
|
|
481
|
+
* Design Philosophy:
|
|
482
|
+
* - Store both intent (timezone + local time) AND execution time (UTC)
|
|
483
|
+
* - Gracefully handle invalid times by adjusting forward
|
|
484
|
+
* - Warn users about ambiguous times during fall back
|
|
485
|
+
* - Use IANA timezone database (not abbreviations like "EST")
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* ```typescript
|
|
489
|
+
* const handler = new TimezoneHandler();
|
|
490
|
+
*
|
|
491
|
+
* // Schedule for 9:00 AM New York time
|
|
492
|
+
* const result = handler.calculateExecutionTime(
|
|
493
|
+
* '2024-03-10T09:00:00',
|
|
494
|
+
* 'America/New_York'
|
|
495
|
+
* );
|
|
496
|
+
*
|
|
497
|
+
* console.log(result.executionTime); // UTC time for scheduler
|
|
498
|
+
* console.log(result.isDSTTransition); // false (9 AM is safe)
|
|
499
|
+
* ```
|
|
500
|
+
*/
|
|
501
|
+
var TimezoneHandler = class {
|
|
502
|
+
/**
|
|
503
|
+
* Calculate UTC execution time from user's local timezone intent
|
|
504
|
+
*
|
|
505
|
+
* @param scheduledFor - Local date/time as ISO string WITHOUT timezone (naive datetime)
|
|
506
|
+
* Format: "YYYY-MM-DDTHH:mm:ss" (e.g., "2024-03-10T09:00:00")
|
|
507
|
+
*
|
|
508
|
+
* This represents the LOCAL time in the target timezone.
|
|
509
|
+
* Do NOT include timezone offset (Z, +00:00, etc.)
|
|
510
|
+
*
|
|
511
|
+
* @param timezone - IANA timezone name (e.g., "America/New_York", "Europe/London")
|
|
512
|
+
* @returns Calculation result with execution time and DST metadata
|
|
513
|
+
*
|
|
514
|
+
* @throws {Error} If timezone is invalid or scheduledFor format is invalid
|
|
515
|
+
*
|
|
516
|
+
* @example Basic Usage
|
|
517
|
+
* ```typescript
|
|
518
|
+
* // Schedule for 9:00 AM New York time
|
|
519
|
+
* const result = handler.calculateExecutionTime(
|
|
520
|
+
* '2024-03-10T09:00:00',
|
|
521
|
+
* 'America/New_York'
|
|
522
|
+
* );
|
|
523
|
+
* console.log(result.executionTime); // UTC Date object for scheduler
|
|
524
|
+
* console.log(result.localTimeDisplay); // "2024-03-10 09:00:00 EDT"
|
|
525
|
+
* ```
|
|
526
|
+
*
|
|
527
|
+
* @example Spring Forward Edge Case (non-existent time)
|
|
528
|
+
* ```typescript
|
|
529
|
+
* const result = handler.calculateExecutionTime(
|
|
530
|
+
* '2024-03-10T02:30:00', // 2:30 AM doesn't exist (DST springs forward)
|
|
531
|
+
* 'America/New_York'
|
|
532
|
+
* );
|
|
533
|
+
* // Result: Adjusted to 3:30 AM, isDSTTransition=true, dstNote explains adjustment
|
|
534
|
+
* ```
|
|
535
|
+
*
|
|
536
|
+
* @example Fall Back Edge Case (ambiguous time)
|
|
537
|
+
* ```typescript
|
|
538
|
+
* const result = handler.calculateExecutionTime(
|
|
539
|
+
* '2024-11-03T01:30:00', // 1:30 AM occurs twice (DST falls back)
|
|
540
|
+
* 'America/New_York'
|
|
541
|
+
* );
|
|
542
|
+
* // Result: Uses first occurrence (DST), isDSTTransition=true, dstNote warns of ambiguity
|
|
543
|
+
* ```
|
|
544
|
+
*/
|
|
545
|
+
calculateExecutionTime(scheduledFor, timezone) {
|
|
546
|
+
if (!DateTime.local().setZone(timezone).isValid) throw new Error(`Invalid timezone: ${timezone}. Use IANA timezone names like "America/New_York"`);
|
|
547
|
+
let isoString;
|
|
548
|
+
if (scheduledFor instanceof Date) isoString = `${scheduledFor.getFullYear()}-${String(scheduledFor.getMonth() + 1).padStart(2, "0")}-${String(scheduledFor.getDate()).padStart(2, "0")}T${String(scheduledFor.getHours()).padStart(2, "0")}:${String(scheduledFor.getMinutes()).padStart(2, "0")}:${String(scheduledFor.getSeconds()).padStart(2, "0")}`;
|
|
549
|
+
else isoString = scheduledFor;
|
|
550
|
+
const dt = DateTime.fromISO(isoString, { zone: timezone });
|
|
551
|
+
if (!dt.isValid) throw new Error(`Invalid scheduledFor: "${scheduledFor}". Expected ISO format without timezone: "YYYY-MM-DDTHH:mm:ss" (e.g., "2024-03-10T09:00:00")`);
|
|
552
|
+
const year = dt.year;
|
|
553
|
+
const month = dt.month;
|
|
554
|
+
const day = dt.day;
|
|
555
|
+
const hour = dt.hour;
|
|
556
|
+
const minute = dt.minute;
|
|
557
|
+
const second = dt.second;
|
|
558
|
+
const dtInZone = DateTime.fromObject({
|
|
559
|
+
year,
|
|
560
|
+
month,
|
|
561
|
+
day,
|
|
562
|
+
hour,
|
|
563
|
+
minute,
|
|
564
|
+
second
|
|
565
|
+
}, { zone: timezone });
|
|
566
|
+
if (!dtInZone.isValid) {
|
|
567
|
+
const reason = dtInZone.invalidReason || "unknown";
|
|
568
|
+
const adjustedDt = DateTime.fromObject({
|
|
569
|
+
year,
|
|
570
|
+
month,
|
|
571
|
+
day,
|
|
572
|
+
hour: hour + 1,
|
|
573
|
+
minute,
|
|
574
|
+
second
|
|
575
|
+
}, { zone: timezone });
|
|
576
|
+
return {
|
|
577
|
+
executionTime: adjustedDt.toUTC().toJSDate(),
|
|
578
|
+
localTimeDisplay: adjustedDt.toFormat("yyyy-MM-dd HH:mm:ss ZZZZ"),
|
|
579
|
+
isDSTTransition: true,
|
|
580
|
+
dstNote: `Scheduled time ${hour}:${String(minute).padStart(2, "0")} does not exist due to DST spring forward. Adjusted to ${adjustedDt.hour}:${String(adjustedDt.minute).padStart(2, "0")}. Reason: ${reason}`
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const oneHourLater = dtInZone.plus({ hours: 1 });
|
|
584
|
+
if (dtInZone.isInDST !== oneHourLater.isInDST && dtInZone.offset !== oneHourLater.offset) return {
|
|
585
|
+
executionTime: dtInZone.toUTC().toJSDate(),
|
|
586
|
+
localTimeDisplay: dtInZone.toFormat("yyyy-MM-dd HH:mm:ss ZZZZ"),
|
|
587
|
+
isDSTTransition: true,
|
|
588
|
+
dstNote: `Scheduled time ${hour}:${String(minute).padStart(2, "0")} is ambiguous due to DST fall back (occurs twice). Using first occurrence (${dtInZone.offsetNameShort}). Consider scheduling outside 1-2 AM window during fall transitions.`
|
|
589
|
+
};
|
|
590
|
+
return {
|
|
591
|
+
executionTime: dtInZone.toUTC().toJSDate(),
|
|
592
|
+
localTimeDisplay: dtInZone.toFormat("yyyy-MM-dd HH:mm:ss ZZZZ"),
|
|
593
|
+
isDSTTransition: false
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Validate if a timezone is recognized by IANA database
|
|
598
|
+
*
|
|
599
|
+
* @param timezone - Timezone string to validate
|
|
600
|
+
* @returns true if valid, false otherwise
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* ```typescript
|
|
604
|
+
* handler.isValidTimezone('America/New_York'); // true
|
|
605
|
+
* handler.isValidTimezone('EST'); // false (use IANA names)
|
|
606
|
+
* handler.isValidTimezone('Invalid/Zone'); // false
|
|
607
|
+
* ```
|
|
608
|
+
*/
|
|
609
|
+
isValidTimezone(timezone) {
|
|
610
|
+
try {
|
|
611
|
+
return DateTime.local().setZone(timezone).isValid;
|
|
612
|
+
} catch {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Get current offset for a timezone (useful for debugging)
|
|
618
|
+
*
|
|
619
|
+
* @param timezone - IANA timezone name
|
|
620
|
+
* @returns Offset in minutes from UTC (e.g., -300 for EST)
|
|
621
|
+
*
|
|
622
|
+
* @example
|
|
623
|
+
* ```typescript
|
|
624
|
+
* handler.getCurrentOffset('America/New_York'); // -300 (EST) or -240 (EDT)
|
|
625
|
+
* ```
|
|
626
|
+
*/
|
|
627
|
+
getCurrentOffset(timezone) {
|
|
628
|
+
const dt = DateTime.local().setZone(timezone);
|
|
629
|
+
if (!dt.isValid) throw new Error(`Invalid timezone: ${timezone}`);
|
|
630
|
+
return dt.offset;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Check if a timezone is currently in DST
|
|
634
|
+
*
|
|
635
|
+
* @param timezone - IANA timezone name
|
|
636
|
+
* @returns true if currently observing DST, false otherwise
|
|
637
|
+
*
|
|
638
|
+
* @example
|
|
639
|
+
* ```typescript
|
|
640
|
+
* handler.isInDST('America/New_York'); // true in summer, false in winter
|
|
641
|
+
* ```
|
|
642
|
+
*/
|
|
643
|
+
isInDST(timezone) {
|
|
644
|
+
const dt = DateTime.local().setZone(timezone);
|
|
645
|
+
if (!dt.isValid) throw new Error(`Invalid timezone: ${timezone}`);
|
|
646
|
+
return dt.isInDST;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
/**
|
|
650
|
+
* Singleton instance for convenience
|
|
651
|
+
* Use this for most cases unless you need custom configuration
|
|
652
|
+
*/
|
|
653
|
+
const timezoneHandler = new TimezoneHandler();
|
|
654
|
+
|
|
655
|
+
//#endregion
|
|
656
|
+
//#region src/scheduling/scheduling.service.ts
|
|
657
|
+
/**
|
|
658
|
+
* Scheduling Service - Facade for timezone-aware workflow scheduling
|
|
659
|
+
*
|
|
660
|
+
* Provides high-level API for scheduling workflows at specific times with full timezone support.
|
|
661
|
+
* Handles DST transitions, recurrence patterns, and multi-tenant isolation.
|
|
662
|
+
*
|
|
663
|
+
* Design Philosophy:
|
|
664
|
+
* - Simple API: One method to schedule, one to reschedule, one to cancel
|
|
665
|
+
* - Smart Defaults: Sensible timezone handling with clear DST warnings
|
|
666
|
+
* - Zero Surprises: Explicit about what time workflow will actually execute
|
|
667
|
+
* - Resource Efficient: Uses keyset pagination for large-scale scheduling
|
|
668
|
+
* - Unified Container: Uses the same container/repository for scheduling and execution
|
|
669
|
+
*
|
|
670
|
+
* @example Basic Scheduling
|
|
671
|
+
* ```typescript
|
|
672
|
+
* import { SchedulingService } from '@classytic/streamline/scheduling';
|
|
673
|
+
* import { myWorkflow, myHandlers } from './workflows';
|
|
674
|
+
*
|
|
675
|
+
* const service = new SchedulingService(myWorkflow, myHandlers);
|
|
676
|
+
*
|
|
677
|
+
* // Schedule workflow for 9:00 AM New York time
|
|
678
|
+
* const run = await service.schedule({
|
|
679
|
+
* scheduledFor: '2024-03-15T09:00:00',
|
|
680
|
+
* timezone: 'America/New_York',
|
|
681
|
+
* input: { task: 'Send morning email' }
|
|
682
|
+
* });
|
|
683
|
+
*
|
|
684
|
+
* console.log(run.scheduling?.executionTime); // UTC time when scheduler will execute
|
|
685
|
+
* console.log(run.scheduling?.localTimeDisplay); // "2024-03-15 09:00:00 EDT"
|
|
686
|
+
* console.log(run.scheduling?.isDSTTransition); // false (9 AM is safe)
|
|
687
|
+
* ```
|
|
688
|
+
*
|
|
689
|
+
* @example Multi-Tenant Scheduling
|
|
690
|
+
* ```typescript
|
|
691
|
+
* const service = new SchedulingService(workflow, handlers, {
|
|
692
|
+
* multiTenant: {
|
|
693
|
+
* tenantField: 'context.tenantId',
|
|
694
|
+
* strict: true
|
|
695
|
+
* }
|
|
696
|
+
* });
|
|
697
|
+
*
|
|
698
|
+
* const run = await service.schedule({
|
|
699
|
+
* scheduledFor: '2024-12-25T10:00:00',
|
|
700
|
+
* timezone: 'America/Los_Angeles',
|
|
701
|
+
* input: { postContent: 'Happy Holidays!' },
|
|
702
|
+
* tenantId: 'client-123'
|
|
703
|
+
* });
|
|
704
|
+
* ```
|
|
705
|
+
*/
|
|
706
|
+
/**
|
|
707
|
+
* SchedulingService - High-level API for timezone-aware workflow scheduling
|
|
708
|
+
*
|
|
709
|
+
* Combines WorkflowEngine, TimezoneHandler, and Repository for easy scheduling.
|
|
710
|
+
* Handles all the complexity of timezone conversion, DST transitions, and scheduling.
|
|
711
|
+
*
|
|
712
|
+
* IMPORTANT: Uses a unified container for both scheduling and execution to ensure
|
|
713
|
+
* consistent multi-tenant isolation and proper hook registration.
|
|
714
|
+
*
|
|
715
|
+
* @typeParam TContext - Workflow context type
|
|
716
|
+
*/
|
|
717
|
+
var SchedulingService = class {
|
|
718
|
+
engine;
|
|
719
|
+
workflow;
|
|
720
|
+
timezoneHandler;
|
|
721
|
+
/** Exposed for testing and advanced use cases */
|
|
722
|
+
container;
|
|
723
|
+
constructor(workflow, handlers, config = {}) {
|
|
724
|
+
this.workflow = workflow;
|
|
725
|
+
this.timezoneHandler = new TimezoneHandler();
|
|
726
|
+
if (config.container) if ("repository" in config.container && "eventBus" in config.container && "cache" in config.container) this.container = config.container;
|
|
727
|
+
else this.container = createContainer(config.container);
|
|
728
|
+
else if (config.multiTenant) this.container = createContainer({ repository: { multiTenant: config.multiTenant } });
|
|
729
|
+
else this.container = createContainer();
|
|
730
|
+
this.engine = new WorkflowEngine(workflow, handlers, this.container, { autoExecute: config.autoExecute !== false });
|
|
731
|
+
}
|
|
732
|
+
/** Get the repository (uses container's repository) */
|
|
733
|
+
get repository() {
|
|
734
|
+
return this.container.repository;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Convert Date to ISO string format (YYYY-MM-DDTHH:mm:ss)
|
|
738
|
+
* Uses local components to preserve the "naive" datetime interpretation
|
|
739
|
+
*/
|
|
740
|
+
convertDateToISOString(date) {
|
|
741
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Schedule a workflow for future execution at a specific timezone
|
|
745
|
+
*
|
|
746
|
+
* @param options - Scheduling options with timezone information
|
|
747
|
+
* @returns Created workflow run with scheduling metadata
|
|
748
|
+
* @throws {Error} If timezone is invalid or scheduling fails
|
|
749
|
+
*
|
|
750
|
+
* @example
|
|
751
|
+
* ```typescript
|
|
752
|
+
* const run = await service.schedule({
|
|
753
|
+
* scheduledFor: '2024-06-15T14:30:00',
|
|
754
|
+
* timezone: 'Europe/London',
|
|
755
|
+
* input: { userId: '123', action: 'send-reminder' }
|
|
756
|
+
* });
|
|
757
|
+
*
|
|
758
|
+
* // Check if DST transition affected scheduling
|
|
759
|
+
* if (run.scheduling?.isDSTTransition) {
|
|
760
|
+
* console.warn('DST Note:', run.scheduling.dstNote);
|
|
761
|
+
* }
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
async schedule(options) {
|
|
765
|
+
const scheduledForString = options.scheduledFor instanceof Date ? this.convertDateToISOString(options.scheduledFor) : options.scheduledFor;
|
|
766
|
+
const timezoneResult = this.timezoneHandler.calculateExecutionTime(scheduledForString, options.timezone);
|
|
767
|
+
const now = /* @__PURE__ */ new Date();
|
|
768
|
+
if (timezoneResult.executionTime.getTime() < now.getTime() - SCHEDULING.PAST_SCHEDULE_GRACE_MS) {
|
|
769
|
+
const minutesInPast = Math.round((now.getTime() - timezoneResult.executionTime.getTime()) / 6e4);
|
|
770
|
+
logger.warn("Scheduling workflow in the past - will execute immediately", {
|
|
771
|
+
workflowId: this.workflow.id,
|
|
772
|
+
minutesInPast,
|
|
773
|
+
scheduledFor: timezoneResult.localTimeDisplay,
|
|
774
|
+
currentTime: now.toISOString()
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const context = {
|
|
778
|
+
...this.workflow.createContext(options.input),
|
|
779
|
+
...options.tenantId && { tenantId: options.tenantId }
|
|
780
|
+
};
|
|
781
|
+
const steps = this.workflow.steps.map((step) => ({
|
|
782
|
+
stepId: step.id,
|
|
783
|
+
status: "pending",
|
|
784
|
+
attempts: 0
|
|
785
|
+
}));
|
|
786
|
+
const firstStepId = this.workflow.steps[0]?.id || null;
|
|
787
|
+
const workflowRunData = {
|
|
788
|
+
_id: randomUUID(),
|
|
789
|
+
workflowId: this.workflow.id,
|
|
790
|
+
status: "draft",
|
|
791
|
+
steps,
|
|
792
|
+
currentStepId: firstStepId,
|
|
793
|
+
context,
|
|
794
|
+
input: options.input,
|
|
795
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
796
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
797
|
+
scheduling: {
|
|
798
|
+
scheduledFor: scheduledForString,
|
|
799
|
+
timezone: options.timezone,
|
|
800
|
+
localTimeDisplay: timezoneResult.localTimeDisplay,
|
|
801
|
+
executionTime: timezoneResult.executionTime,
|
|
802
|
+
isDSTTransition: timezoneResult.isDSTTransition,
|
|
803
|
+
dstNote: timezoneResult.dstNote,
|
|
804
|
+
recurrence: options.recurrence
|
|
805
|
+
},
|
|
806
|
+
userId: options.userId,
|
|
807
|
+
tags: options.tags,
|
|
808
|
+
meta: options.meta
|
|
809
|
+
};
|
|
810
|
+
return await this.repository.create(workflowRunData);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Reschedule an existing workflow to a new time
|
|
814
|
+
*
|
|
815
|
+
* @param runId - Workflow run ID to reschedule
|
|
816
|
+
* @param newScheduledFor - New local date/time as ISO string (format: "YYYY-MM-DDTHH:mm:ss")
|
|
817
|
+
* @param newTimezone - Optional new timezone (if changing timezone)
|
|
818
|
+
* @returns Updated workflow run
|
|
819
|
+
* @throws {Error} If workflow not found or already executed
|
|
820
|
+
*
|
|
821
|
+
* @example Reschedule to different time (same timezone)
|
|
822
|
+
* ```typescript
|
|
823
|
+
* await service.reschedule(runId, '2024-06-16T15:00:00');
|
|
824
|
+
* ```
|
|
825
|
+
*
|
|
826
|
+
* @example Reschedule to different time AND timezone
|
|
827
|
+
* ```typescript
|
|
828
|
+
* await service.reschedule(
|
|
829
|
+
* runId,
|
|
830
|
+
* '2024-06-16T10:00:00',
|
|
831
|
+
* 'America/New_York'
|
|
832
|
+
* );
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
async reschedule(runId, newScheduledFor, newTimezone) {
|
|
836
|
+
const run = await this.repository.getById(runId);
|
|
837
|
+
if (!run) throw new Error(`Workflow run ${runId} not found`);
|
|
838
|
+
if (run.status !== "draft") throw new Error(`Cannot reschedule workflow ${runId} with status ${run.status}. Only draft workflows can be rescheduled.`);
|
|
839
|
+
if (!run.scheduling) throw new Error(`Workflow run ${runId} is not a scheduled workflow`);
|
|
840
|
+
const timezone = newTimezone || run.scheduling.timezone;
|
|
841
|
+
const timezoneResult = this.timezoneHandler.calculateExecutionTime(newScheduledFor, timezone);
|
|
842
|
+
const updateData = {
|
|
843
|
+
scheduling: {
|
|
844
|
+
scheduledFor: newScheduledFor instanceof Date ? this.convertDateToISOString(newScheduledFor) : newScheduledFor,
|
|
845
|
+
timezone,
|
|
846
|
+
localTimeDisplay: timezoneResult.localTimeDisplay,
|
|
847
|
+
executionTime: timezoneResult.executionTime,
|
|
848
|
+
isDSTTransition: timezoneResult.isDSTTransition,
|
|
849
|
+
dstNote: timezoneResult.dstNote,
|
|
850
|
+
recurrence: run.scheduling.recurrence
|
|
851
|
+
},
|
|
852
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
853
|
+
};
|
|
854
|
+
return await this.repository.update(runId, updateData);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Cancel a scheduled workflow
|
|
858
|
+
*
|
|
859
|
+
* @param runId - Workflow run ID to cancel
|
|
860
|
+
* @returns Cancelled workflow run
|
|
861
|
+
* @throws {Error} If workflow not found or already executed
|
|
862
|
+
*
|
|
863
|
+
* @example
|
|
864
|
+
* ```typescript
|
|
865
|
+
* await service.cancelScheduled(runId);
|
|
866
|
+
* ```
|
|
867
|
+
*/
|
|
868
|
+
async cancelScheduled(runId) {
|
|
869
|
+
const run = await this.repository.getById(runId);
|
|
870
|
+
if (!run) throw new Error(`Workflow run ${runId} not found`);
|
|
871
|
+
if (run.status !== "draft") throw new Error(`Cannot cancel workflow ${runId} with status ${run.status}. Only draft workflows can be cancelled.`);
|
|
872
|
+
const cancelData = {
|
|
873
|
+
status: "cancelled",
|
|
874
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
875
|
+
endedAt: /* @__PURE__ */ new Date()
|
|
876
|
+
};
|
|
877
|
+
return await this.repository.update(runId, cancelData);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Get scheduled workflows (with pagination)
|
|
881
|
+
*
|
|
882
|
+
* Supports both offset (page-based) and keyset (cursor-based) pagination.
|
|
883
|
+
* Use keyset pagination for large datasets (>10k workflows) for better performance.
|
|
884
|
+
*
|
|
885
|
+
* @param options - Query and pagination options
|
|
886
|
+
* @returns Paginated results with scheduled workflows
|
|
887
|
+
*
|
|
888
|
+
* @example Offset Pagination (Simple)
|
|
889
|
+
* ```typescript
|
|
890
|
+
* const result = await service.getScheduled({
|
|
891
|
+
* page: 1,
|
|
892
|
+
* limit: 50,
|
|
893
|
+
* tenantId: 'client-123'
|
|
894
|
+
* });
|
|
895
|
+
*
|
|
896
|
+
* console.log(result.data); // Array of workflows
|
|
897
|
+
* console.log(result.hasNextPage); // true if more pages exist
|
|
898
|
+
* ```
|
|
899
|
+
*
|
|
900
|
+
* @example Keyset Pagination (Efficient for large datasets)
|
|
901
|
+
* ```typescript
|
|
902
|
+
* // First page
|
|
903
|
+
* const result = await service.getScheduled({
|
|
904
|
+
* cursor: null,
|
|
905
|
+
* limit: 1000
|
|
906
|
+
* });
|
|
907
|
+
*
|
|
908
|
+
* // Next page
|
|
909
|
+
* const nextResult = await service.getScheduled({
|
|
910
|
+
* cursor: result.nextCursor,
|
|
911
|
+
* limit: 1000
|
|
912
|
+
* });
|
|
913
|
+
* ```
|
|
914
|
+
*
|
|
915
|
+
* @example Filter by execution time range
|
|
916
|
+
* ```typescript
|
|
917
|
+
* const result = await service.getScheduled({
|
|
918
|
+
* executionTimeRange: {
|
|
919
|
+
* from: new Date('2024-06-01'),
|
|
920
|
+
* to: new Date('2024-06-30')
|
|
921
|
+
* },
|
|
922
|
+
* limit: 100
|
|
923
|
+
* });
|
|
924
|
+
* ```
|
|
925
|
+
*/
|
|
926
|
+
async getScheduled(options = {}) {
|
|
927
|
+
const { page = 1, limit = 100, cursor, tenantId, executionTimeRange, recurring } = options;
|
|
928
|
+
const filters = {
|
|
929
|
+
status: "draft",
|
|
930
|
+
"scheduling.executionTime": { $exists: true },
|
|
931
|
+
paused: { $ne: true }
|
|
932
|
+
};
|
|
933
|
+
if (executionTimeRange) filters["scheduling.executionTime"] = {
|
|
934
|
+
$gte: executionTimeRange.from,
|
|
935
|
+
$lte: executionTimeRange.to
|
|
936
|
+
};
|
|
937
|
+
if (recurring !== void 0) if (recurring) filters["scheduling.recurrence"] = { $exists: true };
|
|
938
|
+
else filters["scheduling.recurrence"] = { $exists: false };
|
|
939
|
+
return await this.repository.getAll({
|
|
940
|
+
filters,
|
|
941
|
+
sort: { "scheduling.executionTime": 1 },
|
|
942
|
+
page,
|
|
943
|
+
limit,
|
|
944
|
+
cursor: cursor ?? void 0,
|
|
945
|
+
...tenantId && { tenantId }
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Get workflow run by ID
|
|
950
|
+
*
|
|
951
|
+
* @param runId - Workflow run ID
|
|
952
|
+
* @returns Workflow run or null if not found
|
|
953
|
+
*/
|
|
954
|
+
async get(runId) {
|
|
955
|
+
try {
|
|
956
|
+
return await this.repository.getById(runId);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
const err = error;
|
|
959
|
+
if (err.status === 404 || err.message?.includes("not found")) return null;
|
|
960
|
+
throw error;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Execute a scheduled workflow immediately (bypass schedule)
|
|
965
|
+
*
|
|
966
|
+
* @param runId - Workflow run ID to execute
|
|
967
|
+
* @returns Executed workflow run
|
|
968
|
+
* @throws {Error} If workflow not found or not in draft status
|
|
969
|
+
*
|
|
970
|
+
* @example
|
|
971
|
+
* ```typescript
|
|
972
|
+
* // Execute a scheduled workflow now instead of waiting for execution time
|
|
973
|
+
* const run = await service.executeNow(runId);
|
|
974
|
+
* ```
|
|
975
|
+
*/
|
|
976
|
+
async executeNow(runId) {
|
|
977
|
+
const run = await this.repository.getById(runId);
|
|
978
|
+
if (!run) throw new Error(`Workflow run ${runId} not found`);
|
|
979
|
+
if (run.status !== "draft") throw new Error(`Cannot execute workflow ${runId} with status ${run.status}. Only draft workflows can be executed.`);
|
|
980
|
+
const updateData = {
|
|
981
|
+
status: "running",
|
|
982
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
983
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
984
|
+
};
|
|
985
|
+
await this.repository.update(runId, updateData);
|
|
986
|
+
hookRegistry.register(runId, this.engine);
|
|
987
|
+
return await this.engine.execute(runId);
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
//#endregion
|
|
992
|
+
//#region src/features/parallel.ts
|
|
993
|
+
async function executeWithConcurrency(tasks, concurrency) {
|
|
994
|
+
const results = new Array(tasks.length);
|
|
995
|
+
const errors = [];
|
|
996
|
+
const executing = /* @__PURE__ */ new Set();
|
|
997
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
998
|
+
const index = i;
|
|
999
|
+
const promise = tasks[index]().then((result) => {
|
|
1000
|
+
results[index] = result;
|
|
1001
|
+
}).catch((error) => {
|
|
1002
|
+
errors.push({
|
|
1003
|
+
index,
|
|
1004
|
+
error
|
|
1005
|
+
});
|
|
1006
|
+
}).finally(() => {
|
|
1007
|
+
executing.delete(promise);
|
|
1008
|
+
});
|
|
1009
|
+
executing.add(promise);
|
|
1010
|
+
if (executing.size >= concurrency) await Promise.race(executing);
|
|
1011
|
+
}
|
|
1012
|
+
await Promise.all(executing);
|
|
1013
|
+
if (errors.length > 0) {
|
|
1014
|
+
errors.sort((a, b) => a.index - b.index);
|
|
1015
|
+
throw errors[0].error;
|
|
1016
|
+
}
|
|
1017
|
+
return results;
|
|
1018
|
+
}
|
|
1019
|
+
async function executeWithConcurrencySettled(tasks, concurrency) {
|
|
1020
|
+
const results = new Array(tasks.length);
|
|
1021
|
+
const executing = /* @__PURE__ */ new Set();
|
|
1022
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
1023
|
+
const index = i;
|
|
1024
|
+
const promise = tasks[index]().then((value) => {
|
|
1025
|
+
results[index] = {
|
|
1026
|
+
success: true,
|
|
1027
|
+
value
|
|
1028
|
+
};
|
|
1029
|
+
}).catch((reason) => {
|
|
1030
|
+
results[index] = {
|
|
1031
|
+
success: false,
|
|
1032
|
+
reason
|
|
1033
|
+
};
|
|
1034
|
+
}).finally(() => {
|
|
1035
|
+
executing.delete(promise);
|
|
1036
|
+
});
|
|
1037
|
+
executing.add(promise);
|
|
1038
|
+
if (executing.size >= concurrency) await Promise.race(executing);
|
|
1039
|
+
}
|
|
1040
|
+
await Promise.all(executing);
|
|
1041
|
+
return results;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Execute multiple async tasks in parallel with configurable mode,
|
|
1045
|
+
* concurrency limits, and timeouts.
|
|
1046
|
+
*
|
|
1047
|
+
* @param tasks - Array of async task functions to execute
|
|
1048
|
+
* @param options - Execution options (mode, concurrency, timeout)
|
|
1049
|
+
* @returns Array of results (exact type depends on mode)
|
|
1050
|
+
*
|
|
1051
|
+
* @example Basic parallel execution
|
|
1052
|
+
* ```typescript
|
|
1053
|
+
* const results = await executeParallel([
|
|
1054
|
+
* () => fetch('/api/users'),
|
|
1055
|
+
* () => fetch('/api/posts'),
|
|
1056
|
+
* () => fetch('/api/comments'),
|
|
1057
|
+
* ]);
|
|
1058
|
+
* ```
|
|
1059
|
+
*
|
|
1060
|
+
* @example With concurrency limit
|
|
1061
|
+
* ```typescript
|
|
1062
|
+
* // Only 2 requests at a time
|
|
1063
|
+
* const results = await executeParallel(urlTasks, { concurrency: 2 });
|
|
1064
|
+
* ```
|
|
1065
|
+
*
|
|
1066
|
+
* @example With allSettled mode (never throws)
|
|
1067
|
+
* ```typescript
|
|
1068
|
+
* const results = await executeParallel(tasks, { mode: 'allSettled' });
|
|
1069
|
+
* for (const result of results) {
|
|
1070
|
+
* if (result.success) {
|
|
1071
|
+
* console.log('Success:', result.value);
|
|
1072
|
+
* } else {
|
|
1073
|
+
* console.log('Failed:', result.reason);
|
|
1074
|
+
* }
|
|
1075
|
+
* }
|
|
1076
|
+
* ```
|
|
1077
|
+
*/
|
|
1078
|
+
async function executeParallel(tasks, options = {}) {
|
|
1079
|
+
const { mode = "all", concurrency = Infinity, timeout } = options;
|
|
1080
|
+
const wrappedTasks = timeout ? tasks.map((task) => () => Promise.race([task(), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Task timeout after ${timeout}ms`)), timeout))])) : tasks;
|
|
1081
|
+
if (concurrency !== Infinity && (mode === "race" || mode === "any")) throw new Error(`executeParallel: mode '${mode}' cannot be combined with concurrency limiting. The '${mode}' mode requires all tasks to start simultaneously to determine the winner. Either remove the concurrency limit or use mode 'all' or 'allSettled'.`);
|
|
1082
|
+
if (concurrency !== Infinity) switch (mode) {
|
|
1083
|
+
case "allSettled": return executeWithConcurrencySettled(wrappedTasks, concurrency);
|
|
1084
|
+
default: return executeWithConcurrency(wrappedTasks, concurrency);
|
|
1085
|
+
}
|
|
1086
|
+
switch (mode) {
|
|
1087
|
+
case "all": return Promise.all(wrappedTasks.map((t) => t()));
|
|
1088
|
+
case "race": return [await Promise.race(wrappedTasks.map((t) => t()))];
|
|
1089
|
+
case "any": return Promise.any(wrappedTasks.map((t) => t())).then((result) => [result]);
|
|
1090
|
+
case "allSettled": return (await Promise.allSettled(wrappedTasks.map((t) => t()))).map((result) => result.status === "fulfilled" ? {
|
|
1091
|
+
success: true,
|
|
1092
|
+
value: result.value
|
|
1093
|
+
} : {
|
|
1094
|
+
success: false,
|
|
1095
|
+
reason: result.reason
|
|
1096
|
+
});
|
|
1097
|
+
default: return Promise.all(wrappedTasks.map((t) => t()));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
//#endregion
|
|
1102
|
+
export { COMPUTED, CommonQueries, DataCorruptionError, ErrorCode, InvalidStateError, MaxRetriesExceededError, RUN_STATUS, RUN_STATUS_VALUES, STEP_STATUS, STEP_STATUS_VALUES, SchedulingService, StepNotFoundError, StepTimeoutError, TimezoneHandler, WaitSignal, WorkflowCache, WorkflowDefinitionModel, WorkflowEngine, WorkflowError, WorkflowEventBus, WorkflowNotFoundError, WorkflowQueryBuilder, WorkflowRunModel, canRewindTo, conditions, createCondition, createContainer, createHook, createWorkflow, createWorkflowRepository, deriveRunStatus, executeParallel, getExecutionPath, getStepTimeline, getStepUIStates, getWaitingInfo, getWorkflowProgress, globalEventBus, hookRegistry, hookToken, isConditionalStep, isRunStatus, isStepStatus, isStreamlineContainer, isTerminalState, isValidRunTransition, isValidStepTransition, resumeHook, shouldSkipStep, singleTenantPlugin, tenantFilterPlugin, timezoneHandler, workflowDefinitionRepository, workflowRunRepository };
|