@kylebegeman/pulse 0.3.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.
Files changed (62) hide show
  1. package/README.md +1078 -0
  2. package/dist/cron.d.ts +16 -0
  3. package/dist/cron.d.ts.map +1 -0
  4. package/dist/cron.js +32 -0
  5. package/dist/cron.js.map +1 -0
  6. package/dist/executor.d.ts +43 -0
  7. package/dist/executor.d.ts.map +1 -0
  8. package/dist/executor.js +333 -0
  9. package/dist/executor.js.map +1 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +198 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/logs.d.ts +11 -0
  15. package/dist/logs.d.ts.map +1 -0
  16. package/dist/logs.js +25 -0
  17. package/dist/logs.js.map +1 -0
  18. package/dist/matcher.d.ts +23 -0
  19. package/dist/matcher.d.ts.map +1 -0
  20. package/dist/matcher.js +184 -0
  21. package/dist/matcher.js.map +1 -0
  22. package/dist/queue.d.ts +42 -0
  23. package/dist/queue.d.ts.map +1 -0
  24. package/dist/queue.js +85 -0
  25. package/dist/queue.js.map +1 -0
  26. package/dist/registry.d.ts +23 -0
  27. package/dist/registry.d.ts.map +1 -0
  28. package/dist/registry.js +33 -0
  29. package/dist/registry.js.map +1 -0
  30. package/dist/replay.d.ts +10 -0
  31. package/dist/replay.d.ts.map +1 -0
  32. package/dist/replay.js +24 -0
  33. package/dist/replay.js.map +1 -0
  34. package/dist/runs.d.ts +37 -0
  35. package/dist/runs.d.ts.map +1 -0
  36. package/dist/runs.js +296 -0
  37. package/dist/runs.js.map +1 -0
  38. package/dist/scheduler.d.ts +21 -0
  39. package/dist/scheduler.d.ts.map +1 -0
  40. package/dist/scheduler.js +67 -0
  41. package/dist/scheduler.js.map +1 -0
  42. package/dist/schema/migrate.d.ts +3 -0
  43. package/dist/schema/migrate.d.ts.map +1 -0
  44. package/dist/schema/migrate.js +56 -0
  45. package/dist/schema/migrate.js.map +1 -0
  46. package/dist/schema/tables.d.ts +10 -0
  47. package/dist/schema/tables.d.ts.map +1 -0
  48. package/dist/schema/tables.js +123 -0
  49. package/dist/schema/tables.js.map +1 -0
  50. package/dist/types.d.ts +186 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +2 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/utils/errors.d.ts +30 -0
  55. package/dist/utils/errors.d.ts.map +1 -0
  56. package/dist/utils/errors.js +61 -0
  57. package/dist/utils/errors.js.map +1 -0
  58. package/dist/utils/ids.d.ts +2 -0
  59. package/dist/utils/ids.d.ts.map +1 -0
  60. package/dist/utils/ids.js +6 -0
  61. package/dist/utils/ids.js.map +1 -0
  62. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,1078 @@
1
+ <div align="center">
2
+
3
+ # Pulse
4
+
5
+ **Signal-driven workflow engine for multi-tenant applications**
6
+
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org)
8
+ [![Node.js](https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white)](https://nodejs.org)
9
+ [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-12+-4169e1?logo=postgresql&logoColor=white)](https://www.postgresql.org)
10
+ [![Redis](https://img.shields.io/badge/Redis-6+-dc382d?logo=redis&logoColor=white)](https://redis.io)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
+
13
+ [Quick Start](#quick-start) &#8226; [Core Concepts](#core-concepts) &#8226; [API Reference](#api-reference) &#8226; [Examples](#examples) &#8226; [Database Schema](#database-schema)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## Overview
20
+
21
+ Pulse is a TypeScript library for building event-driven workflow automation. Your application emits **signals**, and the engine matches them to registered **workflows** — scheduling and executing **steps** in sequence with full persistence, observability, and replay support.
22
+
23
+ ```
24
+ Signal -> Match -> Schedule Steps -> Evaluate Conditions -> Execute Actions -> Record Results
25
+ ```
26
+
27
+ Built for multi-tenant SaaS. Every signal, workflow, and run is scoped to a tenant. The engine runs in-process alongside your application using your existing PostgreSQL database and Redis instance.
28
+
29
+ ### Highlights
30
+
31
+ - **Signal-driven** — Emit structured events, let the engine match and execute workflows
32
+ - **Multi-tenant** — Every resource is scoped to a tenant out of the box
33
+ - **Parallel execution** — Fan out into concurrent branches with automatic convergence
34
+ - **Cron scheduling** — Trigger workflows on recurring schedules via BullMQ
35
+ - **Replay** — Re-process historical signals with replay-safety controls
36
+ - **Cancellation & retry** — Cancel in-progress runs or retry from the point of failure
37
+ - **Timeline** — Chronological audit trail for every run
38
+ - **Lifecycle hooks** — Observe step and run completion for integrations
39
+ - **Production-hardened** — CAS step claiming, state guards, idempotent migrations
40
+
41
+ ---
42
+
43
+ ## Quick Start
44
+
45
+ ```ts
46
+ import { createEngine } from '@kylebegeman/pulse'
47
+ import { Pool } from 'pg'
48
+ import Redis from 'ioredis'
49
+
50
+ const engine = createEngine({
51
+ db: new Pool({ connectionString: process.env.DATABASE_URL }),
52
+ redis: new Redis(process.env.REDIS_URL),
53
+ })
54
+
55
+ // Register a trigger, action, and condition
56
+ engine.registerTrigger('heartbeat.missed', {
57
+ source: 'heartbeat',
58
+ resourceType: 'service',
59
+ })
60
+
61
+ engine.registerAction('create_incident', async (ctx) => {
62
+ const incident = await createIncident({
63
+ service: ctx.trigger.resourceId,
64
+ tenant: ctx.tenantId,
65
+ })
66
+ ctx.log('Incident created', { incidentId: incident.id })
67
+ return { success: true, data: { incidentId: incident.id } }
68
+ }, { replaySafe: true })
69
+
70
+ engine.registerCondition('is_still_failing', async (ctx) => {
71
+ const status = await checkServiceHealth(ctx.trigger.resourceId)
72
+ return status === 'unhealthy'
73
+ })
74
+
75
+ // Run migrations and create a workflow
76
+ await engine.migrate()
77
+
78
+ await engine.createWorkflow({
79
+ tenantId: 'workspace_1',
80
+ name: 'Incident on missed heartbeat',
81
+ triggerType: 'heartbeat.missed',
82
+ steps: [
83
+ { type: 'delay', name: 'wait_5m', delayMs: 5 * 60 * 1000 },
84
+ { type: 'condition', name: 'is_still_failing' },
85
+ { type: 'action', name: 'create_incident' },
86
+ ],
87
+ config: {},
88
+ isEnabled: true,
89
+ })
90
+
91
+ // Start processing and emit signals
92
+ await engine.start()
93
+
94
+ await engine.emit({
95
+ tenantId: 'workspace_1',
96
+ source: 'heartbeat',
97
+ type: 'heartbeat.missed',
98
+ resourceType: 'service',
99
+ resourceId: 'api-server',
100
+ payload: { lastSeenAt: new Date().toISOString() },
101
+ })
102
+
103
+ // Graceful shutdown
104
+ await engine.stop()
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Installation
110
+
111
+ Pulse is a private package. Install from GitHub:
112
+
113
+ ```bash
114
+ # package.json
115
+ "@kylebegeman/pulse": "github:mrbagels/pulse"
116
+
117
+ # or via SSH
118
+ npm install git+ssh://git@github.com:mrbagels/pulse.git
119
+ ```
120
+
121
+ **Peer requirements:**
122
+
123
+ | Dependency | Purpose |
124
+ |:--|:--|
125
+ | `pg` | PostgreSQL client — engine uses your connection pool |
126
+ | `ioredis` | Redis client — used by BullMQ for job queuing |
127
+
128
+ ---
129
+
130
+ ## Core Concepts
131
+
132
+ ### Signals (Triggers)
133
+
134
+ A **signal** is a structured event emitted by your application. Every signal includes a tenant, a source system, a type, and optional resource and payload data.
135
+
136
+ ```ts
137
+ await engine.emit({
138
+ tenantId: 'workspace_1',
139
+ source: 'heartbeat',
140
+ type: 'heartbeat.missed',
141
+ resourceType: 'service',
142
+ resourceId: 'api-server',
143
+ environment: 'production',
144
+ payload: { lastSeenAt: '...' },
145
+ })
146
+ ```
147
+
148
+ Triggers are **registered** to declare their source, expected resource type, and optional Zod payload schema:
149
+
150
+ ```ts
151
+ import { z } from 'zod'
152
+
153
+ engine.registerTrigger('heartbeat.missed', {
154
+ source: 'heartbeat',
155
+ resourceType: 'service',
156
+ payloadSchema: z.object({
157
+ lastSeenAt: z.string().datetime(),
158
+ }),
159
+ })
160
+ ```
161
+
162
+ All emitted signals are persisted to the database for auditing and [replay](#replay).
163
+
164
+ ### Workflows
165
+
166
+ A **workflow** is a named sequence of steps that runs when a matching signal is emitted. Workflows are stored in the database and scoped to a tenant.
167
+
168
+ ```ts
169
+ await engine.createWorkflow({
170
+ tenantId: 'workspace_1',
171
+ name: 'Incident on missed heartbeat',
172
+ triggerType: 'heartbeat.missed',
173
+ environmentFilter: 'production',
174
+ resourceTypeFilter: 'service',
175
+ steps: [
176
+ { type: 'delay', name: 'wait_5m', delayMs: 300_000 },
177
+ { type: 'condition', name: 'is_still_failing' },
178
+ { type: 'action', name: 'create_incident' },
179
+ ],
180
+ config: { severity: 'high' },
181
+ isEnabled: true,
182
+ })
183
+ ```
184
+
185
+ When a signal is emitted, the engine finds all enabled workflows matching the signal type (and optional filters), creates a **run** for each, and begins scheduling steps. Workflows can be enabled or disabled at runtime:
186
+
187
+ ```ts
188
+ await engine.disableWorkflow('wfd_abc123')
189
+ await engine.enableWorkflow('wfd_abc123')
190
+ ```
191
+
192
+ ### Steps
193
+
194
+ Each workflow contains an ordered list of steps. Steps execute sequentially — each must complete before the next begins.
195
+
196
+ | Type | Purpose |
197
+ |:--|:--|
198
+ | `action` | Execute a registered handler function |
199
+ | `condition` | Evaluate a boolean to branch or complete early |
200
+ | `delay` | Pause execution for a duration (via BullMQ delayed jobs) |
201
+ | `parallel` | Execute multiple branches concurrently |
202
+
203
+ #### Action steps
204
+
205
+ ```ts
206
+ engine.registerAction('send_alert', async (ctx) => {
207
+ await sendSlackMessage(ctx.config.channel, `Alert for ${ctx.trigger.resourceId}`)
208
+ return { success: true, data: { sent: true } }
209
+ }, { replaySafe: false })
210
+ ```
211
+
212
+ Actions support retry policies and timeouts:
213
+
214
+ ```ts
215
+ {
216
+ type: 'action',
217
+ name: 'send_alert',
218
+ retryPolicy: { maxAttempts: 3, backoffMs: 5000 },
219
+ timeoutMs: 30_000,
220
+ }
221
+ ```
222
+
223
+ #### Condition steps
224
+
225
+ ```ts
226
+ engine.registerCondition('monitor_still_failing', async (ctx) => {
227
+ const health = await checkHealth(ctx.trigger.resourceId)
228
+ return health.status === 'down'
229
+ })
230
+ ```
231
+
232
+ Control what happens when a condition returns `false`:
233
+
234
+ ```ts
235
+ { type: 'condition', name: 'check', onFalse: 'complete' } // Default: complete early
236
+ { type: 'condition', name: 'check', onFalse: 'skip' } // Skip next step
237
+ { type: 'condition', name: 'check', onFalse: 3 } // Skip next N steps
238
+ ```
239
+
240
+ #### Delay steps
241
+
242
+ ```ts
243
+ { type: 'delay', name: 'wait_5_minutes', delayMs: 5 * 60 * 1000 }
244
+ ```
245
+
246
+ Maximum delay: 30 days. Uses BullMQ delayed jobs for reliability.
247
+
248
+ #### Parallel steps
249
+
250
+ Run multiple branches concurrently. All branches must complete before the workflow continues.
251
+
252
+ ```ts
253
+ {
254
+ type: 'parallel',
255
+ name: 'notify_all_channels',
256
+ branches: [
257
+ [{ type: 'action', name: 'send_email' }, { type: 'action', name: 'log_email_sent' }],
258
+ [{ type: 'action', name: 'send_slack' }],
259
+ [{ type: 'action', name: 'send_sms' }],
260
+ ],
261
+ }
262
+ ```
263
+
264
+ - Branches execute independently via BullMQ jobs
265
+ - Branches can contain any step types
266
+ - If any branch fails, the parallel step fails and the run fails
267
+ - Minimum 2 branches required
268
+
269
+ ### Cron Scheduling
270
+
271
+ Workflows can run on recurring schedules. When a cron fires, the engine emits a trigger of the workflow's type automatically.
272
+
273
+ ```ts
274
+ await engine.createWorkflow({
275
+ tenantId: 'workspace_1',
276
+ name: 'Daily cleanup',
277
+ triggerType: 'maintenance.cleanup',
278
+ steps: [{ type: 'action', name: 'cleanup_old_records' }],
279
+ config: {},
280
+ isEnabled: true,
281
+ cronExpression: '0 2 * * *', // Daily at 2:00 AM
282
+ })
283
+ ```
284
+
285
+ Cron jobs use BullMQ repeatable jobs. Enabling/disabling a workflow starts/stops its cron. Standard 5-field format: `minute hour dayOfMonth month dayOfWeek`.
286
+
287
+ ### Handler Context
288
+
289
+ Every action and condition handler receives a `WorkflowContext`:
290
+
291
+ ```ts
292
+ engine.registerAction('my_action', async (ctx) => {
293
+ ctx.tenantId // Current tenant
294
+ ctx.trigger // The signal that started this run
295
+ ctx.run // Current run state
296
+ ctx.step // Current step state
297
+ ctx.config // Workflow-level config
298
+ ctx.isReplay // True during replay execution
299
+ ctx.emit(...) // Emit another signal (workflow chaining)
300
+ ctx.log(...) // Write to execution log
301
+
302
+ return { success: true }
303
+ })
304
+ ```
305
+
306
+ ### Lifecycle Hooks
307
+
308
+ ```ts
309
+ const engine = createEngine({
310
+ db: pool,
311
+ redis: redisClient,
312
+ onStepComplete: (event) => {
313
+ console.log(`Step ${event.step.stepName}: ${event.step.status}`)
314
+ },
315
+ onRunComplete: (event) => {
316
+ console.log(`Run ${event.run.id}: ${event.status}`)
317
+ },
318
+ })
319
+ ```
320
+
321
+ Hooks are fire-and-forget — errors in hooks do not affect workflow execution.
322
+
323
+ ### Replay
324
+
325
+ Re-process a historical signal through the matching pipeline:
326
+
327
+ ```ts
328
+ await engine.replay('trg_abc123') // Full replay
329
+ await engine.replay('trg_abc123', { dryRun: true }) // Dry run — skip actions
330
+ ```
331
+
332
+ Actions declare replay safety. Actions with `replaySafe: false` are **skipped** during replay to prevent duplicate side effects.
333
+
334
+ ```ts
335
+ engine.registerAction('create_incident', handler, { replaySafe: true })
336
+ engine.registerAction('send_email', handler, { replaySafe: false })
337
+ ```
338
+
339
+ ### Cancel & Retry
340
+
341
+ **Cancel** an in-progress run:
342
+
343
+ ```ts
344
+ const canceledRun = await engine.cancelRun('run_abc123', 'No longer needed')
345
+ ```
346
+
347
+ Sets status to `canceled`, marks pending steps as `skipped`, removes pending BullMQ jobs.
348
+
349
+ **Retry** a failed run from the point of failure:
350
+
351
+ ```ts
352
+ const retriedRun = await engine.retryRun('run_abc123')
353
+ ```
354
+
355
+ Resets the failed step to `pending`, sets the run back to `running`, and re-enqueues via BullMQ.
356
+
357
+ **List** failed runs:
358
+
359
+ ```ts
360
+ const allFailed = await engine.getFailedRuns()
361
+ const tenantFailed = await engine.getFailedRuns('workspace_1')
362
+ ```
363
+
364
+ ### Run Timeline
365
+
366
+ Chronological audit trail for debugging dashboards:
367
+
368
+ ```ts
369
+ const timeline = await engine.getRunTimeline('run_abc123')
370
+
371
+ for (const entry of timeline) {
372
+ console.log(`[${entry.timestamp}] ${entry.type}`, entry.detail)
373
+ }
374
+ ```
375
+
376
+ Entry types: `run_created`, `step_scheduled`, `step_started`, `step_completed`, `step_failed`, `step_skipped`, `run_completed`, `run_failed`, `run_canceled`, `log`
377
+
378
+ ---
379
+
380
+ ## API Reference
381
+
382
+ ### `createEngine(config)`
383
+
384
+ ```ts
385
+ import { createEngine } from '@kylebegeman/pulse'
386
+
387
+ const engine = createEngine({
388
+ db: pool, // Required — pg Pool instance
389
+ redis: redisClient, // Required — ioredis instance
390
+ tablePrefix: 'pulse_', // Default: 'pulse_'
391
+ queuePrefix: 'pulse', // Default: 'pulse'
392
+ concurrency: 5, // Default: 5
393
+ onStepComplete: (e) => {}, // Optional lifecycle hook
394
+ onRunComplete: (e) => {}, // Optional lifecycle hook
395
+ })
396
+ ```
397
+
398
+ ### Engine Methods
399
+
400
+ #### Registration
401
+
402
+ | Method | Description |
403
+ |:--|:--|
404
+ | `registerTrigger(type, registration)` | Register a signal type with source, optional resource type, and optional Zod payload schema |
405
+ | `registerAction(name, handler, options?)` | Register an action handler. Set `replaySafe` in options |
406
+ | `registerCondition(name, handler)` | Register a condition handler returning a boolean |
407
+
408
+ #### Signals
409
+
410
+ | Method | Returns | Description |
411
+ |:--|:--|:--|
412
+ | `emit(trigger)` | `TriggerEnvelope` | Emit a signal — persists, matches workflows, creates runs |
413
+
414
+ #### Lifecycle
415
+
416
+ | Method | Description |
417
+ |:--|:--|
418
+ | `start()` | Start BullMQ workers and restore cron jobs |
419
+ | `stop()` | Graceful shutdown — waits for active jobs, stops cron |
420
+
421
+ #### Queries
422
+
423
+ | Method | Returns | Description |
424
+ |:--|:--|:--|
425
+ | `getRun(runId)` | `WorkflowRun \| null` | Get a run by ID |
426
+ | `getRunSteps(runId)` | `WorkflowStepRun[]` | Get all step runs for a run |
427
+ | `getRunsByTrigger(triggerId)` | `WorkflowRun[]` | Get runs spawned by a trigger |
428
+ | `getTrigger(triggerId)` | `TriggerEnvelope \| null` | Get a persisted trigger |
429
+
430
+ #### Timeline
431
+
432
+ | Method | Returns | Description |
433
+ |:--|:--|:--|
434
+ | `getRunTimeline(runId)` | `RunTimelineEntry[]` | Chronological lifecycle view |
435
+
436
+ #### Cancel & Retry
437
+
438
+ | Method | Returns | Description |
439
+ |:--|:--|:--|
440
+ | `cancelRun(runId, reason?)` | `WorkflowRun` | Cancel an in-progress run |
441
+ | `retryRun(runId)` | `WorkflowRun` | Retry a failed run from the failed step |
442
+ | `getFailedRuns(tenantId?)` | `WorkflowRun[]` | List failed runs |
443
+
444
+ #### Replay
445
+
446
+ | Method | Returns | Description |
447
+ |:--|:--|:--|
448
+ | `replay(triggerId, options?)` | `WorkflowRun[]` | Re-emit a historical trigger |
449
+
450
+ #### Workflow Management
451
+
452
+ | Method | Returns | Description |
453
+ |:--|:--|:--|
454
+ | `createWorkflow(definition)` | `WorkflowDefinition` | Create a workflow, schedules cron if applicable |
455
+ | `enableWorkflow(definitionId)` | `void` | Enable a workflow |
456
+ | `disableWorkflow(definitionId)` | `void` | Disable a workflow |
457
+
458
+ #### Schema
459
+
460
+ | Method | Description |
461
+ |:--|:--|
462
+ | `migrate()` | Create `pulse_*` tables (idempotent) |
463
+
464
+ ---
465
+
466
+ ## Examples
467
+
468
+ ### Monitoring: Incident on Missed Heartbeat
469
+
470
+ Wait for a missed heartbeat, verify the service is still down, then create an incident and page on-call.
471
+
472
+ ```ts
473
+ engine.registerTrigger('heartbeat.missed', {
474
+ source: 'heartbeat',
475
+ resourceType: 'service',
476
+ })
477
+
478
+ engine.registerCondition('service_still_down', async (ctx) => {
479
+ const status = await healthCheck(ctx.trigger.resourceId)
480
+ return status !== 'healthy'
481
+ })
482
+
483
+ engine.registerAction('create_incident', async (ctx) => {
484
+ const incident = await db.incidents.create({
485
+ tenantId: ctx.tenantId,
486
+ serviceId: ctx.trigger.resourceId,
487
+ severity: ctx.config.severity || 'medium',
488
+ })
489
+ ctx.log('Incident created', { incidentId: incident.id })
490
+ return { success: true, data: { incidentId: incident.id } }
491
+ }, { replaySafe: true })
492
+
493
+ engine.registerAction('notify_oncall', async (ctx) => {
494
+ await pagerduty.trigger({ service: ctx.trigger.resourceId })
495
+ return { success: true }
496
+ }, { replaySafe: false })
497
+
498
+ await engine.createWorkflow({
499
+ tenantId: 'workspace_1',
500
+ name: 'Incident on missed heartbeat',
501
+ triggerType: 'heartbeat.missed',
502
+ environmentFilter: 'production',
503
+ steps: [
504
+ { type: 'delay', name: 'wait_5m', delayMs: 5 * 60 * 1000 },
505
+ { type: 'condition', name: 'service_still_down' },
506
+ { type: 'action', name: 'create_incident' },
507
+ { type: 'action', name: 'notify_oncall' },
508
+ ],
509
+ config: { severity: 'high' },
510
+ isEnabled: true,
511
+ })
512
+ ```
513
+
514
+ ### Billing: Trial Expiration
515
+
516
+ Send a warning email, wait 3 days, then downgrade if the user hasn't upgraded.
517
+
518
+ ```ts
519
+ engine.registerAction('send_trial_warning', async (ctx) => {
520
+ await emails.send({
521
+ to: ctx.trigger.payload.email,
522
+ template: 'trial-expiring',
523
+ data: { daysLeft: ctx.trigger.payload.daysLeft },
524
+ })
525
+ return { success: true }
526
+ }, { replaySafe: false })
527
+
528
+ engine.registerCondition('has_not_upgraded', async (ctx) => {
529
+ const sub = await billing.getSubscription(ctx.tenantId)
530
+ return sub.plan === 'trial'
531
+ })
532
+
533
+ engine.registerAction('downgrade_to_free', async (ctx) => {
534
+ await billing.changePlan(ctx.tenantId, 'free')
535
+ ctx.log('Downgraded to free plan')
536
+ return { success: true }
537
+ }, { replaySafe: true })
538
+
539
+ await engine.createWorkflow({
540
+ tenantId: 'workspace_1',
541
+ name: 'Trial expiration',
542
+ triggerType: 'trial.expiring',
543
+ steps: [
544
+ { type: 'action', name: 'send_trial_warning' },
545
+ { type: 'delay', name: 'wait_3_days', delayMs: 3 * 24 * 60 * 60 * 1000 },
546
+ { type: 'condition', name: 'has_not_upgraded' },
547
+ { type: 'action', name: 'downgrade_to_free' },
548
+ ],
549
+ config: {},
550
+ isEnabled: true,
551
+ })
552
+ ```
553
+
554
+ ### Workflow Chaining
555
+
556
+ Actions can emit signals, allowing workflows to trigger other workflows.
557
+
558
+ ```ts
559
+ engine.registerAction('attempt_auto_fix', async (ctx) => {
560
+ const result = await autoRemediate(ctx.trigger.resourceId)
561
+
562
+ if (result.fixed) {
563
+ await ctx.emit({
564
+ tenantId: ctx.tenantId,
565
+ source: 'auto-remediation',
566
+ type: 'service.recovered',
567
+ resourceType: 'service',
568
+ resourceId: ctx.trigger.resourceId,
569
+ payload: { fix: result.action },
570
+ })
571
+ }
572
+
573
+ return { success: true, data: result }
574
+ }, { replaySafe: true })
575
+
576
+ // A separate workflow reacts to the recovery signal
577
+ await engine.createWorkflow({
578
+ tenantId: 'workspace_1',
579
+ name: 'Auto-close on recovery',
580
+ triggerType: 'service.recovered',
581
+ steps: [{ type: 'action', name: 'close_incident' }],
582
+ config: {},
583
+ isEnabled: true,
584
+ })
585
+ ```
586
+
587
+ ### Parallel: Multi-Channel Notification
588
+
589
+ Send notifications across multiple channels simultaneously.
590
+
591
+ ```ts
592
+ await engine.createWorkflow({
593
+ tenantId: 'workspace_1',
594
+ name: 'Multi-channel alert',
595
+ triggerType: 'alert.triggered',
596
+ steps: [
597
+ {
598
+ type: 'parallel',
599
+ name: 'notify_all',
600
+ branches: [
601
+ [{ type: 'action', name: 'send_email' }],
602
+ [{ type: 'action', name: 'send_slack' }],
603
+ [{ type: 'action', name: 'send_sms' }],
604
+ ],
605
+ },
606
+ { type: 'action', name: 'mark_notified' },
607
+ ],
608
+ config: {
609
+ alertEmail: 'oncall@company.com',
610
+ slackChannel: '#alerts',
611
+ phoneNumber: '+1234567890',
612
+ },
613
+ isEnabled: true,
614
+ })
615
+ ```
616
+
617
+ ### Cron: Daily Cleanup Job
618
+
619
+ ```ts
620
+ await engine.createWorkflow({
621
+ tenantId: 'workspace_1',
622
+ name: 'Daily cleanup',
623
+ triggerType: 'maintenance.cleanup',
624
+ steps: [
625
+ { type: 'action', name: 'cleanup_old_records' },
626
+ { type: 'action', name: 'send_cleanup_report' },
627
+ ],
628
+ config: {},
629
+ isEnabled: true,
630
+ cronExpression: '0 2 * * *',
631
+ })
632
+ ```
633
+
634
+ ### Error Recovery: Retry Failed Runs
635
+
636
+ ```ts
637
+ const failed = await engine.getFailedRuns('workspace_1')
638
+
639
+ for (const run of failed) {
640
+ const retried = await engine.retryRun(run.id)
641
+ console.log(`Retried run ${retried.id}, now ${retried.status}`)
642
+ }
643
+
644
+ await engine.cancelRun('run_xyz789', 'Superseded by newer deployment')
645
+ ```
646
+
647
+ ---
648
+
649
+ ## Runtime Guarantees
650
+
651
+ ### Execution Semantics
652
+
653
+ Pulse provides **at-least-once** execution. If a worker crashes after an action completes but before the engine records success, the step may execute again on retry. Design actions to be **idempotent** where possible:
654
+
655
+ ```ts
656
+ engine.registerAction('send_email', async (ctx) => {
657
+ const idempotencyKey = `${ctx.run.id}:${ctx.step.name}`
658
+ // Use this key with your provider to prevent duplicates
659
+ })
660
+ ```
661
+
662
+ ### Step Claiming
663
+
664
+ Every step is claimed atomically before execution using compare-and-set:
665
+
666
+ ```sql
667
+ UPDATE step_runs SET status = 'running', started_at = NOW()
668
+ WHERE id = $1 AND status IN ('pending', 'scheduled')
669
+ RETURNING *
670
+ ```
671
+
672
+ If two workers pick up the same job, only one successfully claims it. The other silently no-ops.
673
+
674
+ ### State Transitions
675
+
676
+ Run status changes are guarded by the current status. Invalid transitions are rejected:
677
+
678
+ ```
679
+ pending -> running, canceled
680
+ running -> waiting, completed, failed, canceled
681
+ waiting -> running, canceled
682
+ failed -> running (retry)
683
+ ```
684
+
685
+ ### Context Accumulation
686
+
687
+ Each action's return value is stored in the run context keyed by action name:
688
+
689
+ ```ts
690
+ // After "check_status" returns { healthy: true }
691
+ // ctx.run.context.check_status === { healthy: true }
692
+ ```
693
+
694
+ Step names within a workflow must be unique to prevent key collisions.
695
+
696
+ ---
697
+
698
+ ## Database Schema
699
+
700
+ The engine creates tables with the prefix `pulse_` (configurable). All tables use `TEXT` primary keys with auto-generated IDs. Migrations are idempotent — safe to call on every startup.
701
+
702
+ ```ts
703
+ await engine.migrate()
704
+ ```
705
+
706
+ ### `pulse_triggers`
707
+
708
+ | Column | Type | Description |
709
+ |:--|:--|:--|
710
+ | `id` | `TEXT PK` | Trigger ID (`trg_...`) |
711
+ | `tenant_id` | `TEXT` | Tenant scope |
712
+ | `source` | `TEXT` | Originating system |
713
+ | `type` | `TEXT` | Signal type |
714
+ | `resource_type` | `TEXT` | Resource category |
715
+ | `resource_id` | `TEXT` | Specific resource |
716
+ | `environment` | `TEXT` | Environment scope |
717
+ | `payload` | `JSONB` | Arbitrary event data |
718
+ | `created_at` | `TIMESTAMPTZ` | When emitted |
719
+
720
+ ### `pulse_workflow_definitions`
721
+
722
+ | Column | Type | Description |
723
+ |:--|:--|:--|
724
+ | `id` | `TEXT PK` | Definition ID |
725
+ | `tenant_id` | `TEXT` | Tenant scope |
726
+ | `name` | `TEXT` | Human-readable name |
727
+ | `description` | `TEXT` | Optional description |
728
+ | `trigger_type` | `TEXT` | Signal type to match |
729
+ | `environment_filter` | `TEXT` | Optional environment filter |
730
+ | `resource_type_filter` | `TEXT` | Optional resource type filter |
731
+ | `steps` | `JSONB` | Ordered step definitions |
732
+ | `config` | `JSONB` | Config passed to handlers |
733
+ | `is_enabled` | `BOOLEAN` | Whether active |
734
+ | `cron_expression` | `TEXT` | Optional cron schedule |
735
+ | `created_at` | `TIMESTAMPTZ` | Creation time |
736
+ | `updated_at` | `TIMESTAMPTZ` | Last update |
737
+
738
+ ### `pulse_workflow_runs`
739
+
740
+ | Column | Type | Description |
741
+ |:--|:--|:--|
742
+ | `id` | `TEXT PK` | Run ID |
743
+ | `definition_id` | `TEXT FK` | Workflow definition |
744
+ | `tenant_id` | `TEXT` | Tenant scope |
745
+ | `trigger_id` | `TEXT FK` | Signal that started this run |
746
+ | `status` | `TEXT` | `pending` / `running` / `waiting` / `completed` / `failed` / `canceled` |
747
+ | `context` | `JSONB` | Shared run context |
748
+ | `current_step_index` | `INTEGER` | Active step position |
749
+ | `is_replay` | `BOOLEAN` | Whether this is a replay |
750
+ | `definition_snapshot` | `JSONB` | Frozen copy of workflow at run creation |
751
+ | `started_at` | `TIMESTAMPTZ` | When execution began |
752
+ | `completed_at` | `TIMESTAMPTZ` | When completed |
753
+ | `failed_at` | `TIMESTAMPTZ` | When failed |
754
+ | `canceled_at` | `TIMESTAMPTZ` | When canceled |
755
+ | `cancel_reason` | `TEXT` | Cancel reason |
756
+ | `created_at` | `TIMESTAMPTZ` | Creation time |
757
+ | `updated_at` | `TIMESTAMPTZ` | Last update |
758
+
759
+ ### `pulse_workflow_step_runs`
760
+
761
+ | Column | Type | Description |
762
+ |:--|:--|:--|
763
+ | `id` | `TEXT PK` | Step run ID |
764
+ | `run_id` | `TEXT FK` | Parent workflow run |
765
+ | `tenant_id` | `TEXT` | Tenant scope |
766
+ | `step_index` | `INTEGER` | Position in sequence |
767
+ | `step_type` | `TEXT` | `action` / `condition` / `delay` / `parallel` |
768
+ | `step_name` | `TEXT` | Registered handler name |
769
+ | `status` | `TEXT` | `pending` / `scheduled` / `running` / `completed` / `failed` / `skipped` |
770
+ | `scheduled_for` | `TIMESTAMPTZ` | When to execute (delays) |
771
+ | `started_at` | `TIMESTAMPTZ` | When execution began |
772
+ | `completed_at` | `TIMESTAMPTZ` | When completed |
773
+ | `result` | `JSONB` | Action result data |
774
+ | `error_message` | `TEXT` | Error details |
775
+ | `branch_index` | `INTEGER` | Branch index (parallel) |
776
+ | `parent_step_run_id` | `TEXT` | Parent parallel step |
777
+ | `created_at` | `TIMESTAMPTZ` | Creation time |
778
+
779
+ ### `pulse_execution_logs`
780
+
781
+ | Column | Type | Description |
782
+ |:--|:--|:--|
783
+ | `id` | `TEXT PK` | Log entry ID |
784
+ | `run_id` | `TEXT FK` | Parent workflow run |
785
+ | `step_run_id` | `TEXT FK` | Associated step |
786
+ | `tenant_id` | `TEXT` | Tenant scope |
787
+ | `level` | `TEXT` | `info` / `warn` / `error` |
788
+ | `message` | `TEXT` | Log message |
789
+ | `data` | `JSONB` | Structured log data |
790
+ | `created_at` | `TIMESTAMPTZ` | When written |
791
+
792
+ ---
793
+
794
+ ## Type Reference
795
+
796
+ <details>
797
+ <summary><code>TriggerInput</code></summary>
798
+
799
+ ```ts
800
+ interface TriggerInput {
801
+ tenantId: string
802
+ source: string
803
+ type: string
804
+ resourceType?: string
805
+ resourceId?: string
806
+ environment?: string
807
+ payload?: Record<string, unknown>
808
+ }
809
+ ```
810
+ </details>
811
+
812
+ <details>
813
+ <summary><code>TriggerEnvelope</code></summary>
814
+
815
+ ```ts
816
+ interface TriggerEnvelope extends TriggerInput {
817
+ id: string
818
+ createdAt: Date
819
+ }
820
+ ```
821
+ </details>
822
+
823
+ <details>
824
+ <summary><code>TriggerRegistration</code></summary>
825
+
826
+ ```ts
827
+ interface TriggerRegistration {
828
+ source: string
829
+ resourceType?: string
830
+ payloadSchema?: ZodSchema
831
+ }
832
+ ```
833
+ </details>
834
+
835
+ <details>
836
+ <summary><code>WorkflowDefinition</code></summary>
837
+
838
+ ```ts
839
+ interface WorkflowDefinition {
840
+ id: string
841
+ tenantId: string
842
+ name: string
843
+ description?: string
844
+ triggerType: string
845
+ environmentFilter?: string
846
+ resourceTypeFilter?: string
847
+ steps: WorkflowStep[]
848
+ config: Record<string, unknown>
849
+ isEnabled: boolean
850
+ cronExpression?: string
851
+ createdAt: Date
852
+ updatedAt: Date
853
+ }
854
+ ```
855
+ </details>
856
+
857
+ <details>
858
+ <summary><code>WorkflowStep</code></summary>
859
+
860
+ ```ts
861
+ type StepType = 'action' | 'condition' | 'delay' | 'parallel'
862
+
863
+ interface WorkflowStep {
864
+ type: StepType
865
+ name: string
866
+ config?: Record<string, unknown>
867
+ delayMs?: number
868
+ timeoutMs?: number
869
+ retryPolicy?: RetryPolicy
870
+ onFalse?: 'complete' | 'skip' | number
871
+ branches?: WorkflowStep[][]
872
+ }
873
+ ```
874
+ </details>
875
+
876
+ <details>
877
+ <summary><code>WorkflowRun</code></summary>
878
+
879
+ ```ts
880
+ type WorkflowStatus = 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'canceled'
881
+
882
+ interface WorkflowRun {
883
+ id: string
884
+ definitionId: string
885
+ tenantId: string
886
+ triggerId: string
887
+ status: WorkflowStatus
888
+ context: Record<string, unknown>
889
+ currentStepIndex: number
890
+ isReplay: boolean
891
+ definitionSnapshot: DefinitionSnapshot
892
+ startedAt?: Date
893
+ completedAt?: Date
894
+ failedAt?: Date
895
+ canceledAt?: Date
896
+ cancelReason?: string
897
+ createdAt: Date
898
+ updatedAt: Date
899
+ }
900
+ ```
901
+ </details>
902
+
903
+ <details>
904
+ <summary><code>WorkflowStepRun</code></summary>
905
+
906
+ ```ts
907
+ type StepStatus = 'pending' | 'scheduled' | 'running' | 'completed' | 'failed' | 'skipped'
908
+
909
+ interface WorkflowStepRun {
910
+ id: string
911
+ runId: string
912
+ tenantId: string
913
+ stepIndex: number
914
+ stepType: StepType
915
+ stepName: string
916
+ status: StepStatus
917
+ scheduledFor?: Date
918
+ startedAt?: Date
919
+ completedAt?: Date
920
+ result?: Record<string, unknown>
921
+ errorMessage?: string
922
+ branchIndex?: number
923
+ parentStepRunId?: string
924
+ createdAt: Date
925
+ }
926
+ ```
927
+ </details>
928
+
929
+ <details>
930
+ <summary><code>RunTimelineEntry</code></summary>
931
+
932
+ ```ts
933
+ interface RunTimelineEntry {
934
+ timestamp: Date
935
+ type:
936
+ | 'run_created' | 'run_completed' | 'run_failed' | 'run_canceled'
937
+ | 'step_scheduled' | 'step_started' | 'step_completed'
938
+ | 'step_failed' | 'step_skipped' | 'log'
939
+ stepIndex?: number
940
+ stepName?: string
941
+ stepType?: string
942
+ detail?: Record<string, unknown>
943
+ }
944
+ ```
945
+ </details>
946
+
947
+ <details>
948
+ <summary><code>WorkflowContext</code></summary>
949
+
950
+ ```ts
951
+ interface WorkflowContext {
952
+ tenantId: string
953
+ trigger: TriggerEnvelope
954
+ run: WorkflowRun
955
+ step: WorkflowStepRun
956
+ config: Record<string, unknown>
957
+ isReplay: boolean
958
+ emit: (trigger: TriggerInput) => Promise<TriggerEnvelope>
959
+ log: (message: string, data?: Record<string, unknown>) => void
960
+ }
961
+ ```
962
+ </details>
963
+
964
+ <details>
965
+ <summary><code>EngineConfig</code></summary>
966
+
967
+ ```ts
968
+ interface EngineConfig {
969
+ db: Pool
970
+ redis: unknown
971
+ tablePrefix?: string // Default: 'pulse_'
972
+ queuePrefix?: string // Default: 'pulse'
973
+ concurrency?: number // Default: 5
974
+ onStepComplete?: LifecycleHook<StepCompleteEvent>
975
+ onRunComplete?: LifecycleHook<RunCompleteEvent>
976
+ }
977
+ ```
978
+ </details>
979
+
980
+ <details>
981
+ <summary><code>ActionResult</code> / <code>ActionOptions</code> / <code>RetryPolicy</code> / <code>ReplayOptions</code></summary>
982
+
983
+ ```ts
984
+ interface ActionResult {
985
+ success: boolean
986
+ data?: Record<string, unknown>
987
+ error?: string
988
+ }
989
+
990
+ interface ActionOptions {
991
+ replaySafe: boolean
992
+ }
993
+
994
+ interface RetryPolicy {
995
+ maxAttempts: number // 1-10
996
+ backoffMs: number // 100-300000 ms
997
+ }
998
+
999
+ interface ReplayOptions {
1000
+ dryRun?: boolean
1001
+ }
1002
+ ```
1003
+ </details>
1004
+
1005
+ ---
1006
+
1007
+ ## Architecture
1008
+
1009
+ ```
1010
+ ┌──────────────────────────────────────────────────────────────┐
1011
+ │ Your Application │
1012
+ │ │
1013
+ │ engine.emit({ type: 'heartbeat.missed', ... }) │
1014
+ │ │ │
1015
+ │ v │
1016
+ │ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │
1017
+ │ │ Registry │ │ Matcher │--->│ Run Manager │ │
1018
+ │ │ │ │ │ │ (state machine) │ │
1019
+ │ │ triggers │ │ matches │ │ │ │
1020
+ │ │ actions │ │ signal │ │ pending -> running │ │
1021
+ │ │ conds │ │ to wfs │ │ -> waiting -> done │ │
1022
+ │ └──────────┘ └──────────┘ └─────────┬──────────┘ │
1023
+ │ │ │
1024
+ │ v │
1025
+ │ ┌──────────────────────┐ ┌────────────────────────┐ │
1026
+ │ │ Step Scheduler │ │ Step Executor │ │
1027
+ │ │ │--->│ │ │
1028
+ │ │ BullMQ delayed jobs │ │ delay -> cond -> action │ │
1029
+ │ │ + parallel branches │ │ + parallel dispatch │ │
1030
+ │ └──────────────────────┘ └────────────────────────┘ │
1031
+ │ │
1032
+ │ ┌───────────────┐ ┌──────────────┐ ┌────────────────┐ │
1033
+ │ │ Replay Mgr │ │ Cron Mgr │ │ Timeline & │ │
1034
+ │ │ │ │ │ │ Exec Logs │ │
1035
+ │ │ re-emit with │ │ BullMQ │ │ │ │
1036
+ │ │ safety checks │ │ repeatables │ │ ctx.log() │ │
1037
+ │ └───────────────┘ └──────────────┘ └────────────────┘ │
1038
+ │ │
1039
+ │ ┌──────────────┐ ┌────────────────┐ │
1040
+ │ │ PostgreSQL │ │ Redis │ │
1041
+ │ │ (your DB) │ │ (BullMQ jobs) │ │
1042
+ │ └──────────────┘ └────────────────┘ │
1043
+ └──────────────────────────────────────────────────────────────┘
1044
+ ```
1045
+
1046
+ ### Module Breakdown
1047
+
1048
+ | Module | File | Purpose |
1049
+ |:--|:--|:--|
1050
+ | Engine Factory | `src/index.ts` | `createEngine()` — wires everything together |
1051
+ | Types | `src/types.ts` | All TypeScript interfaces and type definitions |
1052
+ | Registry | `src/registry.ts` | Trigger, action, and condition registration |
1053
+ | Matcher | `src/matcher.ts` | Signal-to-workflow matching, run creation, validation |
1054
+ | Run Manager | `src/runs.ts` | Run lifecycle, state machine, timeline, cancel |
1055
+ | Scheduler | `src/scheduler.ts` | Step scheduling with BullMQ delayed jobs + parallel |
1056
+ | Executor | `src/executor.ts` | Step dispatch — delay, condition, action, parallel |
1057
+ | Cron Manager | `src/cron.ts` | Cron scheduling via BullMQ repeatable jobs |
1058
+ | Replay | `src/replay.ts` | Trigger persistence and replay support |
1059
+ | Queue | `src/queue.ts` | BullMQ queue and worker setup |
1060
+ | Logs | `src/logs.ts` | Execution logging and query helpers |
1061
+ | Schema | `src/schema/` | Table definitions and migration runner |
1062
+
1063
+ ---
1064
+
1065
+ ## Requirements
1066
+
1067
+ | Requirement | Version |
1068
+ |:--|:--|
1069
+ | Node.js | 18+ |
1070
+ | PostgreSQL | 12+ |
1071
+ | Redis | 6+ |
1072
+ | TypeScript | 5.0+ |
1073
+
1074
+ ---
1075
+
1076
+ ## License
1077
+
1078
+ MIT