This document defines the formal grammar for pg_durable's DSL expressions.
UPPERCASE = terminal (keyword, operator)
lowercase = non-terminal
'literal' = literal string
| = alternative
[] = optional
{} = zero or more
() = grouping
durable_function ::= df.start( expression [, label] )
label ::= STRINGexpression ::= loop_expr
loop_expr ::= '@>' loop_expr
| cond_expr
cond_expr ::= sequence_expr '?>' sequence_expr '!>' sequence_expr
| sequence_expr
sequence_expr ::= parallel_expr { '~>' parallel_expr }
parallel_expr ::= race_expr { '&' race_expr }
race_expr ::= bind_expr { '|' bind_expr }
bind_expr ::= atom_expr [ '|=>' NAME ]
atom_expr ::= '(' expression ')'
| sql_string
| node_functionnode_function ::= df.sql( QUERY )
| df.sleep( SECONDS )
| df.wait_for_schedule( CRON_EXPR )
| df.wait_for_signal( NAME [, TIMEOUT] )
| df.http( URL [, METHOD [, BODY [, HEADERS [, TIMEOUT]]]] )
| df.join( expression, expression )
| df.join3( expression, expression, expression )
| df.race( expression, expression )
| df.seq( expression, expression )
| df.if( condition, then_expr, else_expr )
| df.loop( expression [, condition] )
| df.break( [value] )
| df.as( expression, NAME )QUERY ::= SQL query string (single-quoted)
SECONDS ::= positive integer
CRON_EXPR ::= cron expression (5-part: "min hour dom month dow")
NAME ::= identifier string
URL ::= HTTP/HTTPS URL string
METHOD ::= 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
BODY ::= JSON string (supports $variable substitution)
HEADERS ::= JSONB object
TIMEOUT ::= positive integer (seconds)
STRING ::= single-quoted SQL stringFrom highest to lowest binding:
| Precedence | Operator | Associativity | Meaning |
|---|---|---|---|
| 1 (highest) | ` | =>` | left |
| 2 | & |
left | Parallel join (wait all) |
| 3 | | |
left | Race (first wins) |
| 4 | ~> |
left | Sequence (then) |
| 5 | ?> !> |
right | Conditional (if-then-else) |
| 6 (lowest) | @> |
prefix | Loop (forever) |
-- Using operator
'SELECT 1' ~> 'SELECT 2' ~> 'SELECT 3'
-- Using function
df.seq(df.seq('SELECT 1', 'SELECT 2'), 'SELECT 3')-- Wait for both to complete
'SELECT pg_sleep(1)' & 'SELECT pg_sleep(2)'
-- Three-way join
df.join3('SELECT 1', 'SELECT 2', 'SELECT 3')-- First to complete wins
df.sleep(10) | df.wait_for_signal('cancel')-- Bind SQL result to variable
'SELECT id FROM users LIMIT 1' |=> 'user_id'
~> 'SELECT * FROM orders WHERE user_id = $user_id'
-- Bind HTTP response
df.http('https://api.example.com/data') |=> 'response'
~> 'INSERT INTO api_data VALUES ($response::jsonb)'-- If-then-else with operators
'SELECT count(*) > 0 FROM users'
?> 'SELECT ''has users'''
!> 'SELECT ''no users'''
-- Using function
df.if(
'SELECT balance > 100 FROM accounts WHERE id = 1',
'UPDATE accounts SET status = ''gold'' WHERE id = 1',
'UPDATE accounts SET status = ''standard'' WHERE id = 1'
)-- Infinite loop with @> operator
@> (
df.wait_for_schedule('*/5 * * * *')
~> 'INSERT INTO heartbeats VALUES (now())'
)
-- While loop (continues while condition is true)
df.loop(
'SELECT process_item()',
'SELECT count(*) > 0 FROM queue' -- while queue has items
)
-- Loop with df.break() to exit
df.loop(
'SELECT process_batch()' |=> 'batch'
~> (
'$batch.done'
?> df.break('{"status": "complete"}') -- exit with value
!> df.sleep(5)
)
)
-- Loop with escape via race (alternative to break)
@> (
'CALL process_queue()'
~> df.sleep(60)
) | df.wait_for_signal('shutdown')-- Wait for human approval
'INSERT INTO orders VALUES (...) RETURNING id' |=> 'order_id'
~> df.wait_for_signal('approval', 3600) -- 1 hour timeout
~> 'UPDATE orders SET status = CASE
WHEN $approval.timed_out THEN ''expired''
ELSE ''approved''
END WHERE id = $order_id'-- GET request
df.http('https://api.github.com/repos/owner/repo/commits', 'GET')
-- POST with body and headers
df.http(
'https://api.example.com/webhook',
'POST',
'{"event": "completed", "id": $order_id}',
'{"Authorization": "Bearer $token", "Content-Type": "application/json"}'::jsonb,
30
)df.start(
-- Step 1: Create order
'INSERT INTO orders (customer_id, amount)
VALUES (42, 100.00) RETURNING id' |=> 'order_id'
-- Step 2: Parallel - reserve inventory and notify
~> (
'UPDATE inventory SET reserved = reserved + 1 WHERE sku = ''WIDGET'''
&
df.http('https://hooks.slack.com/...', 'POST', '{"text": "New order $order_id"}')
)
-- Step 3: Wait for payment confirmation
~> (
df.wait_for_signal('payment_confirmed', 86400) -- 24 hour timeout
| df.wait_for_signal('payment_failed')
) |=> 'payment'
-- Step 4: Conditional completion
~> (
'$payment.signal_name = ''payment_confirmed'''
?> 'UPDATE orders SET status = ''completed'' WHERE id = $order_id'
!> 'UPDATE orders SET status = ''failed'' WHERE id = $order_id'
),
'order-workflow' -- label
);Variables set with df.setvar() use curly-brace syntax {name} and are substituted at execution time:
SELECT df.setvar('customer_id', '42');
SELECT df.setvar('sku', 'WIDGET-001');
SELECT df.start('SELECT * FROM products WHERE sku = ''{sku}''');Result bindings (|=>) create named results available via dollar-sign syntax $name in subsequent steps:
'SELECT id FROM users WHERE email = ''{email}''' |=> 'user_id'
~> 'SELECT * FROM orders WHERE user_id = $user_id'Variables ({name}) and named results ($name) are substituted at execution time, not at definition time.
Plain SQL strings are automatically wrapped in df.sql():
-- These are equivalent:
'SELECT 1' ~> 'SELECT 2'
df.sql('SELECT 1') ~> df.sql('SELECT 2')
df.seq(df.sql('SELECT 1'), df.sql('SELECT 2'))This applies to all operator positions and function arguments.
| Type | Created By | Description |
|---|---|---|
SQL |
df.sql(), plain strings |
Execute SQL query |
THEN |
~>, df.seq() |
Sequential execution |
JOIN |
&, df.join() |
Parallel, wait all |
RACE |
|, df.race() |
Parallel, first wins |
IF |
?> !>, df.if() |
Conditional branch |
LOOP |
@>, df.loop() |
Loop (infinite or while-condition) |
BREAK |
df.break() |
Exit enclosing loop with optional value |
SLEEP |
df.sleep() |
Pause for N seconds |
WAIT_SCHEDULE |
df.wait_for_schedule() |
Wait for cron match |
SIGNAL |
df.wait_for_signal() |
Wait for external event |
HTTP |
df.http() |
Make HTTP request |
bind_expr ::= compensate_expr [ '|=>' NAME ]
compensate_expr ::= atom_expr [ '<->' atom_expr ]
| atom_exprSee spec-compensation.md for details.