How pgflow Works
pgflow is deliberately simple inside: a few SQL tables, couple of SQL functions, and thin helpers around them. Yet those pieces compose into a full workflow engine with Postgres as the single source of truth.
This page walks you through the mental model in three short hops:
- The three layers (DSL → SQL Core → Worker)
- The life-cycle of a task (
poll_for_tasks → complete_task | fail_task
) - Why type safety and queues matter
pgflow follows four core design principles:
- Postgres-first: All state and orchestration logic in your database
- Opinionated over configurable: Simple, sensible defaults that work for most cases
- Robust yet simple: Reliability without complexity
- Compile-time safety: Catch flow definition errors before runtime
1. Three thin layers
Section titled “1. Three thin layers”Layer cheat-sheet:
Layer | What it does | Lives where |
---|---|---|
TypeScript DSL | Describe the flow shape with full type inference | Your repo |
SQL Core | Owns all state & orchestration logic | A handful of Postgres tables + functions |
Worker | Executes user code, then reports back | Edge Function by default (but can be anything) |
2. All state lives in SQL 📊
Section titled “2. All state lives in SQL 📊”pgflow uses two categories of tables:
Definition tables:
flows
- Workflow identities and global optionssteps
- DAG nodes with option overridesdeps
- DAG edges between steps
Runtime tables:
runs
- One row per workflow executionstep_states
- Tracks each step’s status / deps / tasksstep_tasks
- One row per retryable task (fan-outs come later)
Because updates happen inside the same transaction that handles the queue message, pgflow gets ACID guarantees “for free”.
3. The execution cycle
Section titled “3. The execution cycle”- Worker calls
poll_for_tasks(queue, vt, qty)
• Locks & returns up-to-qty
ready tasks • Builds an input object that mergesrun.input
with outputs of completed deps - Worker runs your handler function.
- On success →
complete_task(run_id, step_slug, task_index, output)
On error →fail_task(run_id, step_slug, task_index, error)
- SQL Core, in the same commit:
- stores output / error
- moves step & run state forward
- enqueues next steps if deps are met
- handles errors with configurable retry logic and exponential backoff
Retry settings are configurable at both flow and step levels, with sensible defaults:
- When a task fails, it’s automatically retried if attempts remain
- Each retry uses exponential backoff (base_delay * 2^attempts) to avoid overload
- When retries are exhausted, the step and flow are marked as failed
That’s it—two SQL calls advance the entire graph.
4. Ultra-short example (2 sequential steps)
Section titled “4. Ultra-short example (2 sequential steps)”Flow definition
Section titled “Flow definition”import { Flow } from "npm:@pgflow/dsl";
type Input = { first: string; last: string };
export default new Flow<Input>({ slug: "greet_user" }) .step( { slug: "full_name" }, (input) => `${input.run.first} ${input.run.last}` // return type inferred ) .step( { slug: "greeting", dependsOn: ["full_name"] }, (input) => `Hello, ${input.full_name}!` // safe access, full IntelliSense );
What the compiler generates
Section titled “What the compiler generates”SELECT pgflow.create_flow('greet_user');SELECT pgflow.add_step('greet_user', 'full_name');SELECT pgflow.add_step('greet_user', 'greeting', ARRAY['full_name']);
No boilerplate, no hand-written DAG SQL.
5. Type safety from end to end
Section titled “5. Type safety from end to end”Only the initial input type is annotated (Input
).
Every other type is inferred:
- Return type of
full_name
➜ becomesinput.full_name
type - The compiler prevents you from referencing
input.summary
if it does not exist - Refactors propagate instantly—change one handler’s return type, dependent steps turn red in your IDE
When a step executes, its input combines the flow input with outputs from dependencies:
// If 'greeting' depends on 'full_name', its input looks like:{ "run": { "first": "Jane", "last": "Doe" }, "full_name": "Jane Doe"}
6. Why “poll + complete” is liberating
Section titled “6. Why “poll + complete” is liberating”Because all orchestration lives in the database:
- Workers are stateless – they can crash or scale horizontally
- You can mix & match: one flow processed by Edge Functions, another by a Rust service
- Observability is trivial—
SELECT * FROM pgflow.runs
is your dashboard
The Edge Worker adds powerful reliability features:
- Automatic retries with exponential backoff for transient failures
- Concurrency control for processing multiple tasks in parallel
- Auto-restarts to handle Edge Function CPU/memory limits
- Horizontal scaling with multiple worker instances
- Three simple layers keep concerns separate.
- All state is in Postgres tables—easy to query, backup, and reason about.
- Two SQL functions (
poll_for_tasks
,complete_task
/fail_task
) advance the graph transactionally. - The model feels like “a job queue that enqueues the next job”, but pgflow does the wiring.
- Type inference guarantees that every step only consumes data that actually exists.
You now have the core mental model needed for the rest of the docs—no more than ten minutes well spent 🚀