@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/README.md
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
# @classytic/streamline
|
|
2
|
+
|
|
3
|
+
> MongoDB-native workflow orchestration for developers. Like Temporal, but simpler.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Your Application │
|
|
10
|
+
│ createWorkflow() / WorkflowEngine │
|
|
11
|
+
└──────────────────────────┬──────────────────────────────────┘
|
|
12
|
+
│
|
|
13
|
+
┌──────────────────────────▼──────────────────────────────────┐
|
|
14
|
+
│ Execution Layer │
|
|
15
|
+
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
|
|
16
|
+
│ │ StepExecutor │ │ SmartScheduler │ │ EventBus │ │
|
|
17
|
+
│ │ • Retry logic │ │ • Adaptive poll │ │ • Lifecycle │ │
|
|
18
|
+
│ │ • Timeouts │ │ • Circuit break │ │ events │ │
|
|
19
|
+
│ │ • Atomic claim │ │ • Stale recovery│ │ │ │
|
|
20
|
+
│ └─────────────────┘ └─────────────────┘ └─────────────┘ │
|
|
21
|
+
└──────────────────────────┬──────────────────────────────────┘
|
|
22
|
+
│
|
|
23
|
+
┌──────────────────────────▼──────────────────────────────────┐
|
|
24
|
+
│ Storage Layer │
|
|
25
|
+
│ ┌─────────────────────────────┐ ┌───────────────────────┐ │
|
|
26
|
+
│ │ MongoDB (via Mongoose) │ │ LRU Cache (10K max) │ │
|
|
27
|
+
│ │ • WorkflowRun persistence │ │ • Active workflows │ │
|
|
28
|
+
│ │ • Atomic updates │ │ • O(1) operations │ │
|
|
29
|
+
│ │ • Multi-tenant support │ │ • Auto-eviction │ │
|
|
30
|
+
│ └─────────────────────────────┘ └───────────────────────┘ │
|
|
31
|
+
└─────────────────────────────────────────────────────────────┘
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**State Machine:**
|
|
35
|
+
```
|
|
36
|
+
draft → running → waiting ↔ running → done
|
|
37
|
+
↓ ↓
|
|
38
|
+
failed cancelled
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install @classytic/streamline mongoose
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import mongoose from 'mongoose';
|
|
51
|
+
import { createWorkflow } from '@classytic/streamline';
|
|
52
|
+
|
|
53
|
+
// IMPORTANT: Connect to MongoDB first (reuses your existing connection)
|
|
54
|
+
await mongoose.connect('mongodb://localhost/myapp');
|
|
55
|
+
|
|
56
|
+
// Define workflow with inline handlers
|
|
57
|
+
const scraper = createWorkflow('web-scraper', {
|
|
58
|
+
steps: {
|
|
59
|
+
fetch: async (ctx) => {
|
|
60
|
+
const html = await fetch(ctx.context.url).then(r => r.text());
|
|
61
|
+
return { html };
|
|
62
|
+
},
|
|
63
|
+
parse: async (ctx) => {
|
|
64
|
+
const data = parseHTML(ctx.getOutput('fetch').html);
|
|
65
|
+
return { data };
|
|
66
|
+
},
|
|
67
|
+
save: async (ctx) => {
|
|
68
|
+
await db.save(ctx.getOutput('parse').data);
|
|
69
|
+
return { saved: true };
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
context: (input: any) => ({ url: input.url }),
|
|
73
|
+
version: '1.0.0',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Execute
|
|
77
|
+
const run = await scraper.start({ url: 'https://example.com' });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Core Features
|
|
81
|
+
|
|
82
|
+
### 1. Wait for Human Input
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
const approval = createWorkflow('approval-flow', {
|
|
86
|
+
steps: {
|
|
87
|
+
submit: async (ctx) => ({ submitted: true }),
|
|
88
|
+
wait: async (ctx) => {
|
|
89
|
+
await ctx.wait('Please approve', { request: ctx.context.data });
|
|
90
|
+
// Execution pauses here
|
|
91
|
+
},
|
|
92
|
+
execute: async (ctx) => {
|
|
93
|
+
const approval = ctx.getOutput('wait');
|
|
94
|
+
return { done: true, approved: approval };
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
context: (input: any) => ({ data: input })
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Later, resume
|
|
101
|
+
await approval.resume(runId, { approved: true, by: 'admin' });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 2. Sleep/Timers
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
const workflow = createWorkflow('delayed-task', {
|
|
108
|
+
steps: {
|
|
109
|
+
start: async (ctx) => ({ ready: true }),
|
|
110
|
+
wait: async (ctx) => {
|
|
111
|
+
await ctx.sleep(3600000); // Sleep 1 hour
|
|
112
|
+
},
|
|
113
|
+
complete: async (ctx) => ({ done: true })
|
|
114
|
+
},
|
|
115
|
+
context: () => ({})
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. Parallel Execution
|
|
120
|
+
|
|
121
|
+
Use the `parallel` helper from the features module:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { createWorkflow, executeParallel } from '@classytic/streamline';
|
|
125
|
+
|
|
126
|
+
const workflow = createWorkflow('parallel-fetch', {
|
|
127
|
+
steps: {
|
|
128
|
+
fetchAll: async (ctx) => {
|
|
129
|
+
// Execute multiple tasks in parallel
|
|
130
|
+
const results = await executeParallel([
|
|
131
|
+
() => fetch('https://api1.example.com'),
|
|
132
|
+
() => fetch('https://api2.example.com'),
|
|
133
|
+
() => fetch('https://api3.example.com')
|
|
134
|
+
], { mode: 'all' }); // or 'race', 'any'
|
|
135
|
+
|
|
136
|
+
return { results };
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
context: () => ({})
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 4. Conditional Steps
|
|
144
|
+
|
|
145
|
+
Use conditional execution in your handlers:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const workflow = createWorkflow('conditional-flow', {
|
|
149
|
+
steps: {
|
|
150
|
+
check: async (ctx) => {
|
|
151
|
+
return { tier: ctx.context.tier };
|
|
152
|
+
},
|
|
153
|
+
premiumFeature: async (ctx) => {
|
|
154
|
+
// Only runs for premium users
|
|
155
|
+
if (ctx.context.tier !== 'premium') {
|
|
156
|
+
return { skipped: true };
|
|
157
|
+
}
|
|
158
|
+
return { premium: true };
|
|
159
|
+
},
|
|
160
|
+
expressShipping: async (ctx) => {
|
|
161
|
+
// Conditional logic
|
|
162
|
+
if (ctx.context.priority === 'express') {
|
|
163
|
+
return { shipping: 'express' };
|
|
164
|
+
}
|
|
165
|
+
return { shipping: 'standard' };
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
context: (input: any) => ({
|
|
169
|
+
tier: input.tier,
|
|
170
|
+
priority: input.priority
|
|
171
|
+
})
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 5. Wait for Completion
|
|
176
|
+
|
|
177
|
+
Use `waitFor` to synchronously wait for a workflow to finish:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
const workflow = createWorkflow('data-pipeline', {
|
|
181
|
+
steps: {
|
|
182
|
+
fetch: async (ctx) => fetchData(ctx.input.url),
|
|
183
|
+
process: async (ctx) => processData(ctx.getOutput('fetch')),
|
|
184
|
+
save: async (ctx) => saveResults(ctx.getOutput('process')),
|
|
185
|
+
},
|
|
186
|
+
context: () => ({})
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Start and wait for completion
|
|
190
|
+
const run = await workflow.start({ url: 'https://api.example.com/data' });
|
|
191
|
+
const completed = await workflow.waitFor(run._id, {
|
|
192
|
+
timeout: 60000, // Optional: fail after 60s
|
|
193
|
+
pollInterval: 500 // Optional: check every 500ms (default: 1000ms)
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
console.log(completed.status); // 'done' | 'failed' | 'cancelled'
|
|
197
|
+
console.log(completed.output); // Final step output
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 6. Long-Running Steps (Heartbeat)
|
|
201
|
+
|
|
202
|
+
For steps that run longer than 5 minutes, use `ctx.heartbeat()` to prevent stale detection:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
const workflow = createWorkflow('large-dataset', {
|
|
206
|
+
steps: {
|
|
207
|
+
process: async (ctx) => {
|
|
208
|
+
const batches = splitIntoBatches(ctx.input.data, 1000);
|
|
209
|
+
|
|
210
|
+
for (const batch of batches) {
|
|
211
|
+
await processBatch(batch);
|
|
212
|
+
await ctx.heartbeat(); // Signal we're still alive
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { processed: batches.length };
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
context: () => ({})
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> Note: Heartbeats are sent automatically every 30s during step execution. Use `ctx.heartbeat()` for extra control in very long-running loops.
|
|
223
|
+
|
|
224
|
+
## Multi-Tenant & Indexing
|
|
225
|
+
|
|
226
|
+
### Add Custom Indexes
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
import { WorkflowRunModel } from '@classytic/streamline';
|
|
230
|
+
|
|
231
|
+
// On app startup
|
|
232
|
+
await WorkflowRunModel.collection.createIndex({
|
|
233
|
+
'context.tenantId': 1,
|
|
234
|
+
status: 1
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await WorkflowRunModel.collection.createIndex({
|
|
238
|
+
'context.url': 1,
|
|
239
|
+
workflowId: 1
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// TTL index for auto-cleanup (expire after 30 days)
|
|
243
|
+
await WorkflowRunModel.collection.createIndex(
|
|
244
|
+
{ createdAt: 1 },
|
|
245
|
+
{ expireAfterSeconds: 30 * 24 * 60 * 60 }
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Query Workflows
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// Get all scraper runs
|
|
253
|
+
const runs = await WorkflowRunModel.find({
|
|
254
|
+
workflowId: 'web-scraper',
|
|
255
|
+
status: { $in: ['running', 'waiting'] }
|
|
256
|
+
}).sort({ createdAt: -1 }).exec();
|
|
257
|
+
|
|
258
|
+
// Get runs for specific URL
|
|
259
|
+
const urlRuns = await WorkflowRunModel.find({
|
|
260
|
+
workflowId: 'web-scraper',
|
|
261
|
+
'context.url': 'https://example.com'
|
|
262
|
+
}).exec();
|
|
263
|
+
|
|
264
|
+
// Tenant-scoped queries
|
|
265
|
+
const tenantRuns = await WorkflowRunModel.find({
|
|
266
|
+
'context.tenantId': 'tenant-123',
|
|
267
|
+
status: 'running'
|
|
268
|
+
}).exec();
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Tracking Workflow Runs (UI Integration)
|
|
272
|
+
|
|
273
|
+
### Example: Track Multiple Scraper Runs
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
// scraper-service.ts
|
|
277
|
+
export class ScraperService {
|
|
278
|
+
private engine: WorkflowEngine;
|
|
279
|
+
|
|
280
|
+
async scrapeWebsite(url: string, userId: string) {
|
|
281
|
+
// Start workflow with metadata
|
|
282
|
+
const run = await this.engine.start(
|
|
283
|
+
{ url },
|
|
284
|
+
{ userId, startedBy: 'user' } // meta for tracking
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
runId: run._id,
|
|
289
|
+
status: run.status,
|
|
290
|
+
url
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Get all scraper runs for UI
|
|
295
|
+
async getAllScraperRuns(filters?: {
|
|
296
|
+
status?: string;
|
|
297
|
+
userId?: string;
|
|
298
|
+
limit?: number;
|
|
299
|
+
}) {
|
|
300
|
+
const query: any = { workflowId: 'web-scraper' };
|
|
301
|
+
|
|
302
|
+
if (filters?.status) query.status = filters.status;
|
|
303
|
+
if (filters?.userId) query['meta.userId'] = filters.userId;
|
|
304
|
+
|
|
305
|
+
return await WorkflowRunModel.find(query)
|
|
306
|
+
.sort({ createdAt: -1 })
|
|
307
|
+
.limit(filters?.limit || 50)
|
|
308
|
+
.select('_id status context.url currentStepId createdAt updatedAt steps')
|
|
309
|
+
.lean()
|
|
310
|
+
.exec();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Get single run with full details
|
|
314
|
+
async getRunDetails(runId: string) {
|
|
315
|
+
const run = await WorkflowRunModel.findById(runId).lean().exec();
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
id: run._id,
|
|
319
|
+
status: run.status,
|
|
320
|
+
url: run.context.url,
|
|
321
|
+
currentStep: run.currentStepId,
|
|
322
|
+
steps: run.steps.map(s => ({
|
|
323
|
+
id: s.stepId,
|
|
324
|
+
status: s.status,
|
|
325
|
+
startedAt: s.startedAt,
|
|
326
|
+
endedAt: s.endedAt,
|
|
327
|
+
error: s.error
|
|
328
|
+
})),
|
|
329
|
+
createdAt: run.createdAt,
|
|
330
|
+
duration: run.endedAt ? run.endedAt - run.createdAt : Date.now() - run.createdAt
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### UI Example (React)
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// ScraperDashboard.tsx
|
|
340
|
+
function ScraperDashboard() {
|
|
341
|
+
const [runs, setRuns] = useState([]);
|
|
342
|
+
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
const loadRuns = async () => {
|
|
345
|
+
const response = await fetch('/api/scraper/runs');
|
|
346
|
+
setRuns(await response.json());
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
loadRuns();
|
|
350
|
+
const interval = setInterval(loadRuns, 5000); // Poll every 5s
|
|
351
|
+
return () => clearInterval(interval);
|
|
352
|
+
}, []);
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div>
|
|
356
|
+
<h1>Scraper Runs</h1>
|
|
357
|
+
{runs.map(run => (
|
|
358
|
+
<div key={run.id} className={`run-${run.status}`}>
|
|
359
|
+
<span>{run.url}</span>
|
|
360
|
+
<span>{run.status}</span>
|
|
361
|
+
<span>Step: {run.currentStep}</span>
|
|
362
|
+
<ProgressBar steps={run.steps} />
|
|
363
|
+
</div>
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Cleanup Strategies
|
|
371
|
+
|
|
372
|
+
### Option 1: TTL Index (Auto-Cleanup)
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
// app.ts - On startup
|
|
376
|
+
import { WorkflowRunModel } from '@classytic/streamline';
|
|
377
|
+
|
|
378
|
+
export async function setupAutoCleanup(days = 30) {
|
|
379
|
+
// Auto-delete workflows older than X days
|
|
380
|
+
await WorkflowRunModel.collection.createIndex(
|
|
381
|
+
{ createdAt: 1 },
|
|
382
|
+
{ expireAfterSeconds: days * 24 * 60 * 60 }
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Only auto-delete completed/failed workflows
|
|
387
|
+
await WorkflowRunModel.collection.createIndex(
|
|
388
|
+
{ updatedAt: 1 },
|
|
389
|
+
{
|
|
390
|
+
expireAfterSeconds: 7 * 24 * 60 * 60, // 7 days
|
|
391
|
+
partialFilterExpression: {
|
|
392
|
+
status: { $in: ['done', 'failed', 'cancelled'] }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Option 2: Manual Cleanup
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
// cleanup-service.ts
|
|
402
|
+
export class CleanupService {
|
|
403
|
+
// Delete old completed workflows
|
|
404
|
+
async cleanupOldWorkflows(days = 30) {
|
|
405
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
406
|
+
|
|
407
|
+
const result = await WorkflowRunModel.deleteMany({
|
|
408
|
+
status: { $in: ['done', 'failed', 'cancelled'] },
|
|
409
|
+
updatedAt: { $lt: cutoff }
|
|
410
|
+
}).exec();
|
|
411
|
+
|
|
412
|
+
console.log(`Cleaned up ${result.deletedCount} workflows`);
|
|
413
|
+
return result.deletedCount;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Archive old workflows (move to archive collection)
|
|
417
|
+
async archiveOldWorkflows(days = 90) {
|
|
418
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
419
|
+
|
|
420
|
+
const oldRuns = await WorkflowRunModel.find({
|
|
421
|
+
status: { $in: ['done', 'failed'] },
|
|
422
|
+
updatedAt: { $lt: cutoff }
|
|
423
|
+
}).lean().exec();
|
|
424
|
+
|
|
425
|
+
// Move to archive
|
|
426
|
+
if (oldRuns.length > 0) {
|
|
427
|
+
await ArchiveModel.insertMany(oldRuns);
|
|
428
|
+
await WorkflowRunModel.deleteMany({
|
|
429
|
+
_id: { $in: oldRuns.map(r => r._id) }
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return oldRuns.length;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Cleanup by tenant
|
|
437
|
+
async cleanupTenantWorkflows(tenantId: string) {
|
|
438
|
+
const result = await WorkflowRunModel.deleteMany({
|
|
439
|
+
'context.tenantId': tenantId,
|
|
440
|
+
status: { $in: ['done', 'failed', 'cancelled'] }
|
|
441
|
+
}).exec();
|
|
442
|
+
|
|
443
|
+
return result.deletedCount;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Schedule cleanup (cron job)
|
|
448
|
+
import cron from 'node-cron';
|
|
449
|
+
|
|
450
|
+
cron.schedule('0 2 * * *', async () => { // 2 AM daily
|
|
451
|
+
await cleanupService.cleanupOldWorkflows(30);
|
|
452
|
+
});
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Option 3: Workflow-Specific Expiry
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
// Store expiry in context
|
|
459
|
+
const run = await engine.start({
|
|
460
|
+
url: 'https://example.com',
|
|
461
|
+
expireAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Index on expireAt
|
|
465
|
+
await WorkflowRunModel.collection.createIndex({ 'context.expireAt': 1 });
|
|
466
|
+
|
|
467
|
+
// Cleanup expired
|
|
468
|
+
async function cleanupExpired() {
|
|
469
|
+
const result = await WorkflowRunModel.deleteMany({
|
|
470
|
+
'context.expireAt': { $lt: new Date() },
|
|
471
|
+
status: { $in: ['done', 'failed', 'cancelled'] }
|
|
472
|
+
}).exec();
|
|
473
|
+
|
|
474
|
+
return result.deletedCount;
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
## Helper: Index Setup Function
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
// db-setup.ts
|
|
482
|
+
import { WorkflowRunModel } from '@classytic/streamline';
|
|
483
|
+
|
|
484
|
+
export async function setupWorkflowIndexes(config: {
|
|
485
|
+
tenantField?: string;
|
|
486
|
+
userField?: string;
|
|
487
|
+
autoCleanupDays?: number;
|
|
488
|
+
contextFields?: string[];
|
|
489
|
+
}) {
|
|
490
|
+
const indexes = [];
|
|
491
|
+
|
|
492
|
+
// Basic indexes
|
|
493
|
+
indexes.push(
|
|
494
|
+
{ workflowId: 1, status: 1 },
|
|
495
|
+
{ status: 1, updatedAt: -1 },
|
|
496
|
+
{ currentStepId: 1 }
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
// Tenant index
|
|
500
|
+
if (config.tenantField) {
|
|
501
|
+
indexes.push({ [`context.${config.tenantField}`]: 1, status: 1 });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// User index
|
|
505
|
+
if (config.userField) {
|
|
506
|
+
indexes.push({ [`context.${config.userField}`]: 1, createdAt: -1 });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Custom context fields
|
|
510
|
+
config.contextFields?.forEach(field => {
|
|
511
|
+
indexes.push({ [`context.${field}`]: 1 });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Create indexes
|
|
515
|
+
for (const index of indexes) {
|
|
516
|
+
await WorkflowRunModel.collection.createIndex(index);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// TTL index for auto-cleanup
|
|
520
|
+
if (config.autoCleanupDays) {
|
|
521
|
+
await WorkflowRunModel.collection.createIndex(
|
|
522
|
+
{ updatedAt: 1 },
|
|
523
|
+
{
|
|
524
|
+
expireAfterSeconds: config.autoCleanupDays * 24 * 60 * 60,
|
|
525
|
+
partialFilterExpression: {
|
|
526
|
+
status: { $in: ['done', 'failed', 'cancelled'] }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
console.log('Workflow indexes created');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Usage
|
|
536
|
+
await setupWorkflowIndexes({
|
|
537
|
+
tenantField: 'tenantId',
|
|
538
|
+
userField: 'userId',
|
|
539
|
+
autoCleanupDays: 30,
|
|
540
|
+
contextFields: ['url', 'orderId', 'email']
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
## Webhooks & External Resume
|
|
545
|
+
|
|
546
|
+
Use `resumeHook` to resume workflows from API endpoints:
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
import { createHook, resumeHook } from '@classytic/streamline';
|
|
550
|
+
|
|
551
|
+
// In workflow step - create a hook and wait
|
|
552
|
+
const approval = createWorkflow('approval', {
|
|
553
|
+
steps: {
|
|
554
|
+
request: async (ctx) => {
|
|
555
|
+
const hook = createHook(ctx, 'awaiting-approval');
|
|
556
|
+
console.log('Resume URL:', hook.path); // /hooks/runId:stepId:timestamp
|
|
557
|
+
return ctx.wait('Waiting for approval');
|
|
558
|
+
},
|
|
559
|
+
process: async (ctx) => {
|
|
560
|
+
const { approved } = ctx.getOutput('request');
|
|
561
|
+
return { approved };
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
context: () => ({})
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// In API route - resume the workflow
|
|
568
|
+
app.post('/hooks/:token', async (req, res) => {
|
|
569
|
+
const { runId, run } = await resumeHook(req.params.token, req.body);
|
|
570
|
+
res.json({ success: true, runId, status: run.status });
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## Monitoring & Observability
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
import { globalEventBus } from '@classytic/streamline';
|
|
578
|
+
|
|
579
|
+
// Hook into events
|
|
580
|
+
globalEventBus.on('workflow:started', ({ runId }) => {
|
|
581
|
+
metrics.increment('workflow.started');
|
|
582
|
+
logger.info('Workflow started', { runId });
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
globalEventBus.on('workflow:completed', ({ runId }) => {
|
|
586
|
+
metrics.increment('workflow.completed');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
globalEventBus.on('workflow:failed', ({ runId, data }) => {
|
|
590
|
+
metrics.increment('workflow.failed');
|
|
591
|
+
alerting.notify('Workflow failed', { runId, error: data.error });
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Engine errors (execution failures, scheduler issues)
|
|
595
|
+
globalEventBus.on('engine:error', ({ runId, error, context }) => {
|
|
596
|
+
logger.error('Engine error', { runId, error, context });
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
globalEventBus.on('scheduler:error', ({ error, context }) => {
|
|
600
|
+
logger.error('Scheduler error', { error, context });
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
globalEventBus.on('step:started', ({ runId, stepId }) => {
|
|
604
|
+
metrics.timing('step.duration.start', { runId, stepId });
|
|
605
|
+
});
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
## API Reference
|
|
609
|
+
|
|
610
|
+
### Workflow (from createWorkflow)
|
|
611
|
+
|
|
612
|
+
- `start(input, meta?)` - Start new workflow
|
|
613
|
+
- `execute(runId)` - Execute steps
|
|
614
|
+
- `resume(runId, payload?)` - Resume from wait
|
|
615
|
+
- `get(runId)` - Get workflow state
|
|
616
|
+
- `cancel(runId)` - Cancel workflow
|
|
617
|
+
- `pause(runId)` - Pause workflow (scheduler skips)
|
|
618
|
+
- `rewindTo(runId, stepId)` - Rewind to step
|
|
619
|
+
- `waitFor(runId, options?)` - Wait for completion
|
|
620
|
+
- `shutdown()` - Graceful shutdown
|
|
621
|
+
|
|
622
|
+
### StepContext (in handlers)
|
|
623
|
+
|
|
624
|
+
- `ctx.set(key, value)` - Update context
|
|
625
|
+
- `ctx.getOutput(stepId)` - Get previous step output
|
|
626
|
+
- `ctx.wait(reason, data?)` - Wait for human input
|
|
627
|
+
- `ctx.waitFor(eventName)` - Wait for event
|
|
628
|
+
- `ctx.sleep(ms)` - Sleep for duration
|
|
629
|
+
- `ctx.heartbeat()` - Send heartbeat (long-running steps)
|
|
630
|
+
- `ctx.emit(event, data)` - Emit custom event
|
|
631
|
+
- `ctx.log(message, data?)` - Log message
|
|
632
|
+
- `ctx.signal` - AbortSignal for cancellation
|
|
633
|
+
|
|
634
|
+
### Error Handling
|
|
635
|
+
|
|
636
|
+
All errors include standardized codes for programmatic handling:
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
import { ErrorCode, WorkflowNotFoundError } from '@classytic/streamline';
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
await workflow.resume(runId, payload);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
switch (err.code) {
|
|
645
|
+
case ErrorCode.WORKFLOW_NOT_FOUND:
|
|
646
|
+
return res.status(404).json({ error: 'Workflow not found' });
|
|
647
|
+
case ErrorCode.INVALID_STATE:
|
|
648
|
+
return res.status(400).json({ error: 'Cannot resume - workflow not waiting' });
|
|
649
|
+
case ErrorCode.STEP_TIMEOUT:
|
|
650
|
+
return res.status(408).json({ error: 'Step timed out' });
|
|
651
|
+
default:
|
|
652
|
+
throw err;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
**Available error codes:**
|
|
658
|
+
|
|
659
|
+
| Code | Description |
|
|
660
|
+
|------|-------------|
|
|
661
|
+
| `WORKFLOW_NOT_FOUND` | Workflow run doesn't exist |
|
|
662
|
+
| `WORKFLOW_CANCELLED` | Workflow was cancelled |
|
|
663
|
+
| `STEP_NOT_FOUND` | Step ID not in workflow definition |
|
|
664
|
+
| `STEP_TIMEOUT` | Step exceeded timeout |
|
|
665
|
+
| `INVALID_STATE` | Invalid state transition |
|
|
666
|
+
| `DATA_CORRUPTION` | Internal data inconsistency |
|
|
667
|
+
| `MAX_RETRIES_EXCEEDED` | Step failed after all retries |
|
|
668
|
+
|
|
669
|
+
### WorkflowRunModel (Mongoose)
|
|
670
|
+
|
|
671
|
+
Direct Mongoose model for queries:
|
|
672
|
+
|
|
673
|
+
```typescript
|
|
674
|
+
import { WorkflowRunModel } from '@classytic/streamline';
|
|
675
|
+
|
|
676
|
+
await WorkflowRunModel.find({ status: 'running' }).exec();
|
|
677
|
+
await WorkflowRunModel.updateOne({ _id: runId }, { status: 'cancelled' });
|
|
678
|
+
await WorkflowRunModel.deleteMany({ status: 'done' });
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
## Examples
|
|
682
|
+
|
|
683
|
+
See [docs/examples/](./docs/examples) for complete examples:
|
|
684
|
+
|
|
685
|
+
- [Hello World](./docs/examples/hello-world.ts)
|
|
686
|
+
- [Wait & Resume](./docs/examples/wait-workflow.ts)
|
|
687
|
+
- [Sleep Timer](./docs/examples/sleep-workflow.ts)
|
|
688
|
+
- [Parallel Execution](./docs/examples/parallel-workflow.ts)
|
|
689
|
+
- [Conditional Steps](./docs/examples/conditional-workflow.ts)
|
|
690
|
+
- [Newsletter Automation](./docs/examples/newsletter-automation.ts)
|
|
691
|
+
- [AI Pipeline](./docs/examples/ai-pipeline.ts)
|
|
692
|
+
|
|
693
|
+
## Testing
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
npm test # Run all tests
|
|
697
|
+
npm test -- --coverage # With coverage
|
|
698
|
+
npm run test:watch # Watch mode
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
See [TESTING.md](./TESTING.md) for testing guide.
|
|
702
|
+
|
|
703
|
+
## Architecture Details
|
|
704
|
+
|
|
705
|
+
- **Core**: ~7,000 lines of TypeScript (34 modules)
|
|
706
|
+
- **Storage**: MongoDB via MongoKit Repository
|
|
707
|
+
- **Cache**: LRU cache for active workflows (10K max, O(1) operations)
|
|
708
|
+
- **Events**: Typed EventEmitter-based pub/sub
|
|
709
|
+
- **Scheduler**: Adaptive polling (10s-5min based on load)
|
|
710
|
+
- **Concurrency**: Atomic claiming prevents duplicate execution
|
|
711
|
+
- **Memory**: Auto garbage collection via WeakRef
|
|
712
|
+
|
|
713
|
+
## Advanced: Dependency Injection
|
|
714
|
+
|
|
715
|
+
For testing or running multiple isolated engines, use the container directly:
|
|
716
|
+
|
|
717
|
+
```typescript
|
|
718
|
+
import { WorkflowEngine, createContainer } from '@classytic/streamline';
|
|
719
|
+
|
|
720
|
+
// Each container has isolated eventBus and cache
|
|
721
|
+
const container1 = createContainer();
|
|
722
|
+
const container2 = createContainer();
|
|
723
|
+
|
|
724
|
+
// Create isolated engines
|
|
725
|
+
const engine1 = new WorkflowEngine(definition, handlers, container1);
|
|
726
|
+
const engine2 = new WorkflowEngine(definition, handlers, container2);
|
|
727
|
+
|
|
728
|
+
// Events on engine1 don't affect engine2
|
|
729
|
+
container1.eventBus.on('workflow:completed', () => { /* only engine1 */ });
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
> Note: `createWorkflow()` automatically creates a container, so you don't need this for normal use.
|
|
733
|
+
|
|
734
|
+
## License
|
|
735
|
+
|
|
736
|
+
MIT
|
|
737
|
+
|
|
738
|
+
## Contributing
|
|
739
|
+
|
|
740
|
+
Issues and PRs welcome at [github.com/classytic/streamline](https://github.com/classytic/streamline)
|