@classytic/streamline 1.0.0 → 2.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 CHANGED
@@ -1,6 +1,7 @@
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";
1
+ import { a as logger, r as WaitSignal, t as GotoSignal } from "./context-DMkusPr_.mjs";
2
+ import { A as isValidRunTransition, C as shouldSkipStep, D as isRunStatus, E as deriveRunStatus, O as isStepStatus, S as isConditionalStep, T as STEP_STATUS_VALUES, _ as validateRetryConfig, a as singleTenantPlugin, b as conditions, c as RUN_STATUS, d as WorkflowRunModel, f as WorkflowCache, g as validateId, h as workflowRegistry, i as workflowRunRepository, j as isValidStepTransition, k as isTerminalState, 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 COMPUTED, w as RUN_STATUS_VALUES, x as createCondition, y as SCHEDULING } from "./container-BSxj_Or4.mjs";
3
+ 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-Ba7ZziTN.mjs";
4
+ import { n as globalEventBus, t as WorkflowEventBus } from "./events-CpesEn3I.mjs";
4
5
  import { randomBytes, randomUUID } from "node:crypto";
5
6
  import mongoose, { Schema } from "mongoose";
6
7
  import semver from "semver";
@@ -39,6 +40,30 @@ import { DateTime } from "luxon";
39
40
  * ```
40
41
  */
41
42
  const toName = (id) => id.replace(/-/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\b\w/g, (c) => c.toUpperCase());
43
+ /** Type guard: is the step entry a StepConfig object (vs a plain handler fn)? */
44
+ function isStepConfig(step) {
45
+ return typeof step === "object" && step !== null && "handler" in step;
46
+ }
47
+ /**
48
+ * Convert a StepConfig entry into a Step definition.
49
+ *
50
+ * The Step interface uses `(context: unknown)` for conditions because steps are
51
+ * stored in WorkflowDefinition (untyped at runtime). This is the one boundary
52
+ * where typed `TContext` callbacks widen to `unknown` — safe because the executor
53
+ * always passes the correct TContext at call sites.
54
+ */
55
+ function toStepDef(stepId, entry) {
56
+ const step = {
57
+ id: stepId,
58
+ name: toName(stepId)
59
+ };
60
+ if (entry.timeout !== void 0) step.timeout = entry.timeout;
61
+ if (entry.retries !== void 0) step.retries = entry.retries;
62
+ if (entry.condition) step.condition = entry.condition;
63
+ if (entry.skipIf) step.skipIf = entry.skipIf;
64
+ if (entry.runIf) step.runIf = entry.runIf;
65
+ return step;
66
+ }
42
67
  /**
43
68
  * Create a workflow with inline step handlers
44
69
  *
@@ -57,12 +82,17 @@ const toName = (id) => id.replace(/-/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2")
57
82
  * await orderProcess.start({ id: '123', email: 'user@example.com' });
58
83
  * ```
59
84
  *
60
- * @example Custom container for testing
85
+ * @example Per-step configuration
61
86
  * ```typescript
62
- * const container = createContainer();
63
- * const workflow = createWorkflow('test-workflow', {
64
- * steps: { ... },
65
- * container
87
+ * const pipeline = createWorkflow('ci-pipeline', {
88
+ * steps: {
89
+ * clone: { handler: async (ctx) => { ... }, timeout: 120_000 },
90
+ * build: { handler: async (ctx) => { ... }, retries: 5 },
91
+ * deploy: {
92
+ * handler: async (ctx) => { ... },
93
+ * skipIf: (ctx) => !ctx.shouldDeploy,
94
+ * },
95
+ * },
66
96
  * });
67
97
  * ```
68
98
  */
@@ -71,19 +101,34 @@ function createWorkflow(id, config) {
71
101
  const stepIds = Object.keys(config.steps);
72
102
  if (stepIds.length === 0) throw new Error("Workflow must have at least one step");
73
103
  if (config.defaults) validateRetryConfig(config.defaults.retries, config.defaults.timeout);
104
+ const handlers = {};
105
+ const compensationHandlers = {};
106
+ const steps = stepIds.map((stepId) => {
107
+ const entry = config.steps[stepId];
108
+ if (isStepConfig(entry)) {
109
+ handlers[stepId] = entry.handler;
110
+ if (entry.onCompensate) compensationHandlers[stepId] = entry.onCompensate;
111
+ return toStepDef(stepId, entry);
112
+ }
113
+ handlers[stepId] = entry;
114
+ return {
115
+ id: stepId,
116
+ name: toName(stepId)
117
+ };
118
+ });
74
119
  const definition = {
75
120
  id,
76
121
  name: toName(id),
77
122
  version: config.version ?? "1.0.0",
78
- steps: stepIds.map((stepId) => ({
79
- id: stepId,
80
- name: toName(stepId)
81
- })),
123
+ steps,
82
124
  createContext: config.context ?? ((input) => input),
83
125
  defaults: config.defaults
84
126
  };
85
127
  const container = config.container ?? createContainer();
86
- const engine = new WorkflowEngine(definition, config.steps, container, { ...config.autoExecute !== void 0 && { autoExecute: config.autoExecute } });
128
+ const engine = new WorkflowEngine(definition, handlers, container, {
129
+ ...config.autoExecute !== void 0 && { autoExecute: config.autoExecute },
130
+ compensationHandlers: Object.keys(compensationHandlers).length > 0 ? compensationHandlers : void 0
131
+ });
87
132
  const waitFor = async (runId, options = {}) => {
88
133
  const { pollInterval = 1e3, timeout } = options;
89
134
  const startTime = Date.now();
@@ -114,17 +159,20 @@ function createWorkflow(id, config) {
114
159
  //#endregion
115
160
  //#region src/features/hooks.ts
116
161
  /**
117
- * Hooks & Webhooks
162
+ * Hooks & Webhooks — Durable external resume
118
163
  *
119
- * Inspired by Vercel's workflow hooks - pause execution and wait for external input.
164
+ * Pause execution and wait for external input (webhooks, approvals, etc.).
165
+ * Durable across process restarts and multi-worker deployments:
166
+ * - Fast path: in-memory hookRegistry (same-process)
167
+ * - Fallback: MongoDB lookup + repository-based resume (cross-process)
120
168
  *
121
169
  * @example
122
170
  * ```typescript
123
171
  * const approval = createWorkflow('doc-approval', {
124
172
  * steps: {
125
173
  * request: async (ctx) => {
126
- * await sendApprovalEmail(ctx.input.docId);
127
- * return createHook(ctx, 'approval');
174
+ * const hook = createHook(ctx, 'approval');
175
+ * return ctx.wait(hook.token, { hookToken: hook.token });
128
176
  * },
129
177
  * process: async (ctx) => {
130
178
  * const { approved } = ctx.getOutput<{ approved: boolean }>('request');
@@ -133,32 +181,18 @@ function createWorkflow(id, config) {
133
181
  * },
134
182
  * });
135
183
  *
136
- * // Resume hook from API route
184
+ * // Resume from API route — works across workers/restarts
137
185
  * const result = await resumeHook(token, { approved: true });
138
- * console.log(result.run); // The resumed workflow run
139
186
  * ```
140
187
  */
141
188
  /**
142
189
  * Create a hook that pauses workflow until external input.
143
190
  * The token includes a crypto-random suffix for security.
144
191
  *
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
192
  * @example
152
193
  * ```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 });
194
+ * const hook = createHook(ctx, 'waiting-for-approval');
195
+ * return ctx.wait(hook.token, { hookToken: hook.token });
162
196
  * ```
163
197
  */
164
198
  function createHook(ctx, reason, options) {
@@ -172,14 +206,14 @@ function createHook(ctx, reason, options) {
172
206
  /**
173
207
  * Resume a paused workflow by hook token.
174
208
  *
175
- * Security: If the workflow was paused with a hookToken in waitingFor.data,
176
- * this function validates the token before resuming.
209
+ * **Durable**: Works across process restarts and multi-worker deployments.
210
+ * - Fast path: Uses in-memory hookRegistry if the engine is in this process.
211
+ * - Fallback: Looks up the workflow in MongoDB and resumes via atomic DB operations.
177
212
  *
178
- * Multi-worker support: Falls back to DB lookup if engine not in local registry.
213
+ * Security: Validates the token against the stored hookToken if present.
179
214
  *
180
215
  * @example
181
216
  * ```typescript
182
- * // API route handler
183
217
  * app.post('/hooks/:token', async (req, res) => {
184
218
  * const result = await resumeHook(req.params.token, req.body);
185
219
  * res.json({ success: true, runId: result.runId, status: result.run.status });
@@ -190,12 +224,15 @@ async function resumeHook(token, payload) {
190
224
  const [runId] = token.split(":");
191
225
  if (!runId) throw new Error(`Invalid hook token: ${token}`);
192
226
  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.`);
227
+ if (engine) return resumeViaEngine(engine, runId, token, payload);
228
+ return resumeViaDb(runId, token, payload);
229
+ }
230
+ /** Resume using the in-memory engine reference (fast path) */
231
+ async function resumeViaEngine(engine, runId, token, payload) {
194
232
  const run = await engine.container.repository.getById(runId);
195
233
  if (!run) throw new Error(`Workflow not found for token: ${token}`);
196
234
  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}`);
235
+ validateHookToken(run, token);
199
236
  const resumedRun = await engine.resume(runId, payload);
200
237
  return {
201
238
  runId: run._id,
@@ -203,11 +240,71 @@ async function resumeHook(token, payload) {
203
240
  };
204
241
  }
205
242
  /**
243
+ * Resume using direct MongoDB operations (durable fallback).
244
+ * Works when the engine that started the workflow is gone (restart, different worker).
245
+ */
246
+ async function resumeViaDb(runId, token, payload) {
247
+ const run = await workflowRunRepository.getById(runId);
248
+ if (!run) throw new Error(`Workflow not found for token: ${token}`);
249
+ if (run.status !== "waiting") throw new Error(`Workflow ${runId} is not waiting (status: ${run.status})`);
250
+ validateHookToken(run, token);
251
+ const stepIndex = run.steps.findIndex((s) => s.status === "waiting");
252
+ if (stepIndex === -1) throw new Error(`No waiting step found in workflow ${runId}`);
253
+ const now = /* @__PURE__ */ new Date();
254
+ const stepId = run.steps[stepIndex].stepId;
255
+ if ((await workflowRunRepository.updateOne({
256
+ _id: runId,
257
+ status: "waiting",
258
+ [`steps.${stepIndex}.status`]: "waiting"
259
+ }, {
260
+ $set: {
261
+ status: "running",
262
+ updatedAt: now,
263
+ lastHeartbeat: now,
264
+ [`steps.${stepIndex}.status`]: "done",
265
+ [`steps.${stepIndex}.endedAt`]: now,
266
+ [`steps.${stepIndex}.output`]: payload
267
+ },
268
+ $unset: { [`steps.${stepIndex}.waitingFor`]: "" }
269
+ }, { bypassTenant: true })).modifiedCount === 0) throw new Error(`Failed to resume workflow ${runId} — already resumed or cancelled`);
270
+ const allStepIds = run.steps.map((s) => s.stepId);
271
+ const currentIndex = allStepIds.indexOf(stepId);
272
+ const nextStepId = currentIndex < allStepIds.length - 1 ? allStepIds[currentIndex + 1] : null;
273
+ if (nextStepId) await workflowRunRepository.updateOne({ _id: runId }, { $set: {
274
+ currentStepId: nextStepId,
275
+ updatedAt: /* @__PURE__ */ new Date()
276
+ } }, { bypassTenant: true });
277
+ else await workflowRunRepository.updateOne({ _id: runId }, { $set: {
278
+ status: "done",
279
+ currentStepId: null,
280
+ endedAt: /* @__PURE__ */ new Date(),
281
+ updatedAt: /* @__PURE__ */ new Date(),
282
+ output: payload
283
+ } }, { bypassTenant: true });
284
+ const engine = workflowRegistry.getEngine(run.workflowId);
285
+ if (engine) {
286
+ engine.container.cache.delete(runId);
287
+ if (nextStepId) setImmediate(() => {
288
+ engine.execute(runId).catch(() => {});
289
+ });
290
+ } else if (nextStepId) await workflowRunRepository.updateOne({ _id: runId }, { $set: { lastHeartbeat: /* @__PURE__ */ new Date(0) } }, { bypassTenant: true });
291
+ const updated = await workflowRunRepository.getById(runId);
292
+ if (!updated) throw new Error(`Workflow ${runId} disappeared after resume`);
293
+ return {
294
+ runId,
295
+ run: updated
296
+ };
297
+ }
298
+ /** Validate hook token against stored token (security) */
299
+ function validateHookToken(run, token) {
300
+ const storedToken = (run.steps.find((s) => s.status === "waiting")?.waitingFor?.data)?.hookToken;
301
+ if (storedToken && storedToken !== token) throw new Error(`Invalid hook token for workflow ${run._id}`);
302
+ }
303
+ /**
206
304
  * Generate a deterministic token for idempotent hooks
207
305
  *
208
306
  * @example
209
307
  * ```typescript
210
- * // Slack bot - same channel always gets same token
211
308
  * const token = hookToken('slack', channelId);
212
309
  * const hook = createHook(ctx, 'slack-message', { token });
213
310
  * ```
@@ -1099,4 +1196,4 @@ async function executeParallel(tasks, options = {}) {
1099
1196
  }
1100
1197
 
1101
1198
  //#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 };
1199
+ export { COMPUTED, CommonQueries, DataCorruptionError, ErrorCode, GotoSignal, 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, workflowRegistry, workflowRunRepository };
@@ -1,4 +1,4 @@
1
- import { _ as WorkflowHandlers, h as WorkflowDefinition } from "../types-DG85_LzF.mjs";
1
+ import { _ as WorkflowHandlers, h as WorkflowDefinition } from "../types-DjgzSrNY.mjs";
2
2
 
3
3
  //#region src/integrations/fastify.d.ts
4
4
  interface WorkflowPluginOptions {
@@ -1,4 +1,4 @@
1
- import { p as WorkflowEngine, t as createContainer } from "../container-BzpIMrrj.mjs";
1
+ import { p as WorkflowEngine, t as createContainer } from "../container-BSxj_Or4.mjs";
2
2
 
3
3
  //#region src/integrations/fastify.ts
4
4
  async function workflowPlugin(fastify, options) {
@@ -0,0 +1,18 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) {
6
+ __defProp(target, name, {
7
+ get: all[name],
8
+ enumerable: true
9
+ });
10
+ }
11
+ if (!no_symbols) {
12
+ __defProp(target, Symbol.toStringTag, { value: "Module" });
13
+ }
14
+ return target;
15
+ };
16
+
17
+ //#endregion
18
+ export { __exportAll as t };
@@ -1,4 +1,4 @@
1
- import { t as WorkflowEventBus } from "../events-C0sZINZq.mjs";
1
+ import { l as WorkflowEventBus } from "../events-DC0ddZZ9.mjs";
2
2
  import { Tracer } from "@opentelemetry/api";
3
3
 
4
4
  //#region src/telemetry/index.d.ts
@@ -1,4 +1,4 @@
1
- import { n as globalEventBus } from "../events-B5aTz7kD.mjs";
1
+ import { n as globalEventBus } from "../events-CpesEn3I.mjs";
2
2
 
3
3
  //#region src/telemetry/index.ts
4
4
  const spans = /* @__PURE__ */ new Map();
@@ -74,7 +74,7 @@ interface WorkflowError {
74
74
  stack?: string;
75
75
  }
76
76
  interface WaitingFor {
77
- type: 'human' | 'webhook' | 'timer' | 'event';
77
+ type: 'human' | 'webhook' | 'timer' | 'event' | 'childWorkflow';
78
78
  reason: string;
79
79
  resumeAt?: Date;
80
80
  eventName?: string;
@@ -225,6 +225,116 @@ interface StepContext<TContext = Record<string, unknown>> {
225
225
  heartbeat: () => Promise<void>;
226
226
  emit: (eventName: string, data: unknown) => void;
227
227
  log: (message: string, data?: unknown) => void;
228
+ /**
229
+ * Save a checkpoint for crash-safe batch processing (durable loop).
230
+ *
231
+ * On crash recovery, `getCheckpoint()` returns the last saved value,
232
+ * so the step resumes from where it left off instead of restarting.
233
+ *
234
+ * @example
235
+ * ```typescript
236
+ * async function processBatches(ctx) {
237
+ * const batches = splitIntoBatches(ctx.input.data, 100);
238
+ * const lastProcessed = ctx.getCheckpoint<number>() ?? -1;
239
+ *
240
+ * for (let i = lastProcessed + 1; i < batches.length; i++) {
241
+ * await processBatch(batches[i]);
242
+ * await ctx.checkpoint(i); // Survives crashes
243
+ * await ctx.heartbeat();
244
+ * }
245
+ * return { processed: batches.length };
246
+ * }
247
+ * ```
248
+ */
249
+ checkpoint: (value: unknown) => Promise<void>;
250
+ /**
251
+ * Read the last checkpoint value saved by `checkpoint()`.
252
+ * Returns `undefined` on first execution (no prior crash).
253
+ */
254
+ getCheckpoint: <T = unknown>() => T | undefined;
255
+ /**
256
+ * Start a child workflow and durably wait for it to complete.
257
+ *
258
+ * The parent step enters a waiting state. When the child reaches a terminal
259
+ * state (done/failed/cancelled), the parent automatically resumes with the
260
+ * child's output as this step's output.
261
+ *
262
+ * Durable: survives process restarts. The scheduler picks up the parent
263
+ * when the child completes via the `childRunId` stored in waitingFor.
264
+ *
265
+ * @param workflowId - The child workflow's registered ID
266
+ * @param input - Input data for the child workflow
267
+ * @returns The child workflow's output (after it completes)
268
+ *
269
+ * @example
270
+ * ```typescript
271
+ * const pipeline = createWorkflow('pipeline', {
272
+ * steps: {
273
+ * validate: async (ctx) => { ... },
274
+ * runSubPipeline: async (ctx) => {
275
+ * return ctx.startChildWorkflow('sub-pipeline', { data: ctx.context.data });
276
+ * // Parent waits here. Resumes when child completes.
277
+ * },
278
+ * finalize: async (ctx) => {
279
+ * const childResult = ctx.getOutput('runSubPipeline');
280
+ * return { done: true, childResult };
281
+ * },
282
+ * },
283
+ * });
284
+ * ```
285
+ */
286
+ startChildWorkflow: (workflowId: string, input: unknown) => Promise<never>;
287
+ /**
288
+ * Jump to a different step, breaking out of the linear sequence.
289
+ *
290
+ * Use for conditional branching, error recovery paths, or skip-ahead logic.
291
+ * The target step must exist in the workflow definition.
292
+ *
293
+ * @param stepId - The step ID to jump to
294
+ *
295
+ * @example
296
+ * ```typescript
297
+ * const workflow = createWorkflow('order', {
298
+ * steps: {
299
+ * validate: async (ctx) => {
300
+ * if (!ctx.context.paymentValid) {
301
+ * return ctx.goto('handleFailure'); // Skip to failure handler
302
+ * }
303
+ * return { valid: true };
304
+ * },
305
+ * process: async (ctx) => { ... },
306
+ * handleFailure: async (ctx) => { ... },
307
+ * },
308
+ * });
309
+ * ```
310
+ */
311
+ goto: (stepId: string) => Promise<never>;
312
+ /**
313
+ * Durable scatter/gather — execute tasks in parallel with crash recovery.
314
+ *
315
+ * Unlike `executeParallel()` (in-memory only), `scatter()` persists each
316
+ * task's completion to MongoDB via checkpoints. If the process crashes
317
+ * mid-scatter, only incomplete tasks re-execute on recovery.
318
+ *
319
+ * @param tasks - Named tasks to execute. Keys become result keys.
320
+ * @param options - Concurrency limit (default: Infinity)
321
+ * @returns Record of results keyed by task name
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * const results = await ctx.scatter({
326
+ * user: () => fetchUser(ctx.context.userId),
327
+ * orders: () => fetchOrders(ctx.context.userId),
328
+ * recommendations: () => getRecommendations(ctx.context.userId),
329
+ * });
330
+ *
331
+ * // results.user, results.orders, results.recommendations
332
+ * // If crash after 'user' completes, only 'orders' and 'recommendations' re-run.
333
+ * ```
334
+ */
335
+ scatter: <T extends Record<string, () => Promise<unknown>>>(tasks: T, options?: {
336
+ concurrency?: number;
337
+ }) => Promise<{ [K in keyof T]: Awaited<ReturnType<T[K]>> }>;
228
338
  }
229
339
  type StepHandler<TOutput = unknown, TContext = Record<string, unknown>> = (ctx: StepContext<TContext>) => Promise<TOutput>;
230
340
  type WorkflowHandlers<TContext = Record<string, unknown>> = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/streamline",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "MongoDB-native durable workflow orchestration engine. Like Temporal but simpler - supports sleep, wait, retry, parallel execution, human-in-the-loop, and crash recovery. Perfect for payment gateways, approval flows, and scheduled tasks.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -19,7 +19,8 @@
19
19
  "./telemetry": {
20
20
  "types": "./dist/telemetry/index.d.mts",
21
21
  "default": "./dist/telemetry/index.mjs"
22
- }
22
+ },
23
+ "./package.json": "./package.json"
23
24
  },
24
25
  "files": [
25
26
  "dist",
@@ -73,9 +74,9 @@
73
74
  "url": "https://github.com/classytic/streamline/issues"
74
75
  },
75
76
  "peerDependencies": {
76
- "@classytic/mongokit": "^3.2.3",
77
- "mongoose": "^9.0.0",
78
- "@opentelemetry/api": ">=1.0.0"
77
+ "@classytic/mongokit": ">=3.3.2",
78
+ "@opentelemetry/api": ">=1.0.0",
79
+ "mongoose": "^9.0.0"
79
80
  },
80
81
  "peerDependenciesMeta": {
81
82
  "@opentelemetry/api": {
@@ -83,17 +84,17 @@
83
84
  }
84
85
  },
85
86
  "devDependencies": {
86
- "@classytic/mongokit": "^3.2.3",
87
+ "@classytic/mongokit": ">=3.3.2",
87
88
  "@opentelemetry/api": "^1.9.0",
88
89
  "@types/luxon": "^3.7.1",
89
- "luxon": "^3.7.2",
90
- "semver": "^7.7.3",
91
90
  "@types/node": "^22.0.0",
92
91
  "@types/semver": "^7.7.1",
93
92
  "@vitest/coverage-v8": "^3.0.0",
93
+ "luxon": "^3.7.2",
94
94
  "mongodb-memory-server": "^11.0.1",
95
- "mongoose": "^9.0.0",
96
- "tsdown": "^0.20.3",
95
+ "mongoose": "^9.3.3",
96
+ "semver": "^7.7.3",
97
+ "tsdown": "^0.21.7",
97
98
  "typescript": "^5.0.0",
98
99
  "vitest": "^3.0.0"
99
100
  },