pg-workflows 0.7.0 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pg-workflows
2
2
 
3
- **The simplest Postgres workflow engine for TypeScript.** Durable execution, event-driven orchestration, and automatic retries - powered entirely by PostgreSQL. No extra infrastructure. No vendor lock-in.
3
+ **The simplest Postgres workflow engine for TypeScript.** Durable execution, event-driven orchestration, and automatic retries - powered entirely by PostgreSQL. No Redis, no message broker, no new infrastructure.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/pg-workflows.svg)](https://www.npmjs.com/package/pg-workflows)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
@@ -13,836 +13,154 @@ npm install pg-workflows pg
13
13
 
14
14
  ---
15
15
 
16
- ## Why pg-workflows?
16
+ ## A complete workflow
17
17
 
18
- Most workflow engines ask you to adopt an entirely new platform - a new runtime, a new deployment target, a new bill. **pg-workflows takes a different approach**: if you already have PostgreSQL, you already have everything you need.
19
-
20
- | | pg-workflows | Temporal | Inngest | DBOS | pgflow |
21
- |---|:---:|:---:|:---:|:---:|:---:|
22
- | **Runs on your existing Postgres** | Yes | No | No | Partial | Supabase only |
23
- | **Zero extra infrastructure** | Yes | No | No | No | No |
24
- | **Framework-agnostic** | Yes | Yes | No | Yes | No |
25
- | **Event-driven pause/resume** | Yes | Yes | Yes | No | No |
26
- | **Open source** | MIT | MIT | ELv2 | MIT | Apache-2.0 |
27
- | **TypeScript-first** | Yes | Via SDK | Yes | Via SDK | Yes |
28
-
29
- ### When to use pg-workflows
30
-
31
- - You already run **PostgreSQL** and want to add durable workflows without new services
32
- - You need a **lightweight, self-hosted** workflow engine with zero operational overhead
33
- - You want **event-driven orchestration** (pause, resume, wait for external signals)
34
- - You're building **AI agents or LLM pipelines** that need durable execution, retries, and human-in-the-loop
35
- - You're building with **TypeScript/Node.js** and want a native developer experience
18
+ ```typescript
19
+ import { WorkflowEngine, workflow } from 'pg-workflows'
20
+ import { z } from 'zod'
36
21
 
37
- ### When to consider alternatives
22
+ const onboardUser = workflow(
23
+ 'onboard-user',
24
+ async ({ step, input }) => {
25
+ const user = await step.run('create-account', () => db.users.create(input))
26
+ await step.run('send-welcome', () => sendEmail(user.email, 'Welcome!'))
27
+ return { userId: user.id }
28
+ },
29
+ { inputSchema: z.object({ email: z.string().email() }) },
30
+ )
38
31
 
39
- If you need enterprise-grade features like distributed tracing, complex DAG scheduling, or plan to scale to millions of concurrent workflows, consider [Temporal](https://temporal.io/), [Inngest](https://www.inngest.com/), [Trigger.dev](https://trigger.dev/), or [DBOS](https://www.dbos.dev/).
32
+ const engine = new WorkflowEngine({
33
+ connectionString: process.env.DATABASE_URL,
34
+ workflows: [onboardUser],
35
+ })
36
+ await engine.start()
40
37
 
41
- ---
38
+ await engine.startWorkflow({
39
+ workflowId: 'onboard-user',
40
+ input: { email: 'alice@example.com' },
41
+ })
42
+ ```
42
43
 
43
- ## Features
44
-
45
- - **Durable Execution on Postgres** - Workflow state is persisted in PostgreSQL. Workflows survive process crashes, restarts, and deployments.
46
- - **Step-by-Step Execution** - Break complex processes into discrete, resumable steps. Each step runs exactly once, even across retries.
47
- - **Event-Driven Orchestration** - Pause workflows and wait for external events with `step.waitFor()`. Resume automatically when signals arrive.
48
- - **Polling Steps** - Repeatedly check a condition with `step.poll()` at a configurable interval (minimum 30s) until it returns a truthy value or a timeout expires.
49
- - **Scheduled & Delay Steps** - Wait until a specific date with `step.waitUntil()`, or use `step.delay()` / `step.sleep()` with human-readable durations (`'3 days'`, `{ hours: 2 }`). Past dates run immediately.
50
- - **Pause and Resume** - Manually pause long-running workflows and resume them later via API.
51
- - **Built-in Retries** - Automatic retries with exponential backoff at the workflow level.
52
- - **Configurable Timeouts** - Set workflow-level and step-level timeouts to prevent runaway executions.
53
- - **Progress Tracking** - Monitor workflow completion percentage, completed steps, and total steps in real-time.
54
- - **Input Validation** - Define schemas with any [Standard Schema](https://github.com/standard-schema/standard-schema)-compliant library (Zod, Valibot, ArkType, etc.) for type-safe, validated workflow inputs.
55
- - **Idempotent starts** - Optional `idempotencyKey` on `startWorkflow()` so duplicate API calls or retries return the same run instead of enqueueing another job.
56
- - **Built on pg-boss** - Leverages the battle-tested [pg-boss](https://github.com/timgit/pg-boss) job queue for reliable task scheduling. pg-boss is bundled as a dependency - no separate install or configuration needed.
44
+ That's it. Each step runs **exactly once**. Crash, redeploy, or retry - the workflow resumes from where it left off. State lives in your existing PostgreSQL database.
57
45
 
58
46
  ---
59
47
 
60
- ## How It Works
61
-
62
- pg-workflows uses PostgreSQL as both the **job queue** and the **state store**. Under the hood:
48
+ ## Why pg-workflows
63
49
 
64
- 1. **Define** workflows as TypeScript functions with discrete steps
65
- 2. **Start** a workflow run - the engine creates a database record and enqueues the first execution
66
- 3. **Execute** steps one by one - each step's result is persisted before moving to the next
67
- 4. **Pause** on `waitFor()` or `pause()` - the workflow sleeps with zero resource consumption
68
- 5. **Resume** when an external event arrives or `resumeWorkflow()` is called
69
- 6. **Complete** - the final result is stored and the workflow is marked as done
70
-
71
- All state lives in PostgreSQL. No Redis. No message broker. No external scheduler. Just Postgres.
50
+ - **Zero new infrastructure** - if you have Postgres, you're done. No Redis, no Temporal server, no SaaS bill.
51
+ - **Feels like plain TypeScript** - workflows are async functions. No DSL, no YAML, no DAG builder.
52
+ - **Durable by default** - step results are persisted. Process crashes don't lose work or repeat expensive calls.
53
+ - **Pause, wait, resume** - `step.waitFor('event-name')` pauses the workflow until your API fires an event. Zero resources consumed while waiting.
54
+ - **Schedules & polling built in** - `step.delay('3 days')`, `step.waitUntil('2025-01-01')`, `step.poll(...)` - no cron, no external scheduler.
55
+ - **Built for AI agents** - cache expensive LLM calls, retry on 429s, pause for human review. [See AI patterns →](docs/ai-agents.md)
56
+ - **Client/worker separation** - keep your API service light; run handlers in a worker. [See architecture →](docs/architecture.md)
57
+ - **Idempotent starts** - pass an `idempotencyKey` and duplicate calls safely return the same run.
72
58
 
73
59
  ---
74
60
 
75
- ## Quick Start
61
+ ## Quick start
76
62
 
77
63
  ### 1. Install
78
64
 
79
65
  ```bash
80
66
  npm install pg-workflows pg
81
- # or
82
- yarn add pg-workflows pg
83
- # or
84
- bun add pg-workflows pg
85
67
  ```
86
68
 
87
- > `pg` is a peer dependency - you bring your own PostgreSQL driver. `pg-boss` is bundled automatically.
69
+ > `pg` is a peer dependency. `pg-boss` is bundled - nothing else to configure. The engine runs migrations automatically on start.
88
70
 
89
- ### 2. Define a Workflow
71
+ ### 2. Define a workflow
90
72
 
91
73
  ```typescript
92
- import { WorkflowEngine, workflow } from 'pg-workflows';
93
- import { z } from 'zod';
74
+ import { workflow } from 'pg-workflows'
75
+ import { z } from 'zod'
94
76
 
95
- // Define a durable workflow
96
- const sendWelcomeEmail = workflow(
97
- 'send-welcome-email',
77
+ export const sendWelcome = workflow(
78
+ 'send-welcome',
98
79
  async ({ step, input }) => {
99
- // Step 1: Create user record (runs exactly once)
100
80
  const user = await step.run('create-user', async () => {
101
- return { id: '123', email: input.email };
102
- });
81
+ return { id: '123', email: input.email }
82
+ })
103
83
 
104
- // Step 2: Send email (runs exactly once)
105
84
  await step.run('send-email', async () => {
106
- await sendEmail(user.email, 'Welcome!');
107
- });
85
+ await sendEmail(user.email, 'Welcome!')
86
+ })
108
87
 
109
- // Step 3: Wait for user confirmation (pauses the workflow)
110
- const confirmation = await step.waitFor('wait-confirmation', {
88
+ // Pause until your API confirms the user. Zero cost while waiting.
89
+ const confirmation = await step.waitFor('await-confirmation', {
111
90
  eventName: 'user-confirmed',
112
91
  timeout: 24 * 60 * 60 * 1000, // 24 hours
113
- });
92
+ })
114
93
 
115
- return { success: true, user, confirmation };
94
+ return { success: true, user, confirmation }
116
95
  },
117
96
  {
118
- inputSchema: z.object({
119
- email: z.string().email(),
120
- }),
121
- timeout: 48 * 60 * 60 * 1000, // 48 hours
97
+ inputSchema: z.object({ email: z.string().email() }),
122
98
  retries: 3,
123
- }
124
- );
99
+ },
100
+ )
125
101
  ```
126
102
 
127
- ### 3. Start the Engine
103
+ ### 3. Start the engine and run it
128
104
 
129
105
  ```typescript
130
- // Option A: Connection string (simplest - engine manages everything)
131
- const engine = new WorkflowEngine({
132
- connectionString: process.env.DATABASE_URL,
133
- workflows: [sendWelcomeEmail],
134
- });
106
+ import { WorkflowEngine } from 'pg-workflows'
107
+ import { sendWelcome } from './workflows'
135
108
 
136
- // Option B: Bring your own pg.Pool
137
- import pg from 'pg';
138
- const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
139
109
  const engine = new WorkflowEngine({
140
- pool,
141
- workflows: [sendWelcomeEmail],
142
- });
143
-
144
- await engine.start();
145
- ```
146
-
147
- ### 4. Run Workflows
110
+ connectionString: process.env.DATABASE_URL,
111
+ workflows: [sendWelcome],
112
+ })
113
+ await engine.start()
148
114
 
149
- ```typescript
150
- // Start a workflow run
151
115
  const run = await engine.startWorkflow({
152
- workflowId: 'send-welcome-email',
153
- resourceId: 'user-123',
116
+ workflowId: 'send-welcome',
154
117
  input: { email: 'user@example.com' },
155
- // Optional: same key returns the existing run (deduplicates double-submits / retries)
156
- idempotencyKey: 'welcome:user-123',
157
- });
118
+ })
158
119
 
159
- // Send an event to resume the waiting workflow
120
+ // Later - resume the workflow with an event:
160
121
  await engine.triggerEvent({
161
122
  runId: run.id,
162
- resourceId: 'user-123',
163
123
  eventName: 'user-confirmed',
164
124
  data: { confirmedAt: new Date() },
165
- });
166
-
167
- // Check progress
168
- const progress = await engine.checkProgress({
169
- runId: run.id,
170
- resourceId: 'user-123',
171
- });
125
+ })
172
126
 
173
- console.log(`Progress: ${progress.completionPercentage}%`);
127
+ // Track progress anytime:
128
+ const progress = await engine.checkProgress({ runId: run.id })
129
+ console.log(`${progress.completionPercentage}% complete`)
174
130
  ```
175
131
 
176
- ---
177
-
178
- ## What Can You Build?
179
-
180
- - **AI Agents & LLM Pipelines** - Build durable AI agents that survive crashes, retry on flaky LLM APIs, and pause for human-in-the-loop review. [See examples below.](#ai--agent-workflows)
181
- - **User Onboarding Flows** - Multi-step signup sequences with email verification, waiting for user actions, and conditional paths.
182
- - **Payment & Checkout Pipelines** - Durable payment processing that survives failures, with automatic retries and event-driven confirmations.
183
- - **Background Job Orchestration** - Replace fragile cron jobs with durable, observable workflows that track progress.
184
- - **Approval Workflows** - Pause execution and wait for human approval events before proceeding.
185
- - **Data Processing Pipelines** - ETL workflows with step-by-step execution, error handling, and progress monitoring.
132
+ That's the whole loop. No extra services. Everything durable. Everything queryable with plain SQL.
186
133
 
187
134
  ---
188
135
 
189
- ## AI & Agent Workflows
136
+ ## What can you build?
190
137
 
191
- AI agents and LLM pipelines are one of the best use cases for durable execution. LLM calls are **slow**, **expensive**, and **unreliable** - exactly the kind of work that should never be repeated unnecessarily. pg-workflows gives you:
138
+ - **AI agents & LLM pipelines** - durable multi-step agents, cached expensive calls, human-in-the-loop
139
+ - **User onboarding** - signup flows with email verification and conditional paths
140
+ - **Payment & checkout** - retry-safe payment processing with event-driven confirmations
141
+ - **Background job orchestration** - replace fragile cron jobs with observable workflows
142
+ - **Approval workflows** - pause indefinitely until a human reviews
143
+ - **Data pipelines** - ETL with step-by-step durability and progress tracking
192
144
 
193
- - **Cached step results** - If your process crashes after a $0.50 GPT-4 call, the result is already persisted. On retry, it skips the LLM call and picks up where it left off.
194
- - **Automatic retries** - LLM APIs return 429s and 500s. Built-in exponential backoff handles transient failures without custom retry logic.
195
- - **Human-in-the-loop** - Pause an AI pipeline with `step.waitFor()` to wait for human review, approval, or feedback before continuing.
196
- - **Observable progress** - Track which step your agent is on, how far along it is, and inspect intermediate results with `checkProgress()`.
197
- - **Long-running agents** - Multi-step agents that run for minutes or hours don't need to hold a connection open. They persist state and resume.
198
-
199
- ### Example: Multi-Step AI Agent
200
-
201
- ```typescript
202
- const researchAgent = workflow(
203
- 'research-agent',
204
- async ({ step, input }) => {
205
- // Step 1: Plan the research (persisted - never re-runs on retry)
206
- const plan = await step.run('create-plan', async () => {
207
- return await llm.chat({
208
- model: 'gpt-4o',
209
- messages: [{ role: 'user', content: `Create a research plan for: ${input.topic}` }],
210
- });
211
- });
212
-
213
- // Step 2: Execute each research task durably
214
- const findings = [];
215
- for (const task of plan.tasks) {
216
- const result = await step.run(`research-${task.id}`, async () => {
217
- return await llm.chat({
218
- model: 'gpt-4o',
219
- messages: [{ role: 'user', content: `Research: ${task.description}` }],
220
- });
221
- });
222
- findings.push(result);
223
- }
224
-
225
- // Step 3: Synthesize results
226
- const report = await step.run('synthesize', async () => {
227
- return await llm.chat({
228
- model: 'gpt-4o',
229
- messages: [{ role: 'user', content: `Synthesize these findings: ${JSON.stringify(findings)}` }],
230
- });
231
- });
232
-
233
- return { plan, findings, report };
234
- },
235
- {
236
- retries: 3,
237
- timeout: 30 * 60 * 1000, // 30 minutes
238
- }
239
- );
240
- ```
241
-
242
- If the process crashes after completing 3 of 5 research tasks, the agent **resumes from task 4** - no LLM calls are wasted.
243
-
244
- ### Example: Human-in-the-Loop AI Pipeline
245
-
246
- ```typescript
247
- const contentPipeline = workflow(
248
- 'ai-content-pipeline',
249
- async ({ step, input }) => {
250
- // Step 1: Generate draft with AI
251
- const draft = await step.run('generate-draft', async () => {
252
- return await llm.chat({
253
- model: 'gpt-4o',
254
- messages: [{ role: 'user', content: `Write a blog post about: ${input.topic}` }],
255
- });
256
- });
257
-
258
- // Step 2: Pause for human review - costs nothing while waiting
259
- const review = await step.waitFor('human-review', {
260
- eventName: 'content-reviewed',
261
- timeout: 7 * 24 * 60 * 60 * 1000, // 7 days
262
- });
263
-
264
- // Step 3: Revise based on feedback
265
- if (review.approved) {
266
- return { status: 'published', content: draft };
267
- }
268
-
269
- const revision = await step.run('revise-draft', async () => {
270
- return await llm.chat({
271
- model: 'gpt-4o',
272
- messages: [
273
- { role: 'user', content: `Revise this draft based on feedback:\n\nDraft: ${draft}\n\nFeedback: ${review.feedback}` },
274
- ],
275
- });
276
- });
277
-
278
- return { status: 'revised', content: revision };
279
- },
280
- { retries: 3 }
281
- );
282
-
283
- // A reviewer approves or requests changes via your API
284
- await engine.triggerEvent({
285
- runId: run.id,
286
- eventName: 'content-reviewed',
287
- data: { approved: false, feedback: 'Make the intro more engaging' },
288
- });
289
- ```
290
-
291
- ### Example: RAG Pipeline with Tool Use
292
-
293
- ```typescript
294
- const ragAgent = workflow(
295
- 'rag-agent',
296
- async ({ step, input }) => {
297
- // Step 1: Generate embeddings (cached on retry)
298
- const embedding = await step.run('embed-query', async () => {
299
- return await openai.embeddings.create({
300
- model: 'text-embedding-3-small',
301
- input: input.query,
302
- });
303
- });
304
-
305
- // Step 2: Search vector store
306
- const documents = await step.run('search-docs', async () => {
307
- return await vectorStore.search(embedding, { topK: 10 });
308
- });
309
-
310
- // Step 3: Generate answer with context
311
- const answer = await step.run('generate-answer', async () => {
312
- return await llm.chat({
313
- model: 'gpt-4o',
314
- messages: [
315
- { role: 'system', content: `Answer using these documents:\n${documents.map(d => d.text).join('\n')}` },
316
- { role: 'user', content: input.query },
317
- ],
318
- });
319
- });
320
-
321
- // Step 4: Validate and fact-check
322
- const validation = await step.run('fact-check', async () => {
323
- return await llm.chat({
324
- model: 'gpt-4o',
325
- messages: [
326
- { role: 'user', content: `Fact-check this answer against the source documents. Answer: ${answer}` },
327
- ],
328
- });
329
- });
330
-
331
- return { answer, validation, sources: documents };
332
- },
333
- { retries: 3, timeout: 5 * 60 * 1000 }
334
- );
335
- ```
336
-
337
- ### Why Durable Execution Matters for AI
338
-
339
- | Problem | Without pg-workflows | With pg-workflows |
340
- |---------|---------------------|-------------------|
341
- | Process crashes mid-pipeline | All LLM calls re-run from scratch | Resumes from the last completed step |
342
- | LLM API returns 429/500 | Manual retry logic everywhere | Automatic retries with exponential backoff |
343
- | Human review needed | Custom polling/webhook infrastructure | `step.waitFor()` - zero resource consumption while waiting |
344
- | Debugging failed agents | Lost intermediate state | Full timeline of every step's input/output in PostgreSQL |
345
- | Cost control | Repeated expensive LLM calls on failure | Each LLM call runs exactly once, result cached |
346
- | Long-running pipelines | Timeout or lost connections | Runs for hours/days, state persisted in Postgres |
145
+ See [runnable examples](https://github.com/SokratisVidros/pg-workflows/tree/main/examples) and [common patterns →](docs/examples.md)
347
146
 
348
147
  ---
349
148
 
350
- ## Core Concepts
351
-
352
- ### Workflows
353
-
354
- A workflow is a durable function that breaks complex operations into discrete, resumable steps. Define workflows using the `workflow()` function:
355
-
356
- ```typescript
357
- const myWorkflow = workflow(
358
- 'workflow-id',
359
- async ({ step, input }) => {
360
- // Your workflow logic here
361
- },
362
- {
363
- inputSchema: mySchema, // any Standard Schema-compliant schema
364
- timeout: 60000, // milliseconds
365
- retries: 3,
366
- }
367
- );
368
- ```
369
-
370
- ### Steps
371
-
372
- Steps are the building blocks of durable workflows. Each step is executed **exactly once**, even if the workflow is retried:
373
-
374
- ```typescript
375
- await step.run('step-id', async () => {
376
- // This will only execute once - the result is persisted in Postgres
377
- return { result: 'data' };
378
- });
379
- ```
380
-
381
- ### Event-Driven Workflows
382
-
383
- Wait for external events to pause and resume workflows without consuming resources:
384
-
385
- ```typescript
386
- const eventData = await step.waitFor('wait-step', {
387
- eventName: 'payment-completed',
388
- timeout: 5 * 60 * 1000, // 5 minutes
389
- });
390
- ```
391
-
392
- ### Scheduled and Delay Steps
393
-
394
- Wait until a specific time, or delay for a duration (sugar over `waitUntil`). If the date is in the past, the step runs immediately.
395
-
396
- ```typescript
397
- // Wait until a specific date (Date, ISO string, or { date })
398
- await step.waitUntil('scheduled-step', new Date('2025-06-01'));
399
- await step.waitUntil('scheduled-step', '2025-06-01T12:00:00.000Z');
400
- await step.waitUntil('scheduled-step', { date: new Date('2025-06-01') });
401
-
402
- // Delay for a duration (string or object). sleep is an alias of delay.
403
- await step.delay('cool-off', '3 days');
404
- await step.delay('cool-off', { days: 3 });
405
- await step.delay('ramp-up', '2 days 12 hours');
406
- await step.sleep('backoff', '1 hour');
407
- ```
408
-
409
- ### Resource ID
410
-
411
- The optional `resourceId` associates a workflow run with an external entity in your application - a user, an order, a subscription, or any domain object the workflow operates on. It serves two purposes:
412
-
413
- 1. **Association** - Links each workflow run to the business entity it belongs to, so you can query all runs for a given resource.
414
- 2. **Scoping** - When provided, all read and write operations (get, update, pause, resume, cancel, trigger events) include `resource_id` in their database queries, ensuring you only access workflow runs that belong to that resource. This is useful for enforcing tenant isolation or ownership checks.
415
-
416
- `resourceId` is optional on every API method. If you don't need to group or scope runs by an external entity, you can omit it entirely and use `runId` alone.
417
-
418
- ```typescript
419
- // Start a workflow scoped to a specific user
420
- const run = await engine.startWorkflow({
421
- workflowId: 'send-welcome-email',
422
- resourceId: 'user-123', // ties this run to user-123
423
- input: { email: 'user@example.com' },
424
- });
425
-
426
- // Later, list all workflow runs for that user
427
- const { items } = await engine.getRuns({
428
- resourceId: 'user-123',
429
- });
430
- ```
431
-
432
- ### Idempotency key
433
-
434
- Pass an optional `idempotencyKey` to `startWorkflow()` when the same logical start might be requested more than once (user double-clicks, API retries, or at-least-once webhooks). The engine stores the key on the run; a second `startWorkflow` with the **same** key returns the **existing** run and does **not** enqueue a second job.
435
-
436
- Keys are **globally unique** in the database (up to 256 characters), not scoped per workflow or resource. Prefer stable, namespaced strings so different workflows never collide—for example `send-welcome-email:order-123` instead of a bare order id.
437
-
438
- ```typescript
439
- const run = await engine.startWorkflow({
440
- workflowId: 'send-welcome-email',
441
- input: { email: 'user@example.com' },
442
- idempotencyKey: 'send-welcome-email:checkout-session_cs_abc123',
443
- });
444
-
445
- // Idempotent: returns the same run and run.id as above
446
- const again = await engine.startWorkflow({
447
- workflowId: 'send-welcome-email',
448
- input: { email: 'other@example.com' }, // ignored for deduplication — existing run wins
449
- idempotencyKey: 'send-welcome-email:checkout-session_cs_abc123',
450
- });
451
- ```
452
-
453
- The returned `WorkflowRun` includes `idempotencyKey` (or `null` if omitted).
454
-
455
- ### Pause and Resume
456
-
457
- Manually pause a workflow and resume it later:
458
-
459
- ```typescript
460
- // Pause inside a workflow
461
- await step.pause('pause-step');
462
-
463
- // Resume from outside the workflow
464
- await engine.resumeWorkflow({
465
- runId: run.id,
466
- resourceId: 'resource-123',
467
- });
468
- ```
469
-
470
- ### Fast-Forward
471
-
472
- Skip the current waiting step and immediately resume execution. `fastForwardWorkflow` inspects the paused step and dispatches the right internal action — `triggerEvent` for `waitFor`, timeout triggers for `delay`/`waitUntil`, resume for `pause`, and direct output writes for `poll`. If the workflow is not paused, it's a no-op.
473
-
474
- This is useful for testing, debugging, or manually advancing workflows past long waits.
475
-
476
- ```typescript
477
- // Fast-forward a waitFor step, providing mock event data
478
- await engine.fastForwardWorkflow({
479
- runId: run.id,
480
- resourceId: 'user-123',
481
- data: { approved: true, reviewer: 'admin' },
482
- });
483
-
484
- // Fast-forward a delay/waitUntil step (no data needed)
485
- await engine.fastForwardWorkflow({
486
- runId: run.id,
487
- resourceId: 'user-123',
488
- });
149
+ ## Documentation
489
150
 
490
- // Fast-forward a poll step with mock result data
491
- await engine.fastForwardWorkflow({
492
- runId: run.id,
493
- resourceId: 'user-123',
494
- data: { paymentId: 'pay_123', status: 'completed' },
495
- });
496
- ```
497
-
498
- | Paused step type | Behavior |
499
- |------------------|----------|
500
- | `step.waitFor()` | Triggers the event with `data` (defaults to `{}`) |
501
- | `step.delay()` / `step.waitUntil()` | Triggers the timeout event to skip the wait |
502
- | `step.poll()` | Writes `data` as the poll result and triggers resolution |
503
- | `step.pause()` | Delegates to `resumeWorkflow()` |
504
-
505
- ### Input Validation
506
-
507
- pg-workflows supports any [Standard Schema](https://github.com/standard-schema/standard-schema)-compliant validation library for `inputSchema`. This means you can use Zod, Valibot, ArkType, or any library that implements the Standard Schema spec. When a schema is provided, the workflow input is validated before execution and the handler's `input` parameter is fully typed.
508
-
509
- #### With Zod
510
-
511
- ```typescript
512
- import { workflow } from 'pg-workflows';
513
- import { z } from 'zod';
514
-
515
- const myWorkflow = workflow(
516
- 'user-onboarding',
517
- async ({ step, input }) => {
518
- // input is typed as { email: string; name: string }
519
- await step.run('send-welcome', async () => {
520
- return await sendEmail(input.email, `Welcome, ${input.name}!`);
521
- });
522
- },
523
- {
524
- inputSchema: z.object({
525
- email: z.string().email(),
526
- name: z.string(),
527
- }),
528
- }
529
- );
530
- ```
531
-
532
- #### With Valibot
533
-
534
- ```typescript
535
- import { workflow } from 'pg-workflows';
536
- import * as v from 'valibot';
537
-
538
- const myWorkflow = workflow(
539
- 'user-onboarding',
540
- async ({ step, input }) => {
541
- // input is typed as { email: string; name: string }
542
- await step.run('send-welcome', async () => {
543
- return await sendEmail(input.email, `Welcome, ${input.name}!`);
544
- });
545
- },
546
- {
547
- inputSchema: v.object({
548
- email: v.pipe(v.string(), v.email()),
549
- name: v.string(),
550
- }),
551
- }
552
- );
553
- ```
554
-
555
- #### Without a Schema
556
-
557
- When no `inputSchema` is provided, input is not validated and `input` is typed as `unknown`. This is because the engine has no guarantee about the shape of the data — it passes through whatever was provided to `startWorkflow()`. You are responsible for narrowing the type yourself, either with a type assertion or runtime checks:
558
-
559
- ```typescript
560
- import { workflow } from 'pg-workflows';
561
-
562
- const myWorkflow = workflow(
563
- 'process-order',
564
- async ({ step, input }) => {
565
- // Option 1: Type assertion — you trust the caller
566
- const { orderId, amount } = input as { orderId: string; amount: number };
567
-
568
- await step.run('charge', async () => {
569
- return await chargeOrder(orderId, amount);
570
- });
571
- }
572
- );
573
-
574
- const myDefensiveWorkflow = workflow(
575
- 'process-order-safe',
576
- async ({ step, input }) => {
577
- // Option 2: Runtime checks — you verify before using
578
- if (typeof input !== 'object' || input === null) {
579
- throw new Error('Expected input to be an object');
580
- }
581
- const { orderId, amount } = input as Record<string, unknown>;
582
- if (typeof orderId !== 'string' || typeof amount !== 'number') {
583
- throw new Error('Invalid input shape');
584
- }
585
-
586
- await step.run('charge', async () => {
587
- return await chargeOrder(orderId, amount);
588
- });
589
- }
590
- );
591
- ```
592
-
593
- Using an `inputSchema` is recommended — it validates input at the engine boundary before your handler runs, and gives you full type inference with no manual work.
594
-
595
- ---
596
-
597
- ## Examples
598
-
599
- ### Conditional Steps
600
-
601
- ```typescript
602
- const conditionalWorkflow = workflow('conditional', async ({ step }) => {
603
- const data = await step.run('fetch-data', async () => {
604
- return { isPremium: true };
605
- });
606
-
607
- if (data.isPremium) {
608
- await step.run('premium-action', async () => {
609
- // Only runs for premium users
610
- });
611
- }
612
- });
613
- ```
614
-
615
- ### Batch Processing with Loops
616
-
617
- ```typescript
618
- const batchWorkflow = workflow('batch-process', async ({ step }) => {
619
- const items = await step.run('get-items', async () => {
620
- return [1, 2, 3, 4, 5];
621
- });
622
-
623
- for (const item of items) {
624
- await step.run(`process-${item}`, async () => {
625
- // Each item is processed durably
626
- return processItem(item);
627
- });
628
- }
629
- });
630
- ```
631
-
632
- ### Scheduled Reminder with Delay
633
-
634
- ```typescript
635
- const reminderWorkflow = workflow('send-reminder', async ({ step, input }) => {
636
- await step.run('send-initial', async () => {
637
- return await sendEmail(input.email, 'Welcome!');
638
- });
639
- // Pause for 3 days, then send follow-up (durable - survives restarts)
640
- await step.delay('cool-off', '3 days');
641
- await step.run('send-follow-up', async () => {
642
- return await sendEmail(input.email, 'Here’s a reminder…');
643
- });
644
- }, { inputSchema: z.object({ email: z.string().email() }) });
645
- ```
646
-
647
- ### Polling Until a Condition Is Met
648
-
649
- ```typescript
650
- const paymentWorkflow = workflow('await-payment', async ({ step, input }) => {
651
- const result = await step.poll(
652
- 'wait-for-payment',
653
- async () => {
654
- const payment = await getPaymentStatus(input.paymentId);
655
- return payment.completed ? payment : false;
656
- },
657
- { interval: '1 minute', timeout: '24 hours' },
658
- );
659
-
660
- if (result.timedOut) {
661
- return { status: 'expired' };
662
- }
663
-
664
- return { status: 'paid', payment: result.data };
665
- });
666
- ```
667
-
668
- `conditionFn` returns `false` to keep polling, or a truthy value to resolve the step. The minimum interval is 30s (default). If `timeout` is omitted the step polls indefinitely.
669
-
670
- ### Error Handling with Retries
671
-
672
- ```typescript
673
- const resilientWorkflow = workflow('resilient', async ({ step }) => {
674
- await step.run('risky-operation', async () => {
675
- // Retries up to 3 times with exponential backoff
676
- return await riskyApiCall();
677
- });
678
- }, {
679
- retries: 3,
680
- timeout: 60000,
681
- });
682
- ```
683
-
684
- ### Monitoring Workflow Progress
685
-
686
- ```typescript
687
- const progress = await engine.checkProgress({
688
- runId: run.id,
689
- resourceId: 'resource-123',
690
- });
691
-
692
- console.log({
693
- status: progress.status,
694
- completionPercentage: progress.completionPercentage,
695
- completedSteps: progress.completedSteps,
696
- totalSteps: progress.totalSteps,
697
- });
698
- ```
699
-
700
- ---
701
-
702
- ## API Reference
703
-
704
- ### WorkflowEngine
705
-
706
- #### Constructor
707
-
708
- ```typescript
709
- // With connection string (engine creates and owns the pool)
710
- const engine = new WorkflowEngine({
711
- connectionString: string, // PostgreSQL connection string
712
- workflows?: WorkflowDefinition[], // Optional: register workflows on init
713
- logger?: WorkflowLogger, // Optional: custom logger
714
- boss?: PgBoss, // Optional: bring your own pg-boss instance
715
- });
716
-
717
- // With existing pool (you manage the pool lifecycle)
718
- const engine = new WorkflowEngine({
719
- pool: pg.Pool, // Your pg.Pool instance
720
- workflows?: WorkflowDefinition[],
721
- logger?: WorkflowLogger,
722
- boss?: PgBoss,
723
- });
724
- ```
725
-
726
- Pass either `connectionString` or `pool` (exactly one). When `connectionString` is used, the engine creates the pool internally and closes it on `stop()`.
727
-
728
- When `boss` is omitted, pg-boss is created automatically with an isolated schema (`pgboss_v12_pgworkflow`) to avoid conflicts with other pg-boss installations.
729
-
730
- #### Methods
731
-
732
- | Method | Description |
733
- |--------|-------------|
734
- | `start(asEngine?, options?)` | Start the engine and workers |
735
- | `stop()` | Stop the engine gracefully |
736
- | `registerWorkflow(definition)` | Register a workflow definition |
737
- | `startWorkflow({ workflowId, resourceId?, input, idempotencyKey?, options? })` | Start a new workflow run. `resourceId` optionally ties the run to an external entity (see [Resource ID](#resource-id)). `idempotencyKey` optionally deduplicates starts (see [Idempotency key](#idempotency-key)). |
738
- | `pauseWorkflow({ runId, resourceId? })` | Pause a running workflow |
739
- | `resumeWorkflow({ runId, resourceId?, options? })` | Resume a paused workflow |
740
- | `cancelWorkflow({ runId, resourceId? })` | Cancel a workflow |
741
- | `triggerEvent({ runId, resourceId?, eventName, data?, options? })` | Send an event to a workflow |
742
- | `fastForwardWorkflow({ runId, resourceId?, data? })` | Skip the current waiting step and resume execution |
743
- | `getRun({ runId, resourceId? })` | Get workflow run details |
744
- | `checkProgress({ runId, resourceId? })` | Get workflow progress |
745
- | `getRuns(filters)` | List workflow runs with pagination |
746
-
747
- ### workflow()
748
-
749
- ```typescript
750
- workflow<I extends Parameters>(
751
- id: string,
752
- handler: (context: WorkflowContext) => Promise<unknown>,
753
- options?: {
754
- inputSchema?: I,
755
- timeout?: number,
756
- retries?: number,
757
- }
758
- ): WorkflowDefinition<I>
759
- ```
760
-
761
- ### WorkflowContext
762
-
763
- The context object passed to workflow handlers:
764
-
765
- ```typescript
766
- {
767
- input: T, // Validated input data
768
- workflowId: string, // Workflow ID
769
- runId: string, // Unique run ID
770
- timeline: Record<string, unknown>, // Step execution history
771
- logger: WorkflowLogger, // Logger instance
772
- step: {
773
- run: <T>(stepId, handler) => Promise<T>,
774
- // without timeout: always returns event data T
775
- waitFor: <T>(stepId, { eventName, schema? }) => Promise<T>,
776
- // with timeout: returns event data T or undefined if timeout fires first
777
- waitFor: <T>(stepId, { eventName, timeout, schema? }) => Promise<T | undefined>,
778
- waitUntil: (stepId, date | dateString | { date }) => Promise<void>,
779
- delay: (stepId, duration) => Promise<void>,
780
- sleep: (stepId, duration) => Promise<void>,
781
- pause: (stepId) => Promise<void>,
782
- poll: <T>(stepId, conditionFn, { interval?, timeout? }) => Promise<{ timedOut: false; data: T } | { timedOut: true }>,
783
- }
784
- }
785
- ```
786
-
787
- `duration` is a string (e.g. `'3 days'`, `'2h'`) or an object (`{ weeks?, days?, hours?, minutes?, seconds? }`). See the `Duration` type and `parseDuration` from the package.
788
-
789
- ### WorkflowStatus
790
-
791
- ```typescript
792
- enum WorkflowStatus {
793
- PENDING = 'pending',
794
- RUNNING = 'running',
795
- PAUSED = 'paused',
796
- COMPLETED = 'completed',
797
- FAILED = 'failed',
798
- CANCELLED = 'cancelled',
799
- }
800
- ```
801
-
802
- ---
803
-
804
- ## Configuration
805
-
806
- ### Environment Variables
807
-
808
- | Variable | Description | Default |
809
- |----------|-------------|---------|
810
- | `DATABASE_URL` | PostgreSQL connection string | *required* |
811
- | `WORKFLOW_RUN_WORKERS` | Number of worker processes | `3` |
812
- | `WORKFLOW_RUN_EXPIRE_IN_SECONDS` | Job expiration time in seconds | `300` |
813
-
814
- ### Database Setup
815
-
816
- The engine automatically runs migrations on startup to create the required tables:
817
-
818
- - `workflow_runs` - Stores workflow execution state, step results, and timeline in the `public` schema. The optional `resource_id` column (indexed) associates each run with an external entity in your application. See [Resource ID](#resource-id). The optional `idempotency_key` column has a unique partial index for [idempotent starts](#idempotency-key).
819
- - `pgboss_v12_pgworkflow.*` - pg-boss job queue tables for reliable task scheduling (isolated schema to avoid conflicts)
820
-
821
- ---
822
-
823
- ## The PostgreSQL-for-Everything Philosophy
824
-
825
- As championed by [postgresforeverything.com](https://postgresforeverything.com/), PostgreSQL is one of the most reliable, feature-rich, and cost-effective databases ever built. pg-workflows embraces this philosophy:
826
-
827
- - **One database to rule them all** - Your application data and workflow state live in the same PostgreSQL instance. No distributed systems headaches.
828
- - **Battle-tested reliability** - PostgreSQL's ACID transactions guarantee your workflow state is always consistent.
829
- - **Zero operational overhead** - No Redis cluster to manage. No message broker to monitor. No external service to pay for.
830
- - **Full queryability** - Inspect, debug, and analyze workflow runs with plain SQL.
831
-
832
- If you're already running Postgres (and you probably should be), adding durable workflows is as simple as:
833
-
834
- ```bash
835
- npm install pg-workflows pg
836
- ```
151
+ - **[Architecture](docs/architecture.md)** - single-service and microservices (client/worker) setups
152
+ - **[Core Concepts](docs/core-concepts.md)** - workflows, steps, events, delays, polling, pause/resume, idempotency, input validation
153
+ - **[AI & Agent Workflows](docs/ai-agents.md)** - durable LLM pipelines, human-in-the-loop, RAG
154
+ - **[Examples](docs/examples.md)** - conditional steps, batch loops, scheduled reminders, retries, monitoring
155
+ - **[API Reference](docs/api-reference.md)** - `WorkflowEngine`, `WorkflowClient`, `WorkflowRef`, types
156
+ - **[Configuration](docs/configuration.md)** - env vars, database setup, requirements
837
157
 
838
158
  ---
839
159
 
840
160
  ## Requirements
841
161
 
842
- - Node.js >= 18.0.0
162
+ - Node.js >= 18
843
163
  - PostgreSQL >= 10
844
- - `pg` >= 8.0.0 (peer dependency)
845
- - A [Standard Schema](https://github.com/standard-schema/standard-schema)-compliant validation library (Zod, Valibot, ArkType, etc.) if using `inputSchema`
846
164
 
847
165
  ## Acknowledgments
848
166