@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/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)