Both implementations share the same base PostgreSQL schema and SQL orchestration layer. They can run side-by-side against the same database.
Architecture
| Layer | TypeScript/Deno | Elixir |
|---|---|---|
| DSL | Fluent builder with full generic type inference | Macros (use PgFlow.Flow, use PgFlow.Job) |
| SQL Core | 30+ PL/pgSQL functions | Same SQL, shared via ecto_evolver migrations |
| Worker | Stateless Deno edge functions (HTTP-triggered) | Long-running OTP GenServer per flow/job |
| Polling | pgflow.read_with_poll() (blocking) | pgmq.read() (non-blocking) + adaptive backoff |
| Signal | pgmq polling only | Polling (default) or LISTEN/NOTIFY |
| Client | TS SDK with Supabase Realtime subscriptions | PgFlow.Client with sync polling |
| CLI | pgflow compile via control plane HTTP | mix pgflow.gen.flow_migration generates migrations directly |
| Dashboard | None (relies on Supabase dashboard) | Built-in Phoenix LiveView dashboard |
| Realtime | Supabase Realtime broadcast via realtime.send() | Telemetry events + structured logging |
| Recovery | pgmq visibility timeout + worker restart | Explicit StalledTaskRecovery GenServer |
Flow Definition
TypeScript — fluent builder with method chaining:
const flow = new Flow<{ orderId: number }>({ slug: 'process_order' })
.step({ slug: 'validate' }, (input) => ({ valid: true }))
.step({ slug: 'charge', dependsOn: ['validate'] }, (deps) => ({ charged: true }))
.map({ slug: 'notify' }, (item) => ({ sent: true }))Elixir — macro DSL:
defmodule ProcessOrder do
use PgFlow.Flow
@flow queue: :process_order, max_attempts: 3
step :validate do
fn input, _ctx -> %{valid: true} end
end
step :charge, depends_on: [:validate] do
fn deps, _ctx -> %{charged: true} end
end
map :notify do
fn item, _ctx -> %{sent: true} end
end
endBoth compile to identical SQL: pgflow.create_flow() + pgflow.add_step().
Step Types
| Type | TypeScript | Elixir |
|---|---|---|
| Single step | .step() | step/2, step/3. |
| Array producer | .array() | step/2 returning list. |
| Parallel map | .map() | map/2, map/3 |
| Dependent map | .map({ array: }) | map :name, array: :step |
Step/Flow Options
| Option | TypeScript | Elixir | Default |
|---|---|---|---|
| Identifier | slug | queue: / slug: | — |
| Max retries | maxAttempts | max_attempts: | 1 |
| Retry delay | baseDelay | base_delay: | 1s |
| Timeout | timeout | timeout: | 30s (TS), 60s (Elixir) |
| Start delay | startDelay | start_delay: | 0 |
Handler Context
| Field | TypeScript | Elixir |
|---|---|---|
| Run ID | context.stepTask.run_id | ctx.run_id |
| Step slug | context.stepTask.step_slug | ctx.step_slug |
| Task index | context.stepTask.task_index | ctx.task_index |
| Attempt | context.stepTask.attempt | ctx.attempt |
| Flow input | await context.flowInput | Context.get_flow_input(ctx) |
| DB access | context.sql (postgres.js) | ctx.repo (Ecto) |
| Shutdown signal | context.shutdownSignal | OTP supervision |
| Environment | context.env | Application.get_env/3 |
Both lazy-load flow input to avoid unnecessary DB queries for non-root steps.
Worker Model
TypeScript: Stateless Edge Functions
Supabase Edge Function (triggered per batch)
→ register worker → heartbeat → processBatch → exit
→ Horizontal scaling via multiple function instances
→ Worker deprecation via is_deprecated flag
→ ensure_workers() pings functions via pg_cronElixir: Long-Running OTP GenServers
PgFlow.Supervisor (rest_for_one)
├── Task.Supervisor
├── Signal.Notify (when signal_strategy: :notify)
├── WorkerSupervisor (dynamic)
│ └── Worker.Server (one per flow/job)
├── StalledTaskRecovery
└── :flow_starter (one-shot registration)| Aspect | TypeScript | Elixir |
|---|---|---|
| Lifecycle | Ephemeral per invocation | Persistent GenServer |
| State | Stateless | In-memory (active_tasks map) |
| Scaling | Multiple edge function instances | One worker per flow/job |
| Crash recovery | New function invocation | OTP supervisor restart |
| Stale task recovery | pgmq visibility timeout | Explicit sweep every 15s |
| Worker heartbeat | last_heartbeat_at in DB | DB registration + OTP monitor |
| Worker deprecation | deprecated_at flag | Not needed (OTP restarts) |
| Graceful shutdown | shutdownSignal | Lifecycle state machine |
Polling Protocol
Both use the same two-phase protocol:
- Reserve messages from pgmq (makes them invisible)
pgflow.start_tasks()— atomically claim tasks, return details- Execute handler
pgflow.complete_task()orpgflow.fail_task()
Difference in phase 1:
| Aspect | TypeScript | Elixir |
|---|---|---|
| Read call | pgflow.read_with_poll() (blocking) | pgmq.read() (non-blocking) |
| Idle behavior | Blocks in DB up to maxPollSeconds | Adaptive jittered backoff (1s-5s) |
| Wake-up | pgmq polling only | Polling or LISTEN/NOTIFY |
TypeScript uses blocking read_with_poll because stateless edge functions have no event loop to schedule polls — the database must do the waiting. Elixir workers are long-running GenServers where Process.send_after provides native scheduling, so pgmq.read() returns instantly and the connection goes back to the Ecto pool. This avoids holding DB connections idle and enables adaptive backoff with cancellable timers (NOTIFY or completion events can wake the worker immediately).
Note that read_with_poll is not truly push-based — PostgreSQL runs an internal poll loop at pollIntervalMs (default 200ms) inside the blocking call:
Deno worker PostgreSQL
| |
|---- read_with_poll() ------->|
| (connection held) |-- SELECT FROM q_* ... nothing
| |-- wait 200ms
| |-- SELECT FROM q_* ... nothing
| |-- wait 200ms
| |-- SELECT FROM q_* ... MESSAGE!
|<--- [message] ---------------|The real question is where the poll loop lives and what tradeoffs that creates:
| Deno (pgmq) | Elixir (polling) | Elixir (notify) | |
|---|---|---|---|
| Poll loop location | Inside PostgreSQL | GenServer process | N/A (event-driven) |
| Busy latency | ~200ms (fixed) | ~50ms (faster) | Near-zero |
| Idle latency | ~200ms (fixed) | Up to 5s (slower) | Near-zero + 30s fallback |
| DB connection during idle | Held for up to 5s | Not held | Not held |
| Truly push-based? | No | No | Yes |
Feature Comparison
| Feature | TypeScript | Elixir | Notes |
|---|---|---|---|
| Single steps | Yes | Yes | Identical semantics |
| Map steps | Yes | Yes | Identical |
| Dependent map steps | Yes | Yes | Identical |
| Empty array cascade | Yes | Yes | Same SQL logic |
| Exponential backoff retry | Yes | Yes | Same SQL function |
| Compile-time DAG validation | Yes | Yes | TS generics vs Kahn's algorithm |
| Two-phase polling | Yes | Yes | Same protocol |
| start_delay option | Yes | Yes | Per-step delay before queueing |
| Flow input lazy loading | Yes | Yes | context.flowInput / Context.get_flow_input |
| Type safety | Yes | No | TS generics flow through entire DAG |
| Client SDK (browser) | Yes | No | Supabase Realtime subscriptions |
| Realtime events (WebSocket) | Yes | No | Elixir uses telemetry instead |
| Worker deprecation | Yes | No | OTP supervisors handle restarts |
| LISTEN/NOTIFY signal | No | Yes | pgmq 1.8.0+ with enable_notify_insert |
| Stalled task recovery | No | Yes | StalledTaskRecovery GenServer |
| Jobs DSL | No | Yes | use PgFlow.Job with perform block |
| Cron scheduling | No | Yes | cron: [schedule: "@hourly"] via pg_cron |
| Dashboard | No | Yes | LiveView: flows, jobs, crons, runs, workers |
| Sync flow execution | Via client | Yes | PgFlow.Client.start_flow_sync/3 |
| Telemetry events | No | Yes | 12 events across flow/step/task/worker |
| Structured logging | No | Yes | Fancy (dev) and simple (prod) formats |
| Mix tasks | N/A | Yes | gen.flow, setup, stamp, check_schema, etc. |
Shared SQL Core
Both call the same PostgreSQL functions:
| Function | Purpose |
|---|---|
pgflow.create_flow | Register flow + create pgmq queue |
pgflow.add_step | Add step with dependencies |
pgflow.start_flow | Create run, enqueue root steps |
pgflow.start_tasks | Claim tasks, return details |
pgflow.complete_task | Save output, cascade to dependents |
pgflow.fail_task | Retry with backoff or fail permanently |
pgflow.start_ready_steps | Activate steps with all deps satisfied |
pgflow.cascade_complete_taskless_steps | Handle empty array propagation |
pgflow.maybe_complete_run | Complete run when all steps done |
Database schema (7 tables): flows, steps, deps (definition) + runs, step_states, step_tasks, workers (runtime).
Schema Divergences
The Elixir implementation adds the following extensions that are not present in the upstream TypeScript/Deno project:
| Change | Table | Description |
|---|---|---|
flow_type column | pgflow.flows | Distinguishes background jobs from multi-step DAG workflows in the dashboard. |
| Extension SQL functions | pgflow | register_worker, mark_worker_stopped, recover_stalled_tasks, flow_exists, get_flow_input, get_step_output — installed via mix pgflow.gen.helpers_migration. |
These additions are backward-compatible: existing flow records default to flow_type = 'flow', and extension functions don't modify core pgflow tables. TypeScript workers can safely ignore them.
Compilation
TypeScript: CLI sends HTTP request to ControlPlane edge function, which extracts the flow shape and generates SQL. Written to supabase/migrations/.
Elixir: mix pgflow.gen.flow_migration MyFlow reads __pgflow_definition__/0 at compile time and generates an Ecto migration with the same SQL calls. Also compiles flows at worker startup via FlowCompiler.