@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/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 };