pg_aqueduct
Declarative schema evolution and migration for stream-table DAGs.
pg_aqueduct is the missing migration tool for teams running
pg_trickle in production.
Where Atlas manages relational schema and Terraform manages infrastructure,
pg_aqueduct manages the third axis that neither tool covers: the evolution
of a streaming, incrementally-maintained DAG of materialized views over time —
without losing differential state, without taking the pipeline offline, and
without making the topology of your stream tables your problem to figure out by hand.
Status: v0.20.0 — Executor Architecture, Multi-Tenant Catalog & CLI Polish.
The Problem
If you operate a pg_trickle deployment, your stream-table DAG is your most
valuable database asset. It is also the hardest one to evolve safely. Adding a
column to a base table, changing an aggregation, splitting a node into two — any
of these changes today requires a drop_stream_table() followed by a full
recreate, which discards the materialized rows, triggers an expensive full
refresh that can take minutes or hours on a large table, and forces every
downstream stream table to also be recreated in exactly the right topological
order.
For a small five-node DAG this is an inconvenience. For a 200-node production DAG it is an outage.
pg_aqueduct solves this by classifying every change into the cheapest
applicable migration class — and only doing as much work as the change actually
requires.
| Class | Example | Cost |
|---|---|---|
| Free | Schedule change, CDC mode change | < 1 s, zero downtime |
| In-place | Add aggregate column (same GROUP BY) | Seconds, zero downtime |
| Rebuild | Change GROUP BY keys, change a JOIN | Minutes (maintenance window) |
| Blue/green | Restructure sub-DAG topology | Parallel green DAG + atomic swap |
Quick Start
aqueduct init # scaffold a project and bootstrap the catalog
aqueduct import --from prod # bootstrap from an existing pg_trickle deployment
aqueduct plan --to prod # diff desired vs actual; produce a readable plan
aqueduct apply --to prod # execute the plan and record the migration
aqueduct status --to prod # show drift, last migration, refresh lag
aqueduct rollback --to prod # revert to the previous DAG version
What's in This Book
- API Reference — CLI commands, front-matter directives,
aqueduct.toml - Migration Cookbook — 30 worked patterns, one per common change type
- Security Guide — Least-privilege role, secret backends, audit trail
- HA Operations — Patroni, CloudNativePG, maintenance windows
Source code: trickle-labs/pg-aqueduct
License: Apache 2.0
Installation
aqueduct is distributed as a single static binary — no runtime dependencies, no PostgreSQL client libraries to install.
Download a pre-built binary
Every tagged release publishes archives for four platforms to the GitHub Releases page.
| Platform | Archive |
|---|---|
| Linux x86-64 | aqueduct-<version>-linux-amd64.tar.gz |
| Linux ARM64 | aqueduct-<version>-linux-arm64.tar.gz |
| macOS Apple Silicon | aqueduct-<version>-macos-arm64.tar.gz |
| Windows x86-64 | aqueduct-<version>-windows-amd64.zip |
A SHA256SUMS.txt file is attached to each release so you can verify the download before use.
Linux / macOS
# Replace <version> and <platform> as appropriate
VERSION=0.20.0
PLATFORM=linux-amd64 # or: linux-arm64, macos-arm64
curl -fsSL \
"https://github.com/trickle-labs/pg-aqueduct/releases/download/v${VERSION}/aqueduct-${VERSION}-${PLATFORM}.tar.gz" \
-o "aqueduct-${VERSION}-${PLATFORM}.tar.gz"
# Verify checksum (optional but recommended)
curl -fsSL \
"https://github.com/trickle-labs/pg-aqueduct/releases/download/v${VERSION}/SHA256SUMS.txt" \
| grep "${PLATFORM}" | sha256sum --check
tar xzf "aqueduct-${VERSION}-${PLATFORM}.tar.gz"
sudo mv "aqueduct-${VERSION}-${PLATFORM}/aqueduct" /usr/local/bin/
aqueduct --version
Windows
Download aqueduct-<version>-windows-amd64.zip from the releases page, extract it, and move aqueduct.exe to a directory that is on your %PATH%.
Build from source
You need the Rust toolchain (stable, 1.88 or later).
git clone https://github.com/trickle-labs/pg-aqueduct.git
cd pg-aqueduct
cargo build --release --bin aqueduct
# Binary is at target/release/aqueduct
Verify the installation
aqueduct --version
# aqueduct 0.17.0
Verify build provenance (v0.17+)
Starting from v0.17.0, each release carries a GitHub-native build attestation. You can verify that the binary you downloaded was built from the official source:
# Requires the GitHub CLI (gh)
gh attestation verify "aqueduct-0.17.0-linux-amd64.tar.gz" \
--repo trickle-labs/pg-aqueduct
Once installed, continue with the 5-Minute Tutorial to connect aqueduct to your PostgreSQL instance and run your first migration plan.
pg_aqueduct in 5 Minutes
You have a pg_trickle deployment. Your stream tables are humming along, ingesting
change events and keeping your aggregates fresh. Then comes the day you need to add a
column. Or change a GROUP BY. Or add a new downstream table that depends on one of the
existing ones. And you discover there is no safe way to do that — only drop_stream_table()
followed by a full recreate, which empties your table and triggers a rebuild that takes
minutes or hours on a large dataset.
pg_aqueduct is the tool that fixes this. Think of it the way you think of Atlas for
relational schemas or Terraform for infrastructure: you declare the desired state, run
a plan to see exactly what will change and at what cost, then apply it. pg_aqueduct
figures out the safest, cheapest migration path — and executes it in the right
topological order without losing your materialized data if it doesn't have to.
The Core Workflow
Every interaction with pg_aqueduct follows the same three-step loop:
aqueduct plan --to prod # see what would change
aqueduct apply --to prod # execute the plan
aqueduct status --to prod # verify the result
That is it. plan reads your migration files and diffs them against the live catalog.
apply executes the plan step by step, recording progress so it can resume if something
goes wrong. status shows you the current health of every managed stream table.
A Concrete Example
Imagine you have a stream table called order_totals that aggregates orders by
customer. It is defined in a SQL file with a few special comments — front-matter
directives that tell pg_aqueduct how to configure the stream:
-- migrations/streams/order_totals.sql
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT
customer_id,
SUM(amount) AS total_amount,
COUNT(*) AS order_count
FROM raw.orders
GROUP BY customer_id;
Your product team asks you to add a discount_total column. You update the file:
-- migrations/streams/order_totals.sql
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT
customer_id,
SUM(amount) AS total_amount,
COUNT(*) AS order_count,
SUM(discount) AS discount_total -- new column
FROM raw.orders
GROUP BY customer_id;
Now you run a plan:
$ aqueduct plan --to prod
Project checkout-analytics v3 → v4
Target prod
Changes 1 node affected
~ order_totals [in-place] add column discount_total (SUM)
Step Rows (est) Duration (est) Class
──────────────────────────────────────────────────────────────────
ALTER stream order_totals — < 1s in-place
BACKFILL order_totals 1.2M ~45s in-place
pg_aqueduct recognized that adding a SUM aggregate to an existing GROUP BY is an
in-place migration — the existing rows are preserved, only the new column is
backfilled. No drop, no full rebuild, no downtime. You apply it:
aqueduct apply --to prod
Forty-five seconds later your table has a new column and every downstream consumer was never interrupted. If the team had asked you to change the GROUP BY keys instead, the plan would have classified it as a rebuild and told you upfront that it requires a maintenance window — before touching a single row.
The Four Migration Classes
pg_aqueduct classifies every change into one of four classes, and always applies the
cheapest one that is safe:
| Class | What triggers it | Cost |
|---|---|---|
| Free | Change a refresh schedule, toggle CDC mode | Under 1 second, zero impact |
| In-place | Add an aggregate column (same GROUP BY) | Seconds to minutes, zero downtime |
| Rebuild | Change GROUP BY keys, add a JOIN, change a WHERE | Minutes, gated by maintenance window |
| Blue/green | Restructure the topology of a sub-DAG | Background parallel build + atomic swap |
The principle is conservative: when pg_aqueduct cannot prove a change is safe for
in-place migration, it falls back to the next class up. A false Rebuild is an
inconvenience. A false In-place would be data loss.
Where to Go Next
If you want to see the full lifecycle — creating a project, bootstrapping from an existing deployment, evolving the schema across multiple environments, and rolling back — read the 30-minute tutorial. For a deep reference on every command and directive, see the API Reference. For the 30 most common migration patterns, worked end to end, see the Migration Cookbook.
pg_aqueduct: A Hands-On Tutorial
This tutorial walks you through everything you need to understand and use
pg_aqueduct confidently in a real production workflow. By the end you will have
set up a project from scratch, evolved the schema through several common change types,
promoted changes across environments, and performed a rollback. Plan for about 30
minutes of focused reading and hands-on work.
Part 1 — The Problem Worth Solving
Before writing a single line of configuration, it is worth understanding why
pg_aqueduct exists — because the problem it solves is surprisingly sharp.
pg_trickle keeps your stream tables alive by continuously applying differential
updates: it reads the change stream, runs your SQL query over only the changed rows,
and merges the results back. This is beautifully efficient at runtime. But it creates
a painful constraint at migration time. A stream table is not just a view you can
DROP and recreate — it holds materialized state that took time to build, and it is
connected to other stream tables in a dependency graph. Change one node and the
ripple effects cascade downward.
Today, without pg_aqueduct, the standard answer to any stream-table change is
drop_stream_table() followed by a manual recreate. That destroys the materialized
rows, triggers an expensive full refresh that can run for minutes or hours on a
large table, and forces you to manually recreate every downstream table in exactly
the right topological order. Get that order wrong and your queries fail to parse or,
worse, silently return stale data. For a five-node DAG this is an inconvenience. For
a 200-node production DAG it is an outage.
pg_aqueduct solves this by doing three things. It maintains a declarative source of
truth for your stream-table DAG in version-controlled SQL files. It computes a
semantic diff between that desired state and the live catalog. And it classifies every
change into the cheapest migration that is provably safe, then executes it in the
correct topological order. You describe the end state; pg_aqueduct figures out how
to get there without unnecessary data loss.
Part 2 — Concepts and Mental Model
Stream Tables and DAGs
A stream table is a table maintained by pg_trickle that holds the result of an
aggregation query. Unlike a materialized view that is fully recomputed on refresh, a
stream table processes only the rows that changed since the last refresh cycle. This
is the differential in DIFFERENTIAL mode.
Stream tables can depend on other stream tables, forming a directed acyclic graph —
a DAG. When you have order_totals feeding into customer_tiers, which feeds into
churn_risk_scores, you have a three-node DAG. Each node has its own refresh
schedule, its own SQL query, and potentially its own refresh mode.
raw.orders (base table)
│
▼
order_totals (DIFFERENTIAL, every 30s)
│
▼
customer_tiers (DIFFERENTIAL, every 1m)
│
▼
churn_risk_scores (DIFFERENTIAL, every 5m)
The Migration Classes
The central concept in pg_aqueduct is the migration class. Every change to every
node in your DAG is classified into one of four classes, and pg_aqueduct will only
ever apply the cheapest class that is provably safe for the change you made.
Free class covers changes that do not touch the query logic or the materialized
data at all. Changing a refresh schedule from 30 seconds to 1 minute is a Free
migration — it calls alter_stream_table() once and completes in under a second with
zero impact on consumers. Toggling CDC mode and switching from DIFFERENTIAL to FULL
refresh mode are also Free.
In-place class covers additive changes to the SELECT list that do not touch the
GROUP BY keys, the FROM clause, or any WHERE predicates. Adding a SUM(discount)
column to a table that already GROUP BYs customer_id is In-place: the existing rows
are valid, only the new column is missing. pg_aqueduct adds the column and backfills
it incrementally using pg_trickle's targeted refresh mechanism. The table remains
readable and queryable the entire time. For a 1.2 million row table this typically
takes under a minute.
Rebuild class covers changes that structurally alter the query: changing GROUP BY
keys, adding or removing a JOIN, adding or changing a WHERE predicate, renaming a
column. These changes invalidate the existing materialized rows, so pg_aqueduct has
to drop the stream table and recreate it from scratch. This triggers a full refresh.
Rebuild migrations are gated by your maintenance_window configuration — unless you
explicitly pass --ignore-maintenance-window, they are deferred until the window
opens. Downstream nodes affected by a rebuild are also rebuilt, in topological order.
Blue/green class covers the rarest case: restructuring the topology of a sub-DAG
— splitting a node into two, merging two nodes into one, rewriting the dependency
graph. pg_aqueduct handles this by building a parallel "green" DAG alongside the
existing "blue" one, backfilling it while the blue DAG remains live, and then
atomically swapping consumer views so that downstream consumers see the new topology
without a gap in data availability.
The Project Format
A pg_aqueduct project is a directory with an aqueduct.toml configuration file and
a migrations/streams/ subdirectory containing SQL files. Each SQL file defines one
stream table. The SQL is exactly what you would pass to create_stream_table() in
pg_trickle, preceded by front-matter directives in SQL line-comment form:
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT
customer_id,
SUM(amount) AS total_amount,
COUNT(*) AS order_count
FROM raw.orders
GROUP BY customer_id;
The front-matter directives set the pg_trickle parameters for the stream table.
The schedule directive sets the refresh interval. The refresh_mode directive
sets whether the table uses DIFFERENTIAL or FULL refresh. Additional directives
cover CDC mode, explicit depends_on declarations, and whether to allow full
refreshes during backfill.
The aqueduct.toml file declares named targets (databases) and project-level settings:
[project]
name = "checkout-analytics"
[targets.dev]
dsn = "postgresql://localhost/checkout_dev"
[targets.staging]
dsn = "${AQUEDUCT_STAGING_DSN}"
[targets.prod]
dsn = "${AQUEDUCT_PROD_DSN}"
[apply]
lock_timeout = "30s"
maintenance_window = "02:00-04:00 UTC"
Note that production credentials are never hardcoded — the DSN uses an environment
variable. pg_aqueduct also supports integration with HashiCorp Vault and AWS
Secrets Manager for production secret injection; see the
Security Guide for details.
Part 3 — Setting Up a New Project
Prerequisites
You need PostgreSQL with pg_trickle installed, and the aqueduct binary on your
PATH. You can install the binary from the releases page
or build it from source with cargo install --path crates/aqueduct-cli.
Set an environment variable for your development database:
export AQUEDUCT_DEV_DSN="postgresql://localhost/mydb"
Initializing a Project
mkdir checkout-analytics && cd checkout-analytics
aqueduct init --to dev
aqueduct init creates the project skeleton:
checkout-analytics/
├── aqueduct.toml
└── migrations/
└── streams/ ← your SQL files go here
It also connects to the target database and creates the aqueduct catalog schema,
which holds the migration history, DAG version snapshots, and distributed lock table.
You only need to run init once per target database.
Bootstrapping from an Existing Deployment
If you already have a running pg_trickle deployment and want to bring it under
pg_aqueduct management, use import instead of starting from empty migration files:
aqueduct import --from prod --output migrations/streams/
This reads every row from pgtrickle.pgt_stream_tables and generates a
corresponding SQL migration file for each one, with the correct front-matter
directives extracted from the catalog. After running import, your local state and
the live state are identical, so aqueduct plan --to prod will produce an empty
plan. You are now on a known baseline and can start making changes.
Part 4 — Your First Migration
Let's walk through the three-node checkout analytics DAG from scratch.
Step 1 — Write the Migration Files
Create three files in migrations/streams/:
-- migrations/streams/order_totals.sql
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT
customer_id,
SUM(amount) AS total_amount,
COUNT(*) AS order_count
FROM raw.orders
GROUP BY customer_id;
-- migrations/streams/customer_tiers.sql
-- @aqueduct:schedule = "1m"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
-- @aqueduct:depends_on = ["public.order_totals"]
SELECT
customer_id,
CASE
WHEN total_amount > 1000 THEN 'gold'
WHEN total_amount > 100 THEN 'silver'
ELSE 'bronze'
END AS tier
FROM public.order_totals;
-- migrations/streams/churn_risk_scores.sql
-- @aqueduct:schedule = "5m"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
-- @aqueduct:depends_on = ["public.customer_tiers"]
SELECT
t.customer_id,
t.tier,
CASE WHEN t.tier = 'bronze' AND o.order_count < 2 THEN true
ELSE false
END AS at_risk
FROM public.customer_tiers t
JOIN public.order_totals o USING (customer_id);
The @aqueduct:depends_on directive tells the planner about the dependency graph.
Without it, pg_aqueduct infers dependencies from schema-qualified table references
in the SQL, but explicit declarations are more reliable when the SQL is complex.
Step 2 — Validate Before Connecting
Before touching the database, validate your migration files offline:
aqueduct validate
This checks for syntax errors in the front-matter directives, detects cycles in the dependency graph, and flags common mistakes like sub-5-second schedules. It runs entirely without a database connection — ideal for a CI pre-check step.
Step 3 — Plan
$ aqueduct plan --to dev
Project checkout-analytics (initial)
Target dev (pg_trickle 0.62, pg 18+)
Changes 3 nodes affected
+ order_totals [create] level 0
+ customer_tiers [create] level 1 (depends on order_totals)
+ churn_risk_scores [create] level 2 (depends on customer_tiers, order_totals)
Step Rows (est) Duration (est) Class
──────────────────────────────────────────────────────────────────────
CREATE stream order_totals — < 1s create
BACKFILL order_totals 850K ~30s create
CREATE stream customer_tiers — < 1s create
BACKFILL customer_tiers 850K ~30s create
CREATE stream churn_risk_scores — < 1s create
BACKFILL churn_risk_scores 850K ~30s create
The plan tells you exactly what will happen: three stream tables created in topological order (level 0 before level 1 before level 2), with estimated row counts and durations for each backfill. Review it, then apply:
aqueduct apply --to dev
aqueduct apply asks for confirmation, displays the plan one more time, then
executes each step. Progress is checkpointed to aqueduct.migrations.progress after
every step, so if the process crashes mid-migration, you can resume from where it
left off with aqueduct apply --to dev --resume.
Step 4 — Verify
$ aqueduct status --to dev
Project checkout-analytics v1
Table Mode Schedule Last Refresh Lag Status
─────────────────────────────────────────────────────────────────────────────
order_totals DIFFERENTIAL 30s 2s ago 0ms ✓ ok
customer_tiers DIFFERENTIAL 1m 58s ago 0ms ✓ ok
churn_risk_scores DIFFERENTIAL 5m 4m ago 0ms ✓ ok
All three tables are running and fresh. You are on version 1 of your DAG.
Part 5 — Evolving the Schema
This is where pg_aqueduct earns its place. Let's walk through three common change
types: one Free, one In-place, and one Rebuild.
Free Change — Update the Refresh Schedule
The SRE team decides order_totals is refreshing too aggressively and wants to change
it from 30 seconds to 2 minutes. Edit the file:
-- migrations/streams/order_totals.sql
-- @aqueduct:schedule = "2m" ← changed
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT ...
Plan it:
$ aqueduct plan --to dev
~ order_totals [free] schedule: 30s → 2m
Step Rows (est) Duration (est) Class
──────────────────────────────────────────────────────────────────
ALTER stream order_totals — < 1s free
A single alter_stream_table() call. No data movement, no downtime, done in under a
second. This is what Free class looks like.
In-Place Change — Add a Column
The analytics team wants a discount_total column. Add it to the SELECT list without
touching the GROUP BY:
-- migrations/streams/order_totals.sql
-- @aqueduct:schedule = "2m"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT
customer_id,
SUM(amount) AS total_amount,
COUNT(*) AS order_count,
SUM(discount) AS discount_total -- new
FROM raw.orders
GROUP BY customer_id;
Plan it:
$ aqueduct plan --to dev
~ order_totals [in-place] add column discount_total (SUM)
Step Rows (est) Duration (est) Class
──────────────────────────────────────────────────────────────────
ALTER stream order_totals — < 1s in-place
BACKFILL order_totals 850K ~30s in-place
The existing rows survive. The new column is added and backfilled without dropping the
table. During the backfill, discount_total is NULL for rows that have not been
touched by the backfill yet — so if you have dashboard queries that cannot tolerate
NULL, consider using COALESCE(discount_total, 0) in the query layer until backfill
completes. After aqueduct apply, the column is fully populated and the table is on
version 3.
Rebuild Change — Change the GROUP BY
Now suppose you need to add product_category to the GROUP BY, so totals are broken
down by both customer and category. This is a structural change — the existing rows
are keyed by customer_id alone and cannot be transformed in place.
-- migrations/streams/order_totals.sql
-- @aqueduct:schedule = "2m"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT
customer_id,
product_category, -- added to GROUP BY
SUM(amount) AS total_amount,
COUNT(*) AS order_count,
SUM(discount) AS discount_total
FROM raw.orders
GROUP BY customer_id, product_category; -- changed
Plan it:
$ aqueduct plan --to dev
! order_totals [rebuild] GROUP BY keys changed
! customer_tiers [rebuild] upstream (order_totals) rebuilt → cascade
! churn_risk_scores [rebuild] upstream (customer_tiers) rebuilt → cascade
Step Rows (est) Duration (est) Class
──────────────────────────────────────────────────────────────────────
DROP + RECREATE order_totals 850K ~30s rebuild ⏰
DROP + RECREATE customer_tiers 850K ~30s rebuild ⏰
DROP + RECREATE churn_risk_scores 850K ~30s rebuild ⏰
⏰ Rebuild steps will be deferred to the maintenance window (02:00–04:00 UTC).
Pass --ignore-maintenance-window to run immediately.
Notice that pg_aqueduct automatically cascaded the rebuild to the downstream nodes.
It computed the transitive closure of the dependency graph and included every affected
node — in topological order, rebuilding order_totals before customer_tiers before
churn_risk_scores. You did not have to figure out the order. You did not have to
even know which tables downstream of order_totals exist. pg_aqueduct read the
dependency graph from the catalog and handled it for you.
In production, this plan would be held until 02:00 UTC. In development, use
--ignore-maintenance-window to run it immediately.
Part 6 — Multi-Environment Promotion
One of the most important benefits of pg_aqueduct is repeatable, auditable
promotion across environments. The migration files are version-controlled SQL. The
plan is deterministic. You can test a change end-to-end in dev, review the exact plan
in staging, and apply the same plan to prod — knowing the behavior will be identical.
# Validate offline (no database needed)
aqueduct validate
# Test in dev
aqueduct plan --to dev
aqueduct apply --to dev
# Promote to staging
aqueduct plan --to staging
aqueduct apply --to staging --yes # skip confirmation in CI
# Promote to prod (plan is shown again for final human review)
aqueduct plan --to prod
aqueduct apply --to prod
Because aqueduct plan exits with code 1 when there are pending changes and code 0
when the state is already up to date, you can use it as a gate in CI/CD pipelines:
# .gitlab-ci.yml (excerpt)
migrate-prod:
stage: deploy
script:
- aqueduct apply --to prod --yes
only:
- main
In CI environments where you want to assert that no unreviewed drift exists, use
aqueduct plan --quiet --to prod — it exits 0 for an empty plan and 1 for any
pending change, which you can turn into a pipeline alert.
Part 7 — Rollback
pg_aqueduct stores a full snapshot of the DAG definition at every aqueduct apply.
These snapshots live in aqueduct.dag_versions. Rolling back restores the previous
snapshot by computing a forward migration plan from the current state to the target
version — it is always a forward migration under the hood, which means all the same
safety guarantees apply.
aqueduct rollback --to prod
By default, rollback targets the immediately preceding version. To roll back to a
specific version, use --to-version <N>. The command will refuse to proceed through
a Rebuild-class rollback without an explicit --accept-data-loss flag, because once
a full refresh has replaced the old data with the new, there is nothing to roll back
to without rerunning the refresh in the other direction.
The lossless rollback window is the key insight: if you are rolling back a Free or In-place migration, rollback is always safe and fast. If you are rolling back a Rebuild, you are accepting the cost of another full rebuild in the opposite direction.
Part 8 — Working with the Dependency Graph
Explicit vs. Inferred Dependencies
When a downstream stream table references an upstream one by a schema-qualified name
— public.order_totals — pg_aqueduct infers the dependency automatically by
parsing the SQL. You do not need to declare it explicitly. However, when the upstream
table name is constructed dynamically, or when you want to be explicit for clarity,
use the @aqueduct:depends_on directive:
-- @aqueduct:depends_on = ["public.order_totals", "public.customer_tiers"]
The planner validates these declarations against the actual catalog and warns if a declared dependency does not exist or if an inferred dependency is missing from the declaration.
Lint Checks
aqueduct lint runs a set of checks for common problems:
aqueduct lint
The checks include:
L001— Schedule under 5 seconds (very aggressive, may strainpg_trickle)L002— DIFFERENTIAL mode with a non-deterministic aggregate (e.g.,RANDOM())L003— Cyclic dependency in the graphL004— Undeclared dependency (inferred from SQL but missing fromdepends_on)L005— Unknown front-matter directive (typo guard)L006— Consumer view references a non-managed stream table
Run aqueduct lint --fix to automatically fix L004 (adds the missing depends_on
declarations without changing anything else).
Part 9 — Advanced: Blue/Green Migrations
When a change is so structural that it cannot be expressed as a sequence of per-node
Rebuild migrations — for example, splitting order_totals into two separate tables,
each serving a different downstream — pg_aqueduct can orchestrate a blue/green
migration.
A blue/green migration works like this: pg_aqueduct builds the entire new sub-DAG
(the "green" side) in parallel with the existing one (the "blue" side), using
consumer views to route reads. While the green side backfills, the blue side remains
fully operational. Once the green side has caught up, pg_aqueduct atomically swaps
the consumer views so that downstream queries now read from green. The blue side is
then torn down.
Blue/green is the most expensive migration class but also the most powerful — it provides zero-downtime topology restructuring. In practice, most changes never reach this class. The cookbook's Pattern 30 shows a complete lifecycle including the decision points.
Part 10 — Day-to-Day CLI Cheatsheet
Here is a quick reference for the commands you will use most often:
| Command | What it does |
|---|---|
aqueduct validate | Offline validation — no database |
aqueduct lint | Check for anti-patterns |
aqueduct plan --to <env> | Show pending migrations |
aqueduct apply --to <env> | Execute migrations |
aqueduct apply --to <env> --dry-run | Show plan without executing |
aqueduct apply --to <env> --resume | Resume an interrupted apply |
aqueduct status --to <env> | Live health check of all stream tables |
aqueduct status --to <env> --watch | Re-poll every 5 seconds |
aqueduct diff --table <name> --to <env> | Semantic diff for one table |
aqueduct rollback --to <env> | Rollback to previous version |
aqueduct import --from <env> | Bootstrap migration files from live catalog |
aqueduct destroy --to <env> --dry-run | Preview teardown |
aqueduct destroy --to <env> --confirm | Execute teardown |
What's Next
You now have a complete picture of how pg_aqueduct works. The Migration Cookbook
has 30 worked examples covering every common pattern, each with the exact plan output
you will see and an explanation of why it classifies the way it does. The
API Reference documents every command, front-matter directive,
and aqueduct.toml option in full. For production deployments on Patroni or
CloudNativePG, read the HA Operations Guide.
API Reference
Auto-generated from
aqueduct --helpoutput. Do not edit by hand — runjust gen-docsto regenerate.
Binary version: aqueduct 0.20.0
aqueduct plan
Compute a migration plan by diffing desired vs. actual DAG state
Usage: aqueduct plan [OPTIONS]
Options:
--dsn <DSN> PostgreSQL connection string
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Target name from aqueduct.toml
--project-dir <PROJECT_DIR> Project directory [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--format <FORMAT> Output format: text (default), json, markdown, or yaml [default: text]
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--fail-if-changed Exit non-zero if the plan is non-empty (useful in CI). Synonymous with the default exit-code behaviour (exit 1 for non-empty plans)
--fail-on-drift Exit non-zero if drift is detected between live state and last-applied version
--validate-ivm Check IVM supportability of all queries
--explain-cost Show per-step cost estimates (row count, estimated duration)
--out <OUT> Write the plan to a JSON file for use with `apply --plan`
--allow-plaintext-password Allow DSN with embedded plaintext password (not recommended outside CI/dev)
-h, --help Print help
aqueduct apply
Apply a migration plan to the target database
Usage: aqueduct apply [OPTIONS]
Options:
--dsn <DSN> PostgreSQL connection string
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Target name from aqueduct.toml
--project-dir <PROJECT_DIR> Project directory [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--dry-run Show what would be done without executing
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--ignore-maintenance-window Override the maintenance window restriction
--allow-rebuild Allow rebuild-class migrations even when allow_full_refresh = false
--strategy <STRATEGY> Migration strategy: "default" or "blue-green" [default: default]
--plan <PLAN> Path to a pre-computed plan JSON file (produced by `aqueduct plan --out`). The plan's spec_hash is validated against the current migration files
--no-immediate-downgrade Reject plans that would temporarily downgrade an IMMEDIATE refresh-mode table to DIFFERENTIAL during a rebuild
--resume Resume an interrupted migration
--force-retry <FORCE_RETRY> Force-retry the step at the given index (CORR-2 / v0.15). Requires --yes
--force-skip <FORCE_SKIP> Force-skip the step at the given index (CORR-2 / v0.15). Requires --yes
--print-plan Print the plan before applying
-y, --yes Skip the interactive confirmation prompt (for CI and scripted use)
--allow-plaintext-password Allow DSN with embedded plaintext password (not recommended outside CI/dev)
-h, --help Print help
aqueduct diff
Show per-table diff between desired (migration files) and actual (live) state
Usage: aqueduct diff [OPTIONS]
Options:
--dsn <DSN> PostgreSQL connection string
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Target name from aqueduct.toml
--project-dir <PROJECT_DIR> Project directory [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--table <TABLE> Show diff only for the named stream table
--format <FORMAT> Output format: text (default), json, markdown, or yaml [default: text]
--fail-on-drift Exit 1 when any diff delta is non-Unchanged, exit 0 for a clean diff (U-03)
-h, --help Print help
aqueduct status
Show the current project state and drift status
Usage: aqueduct status [OPTIONS]
Options:
--dsn <DSN>
PostgreSQL connection string
--log-format <LOG_FORMAT>
Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL>
Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO>
Target name from aqueduct.toml
--project-dir <PROJECT_DIR>
Project directory [default: .]
--quiet
Suppress all non-error output (useful for scripted pipelines)
--format <FORMAT>
Output format: text or json [default: text]
--porcelain
Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--fail-on-drift
Exit non-zero if drift is detected
--watch
Watch mode: poll continuously instead of running once
--interval <INTERVAL>
Polling interval in watch mode (e.g. "30s", "1m") [default: 30s]
--max-drift-count <MAX_DRIFT_COUNT>
Exit after N consecutive drift detections (0 = never exit on drift) [default: 0]
--poll-interval <POLL_INTERVAL>
Polling interval for --watch mode (e.g. "30s", "1m"). Alias for --interval [default: 30s]
-h, --help
Print help
aqueduct validate
Offline validation of migration files (no database connection required)
Usage: aqueduct validate [OPTIONS]
Options:
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--project-dir <PROJECT_DIR> Project directory [default: .]
--format <FORMAT> Output format: text or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--quiet Suppress all non-error output (useful for scripted pipelines)
--strict Treat warnings as errors and exit non-zero if any are present
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
-h, --help Print help
aqueduct lint
Check migration files for risky or non-optimal patterns
Usage: aqueduct lint [OPTIONS]
Options:
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--project-dir <PROJECT_DIR> Project directory [default: .]
--format <FORMAT> Output format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--fail-on-warn Exit non-zero if any warnings are found (in addition to errors)
--quiet Suppress all non-error output (useful for scripted pipelines)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
-h, --help Print help
aqueduct import
Import an existing pg_trickle deployment into a migrations directory
Usage: aqueduct import [OPTIONS]
Options:
--from <FROM>
PostgreSQL connection string to import from
--log-format <LOG_FORMAT>
Log format: text (default) or json [default: text]
--from-target <FROM_TARGET>
Target name from aqueduct.toml (used as --from source)
--log-level <LOG_LEVEL>
Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--output <OUTPUT>
Output directory for the generated project [default: .]
--quiet
Suppress all non-error output (useful for scripted pipelines)
--porcelain
Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--project-dir <PROJECT_DIR>
Project directory (for resolving config) [default: .]
--exclude-pattern <EXCLUDE_PATTERN>
Exclude tables matching these patterns (can be specified multiple times)
--no-default-exclusions
Do not apply default exclusion patterns (_pg_ripple.*, _pg_eddy.*, _riverbank.*)
-h, --help
Print help
aqueduct rollback
Rollback to a previous DAG version
Usage: aqueduct rollback [OPTIONS]
Options:
--dsn <DSN> PostgreSQL connection string
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Target name from aqueduct.toml
--project-dir <PROJECT_DIR> Project directory [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--to-version <TO_VERSION> Roll back to a specific version (default: previous version)
--accept-data-loss Accept data loss for rebuild-class rollbacks where the lossless window has passed
--within-window Allow PointInTime rollbacks even when close to window expiry
--dry-run Show what would be done without executing
-h, --help Print help
aqueduct promote
Promote a validated migrations directory from one environment to another
Usage: aqueduct promote [OPTIONS] --from <FROM> --to <TO>
Options:
--from <FROM> Source environment name (e.g. "dev")
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Destination environment name (e.g. "staging")
--project-dir <PROJECT_DIR> Project directory [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--skip-source-check Skip the source-clean validation (use in CI when source is known good)
--dry-run Show what would be done without executing
--yes Auto-approve the promotion (skip interactive prompt)
-h, --help Print help
aqueduct destroy
Destroy all stream tables, consumer views, and catalog entries for a project
Usage: aqueduct destroy [OPTIONS]
Options:
--dsn <DSN> PostgreSQL connection string
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Target name from aqueduct.toml
--project-dir <PROJECT_DIR> Project directory [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--confirm Confirm the destructive operation (required unless --dry-run is set)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--dry-run Show what would be destroyed without executing
--force-cascade Also drop dependent database objects (views, etc.) via CASCADE. Without this flag, `destroy` refuses if any stream table has dependents
--force-unowned Skip ownership verification. By default, `destroy` refuses to drop stream tables that are not registered as owned by this project
-h, --help Print help
aqueduct unlock
Release a stale project lock (emergency use)
Usage: aqueduct unlock [OPTIONS]
Options:
--dsn <DSN> PostgreSQL connection string
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Target name from aqueduct.toml
--project-dir <PROJECT_DIR> Project directory [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--force Force unlock regardless of lock holder (requires confirmation)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
-h, --help Print help
aqueduct init
Bootstrap the aqueduct catalog schema in the target database
Usage: aqueduct init [OPTIONS]
Options:
--dsn <DSN> PostgreSQL connection string (DSN). Overrides the config file
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--to <TO> Target name from aqueduct.toml
--project-dir <PROJECT_DIR> Project directory (default: current directory) [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--schema <SCHEMA> Override the catalog schema name (default: "aqueduct") [default: aqueduct]
--scaffold Scaffold a new project directory with aqueduct.toml and migrations/
--allow-plaintext-password Allow DSN with embedded plaintext password (not recommended outside CI/dev)
-h, --help Print help
aqueduct preview
Create, inspect, or tear down a preview environment for a candidate change
Usage: aqueduct preview [OPTIONS]
Options:
--dsn <DSN>
PostgreSQL connection string
--log-format <LOG_FORMAT>
Log format: text (default) or json
[default: text]
--log-level <LOG_LEVEL>
Verbosity: error, warn, info (default), debug, trace
[env: AQUEDUCT_LOG=]
[default: info]
--to <TO>
Target name from aqueduct.toml
--project-dir <PROJECT_DIR>
Project directory
[default: .]
--quiet
Suppress all non-error output (useful for scripted pipelines)
--branch <BRANCH>
Branch name to create the preview for (used to derive the schema name). Example: `feat/my-feature` → `aqueduct_preview_feat_my_feature`
--porcelain
Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
--backend <BACKEND>
Preview backend: native (default), cnpg, or neon
[default: native]
--sample <SAMPLE>
Sample fraction for TABLESAMPLE (0.0–1.0). Default: 0.1 (10%)
[default: 0.1]
--recreate
Drop and recreate the preview schema if it already exists
--cleanup
Drop an existing preview environment
--list
List all preview environments in the database
--cnpg-endpoint <CNPG_ENDPOINT>
CloudNativePG operator endpoint (required for --backend cnpg)
--neon-api-token <NEON_API_TOKEN>
Neon API token (required for --backend neon)
[env: NEON_API_TOKEN=]
--neon-project-id <NEON_PROJECT_ID>
Neon project ID (required for --backend neon)
[env: NEON_PROJECT_ID=]
--table <TABLE>
Restrict the preview to the subgraph anchored at this table name (P-07 / v0.12).
Only the named table, its ancestors (dependencies), and its descendants (tables that depend on it) are included in the preview. Example: `--table public.order_totals`
-h, --help
Print help (see a summary with '-h')
aqueduct ingest
Ingest compiled dbt artefacts into an aqueduct migrations directory
Usage: aqueduct ingest [OPTIONS] --from <TYPE> --target <PATH>
Options:
--from <TYPE> Source type to ingest from. Currently only `dbt-target` is supported
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--target <PATH> Path to the dbt target directory (contains `manifest.json` and `compiled/`). Required when `--from dbt-target`
--project-dir <PROJECT_DIR> Project directory to write migration files into (default: current directory) [default: .]
--quiet Suppress all non-error output (useful for scripted pipelines)
--format <FORMAT> Output format: text (default) or json [default: text]
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
-h, --help Print help
aqueduct fmt
Canonicalise the SQL body and front-matter directives in migration files
Usage: aqueduct fmt [OPTIONS]
Options:
--log-format <LOG_FORMAT> Log format: text (default) or json [default: text]
--project-dir <PROJECT_DIR> Project directory [default: .]
--check Check formatting only — exit non-zero if any file is not canonical. Does not write any files
--log-level <LOG_LEVEL> Verbosity: error, warn, info (default), debug, trace [env: AQUEDUCT_LOG=] [default: info]
--format <FORMAT> Output format: text (default) or json [default: text]
--quiet Suppress all non-error output (useful for scripted pipelines)
--porcelain Emit machine-parseable key=value output on stdout (implies --quiet for decorative output)
-h, --help Print help
pg_aqueduct Migration Cookbook
30 worked examples covering the most common stream-table evolution patterns.
Every example is verified end-to-end against a Testcontainers PostgreSQL cluster
(see crates/aqueduct-core/tests/integration.rs, test_cookbook_* functions).
Pattern Categories
Free class — metadata-only changes (no rebuild, no data movement)
| # | Pattern | Test |
|---|---|---|
| 01 | Change schedule to a faster interval | test_cookbook_01_change_schedule_faster |
| 02 | Change schedule to a slower interval | test_cookbook_02_change_schedule_slower |
| 03 | Enable CDC mode (WAL replication) | test_cookbook_03_enable_cdc_mode |
| 04 | Switch refresh mode DIFFERENTIAL → FULL | test_cookbook_04_diff_to_full_refresh |
In-place class — additive changes; materialised state preserved
| # | Pattern | Test |
|---|---|---|
| 05 | Add a SUM aggregate column | test_cookbook_05_add_sum_aggregate_column |
| 06 | Add a COUNT(*) column | test_cookbook_06_add_count_column |
| 07 | Drop a column from the SELECT list | test_cookbook_07_drop_aggregate_column |
| 08 | Add an aggregate column without changing GROUP BY | test_cookbook_08_add_passthrough_column |
| 09 | Extend the SELECT list with a new aggregate | test_cookbook_09_widen_column_type |
Rebuild class — structural changes; full DROP + recreate + backfill
| # | Pattern | Test |
|---|---|---|
| 10 | Rename a column | test_cookbook_10_rename_column_is_rebuild |
| 11 | Change GROUP BY keys | test_cookbook_11_change_group_by_is_rebuild |
| 12 | Add a JOIN | test_cookbook_12_add_join_is_rebuild |
| 13 | Remove a JOIN | test_cookbook_13_remove_join_is_rebuild |
| 14 | Change a JOIN condition | test_cookbook_14_change_join_condition_is_rebuild |
| 15 | Add a WHERE predicate | test_cookbook_15_add_where_predicate_is_rebuild |
| 16 | Change a WHERE predicate | test_cookbook_16_change_where_predicate_is_rebuild |
| 17 | Switch refresh mode FULL → DIFFERENTIAL | test_cookbook_17_full_to_diff_is_rebuild |
Create / Drop
| # | Pattern | Test |
|---|---|---|
| 18 | Create a new stream table | test_cookbook_18_create_stream_table |
| 19 | Drop an existing stream table | test_cookbook_19_drop_stream_table |
DAG topology patterns
| # | Pattern | Test |
|---|---|---|
| 20 | Two-node dependency DAG (A → B) | test_cookbook_20_two_node_dag |
| 21 | Add a downstream dependent node | test_cookbook_21_add_downstream_node |
| 22 | Remove a downstream node | test_cookbook_22_remove_downstream_node |
| 23 | Three-level dependency chain | test_cookbook_23_three_level_chain |
Consumer views
| # | Pattern | Test |
|---|---|---|
| 24 | Create a consumer view | test_cookbook_24_create_consumer_view |
| 25 | Drop a consumer view | test_cookbook_25_drop_consumer_view |
Multi-table and advanced patterns
| # | Pattern | Test |
|---|---|---|
| 26 | Source column change cascades to stream tables | test_cookbook_26_source_column_cascade |
| 27 | Change schedule on multiple tables simultaneously | test_cookbook_27_multi_table_schedule_change |
| 28 | Import from live — idempotent round-trip | test_cookbook_28_import_roundtrip |
| 29 | Rollback across version boundaries | test_cookbook_29_rollback_to_prior_state |
| 30 | Full lifecycle: create → evolve → destroy | test_cookbook_30_full_dag_lifecycle |
Classification Quick Reference
Change type Migration class Cost
──────────────────────────────────────────────────────────────
Schedule change Free < 1s
cdc_mode change Free < 1s
refresh_mode DIFF → FULL Free < 1s
Add aggregate column (no GROUP BY Δ) In-place seconds
Drop column from SELECT In-place seconds
Rename column Rebuild minutes
Change GROUP BY keys Rebuild minutes
Add / remove / change JOIN Rebuild minutes
Add / change WHERE predicate Rebuild minutes
refresh_mode FULL → DIFF Rebuild minutes
Create new stream table Create seconds–minutes
Drop stream table Drop < 1s
Blue/green structural DAG change Blue/green background
Pattern 01 — Change Schedule to a Faster Interval
Class: Free
Cost: < 1 second
Test: test_cookbook_01_change_schedule_faster
When to use
Use this pattern when you want stream tables to refresh more frequently — for example, moving from a 1-minute schedule to 10 seconds to reduce analytical latency.
Migration file
-- migrations/streams/hourly_stats.sql
-- @aqueduct:schedule = "10s"
-- @aqueduct:refresh_mode = "FULL"
SELECT region, COUNT(*) AS event_count FROM raw_events GROUP BY region;
Before
# aqueduct.toml excerpt
[targets.prod]
dsn = "${PROD_DSN}"
-- hourly_stats.sql (before)
-- @aqueduct:schedule = "1m"
-- @aqueduct:refresh_mode = "FULL"
SELECT region, COUNT(*) AS event_count FROM raw_events GROUP BY region;
Plan output
Project analytics v3 → v4
Target prod
Changes 1 node affected
~ hourly_stats [free] schedule: 1m → 10s
Why it's Free
pg_trickle stores the schedule as metadata alongside the stream table spec. A schedule
change is a single call to pgtrickle.alter_stream_table(p_schedule => '10s'). No DDL
is emitted, no data is moved, no rebuild is required.
Notes
- Schedules as short as
1sare allowed. Check the pg_trickle documentation for the minimum supported schedule for your cluster. - The
aqueduct lintcommand warns whenschedule < 5son a large table.
Pattern 02 — Change Schedule to a Slower Interval
Class: Free
Cost: < 1 second
Test: test_cookbook_02_change_schedule_slower
When to use
Use this pattern when a stream table is refreshing more often than necessary, consuming compute resources that could be better used elsewhere.
Migration file
-- migrations/streams/fast_stats.sql (after)
-- @aqueduct:schedule = "10m"
-- @aqueduct:refresh_mode = "FULL"
SELECT user_id, SUM(score) AS total_score FROM events GROUP BY user_id;
Plan output
Project analytics v5 → v6
Target prod
Changes 1 node affected
~ fast_stats [free] schedule: 5s → 10m
Why it's Free
Slowing down a schedule is identical in implementation to speeding it up — a single
pgtrickle.alter_stream_table() call updates the metadata. No data is touched.
Notes
aqueduct statuswill show the updated schedule immediately after apply.- For critical reporting tables, consider whether a slower schedule violates any SLA.
Pattern 03 — Enable CDC Mode
Class: Free
Cost: < 1 second
Test: test_cookbook_03_enable_cdc_mode
When to use
Enable CDC (change-data-capture) mode to consume WAL replication events from a source table instead of polling. This reduces lag and load on the source table for high-write scenarios.
Migration file
-- migrations/streams/event_counts.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "FULL"
-- @aqueduct:cdc_mode = "wal"
SELECT event_type, COUNT(*) AS n FROM raw_events GROUP BY event_type;
Plan output
Project analytics v7 → v8
Target prod
Changes 1 node affected
~ event_counts [free] cdc_mode: null → wal
Why it's Free
CDC mode is a property of how pg_trickle sources changes, not of the materialised
table schema. Enabling it is a single metadata update — the materialised data does not
change.
Notes
- WAL CDC requires the source table to have
REPLICA IDENTITY FULLor a primary key. - Set
REPLICA IDENTITY FULLwith Atlas or a manualALTER TABLEbefore enabling CDC. - Disabling CDC is similarly Free.
Pattern 04 — Switch Refresh Mode DIFFERENTIAL → FULL
Class: Free
Cost: < 1 second
Test: test_cookbook_04_diff_to_full_refresh
When to use
Switch to FULL refresh mode when a stream table's query becomes IVM-unsupportable
(e.g., after adding a volatile function) and you want pg_trickle to do a full
re-materialisation on every tick instead of maintaining a delta.
Migration file
-- migrations/streams/c04_agg.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "FULL"
SELECT id, SUM(amount) AS total FROM raw_orders GROUP BY id;
Plan output
Project analytics v9 → v10
Target prod
Changes 1 node affected
~ c04_agg [free] refresh_mode: DIFFERENTIAL → FULL
Why it's Free
Switching DIFFERENTIAL → FULL discards the incremental delta-tracking state managed by
pg_trickle. Since FULL mode re-materialises everything on every tick, no schema
change or data migration is required.
Contrast with FULL → DIFFERENTIAL
The reverse direction (FULL → DIFFERENTIAL) is Rebuild — see Pattern 17. Going from FULL to DIFFERENTIAL requires pg_trickle to establish delta-tracking state from
scratch, which means the table must be rebuilt.
Notes
- After switching to FULL,
aqueduct lintwill warn if the table is large and the schedule is short (high compute cost per refresh cycle).
Pattern 05 — Add a SUM Aggregate Column
Class: In-place
Cost: Seconds to minutes (proportional to row count)
Test: test_cookbook_05_add_sum_aggregate_column
When to use
Add a new SUM aggregate to an existing DIFFERENTIAL stream table without rebuilding it. The new column is backfilled incrementally; the existing materialised data is preserved.
Migration file
-- migrations/streams/c05_totals.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id,
SUM(amount) AS total,
SUM(discount) AS discount_total -- ← new column
FROM raw_orders
GROUP BY id;
Plan output
Project analytics v11 → v12
Target prod
Changes 1 node affected
~ c05_totals [in-place] add column discount_total (SUM)
Cost estimate:
Step Rows (est) Duration (est) Class
──────────────────────────────────────────────────────────────────
ALTER stream c05_totals — < 1s in-place
BACKFILL c05_totals 1.4M ~52s in-place
Why it's In-place
Adding a new SUM aggregate column does not change the GROUP BY keys, the FROM clause,
or the WHERE predicate. The existing rows remain valid — only the new column is
NULL until backfilled. pg_trickle fills the new column incrementally without
re-materialising the entire table.
Notes
- The same pattern applies to
COUNT,MIN,MAX, andAVGcolumns. See Pattern 06 for COUNT. - For tables with
allow_full_refresh = falseinaqueduct.toml, an In-place column addition is always safe.
Pattern 06 — Add a COUNT(*) Column
Class: In-place
Cost: Seconds to minutes
Test: test_cookbook_06_add_count_column
When to use
Add an event-count column alongside existing aggregates. A common use case is adding
COUNT(*) AS num_events to a table that previously only tracked totals.
Migration file
-- migrations/streams/c06_stats.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id,
SUM(value) AS total,
COUNT(*) AS event_count -- ← new column
FROM raw_events
GROUP BY id;
Plan output
~ c06_stats [in-place] add column event_count (COUNT)
Why it's In-place
COUNT(*) is a simple additive aggregate. It does not change the GROUP BY keys or
the row membership of the stream table. The new column is backfilled over the existing
rows without touching any other column data.
Notes
COUNT(DISTINCT col)is not IVM-supportable for DIFFERENTIAL mode in mostpg_trickleversions. UseCOUNT(*)instead, or switch the table to FULL refresh mode.
Pattern 07 — Drop a Column from the SELECT List
Class: In-place
Cost: < 1 second (DDL only)
Test: test_cookbook_07_drop_aggregate_column
When to use
Remove a column that is no longer needed by downstream consumers. Dropping a column
from the SELECT list is an in-place ALTER TABLE DROP COLUMN operation on the
materialised table.
Migration file
-- migrations/streams/c07_stats.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
-- Removed: COUNT(*) AS order_count
SELECT id,
SUM(amount) AS total
FROM raw_c07
GROUP BY id;
Plan output
~ c07_stats [in-place] drop column order_count
Why it's In-place
Dropping a column is always backward-compatible for the materialised table: the
remaining columns are unaffected. pg_trickle stops tracking the deleted column's
delta state, freeing the associated storage.
Important: check downstream consumers first
Before dropping a column, verify that no consumer views, reports, or application code
references the column. Use aqueduct lint to detect consumer view references. If a
consumer view references the column, update the consumer view migration file too.
Notes
- Dropping a column that is part of the GROUP BY is not in-place — that is a GROUP BY key change and requires a Rebuild.
Pattern 08 — Add an Aggregate Column Without Changing GROUP BY
Class: In-place
Cost: Seconds to minutes
Test: test_cookbook_08_add_passthrough_column
When to use
Add a new aggregate expression to the SELECT list that uses the same GROUP BY keys and the same FROM/WHERE structure as the existing query. This is the most common in-place migration pattern.
Migration file
-- migrations/streams/c08_view.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id,
SUM(amount) AS total,
MIN(amount) AS min_amount -- ← new aggregate, same GROUP BY
FROM raw_c08
GROUP BY id;
Plan output
~ c08_view [in-place] add column min_amount (MIN)
Key rule
The classifier checks that the GROUP BY clause, the FROM clause, and the WHERE clause (if any) are identical between the old and new query. If any of those structural parts change, the migration is classified as Rebuild regardless of what the SELECT list looks like.
In-place column additions that change GROUP BY — such as
SELECT id, category, SUM(amount) FROM t GROUP BY id, category — are classified as
Rebuild because the GROUP BY has changed.
Notes
- Supported aggregate functions for in-place addition:
SUM,COUNT,MIN,MAX,AVG, and any other aggregate thatpg_tricklesupports for DIFFERENTIAL mode. - Non-aggregate expressions that reference existing GROUP BY keys (e.g.,
id * 2 AS doubled_id) are also In-place.
Pattern 09 — Extend the SELECT List With a New Aggregate
Class: In-place
Cost: Seconds to minutes
Test: test_cookbook_09_widen_column_type
When to use
Extend an existing stream table's output by adding a new computed aggregate — for
example, adding AVG(amount) alongside COUNT(*) — without modifying the GROUP BY,
FROM, or WHERE clauses.
Migration file
-- migrations/streams/c09_counts.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id,
COUNT(*) AS n,
AVG(amount) AS avg_amount -- ← extended output
FROM raw_c09
GROUP BY id;
Plan output
~ c09_counts [in-place] add column avg_amount (AVG)
Why it's In-place
The SELECT list grew (superset), but the structural query parts are unchanged. The
planner detects a pure Addition in the SELECT list delta — a provably safe in-place
change.
The --strict flag
If you want to disable in-place classification and force every query change to be
reviewed as a potential Rebuild, set --strict on aqueduct plan. This is useful in
regulated environments where data-accuracy guarantees for every column are required
before applying.
Pattern 10 — Rename a Column
Class: Rebuild
Cost: Minutes (proportional to row count)
Test: test_cookbook_10_rename_column_is_rebuild
When to use
Rename an output column (e.g., total → revenue). Despite being a seemingly small
change, a column rename always triggers a full Rebuild.
Migration file
-- migrations/streams/c10_agg.sql (after)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id,
SUM(amount) AS revenue -- ← renamed from `total`
FROM raw_c10
GROUP BY id;
Plan output
! c10_agg [rebuild] column renamed: total → revenue → full rebuild required
Why it's a Rebuild
pg_trickle's DIFFERENTIAL refresh tracks deltas by column name. Renaming a column
destroys the column's delta-state metadata — there is no mapping from the old name to
the new name that preserves row-level accuracy. The table must be rebuilt from scratch.
Minimising downtime
For large tables, use the Blue/green deployment strategy to rename with zero downtime:
- Create a new stream table with the renamed column.
- Run it in parallel with the old table.
- Swap consumer views atomically when the new table has converged.
- Drop the old table after the blue-ttl expires.
This pattern is implemented by aqueduct apply --strategy blue-green.
Notes
- Plan this change during a maintenance window if the table is large.
allow_full_refresh = falseinaqueduct.tomlprevents this plan from executing unless--allow-rebuildis passed.
Pattern 11 — Change GROUP BY Keys
Class: Rebuild | Cost: Minutes | Test: test_cookbook_11_change_group_by_is_rebuild
When to use
Change the granularity of an aggregation — for example, adding region to a GROUP BY that previously only grouped by customer_id.
Migration file
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT customer_id, region, SUM(amount) AS total
FROM raw_c11
GROUP BY customer_id, region;
Why it's a Rebuild
The GROUP BY keys define the primary key of the materialised table. Changing them invalidates every existing row. A full DROP + recreate + backfill is required.
Notes
- Consider adding a separate downstream stream table instead of modifying the existing one (Pattern 21).
- For large tables, use
--strategy blue-greenfor zero-downtime rebuild.
Pattern 12 — Add a JOIN
Class: Rebuild | Cost: Minutes | Test: test_cookbook_12_add_join_is_rebuild
When to use
Enrich a stream table by joining to a reference table (e.g., adding customer name by joining to customers).
Migration file
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT o.customer_id, c.name, SUM(o.amount) AS total
FROM raw_orders o
JOIN customers c ON o.customer_id = c.id
GROUP BY o.customer_id, c.name;
Why it's a Rebuild
A JOIN changes the source set. Rows that previously had no match in the joined table may now be excluded; new rows from the join may appear. The existing materialised state is invalid.
Notes
- For low-cardinality reference tables, consider denormalising the value into the source table instead to keep the query JOIN-free and In-place-eligible.
Pattern 13 — Remove a JOIN
Class: Rebuild | Cost: Minutes | Test: test_cookbook_13_remove_join_is_rebuild
When to use
Simplify a stream table by removing a JOIN that is no longer needed.
Migration file
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT customer_id, SUM(amount) AS total
FROM raw_orders -- JOIN removed
GROUP BY customer_id;
Why it's a Rebuild
Removing a JOIN changes the source set. Rows that previously required a join match may now be included (or different rows may be excluded). The existing materialised state is invalid.
Pattern 14 — Change a JOIN Condition
Class: Rebuild | Cost: Minutes | Test: test_cookbook_14_change_join_condition_is_rebuild
When to use
Tighten or loosen the ON clause of an existing JOIN — for example, adding a filter on the joined table.
Migration file
-- @aqueduct:schedule = "30s"
SELECT o.id, p.name
FROM orders o
JOIN products p ON o.product_id = p.id
AND p.active = true; -- condition added
Why it's a Rebuild
The JOIN condition directly determines which rows are produced. A change means the current materialised state is incorrect and must be rebuilt.
Notes
- Even a small addition to a JOIN condition (e.g.,
AND t.col = x) has correctness implications for all existing rows.
Pattern 15 — Add a WHERE Predicate
Class: Rebuild | Cost: Minutes | Test: test_cookbook_15_add_where_predicate_is_rebuild
When to use
Add a filter to exclude rows that should not appear in the stream table.
Migration file
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id, SUM(amount) AS total
FROM raw_c15
WHERE status = 'active' -- new filter
GROUP BY id;
Why it's a Rebuild
A WHERE predicate changes which rows are included in the stream table. Existing rows that don't pass the new filter are now incorrect — the table must be rebuilt from scratch.
Pattern 16 — Change a WHERE Predicate
Class: Rebuild | Cost: Minutes | Test: test_cookbook_16_change_where_predicate_is_rebuild
When to use
Modify an existing WHERE clause — for example, tightening or loosening the filter condition.
Migration file
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id, amount
FROM events
WHERE type = 'purchase'; -- changed from 'click'
Why it's a Rebuild
A different WHERE predicate means a different set of rows qualifies. The materialised state is stale and must be rebuilt.
Pattern 17 — Switch Refresh Mode FULL to DIFFERENTIAL
Class: Rebuild | Cost: Minutes | Test: test_cookbook_17_full_to_diff_is_rebuild
When to use
Move a stream table from full periodic refreshes to incremental differential refresh to reduce compute cost on large tables.
Migration file
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL" -- was FULL
SELECT id, COUNT(*) AS n FROM raw_c17 GROUP BY id;
Why it's a Rebuild
DIFFERENTIAL mode requires pg_trickle to initialise delta-tracking state (change buffers, sequence counters) that did not exist in FULL mode. This can only be done by rebuilding the table from scratch.
Contrast with DIFF to FULL
The reverse direction (DIFFERENTIAL to FULL) is Free — see Pattern 04.
Notes
- After switching to DIFFERENTIAL,
aqueduct lintchecks IVM-supportability for the query. - Queries with
DISTINCT, volatile functions, or unsupported aggregates cannot be DIFFERENTIAL —aqueduct validatewill report this as a plan error.
Pattern 18 — Create a New Stream Table
Class: Create | Cost: Seconds to minutes (initial backfill) | Test: test_cookbook_18_create_stream_table
When to use
Add a new stream table to the project by creating a new migration file in migrations/streams/.
Migration file
-- migrations/streams/c18_scores.sql
-- @aqueduct:schedule = "1m"
-- @aqueduct:refresh_mode = "FULL"
SELECT id, AVG(score) AS avg_score
FROM raw_c18
GROUP BY id;
Plan output
+ c18_scores [create] new stream table (FULL, schedule 1m)
Notes
- After
aqueduct apply, runaqueduct statusto confirm the table is active and scheduled. - For DIFFERENTIAL mode,
pg_trickleperforms an initial full backfill before entering differential refresh. - Cost estimate: depends on row count of the source table and write throughput.
Pattern 19 — Drop an Existing Stream Table
Class: Drop | Cost: < 1 second | Test: test_cookbook_19_drop_stream_table
When to use
Remove a stream table that is no longer needed by deleting its migration file.
Steps
- Delete (or archive)
migrations/streams/c19_orphan.sql. - Run
aqueduct plan— the plan shows a Drop step. - Run
aqueduct applyto execute.
Plan output
- c19_orphan [drop] stream table removed from migrations directory
Important: check downstream dependencies
Before dropping a table, verify:
- No other stream table declares
@aqueduct:depends_on = ["public.c19_orphan"]. - No consumer view in
migrations/consumers/references the table. - No application code queries it directly.
aqueduct plan fails with a hard error if a downstream stream table still depends on the dropped node.
Pattern 20 — Two-Node Dependency DAG
Class: Create (both nodes) | Test: test_cookbook_20_two_node_dag
When to use
Build a two-level analytics pipeline where a summary stream table depends on a base aggregation stream table.
Migration files
-- migrations/streams/c20_totals.sql
-- @aqueduct:schedule = "30s"
SELECT id, SUM(amount) AS total FROM raw_c20 GROUP BY id;
-- migrations/streams/c20_summary.sql
-- @aqueduct:schedule = "1m"
-- @aqueduct:depends_on = ["public.c20_totals"]
SELECT COUNT(*) AS num_customers FROM public.c20_totals;
Plan output
+ c20_totals [create] level 0
+ c20_summary [create] level 1 (depends on c20_totals)
Notes
aqueductalways applies nodes in topological order.c20_totalsis created beforec20_summaryregardless of file order inmigrations/streams/.- Use
@aqueduct:depends_onto declare explicit dependencies when the planner cannot infer them from schema-qualified names in the SQL.
Pattern 21 — Add a Downstream Dependent Node
Class: Create (new node only) | Test: test_cookbook_21_add_downstream_node
When to use
Add a new stream table that consumes an existing one. Only the new downstream node requires a Create step; the upstream table is unchanged.
New migration file
-- migrations/streams/c21_summary.sql (new)
-- @aqueduct:schedule = "1m"
-- @aqueduct:depends_on = ["public.c21_totals"]
SELECT COUNT(*) AS n FROM public.c21_totals;
Plan output
= c21_totals [unchanged]
+ c21_summary [create] new downstream node
Notes
- The existing upstream table (
c21_totals) is not touched — zero downtime for consumers reading from it. - The plan executes the Create step only after confirming the upstream is healthy.
Pattern 22 — Remove a Downstream Node
Class: Drop (removed node only) | Test: test_cookbook_22_remove_downstream_node
When to use
Remove a stream table that was consuming an upstream table, without affecting the upstream table.
Steps
- Delete
migrations/streams/c22_downstream.sql. aqueduct planemits a Drop only forc22_downstream.- Apply.
Plan output
= c22_base [unchanged]
- c22_downstream [drop]
Notes
- The upstream table (
c22_base) continues running without interruption. - In a multi-node DAG,
aqueductdrops nodes in reverse topological order to avoid foreign-key-style constraint violations inpg_trickle.
Pattern 23 — Three-Level Dependency Chain
Class: Create (all 3 nodes) | Test: test_cookbook_23_three_level_chain
When to use
Build a three-level analytics pipeline: raw data → normalised layer → aggregated summary.
Migration files
-- migrations/streams/c23_lvl1.sql
-- @aqueduct:schedule = "30s"
SELECT id, region, amount FROM raw_c23;
-- migrations/streams/c23_lvl2.sql
-- @aqueduct:schedule = "1m"
-- @aqueduct:depends_on = ["public.c23_lvl1"]
SELECT region, SUM(amount) AS total FROM public.c23_lvl1 GROUP BY region;
-- migrations/streams/c23_lvl3.sql
-- @aqueduct:schedule = "5m"
-- @aqueduct:depends_on = ["public.c23_lvl2"]
SELECT COUNT(DISTINCT region) AS num_regions FROM public.c23_lvl2;
Plan output
+ c23_lvl1 [create] level 0
+ c23_lvl2 [create] level 1
+ c23_lvl3 [create] level 2
Notes
- The planner uses Kahn's algorithm to compute the topological order.
- Cycles are detected at plan time and reported as hard errors before any DDL is executed.
- Schedules should generally be longer at higher levels to avoid stale intermediate data.
Pattern 24 — Create a Consumer View
Class: Create (consumer view) | Test: test_cookbook_24_create_consumer_view
When to use
Expose a stream table through a stable, schema-qualified view for application or API consumption. The application always connects to the stable view name; the underlying stream table can be replaced transparently.
Migration file
-- migrations/consumers/c24_orders_view.sql
-- @aqueduct:kind = consumer
-- @aqueduct:source = public.c24_orders
-- @aqueduct:expose_as = api.orders
SELECT customer_id, total FROM public.c24_orders WHERE total > 0;
Plan output
+ api.orders [consumer create] exposes public.c24_orders
Benefits
- Applications connect to
api.orders— the name never changes. - On blue/green deployments, the view is swapped atomically with
ACCESS EXCLUSIVEon the view (not the underlying table), making the cutover sub-millisecond.
Notes
- Consumer views are tracked in
aqueduct.consumer_viewsand listed withaqueduct consumers list. - The
@aqueduct:expose_asschema must exist beforeaqueduct applyruns. Create it with a pre-hook or Atlas migration.
Pattern 25 — Drop a Consumer View
Class: Drop (consumer view) | Test: test_cookbook_25_drop_consumer_view
When to use
Remove a consumer view that is no longer needed by deleting its migration file.
Steps
- Delete
migrations/consumers/c25_view.sql. aqueduct planemits a consumer-drop step.aqueduct applydrops the view and removes the entry fromaqueduct.consumer_views.
Plan output
- reporting.c25_view [consumer drop]
Important: check application dependencies
aqueduct lint warns about consumer views that reference non-existent stream tables but does not detect external application dependencies. Verify no application code queries the view before dropping it.
Pattern 26 — Source Column Change Cascades to Stream Tables
Class: Rebuild (all affected stream tables) | Test: test_cookbook_26_source_column_cascade
When to use
An owned source table gains a new column that stream tables should use. aqueduct plan detects which stream tables reference the source and marks them for rebuild, generating a single coordinated plan.
Source migration file (updated)
-- migrations/sources/raw_c26.sql
-- @aqueduct:owned = true
CREATE TABLE public.raw_c26 (
id bigint,
amount numeric,
region text -- new column
);
Updated stream table
-- migrations/streams/c26_agg.sql (updated to use new column)
-- @aqueduct:schedule = "30s"
SELECT id, region, SUM(amount) AS total
FROM public.raw_c26
GROUP BY id, region;
Plan output
ALTER TABLE public.raw_c26 ADD COLUMN region text; [base-table DDL]
! c26_agg [rebuild] base-table DDL change cascade
Notes
- Stream tables that do not reference the changed column are unaffected.
- The base-table ALTER and stream-table cascade are always in a single coordinated plan —
aqueductwill never emit one without the other. - Tier 2 changes (new tables, non-referenced columns, indexes) are delegated to Atlas/sqitch via a pre-hook.
Pattern 27 — Change Schedule on Multiple Tables Simultaneously
Class: Free (all tables) | Cost: < 1 second | Test: test_cookbook_27_multi_table_schedule_change
When to use
Adjust the refresh cadence of a set of related stream tables in one aqueduct apply — for example, increasing frequency across a whole analytics pipeline before a business-critical period.
Migration files (updated schedules)
c27_a.sql: -- @aqueduct:schedule = "10s" (was 1m)
c27_b.sql: -- @aqueduct:schedule = "10s" (was 1m)
c27_c.sql: -- @aqueduct:schedule = "10s" (was 1m)
Plan output
~ c27_a [free] schedule: 1m → 10s
~ c27_b [free] schedule: 1m → 10s
~ c27_c [free] schedule: 1m → 10s
Notes
- All Free steps execute as fast serial metadata updates.
- There is no dependency between Free steps on separate tables — they could in principle be parallelised. The current executor applies them sequentially for simplicity.
aqueduct lintwarns whenschedule < 5sand the estimated row count is large.
Pattern 28 — Import from Live — Idempotent Round-Trip
Test: test_cookbook_28_import_roundtrip
When to use
Bootstrap a pg_aqueduct project from an existing live pg_trickle deployment: generate canonical migration files that match the current live state, then confirm that running aqueduct plan produces an empty (no-op) plan.
Commands
# Generate migration files from the live database.
aqueduct import --from prod --output ./my-project/
# Confirm the round-trip: plan should be empty.
cd my-project
aqueduct plan --to prod
What import generates
For each stream table in pgtrickle.pgt_stream_tables, aqueduct import creates:
migrations/streams/{table_name}.sql— SQL body + front-matter directivesmigrations/sources/{source_name}.sql— for referenced base tables (owned = false)aqueduct.toml— skeleton project config
Idempotency guarantee
Running aqueduct import a second time with no live changes produces no file changes. The generated files always round-trip to an empty aqueduct plan.
Notes
- Use
--exclude-pattern '_pg_ripple.*'and--exclude-pattern '_pg_eddy.*'to skip internal extension tables (applied by default). - After import, commit the generated files and run
aqueduct initto record the baseline version.
Pattern 29 — Rollback Across Version Boundaries
Test: test_cookbook_29_rollback_to_prior_state
When to use
Revert a deployed migration to a previous DAG version — for example, after discovering a bug in a newly deployed aggregation.
Commands
# Roll back to the immediately previous version.
aqueduct rollback --to prod
# Roll back to a specific historical version.
aqueduct rollback --to prod --to-version 3
How rollback works
aqueduct rollback is not a reverse replay of the forward migration. Instead:
- It reads the desired DAG spec stored in
aqueduct.dag_versionsfor the target version. - It computes a forward migration plan from the current live state to the target spec.
- It applies that forward plan.
This means rollback uses the same planner, executor, and safety checks as a normal aqueduct apply. If rolling back requires a Rebuild (e.g., after a column rename), the same Rebuild logic applies.
Example (v1 → v2 → rollback to v1)
v1: c29_base only
v2: c29_base + c29_extra (added)
rollback to v1: DROP c29_extra
Lossless guarantee
- Free and In-place migrations: always lossless.
- Rebuild migrations: lossless only if no full refresh has completed since the migration.
aqueduct rollbackreports the estimated lossless window expiry and requires--accept-data-lossif the window has passed.
Notes
aqueduct rollbacknever rolls back base-table DDL (Tier 1 changes). Provide a compensating Atlas migration for any base-table changes.
Pattern 30 — Full DAG Lifecycle: Create, Evolve, Destroy
Test: test_cookbook_30_full_dag_lifecycle
Overview
A complete end-to-end walkthrough of a stream table's lifecycle:
- Create the stream table from a migration file.
- Evolve it with an in-place column addition (zero downtime).
- Destroy all project resources when they are no longer needed.
Step 1: Create
-- migrations/streams/c30_totals.sql
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id, SUM(amount) AS total FROM raw_c30 GROUP BY id;
aqueduct apply --to prod
# Result: creates c30_totals (v1)
Step 2: In-place evolution
-- c30_totals.sql (updated — add COUNT(*) column, same GROUP BY)
-- @aqueduct:schedule = "30s"
-- @aqueduct:refresh_mode = "DIFFERENTIAL"
SELECT id,
SUM(amount) AS total,
COUNT(*) AS order_count -- new column
FROM raw_c30
GROUP BY id;
aqueduct plan --to prod
# Shows: ~ c30_totals [in-place] add column order_count (COUNT)
aqueduct apply --to prod
# Result: in-place column addition (v2) — zero downtime
Step 3: Destroy
When the project is retired, clean up all resources:
aqueduct destroy --project cookbook-30 --to prod --dry-run # preview first
aqueduct destroy --project cookbook-30 --to prod --confirm # execute
The destroy command:
- Drops all stream tables in reverse topological order.
- Drops all consumer views managed by the project.
- Deletes the project's rows from
aqueduct.dag_versions,aqueduct.migrations, andaqueduct.locks. - Does not drop the
aqueductcatalog schema itself (other projects may share it).
Notes
- Always run
--dry-runbefore--confirmfor destroy. - Use
aqueduct statusat each step to verify the pipeline state.
Security Guide
Overview
pg_aqueduct is designed to operate without superuser privileges and with a minimal
footprint on the target PostgreSQL cluster.
Least-privilege role
Create a dedicated aqueduct_admin role for all aqueduct operations:
CREATE ROLE aqueduct_admin LOGIN PASSWORD '...';
-- Access to the aqueduct catalog schema.
GRANT USAGE, CREATE ON SCHEMA aqueduct TO aqueduct_admin;
-- Access to pg_trickle functions.
GRANT USAGE ON SCHEMA pgtrickle TO aqueduct_admin;
-- Read access to system catalogs (for live-state queries).
GRANT SELECT ON pg_catalog.pg_class TO aqueduct_admin;
GRANT SELECT ON pg_catalog.pg_attribute TO aqueduct_admin;
GRANT SELECT ON pg_catalog.pg_type TO aqueduct_admin;
GRANT SELECT ON pg_catalog.pg_namespace TO aqueduct_admin;
-- DDL rights on the stream-table schemas only.
GRANT CREATE ON SCHEMA public TO aqueduct_admin;
-- (add each schema where stream tables live)
The role requires:
- No
SUPERUSER - No
CREATEROLE - No replication privilege
Connection string security
Environment variables (default)
[targets.prod]
dsn = "${AQUEDUCT_PROD_DSN}"
pg_aqueduct expands ${VAR} at runtime. The DSN is never written to disk or logged.
Plaintext passwords
The CLI refuses plaintext passwords in config files:
Error: DSN contains a plaintext password. Use ${ENV_VAR} or a secret backend.
Pass --allow-plaintext-password to override (not recommended in CI).
Secret backends
Use the ${secret:BACKEND:KEY} inline syntax to fetch credentials from an external
secret store at apply time:
[targets.prod]
dsn = "postgresql://app:${secret:vault:database/prod-dsn}@db.example.com/app"
Supported backends:
| Backend | Flag value | Status | Credential source |
|---|---|---|---|
| Environment variable | env | Implemented | ${VAR} (default) |
| SOPS-encrypted file | sops | Implemented | sops -d <file> subprocess |
| age-encrypted file | age | Implemented | age -d -i <identity> <file> subprocess |
| AWS Secrets Manager | aws | Implemented | AWS_REGION + SDK credentials |
| GCP Secret Manager | gcp | Implemented | GOOGLE_APPLICATION_CREDENTIALS |
| HashiCorp Vault | vault | Implemented | VAULT_ADDR + VAULT_TOKEN |
Note: The
aws,gcp, andvaultbackends are implemented as of v0.12. Each backend resolves credentials at apply time and never writes them to disk.
Read-only transactions
aqueduct plan, aqueduct status, and aqueduct validate open read-only transactions
(SET TRANSACTION READ ONLY). These commands cannot accidentally mutate data.
Audit trail
Every aqueduct apply records the following in aqueduct.migrations:
- Authenticated PostgreSQL role (
current_role) - Client IP address (
inet_client_addr()) - CLI version
- Full plan (all steps)
- Start and finish timestamps
Query the audit trail:
SELECT started_at, applied_by, cli_version, status
FROM aqueduct.migrations
ORDER BY started_at DESC
LIMIT 20;
HA security
aqueduct apply refuses to run against a hot standby:
SELECT pg_is_in_recovery(); -- returns true on a standby
If the result is true, the CLI exits non-zero with a clear error:
Error: target is a hot standby — apply requires a primary.
For Patroni clusters, aqueduct performs a synchronous HTTP check against the Patroni
REST endpoint (GET /master) before applying. Pass --patroni-endpoint <url>.
OWASP considerations
- Injection: All database queries use parameterised statements (
$1,$2, ...). SQL identifiers are quoted withformat('%I', name)in PostgreSQL functions. The Rust layer does not perform string interpolation into SQL; explicit trust boundaries for identifier construction were introduced in v0.14 (seeCatalogSchemanewtype and thecatalog.rsquery builder). - Consumer SQL injection (SEC-1): Consumer view bodies are validated as a single
SELECTstatement byvalidate_consumer_sql_is_single_select()before anyCREATE OR REPLACE VIEWis executed. Multi-statement bodies and bare DDL are rejected withAqueductError::UntrustedSqlBodybefore any database call is made. - Sensitive data exposure: DSNs containing passwords are masked in log output. Secret values are never echoed or stored.
- Misconfiguration:
aqueduct lintwarns about overly permissive configurations (e.g.,allow_full_refresh = truein a production target).
CNPG preview TLS
When using aqueduct preview with CloudNativePG, the CLI validates TLS certificates
on all Kubernetes API calls by default. To connect to a development cluster with a
self-signed certificate, set the development-only escape hatch:
export CNPG_INSECURE_SKIP_VERIFY=1
aqueduct preview create --branch feat/my-branch
Warning: Never set
CNPG_INSECURE_SKIP_VERIFYin production. Disabling TLS verification exposes the connection to man-in-the-middle attacks.
Age identity file security
The age secret backend validates both the encrypted-file path (key) and the
identity file path (AGE_KEY_FILE / SOPS_AGE_KEY_FILE) before passing them to
the age subprocess:
- Path traversal: paths containing
..components are rejected withAqueductError::InvalidSecretPath. - Flag injection: identity file paths starting with
-are rejected to prevent the value from being interpreted as a CLI flag by theagebinary.
Role SQL Templates
Copy and customise these templates for your environment. Replace <schema> with
each schema that contains stream tables or consumer views.
These roles follow the principle of least privilege. Each role is scoped to exactly the operations it needs to perform.
aqueduct_planner
Read-only access: runs aqueduct plan, aqueduct status, aqueduct diff.
Cannot write to any table.
CREATE ROLE aqueduct_planner LOGIN PASSWORD 'CHANGE_ME' NOSUPERUSER NOCREATEDB NOCREATEROLE;
GRANT USAGE ON SCHEMA aqueduct TO aqueduct_planner;
GRANT SELECT ON ALL TABLES IN SCHEMA aqueduct TO aqueduct_planner;
GRANT SELECT ON ALL TABLES IN SCHEMA pgtrickle TO aqueduct_planner;
GRANT USAGE ON SCHEMA pgtrickle TO aqueduct_planner;
-- Read access to system catalogs (for live-state queries).
GRANT SELECT ON pg_catalog.pg_class TO aqueduct_planner;
GRANT SELECT ON pg_catalog.pg_attribute TO aqueduct_planner;
GRANT SELECT ON pg_catalog.pg_type TO aqueduct_planner;
GRANT SELECT ON pg_catalog.pg_namespace TO aqueduct_planner;
-- Read access to the stream-table schemas (for query validation).
-- Repeat for each schema that contains stream tables:
GRANT USAGE ON SCHEMA <schema> TO aqueduct_planner;
GRANT SELECT ON ALL TABLES IN SCHEMA <schema> TO aqueduct_planner;
aqueduct_applier
Write access: runs aqueduct apply, aqueduct rollback, aqueduct promote.
Can call pgtrickle DDL functions and write to the aqueduct catalog.
CREATE ROLE aqueduct_applier LOGIN PASSWORD 'CHANGE_ME' NOSUPERUSER NOCREATEDB NOCREATEROLE;
GRANT USAGE ON SCHEMA aqueduct TO aqueduct_applier;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA aqueduct TO aqueduct_applier;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA aqueduct TO aqueduct_applier;
GRANT USAGE ON SCHEMA pgtrickle TO aqueduct_applier;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgtrickle TO aqueduct_applier;
-- DDL rights on the stream-table schemas.
-- Repeat for each schema that contains stream tables:
GRANT USAGE, CREATE ON SCHEMA <schema> TO aqueduct_applier;
GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA <schema> TO aqueduct_applier;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA <schema> TO aqueduct_applier;
aqueduct_preview
Preview access: runs aqueduct preview in a sandboxed schema.
Can create/drop objects in the preview schema only.
CREATE ROLE aqueduct_preview LOGIN PASSWORD 'CHANGE_ME' NOSUPERUSER NOCREATEDB NOCREATEROLE;
GRANT USAGE ON SCHEMA aqueduct TO aqueduct_preview;
GRANT SELECT ON ALL TABLES IN SCHEMA aqueduct TO aqueduct_preview;
GRANT USAGE ON SCHEMA pgtrickle TO aqueduct_preview;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgtrickle TO aqueduct_preview;
-- Grant full access to the preview schema (created by `aqueduct preview`).
-- Replace <preview_schema> with the actual schema used (default: aqueduct_preview).
GRANT USAGE, CREATE ON SCHEMA <preview_schema> TO aqueduct_preview;
GRANT ALL ON ALL TABLES IN SCHEMA <preview_schema> TO aqueduct_preview;
aqueduct_destroy
Destroy access: runs aqueduct destroy to tear down the full project DAG.
This role should be tightly guarded. Consider requiring 2FA or approvals.
CREATE ROLE aqueduct_destroy LOGIN PASSWORD 'CHANGE_ME' NOSUPERUSER NOCREATEDB NOCREATEROLE;
GRANT USAGE ON SCHEMA aqueduct TO aqueduct_destroy;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA aqueduct TO aqueduct_destroy;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA aqueduct TO aqueduct_destroy;
GRANT USAGE ON SCHEMA pgtrickle TO aqueduct_destroy;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgtrickle TO aqueduct_destroy;
-- DDL rights on the stream-table schemas (for DROP TABLE / DROP VIEW).
-- Repeat for each schema that contains stream tables:
GRANT USAGE, CREATE ON SCHEMA <schema> TO aqueduct_destroy;
GRANT ALL ON ALL TABLES IN SCHEMA <schema> TO aqueduct_destroy;
HA Operations Guide
Overview
pg_aqueduct is designed to operate safely in high-availability PostgreSQL deployments,
including Patroni clusters, CloudNativePG, and Stolon.
Primary detection (v0.17)
aqueduct apply always checks that the target is a primary before and between every
migration step:
SELECT pg_is_in_recovery(); -- must return false
If the target is a standby (failover happened), the CLI marks the migration as
interrupted and exits non-zero with a descriptive error.
Supported HA backends
The detect_ha_backend() function (in aqueduct_core::ha) detects the cluster type
automatically:
| Backend | Detection method |
|---|---|
| Plain primary | pg_is_in_recovery() = false |
| Patroni | HTTP GET /master on --patroni-endpoint (returns HTTP 200 when primary) |
| CloudNativePG | app.cnpg.cluster_name GUC present |
| Stolon | application_name contains stolon-keeper |
Patroni failover detection
Pass --patroni-endpoint to enable Patroni-aware failover detection:
aqueduct apply --to prod --patroni-endpoint http://patroni-api:8008
After every migration step, the CLI calls GET /master on the Patroni REST API.
A non-200 response (e.g., HTTP 503 when a replica answers) means the primary has
changed. The migration is marked interrupted and the CLI exits non-zero.
CloudNativePG
No additional flags required. The CLI reads the app.cnpg.cluster_name GUC to detect
a CloudNativePG cluster and relies on the standard primary check (pg_is_in_recovery()).
Maintenance windows
Set a maintenance window in aqueduct.toml to gate Rebuild steps:
[apply]
maintenance_window = "02:00-04:00 UTC"
maintenance_window_applies_to = ["rebuild"]
- Free and In-place steps always execute immediately, regardless of the window.
- Rebuild steps are blocked outside the window.
--ignore-maintenance-windowoverrides for emergency runs.
Concurrent apply protection
aqueduct apply acquires a row-level lock in aqueduct.locks before executing:
SELECT * FROM aqueduct.locks
WHERE project = $1
FOR UPDATE NOWAIT;
- The lock is automatically released on crash (unlike advisory locks) when the client disconnects.
- A background heartbeat updates
acquired_ateveryttl / 3during long migrations. - If the CLI crashes, the lock expires after one TTL and the next
aqueduct apply --resumecan recover.
To release a stale lock manually:
aqueduct unlock --project my-project --to prod
Crash recovery with --resume
If aqueduct apply crashes mid-migration:
aqueduct apply --to prod --resume
--resume reads aqueduct.migrations.progress, identifies completed steps, and skips
them. For ambiguous steps (where the CLI cannot determine completion from the catalog),
it reports a diagnostic and exits non-zero — the operator must inspect and use
--force-retry or --force-skip for the ambiguous step.
Compensating-step protocol (v0.20)
Overview
Certain DDL operations — primarily CREATE and DROP stream tables — are recorded in
aqueduct.ddl_log before execution, along with a compensating SQL statement that can
undo the operation if the process crashes between the DDL and the subsequent
RecordSnapshot step.
The ddl_log table schema:
CREATE TABLE aqueduct.ddl_log (
id BIGSERIAL PRIMARY KEY,
migration_id BIGINT NOT NULL REFERENCES aqueduct.migrations(id),
step_index INT NOT NULL,
step_type TEXT NOT NULL,
applied_sql TEXT NOT NULL,
compensating_sql TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'complete',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The status column progresses through:
| Status | Meaning |
|---|---|
running | DDL has been logged but not yet committed to the catalog |
complete | DDL and its catalog snapshot both succeeded |
compensated | A crash was detected; the compensating SQL was applied on resume |
When compensating steps are recorded
| Step type | Applied SQL | Compensating SQL |
|---|---|---|
CreateStreamTable | CREATE TABLE … | DROP TABLE IF EXISTS … |
DropStreamTable | DROP TABLE … | CREATE TABLE IF NOT EXISTS … |
The row is written with status = 'running' before the DDL executes. It is updated to
status = 'complete' after RecordSnapshot succeeds (both in the same transaction).
What --resume does
On --resume, before identifying the checkpoint step:
SELECT * FROM aqueduct.ddl_log WHERE migration_id = $1 AND status = 'running'is executed (viaGET_PENDING_COMPENSATING_STEPS_SQL).- For each
runningrow, thecompensating_sqlis executed viabatch_execute. - The row is updated to
status = 'compensated'(viaMARK_COMPENSATING_APPLIED_SQL). - The resume checkpoint search then proceeds as normal.
This closes the gap where a crash between CreateStreamTable and RecordSnapshot left
a pg_trickle-managed table without a corresponding catalog snapshot. On the next
--resume, the orphaned table is dropped by the compensating step and then re-created
cleanly from the checkpoint.
Recovery runbook: "table exists in pg_trickle but not in catalog"
Symptom: pg_trickle reports a table that does not appear in aqueduct.dag_versions
or in aqueduct.migrations.progress as completed.
Diagnosis:
-- Check for running ddl_log rows
SELECT id, migration_id, step_index, step_type, status, created_at
FROM aqueduct.ddl_log
WHERE status = 'running'
ORDER BY created_at DESC;
Recovery (automated): Simply run aqueduct apply --resume. The compensating step
will drop the orphaned table and the resume will re-create it.
Recovery (manual): If you need to intervene manually:
-- 1. Execute the compensating SQL (from the ddl_log row)
-- e.g. for a CreateStreamTable crash:
DROP TABLE IF EXISTS public.my_table;
-- 2. Mark the row compensated so --resume skips it
UPDATE aqueduct.ddl_log
SET status = 'compensated'
WHERE id = <row_id>;
Then run aqueduct apply --resume to continue from the checkpoint.
Failover during a migration
Between each plan step, aqueduct apply calls check_still_primary() which executes
SELECT pg_is_in_recovery(). If the optional --patroni-endpoint is set, it also
calls GET /master on the Patroni REST API. On failover detection:
- The CLI marks the migration as
status = 'interrupted'inaqueduct.migrations. - It exits non-zero with a clear message including the last completed step.
- Recovery: reconnect to the new primary and run
aqueduct apply --resume.
The interrupted status is distinct from failed — it signals that the migration was
cleanly paused due to an infrastructure event, not a SQL error.
Observability: per-step JSON events (v0.17)
aqueduct apply emits structured JSON events to stderr for every migration step:
{"schema_version":1,"event":"step_start","step_index":0,"step_type":"LockDag","migration_id":42,"project":"my-project"}
{"schema_version":1,"event":"step_complete","step_index":0,"step_type":"LockDag","migration_id":42,"project":"my-project","duration_ms":3}
{"schema_version":1,"event":"step_failed","step_index":1,"step_type":"Backfill","migration_id":42,"project":"my-project","duration_ms":1500,"error":"deadlock detected"}
The complete event schema is documented in cli-events-schema.json.
Migration audit log
Use aqueduct audit to review migration history:
aqueduct audit --to prod # table format (default)
aqueduct audit --to prod --format json # JSON array
aqueduct audit --to prod --limit 50 # show 50 most recent
Example: Patroni + maintenance window
# aqueduct.toml
[targets.prod]
dsn = "${PROD_DSN}"
[apply]
maintenance_window = "01:00-03:00 UTC"
lock_timeout = "60s"
# In a CI pipeline with Patroni:
aqueduct apply --to prod --patroni-endpoint http://patroni-api:8008 --yes
# → Exits non-zero with "outside maintenance window" if outside 01:00–03:00 UTC
# and the plan contains Rebuild steps.
# → Applies immediately if the plan contains only Free/In-place steps.
# → Marks migration interrupted (not failed) if Patroni reports a failover.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
For planned future versions, see ROADMAP.md.
Unreleased
[0.20.0] - 2025-07-14
This release delivers the three high-priority architectural improvements identified in the Phase 4 engineering assessment: typed executor configuration, full multi-tenant catalog parameterisation, and automated crash recovery via the compensating-step registry.
The ExecutionContext struct replaces the optional builder pattern for the two safety-critical executor fields (desired_state and connection_string), making it a compile error to omit them. All command handlers (apply, rollback, promote) have been updated accordingly.
Every catalog SQL constant now accepts a CatalogSchema parameter and substitutes the schema name via for_schema(). The validate_catalog_schema_not_overridden() stop-gap from v0.19 is removed. aqueduct init --schema my_catalog creates all catalog tables in my_catalog; the multi-tenant isolation integration test verifies that two projects on the same database with different catalog schemas have fully independent version histories.
The --resume flow now queries aqueduct.ddl_log for rows in status = 'running' before advancing to the checkpoint. For each such row the compensating SQL is executed and the row is updated to status = 'compensated'. A new integration test covers the crash-after-DDL, before-RecordSnapshot scenario.
The OutputEmitter is now stored as a process-wide singleton via OnceLock, initialised in main before command dispatch and accessible via output::emitter(). The init command is the first to use the emitter for its success messages.
The httpmock dependency was replaced with wiremock in v0.17, eliminating the last cargo audit --deny warnings finding (RUSTSEC-2025-0052).
The catalog schema version advances from 8 to 9, adding a status column to aqueduct.ddl_log used by compensating-step recovery.
Added
- Typed
ExecutionContextstruct (ARCH-2):ExecutionContextstruct with requireddesired_stateandconnection_stringfields replaces the optional builder pattern. Omitting either field is now a compile error.for_apply(),for_rollback(), andfor_promote()constructors updated. - Full catalog schema parameterisation (ARCH-1):
CatalogSchemathreaded through every catalog SQL function.ensure_catalog_current(),connect_and_migrate(),read_live_state(),get_latest_dag_version(),detect_extension_installed(),read_ddl_log(),import_from_live(),compute_promotion_plan(),validate_source_clean(), anddestroy_project()all accept&CatalogSchema.validate_catalog_schema_not_overridden()stop-gap removed. - Multi-tenant catalog isolation test:
test_multi_tenant_catalog_isolationverifies that two catalogs (tenant_a,tenant_b) on the same database have independentdag_versionstables with no cross-contamination. - Automated compensating-step recovery (CORR-2):
find_resume_step()queriesaqueduct.ddl_logforstatus = 'running'rows and executes theircompensating_sqlviabatch_executebefore advancing to the resume checkpoint. Rows are updated tostatus = 'compensated'on success. COMPLETE_COMPENSATING_STEP_SQLandMARK_COMPENSATING_APPLIED_SQLconstants incatalog.rsfor the two-phase compensating-step lifecycle.- CORR-2 crash recovery integration test:
test_corr2_compensating_step_recoveryinserts a syntheticstatus = 'running'ddl_log row and verifies that--resumemoves it out of therunningstate. - Catalog schema version 9:
CATALOG_SCHEMA_VERSIONadvances from 8 to 9. The v8→v9 migration adds astatus TEXT NOT NULL DEFAULT 'complete'column toaqueduct.ddl_logand backfills existing rows. OutputEmitterprocess-wide singleton (M-1):output::init_emitter(mode)stores the emitter in astatic OnceLock<OutputEmitter>;output::emitter()returns the global reference.main.rscallsinit_emitterbefore dispatch. Theinitcommand uses the emitter for its success messages, replacing directprintln!calls.
Changed
for_schema(sql, schema)now substitutes= 'aqueduct'string-literal comparisons andCREATE/DROP SCHEMA IF NOT EXISTS aqueductstatements in addition to theaqueduct.dot-prefix references.DestroyOptionsgains acatalog_schema: CatalogSchemafield.- All
cargo check --testscall sites forread_live_state,get_latest_dag_version,detect_extension_installed,read_ddl_log,import_from_live,compute_promotion_plan,validate_source_clean, andDestroyOptions::newupdated throughout CLI commands and integration tests. - Workspace version bumped
0.19.0→0.20.0.
Fixed
executor.rs:import_from_livewas callingread_live_state(client, None)(2-arg form) instead of the 3-argread_live_state(client, None, catalog_schema). Fixed.- Extra trailing semicolons (
await?;;) incommands/promote.rsandcommands/status.rsintroduced by the multi-replace pass. Removed.
[0.19.0] - 2026-05-20
This release eliminates every structural correctness bug identified in the Phase 4 engineering assessment, makes the blue/green view swap fully atomic, and ensures every known correctness regression path is covered by a test that would catch it.
The headline change is that the deployment status row update in SwapConsumerViews is now executed inside the surrounding BEGIN/COMMIT block, eliminating the window where consumer views point at the green schema while the deployment row still shows status = 'active'. If the transaction is rolled back for any reason, both the view assignments and the deployment row revert together.
The heartbeat task is now supervised by a JoinHandle-based wrapper. If the heartbeat task panics — something that could previously produce a silent hang — the executor immediately receives a LockLost signal, stops execution, and surfaces the failure. This closes the last known path to a silent runaway executor.
The mock pg_trickle DDL gains a pgtrickle.paused_nodes table so integration tests can assert precisely which nodes were paused during DDL and that the scheduler was fully resumed after apply or after a failure. Two new test-kit helpers (assert_scheduler_idle and assert_scheduler_paused_for) make these assertions one-liners.
Four executor and CLI quality fixes round out the release: the WaitForConvergence poll query is now guarded by a statement_timeout to prevent a hung pg_trickle query from holding the loop past the configured deadline; status --watch applies exponential backoff on repeated connection failures; the EXPLAIN trade-off in cost.rs is documented with a code comment; and aqueduct init and aqueduct apply now return an explicit NotYetImplemented error when catalog_schema is set to anything other than "aqueduct", preventing silent data mixing in multi-tenant deployments until full schema parameterisation ships in v0.20.
All ten failure-mode test cases identified in the Phase 4 assessment are now present and passing.
Added
- Blue/green atomicity (ARCH-3):
SWAP_BLUE_GREEN_SQL(theblue_green_deploymentsstatus row update) is now executed inside theSwapConsumerViewstransaction. A failed mid-swap now rolls back both view assignments and the deployment status atomically. Integration testswap_consumer_views_atomicityverifies this. - Heartbeat panic supervisor (CORR-3):
tokio::spawn(run_heartbeat(...))is wrapped in aJoinHandle-based supervisor that callslock_lost_tx.send(true)if the heartbeat task panics. Integration testheartbeat_panic_signals_lock_lossverifies the signal. - Observable scheduler mock (TEST-3):
mock_pgtrickle.rsgains apgtrickle.paused_nodes(node_name text PRIMARY KEY, paused_at timestamptz)table.pause_schedulerinserts into it;resume_schedulerdeletes from it. - Test-kit helpers:
assert_scheduler_idle(client)andassert_scheduler_paused_for(client, nodes)added toaqueduct-testkit. Integration testsmock_scheduler_records_pauseandmock_scheduler_idle_after_applydemonstrate usage. statement_timeoutguard forWaitForConvergence(M-9): the pollSELECTis wrapped inBEGIN READ ONLY; SET LOCAL statement_timeout = '{timeout}ms'so a hungpg_tricklequery cannot hold the loop open past the configured deadline. Integration testwaitforconvergence_deadline_respectedverifies the deadline is enforced.- Exponential backoff in
status --watch(M-8): consecutive connection or poll failures back off up to5 × intervalbefore retrying; the counter resets on the next successful poll. - EXPLAIN trade-off comment (M-4):
cost.rs::estimate_rowsnow documents whyformat!("EXPLAIN...")is intentional and safe. The EXPLAIN call is also wrapped in aBEGIN READ ONLY; SET LOCAL statement_timeout = '5s'block. - Catalog schema stop-gap (ARCH-1 partial):
validate_catalog_schema_not_overriddenincatalog.rsreturnsAqueductError::NotYetImplemented { feature: "catalog_schema override" }for any value other than"aqueduct". Called fromaqueduct initandaqueduct applyto prevent silent data mixing until full multi-tenant support lands in v0.20. Integration testcatalog_schema_override_rejected_until_implementedverifies this. - Failure-mode tests (Phase 4): all ten test cases identified in the assessment are
now present and passing:
consumer_sql_injection_rejected_at_apply,heartbeat_panic_signals_lock_loss,swap_consumer_views_atomicity,plan_fail_on_drift_counts_consumer_deltas,destroy_auto_migrates_catalog,catalog_schema_override_rejected_until_implemented,age_identity_file_traversal_rejected,cnpg_preview_requires_valid_cert,waitforconvergence_deadline_respected,mock_scheduler_records_pause. AqueductError::NotYetImplemented { feature }variant added (error code 1308).cnpg_preview_requires_valid_certunit test inpreview.rsverifies that acreate_preview_cnpgcall to a plain-HTTP endpoint fails with a TLS/connection error whenCNPG_INSECURE_SKIP_VERIFYis absent (SEC-2).
Changed
- Workspace version bumped to
0.19.0. - Six user-facing integration files updated to reference version
0.19.0:.github/workflows/aqueduct-plan.yml,.github/workflows/aqueduct-apply.yml,ci/gitlab/aqueduct.gitlab-ci.yml,.pre-commit-hooks.yaml,docs/api-reference.md,docs/introduction.md. ROADMAP.mdstatus line updated tov0.19.0 released.
This release closes every open security finding from the Phase 4 engineering assessment, bringing pg_aqueduct to zero open CVEs and zero open critical or high security issues. The headline change is that consumer view bodies are now validated as a single SELECT statement before any database call is made — meaning a misconfigured or maliciously crafted migration file containing DDL like CREATE TABLE or DROP TABLE can never reach the database, even if it somehow made it into your migrations directory. This is an important defence-in-depth layer for teams running Aqueduct in automated CI/CD pipelines.
Two additional critical security fixes ship in this version: the CloudNativePG preview client now validates TLS certificates by default (previously it silently accepted any certificate), and the age secret backend now validates the identity file path for both path traversal and flag injection before passing it to the age subprocess. Together with the consumer SQL validation, these three fixes mean pg_aqueduct meets the full OWASP A03 (Injection) and A02 (Cryptographic Failures) controls relevant to its operation.
Beyond security, this version improves day-to-day operational quality: aqueduct plan --fail-on-drift now correctly detects drift in consumer views and source tables (not just stream tables), aqueduct destroy auto-migrates a stale catalog before querying it, and several documentation files and CI template files that had stale version pins are updated to match the current release. A new .github/SECURITY.md establishes the project's vulnerability reporting policy.
Added
validate_consumer_sql_is_single_select()is now called immediately before everyCREATE OR REPLACE VIEWin the executor (ManageConsumerViewandSwapConsumerViewsarms). A migration file containing injected DDL is rejected withAqueductError::UntrustedSqlBodybefore any database call is made (SEC-1).- Integration test
consumer_sql_injection_rejected_at_applyconfirms that a consumer migration with aCREATE TABLEbody is rejected at the executor level (SEC-1). - CNPG preview HTTP client now validates TLS certificates by default. Set
CNPG_INSECURE_SKIP_VERIFYto skip verification in development environments only. Emits awarn!when the env var is set (SEC-2). - Unit test
cnpg_tls_default_does_not_skip_verificationconfirms the env-var gate logic (SEC-2). agesecret backend now validates the identity file path (fromSOPS_AGE_KEY_FILE/AGE_KEY_FILE) viavalidate_secret_path()and rejects paths starting with-(flag-injection guard). Unit testsage_identity_file_traversal_rejectedandage_identity_file_flag_injection_rejectedcover both rejection cases (SEC-3)..github/SECURITY.md: security policy with contact address, response SLA, supported version matrix, and a summary of implemented controls (L-5).docs/security.md: new sections covering consumer SQL injection protection, CNPG TLS configuration, andageidentity file security (SEC-1, SEC-2, SEC-3).
Changed
aqueduct plan --fail-on-drift: drift count now includessource_deltasandconsumer_deltasin addition todeltas, matching the already-correctstatusimplementation. Previously, consumer-layer and source-layer drift was silently ignored (M-2).aqueduct destroy: changed fromconnect()toconnect_and_migrate()so a stale catalog schema is auto-migrated before destroy queries it (M-5).release.yml: removed globalFORCE_JAVASCRIPT_ACTIONS_TO_NODE24env var — all actions in the workflow use modern Node.js runtimes that do not require this workaround (M-6). Addedjust gen-docsstep to regeneratedocs/api-reference.mdon every release tag.ingest.rstest helpers:fs::write(...).unwrap()replaced with descriptive.expect("...")calls so a failing CI write produces a clear error message (M-7).fmt.rs: renamedKNOWN_FRONTMATTER_KEYStoKNOWN_DIRECTIVE_KEYSwith updated doc comment explaining it is used to skip already-emitted keys, not enforce ordering (L-1).main.rs: replaced stale// L7 (v0.14)tracking comment with descriptive per-platform CI env var documentation (L-3).Cargo.toml: replaced stale# DEP-1 (v0.14): pre-declare serde_yamlcomment with# YAML serialization for plan/status/diff --format yaml output.(L-4).- Six user-facing integration files updated to reference version
0.18.0:.github/workflows/aqueduct-plan.yml,.github/workflows/aqueduct-apply.yml,ci/gitlab/aqueduct.gitlab-ci.yml(also fixes archive name pattern to match the release pipeline:aqueduct-{version}-{os}-{arch}.tar.gz),.pre-commit-hooks.yaml,docs/api-reference.md,docs/introduction.md(CI-1). version-lintCI job extended to check nine files:README.md,docs/installation.md,docs/api-reference.md,docs/introduction.md,ci/gitlab/aqueduct.gitlab-ci.yml,.pre-commit-hooks.yaml,.github/workflows/aqueduct-plan.yml,.github/workflows/aqueduct-apply.yml, andROADMAP.md. Fails if any disagrees with the workspaceCargo.tomlversion (CI-1, L-6).ROADMAP.mdstatus line updated tov0.18.0 released(L-6).- Workspace version bumped to
0.18.0.
[0.17.0] - 2026-06-10
This release is all about confidence and safety for teams running critical database infrastructure. When you're managing a high-availability PostgreSQL cluster using tools like Patroni, you can now tell Aqueduct exactly where to check that the database is still the primary leader between every migration step. If the database fails over to a replica mid-migration, Aqueduct detects this immediately, stops what it's doing, and marks the migration as interrupted — rather than blindly continuing and potentially corrupting data on a read-only standby. This dramatically reduces the risk of one of the most dangerous scenarios in database operations: a migration that half-completes during a failover event.
Beyond failover protection, this version gives teams unprecedented visibility into what Aqueduct is doing at runtime. Every single migration step — when it starts, how long it takes, whether it succeeded or failed — is now emitted as a structured event, making it trivial to connect Aqueduct to monitoring dashboards, alerting pipelines, or log aggregation systems. The new aqueduct audit command lets you look back at recent migrations and see exactly what happened and when. For teams that need to demonstrate compliance or investigate incidents, this audit trail is invaluable. And with a built-in Prometheus metrics endpoint, you can wire Aqueduct directly into Grafana and get real-time migration dashboards with zero extra tooling.
Added
--patroni-endpoint <url>flag onaqueduct apply. Between each migration step, the CLI callsGET <endpoint>/master; a non-200 response marks the migrationinterruptedand aborts (DOC-2 / v0.17-A).aqueduct_core::hamodule:HaBackendenum,detect_ha_backend(),check_still_primary()(usespg_is_in_recovery()), andcheck_patroni_primary()HTTP check (DOC-2 / v0.17-A).- Between-step primary re-check via
SELECT pg_is_in_recovery()in the executor. On failover, migration is markedinterrupted(v0.17-A). - Per-step JSON events emitted to stderr:
step_start,step_complete,step_failedwithstep_index,step_type,migration_id,project, andduration_msfields (v0.17-B). Documented indocs/cli-events-schema.json. aqueduct auditsubcommand: lists recent migrations with step counts, error messages, and durations. Supports--format table|json|yamland--limit N(v0.17-B).aqueduct.migration_historySQL view (catalog v8): joinsaqueduct.migrationsandaqueduct.migration_stepsfor per-step audit queries (v0.17-B).aqueduct_core::catalog::LIST_MIGRATIONS_FOR_AUDIT_SQLquery constant (v0.17-B).- Prometheus metrics endpoint behind
--features metricsonaqueduct-cli. Pass--metrics-addr 0.0.0.0:9090to expose/metricsduringapply(v0.17-B). - Catalog schema v8: adds
'interrupted'to themigrations.statuscheck constraint and a partial index forinterruptedrows. Includes auto-migration from v7 (v0.17-A). - GitHub-native build provenance attestation in release workflow via
actions/attest-build-provenance@v2. Verify withgh attestation verify(v0.17-C). release-verifyCI job: downloads the linux-amd64 release archive, verifies its SHA256 checksum, and confirmsaqueduct --versionmatches the tag (v0.17-C).- Version linting CI step (
version-lintjob): fails if README.md status banner,docs/installation.md, andCargo.tomlworkspace version disagree (v0.17-D). just publish-dry-runrecipe for dry-run crates.io publish verification (v0.17-C).
Changed
- Catalog schema bumped from v7 to v8 (
CATALOG_SCHEMA_VERSION = 8). aqueduct-coreandaqueduct-testkitCargo.toml:publish = true;aqueduct-cli:publish = false(v0.17-C).docs/ha-operations.mdrewritten to accurately describe implemented behaviors:check_still_primary(), Patroni/mastercheck, per-step events,aqueduct audit, and the recovery runbook. Speculativesystem_identifier/timeline_idcontent removed (v0.17-D).docs/installation.md: updated version example to0.17.0, MSRV to 1.88, added build provenance verification section (v0.17-C/D).ci.ymlsecurity-audit job: removed--ignore RUSTSEC-2025-0052(httpmock replaced).httpmockreplaced withwiremockinaqueduct-coredev-dependencies; all five HTTP mock tests updated to the async wiremock API (v0.17-D).
[0.16.0] - 2026-05-20
Version 0.16 is a polish release focused on making Aqueduct a pleasure to work with, both for humans sitting at a terminal and for automated systems parsing its output. Teams integrating Aqueduct into CI/CD pipelines can now choose between structured JSON, clean YAML, a minimal porcelain format designed for shell scripting, or a completely silent quiet mode that suppresses all decorative output and shows only what matters. These improvements mean that whatever your downstream tooling looks like — Slack alerts, dashboards, custom scripts — Aqueduct can speak its language directly without any fragile text scraping.
Under the hood, this version also significantly raised the quality bar for testing, adding a comprehensive suite of binary-level tests that verify every subcommand's exit codes, help text, and output formats behave exactly as documented. This means fewer surprises when you upgrade — if a command's behavior changed accidentally, a test will catch it before it ships. The aqueduct status command gained a new --poll-interval flag with smarter file-change detection, so in watch mode it only reloads migration files when they actually change, making long-running monitoring sessions faster and lighter on the database.
Added
OutputModeenum (Human,Json,Yaml,Quiet,Porcelain) andOutputEmitterstruct inaqueduct-cli::output; global--quietand--porcelainflags are now routed through the emitter (ERG-1, M11).serde_yamlworkspace dependency; YAML output inplan,status, anddiffsubcommands now usesserde_yaml::to_stringinstead of hand-rolled format strings, correctly escaping quotes, colons, and newlines (ERG-3).just gen-docsrecipe that regeneratesdocs/api-reference.mdfromaqueduct --helpoutput; stale references to--patroni-endpoint,--timeout,--lock-timeout, and--no-costremoved (ERG-4, M3).- Binary CLI tests (
assert_cmd) for every subcommand's exit codes and--helpoutput:quiet_suppresses_decorative_output,porcelain_outputs_key_value_only,yaml_escapes_quotes_and_newlines,plan_help_documents_fail_if_changed,apply_help_documents_dry_run,status_help_documents_fail_on_drift,diff_help_documents_fail_on_drift,destroy_help_documents_dry_run,rollback_help_documents_dry_run,plan_missing_dsn_exits_2,validate_exits_0_on_valid_files,validate_differential_ivm_unsupportable_fails,validate_format_json_produces_valid_json,version_flag_outputs_semver(TEST-2). --poll-intervalflag onaqueduct status(PERF-2).- Spec-hash caching in
status --watch: migration files are only reloaded when any.sqlmtime changes; live state always polled fresh (PERF-2). postgres_version_matrix_min_supportedintegration test verifying the full create/apply/status cycle on PostgreSQL 18+ (H10).CONTRIBUTING.mddocumenting Docker/Testcontainers prerequisites,justrecipes, integration test environment variables, PG version matrix, coding standards, and a step-by-step guide for adding cookbook recipes.
Changed
fmt::format_migrationnow returnsResult<Option<String>>and propagates IO errors;aqueduct fmtprints a diagnostic when a file cannot be read instead of silently treating it as empty (L2).CANONICAL_KEY_ORDERconstant inaqueduct-core::fmtrenamed toKNOWN_FRONTMATTER_KEYSto reflect its actual role (L3).TestDb::connection_stringfield visibility narrowed frompubtopub(crate)to prevent external crates from depending on the internal representation (L14).docs/security.md: AWS, GCP, and Vault secret backend rows updated from "Planned (v0.12)" to "Implemented"; injected SQL trust-boundary note updated to describe theCatalogSchemanewtype introduced in v0.14.docs/api-reference.mdregenerated fromaqueduct --helpoutput.
Fixed
aqueduct status --format yamlnow produces valid YAML that passesserde_yaml::from_strround-trip including special characters.aqueduct plan --format yamlcorrectly escapes project names containing quotes, colons, and newlines.
0.15.0 - 2026-05-20
This release is the one that gives operations teams confidence to run Aqueduct in the most demanding production environments. Database schema changes are inherently risky — if something goes wrong halfway through, you need a clear path back. Version 0.15 introduces "saga-style" compensating actions: before Aqueduct executes any potentially destructive DDL, it first records what it would take to undo that action. If the process crashes or is interrupted, you can resume from exactly where you left off, or roll back cleanly without any manual investigation. For teams who have ever been woken up at 3 AM to manually clean up a half-finished migration, this is a transformative improvement.
Security and resilience round out this release. Aqueduct now validates catalog schema names at the earliest possible point, firmly rejecting any value that could be used to inject SQL — a class of vulnerability that has burned many database automation tools. Row-level security policies, which PostgreSQL uses to control which users can see which data, are now properly preserved across table recreations, meaning a migration that rebuilds a stream table won't silently drop your access controls. And for situations where a migration step is genuinely ambiguous — perhaps a step that appears to have started but you can't confirm it completed — operators now have --force-retry and --force-skip flags to advance past stuck states safely.
Added
- Saga-style compensating steps:
CreateStreamTableandDropStreamTablewrite a compensating action toaqueduct.ddl_logbefore executing DDL, enabling crash recovery via--resume. --force-retry <step-index>and--force-skip <step-index>flags onaqueduct applyto advance past ambiguous steps (require--yes).CatalogSchemanewtype validating catalog schema names at parse time, rejecting SQL injection markers, non-identifier characters, leading digits, and reserved system schema names (pg_catalog,information_schema, allpg_*prefixed names).ExecutionContextenum withApply,Rollback,Promote, andDryRunvariants replacing the optional builder pattern; named constructorsfor_apply(),for_rollback(), andfor_promote().StartBlueGreenDeploymentplan step inserting a row intoaqueduct.blue_green_deploymentswithstatus = 'active'as the first post-lock step.- All
SwapConsumerViewssteps wrapped in a singleBEGIN ... COMMITblock for an all-or-nothing consumer view swap. - Real RLS policy capture via
pg_policies/pg_class.relrowsecuritybeforeDropStreamTable;RecreatePolicystep restores policies after table recreation; failures are non-fatal and logged toaqueduct.ddl_log. rebuild-may-affect-rlslint warning when aFULL-mode table has RLS enabled.- Batch convergence polling:
WaitForConvergenceissues a singleSELECT ... WHERE table_name = ANY($2)per poll interval instead of one query per node. convergence_poll_interval_ms(default 500 ms) andconvergence_timeout_secs(default 300 s) options inBuildPlanOptions.validate_migration_files_diagnosticandvalidate_dag_diagnosticpublic functions returning structuredDiagnosticSetentries with file paths, error codes, and severity levels.aqueduct validate --format jsonoutput matching thelintJSON schema.- Catalog schema v7:
aqueduct.ddl_loggainsmigration_id bigintandcompensating_sql text;aqueduct.blue_green_deploymentsgainsrolled_back_at timestamptzand an updatedbg_status_checkconstraint accepting'rolled_back'.
Changed
- Executor writes
status = 'swapped'after the atomic consumer view swap andstatus = 'retired'afterRetireBlueSchema. - Integration tests now target
postgres:18-alpineexclusively (PostgreSQL 18+ required by pg_trickle).
0.14.0 - 2026-05-20
Version 0.14 is a focused correctness and security release that addressed a number of real-world edge cases discovered through extensive integration testing. The most important of these is that failed migrations are now properly marked as recoverable and can be resumed — previously, a failure in certain code paths could leave the migration in a state where Aqueduct wouldn't offer a clean resume path. A heartbeat mechanism that keeps the database advisory lock alive during long-running migrations now correctly signals the main process when the lock is lost, preventing processes from continuing work on a database they no longer exclusively own.
Three security improvements shipped alongside these correctness fixes. Connection strings that embed plaintext passwords are now caught and rejected for keyword-value style DSNs, matching the protection already in place for URL-style connections. Consumer view SQL bodies are validated to be single SELECT statements before any database objects are created, closing off a potential SQL injection path through migration files. Read-only connections now explicitly begin a READ ONLY transaction and use a safe allowlist for statement timeout values, preventing injection through configuration parameters. Together these fixes ensure that even if a migration file or configuration value is maliciously crafted, Aqueduct won't execute arbitrary SQL against your database.
Added
pg_versionfield inaqueduct status --format jsonoutput.pgtrickle_mock.scheduler_statetable in catalog schema v6; mockpause_scheduler/resume_schedulerfunctions insert and delete rows to assert scheduler state transitions in tests.- Nine new integration tests covering all CORR, SEC, and TEST items.
Fixed
- Progress preserved on recoverable failures; migration marked
recoverable_failureand resumable (CORR-1). - Heartbeat correctly signals the main executor loop via
tokio::sync::watchwhen the advisory lock is lost (CORR-3). - Promotion filters destination state by project (CORR-4).
- Promote command migrates catalog before use and records
spec_jsonb(CORR-5). - Consumer view drops now delete the catalog row so drift counts remain accurate (CORR-7).
- Status drift counting now sums stream, source, and consumer deltas (CORR-8).
aqueduct destroyexits with code 2 on error viaanyhow::bail!instead ofstd::process::exit(1).- DSN-redaction regex compiled once via
LazyLockinstead of per-call. - CI environment variable detection now checks for value
"true"rather than mere presence, preventing false-positive CI detection. - GitHub Actions composite
planandapplyactions now map runner OS/architecture to the correct release archive suffix. action-smokeworkflow exercises composite actions end-to-end with a locally-packaged archive.mdbook testis now a blocking CI step; docs-lint fails if any documented subcommand is missing from the binary.
Security
- Keyword-value DSNs (e.g.
host=... password=secret) now also checked for plaintext passwords, matching URI-style DSN validation (SEC-1). - Consumer SQL bodies validated as a single
SELECTstatement before view creation, preventing DDL injection via migration files (SEC-2). connect_read_onlynow issuesBEGIN READ ONLYbeforeSET LOCAL statement_timeout;sanitize_statement_timeoutallowlist prevents injection via the timeout argument (SEC-3).
0.13.0 - 2026-05-20
Version 0.13 delivers the complete blue/green deployment workflow that enterprise teams need for zero-downtime schema migrations on production databases. When your changes are complex enough to require restructuring the underlying tables — for example, changing which columns are used as grouping keys in a large aggregation — Aqueduct now orchestrates the full sequence automatically: creating a parallel "green" schema, building the new stream tables there, waiting for them to fully catch up with the "blue" production tables, atomically swapping all consumer views in a single transaction, and then retiring the old schema. The entire process is hands-off once initiated, dramatically reducing the skill and attention required to execute what was previously one of the most nerve-wracking database operations.
Two other significant capabilities arrived in this release. Pre- and post-migration hooks let teams wire Aqueduct into their broader operational playbook — for example, firing a Slack notification when a migration starts, or triggering a cache flush after it completes — all configured declaratively in aqueduct.toml. The new --out and --plan flags let teams generate a migration plan during a code review, have a human or automated system approve it, and then execute exactly that pre-approved plan later — with a cryptographic hash check ensuring the plan hasn't been tampered with between approval and execution. This separation of planning from execution is a powerful governance tool for organizations with strict change-management requirements.
Added
- Full blue/green migration step sequence for topology-restructuring diffs
(
--strategy blue-green):CreateGreenSchema,CreateStreamTableInGreen,WaitForConvergence,SwapConsumerViews,RetireBlueSchema. IMMEDIATErefresh mode parsed from-- @aqueduct:refresh_mode = "IMMEDIATE", withPauseImmediate/ResumeImmediateplan steps.--no-immediate-downgradeflag rejecting plans that would temporarily degrade anIMMEDIATEtable.- Pre/post migration hooks from
[apply.hooks]inaqueduct.tomlemitted asRunHookplan steps; pre-hook fires afterLockDag, post-hook fires beforeRecordSnapshot. aqueduct plan --out <file>writes a plan with a SHA-256 spec hash;aqueduct apply --plan <file>executes the pre-computed plan and validates the spec hash to reject stale artifacts.- Catalog schema v5:
aqueduct.migration_stepsrecords every executed step withrunning/done/failedstatus for audit and replay. aqueduct importnow callsensure_catalog_currentand records version 1 inaqueduct.dag_versions; a subsequentaqueduct planreturns an empty plan.- Real
create_preview_cnpgandcreate_preview_neonHTTP implementations using the CNPG Kubernetes API and the Neon management API. - Rollback classification:
Safe,PointInTime, orDataLossbased on WAL retention window;--within-window/--accept-data-lossflags. "schema_version": 1field in all structured JSON CLI events; schema published atdocs/cli-events-schema.json.
0.12.0 - 2026-05-20
Version 0.12 is a major leap forward for production readiness, tackling three areas that enterprise teams consistently ask about: secret management, resilience against infrastructure variation, and confidence in the planner's correctness. Rather than requiring database passwords to live in environment variables or configuration files, Aqueduct can now fetch connection credentials directly from AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault at runtime. This means your production database password never needs to touch a CI/CD configuration file, a developer's laptop, or a Kubernetes secret — it stays in your secrets vault and is retrieved only at the moment it's needed.
A subtler but equally important improvement is how Aqueduct now deals with different versions of pg_trickle, the underlying PostgreSQL extension it builds on. Rather than assuming all features are available and failing at runtime, Aqueduct now probes the installed version's capabilities at startup and gracefully skips or adapts any step the current version doesn't support. This makes deployments far more robust across environments where the extension version may differ from what was tested in CI. The addition of property-based fuzz testing — automatically generating thousands of random migration scenarios and verifying that the planner never panics and always produces consistent results — gives the entire planning engine a level of correctness assurance that hand-written tests simply can't match.
Added
PgtrickleCapsstruct probing the installed pg_trickle version at startup viato_regprocedure; each capability individually gated for graceful degradation on older builds.WaitForRefreshplan step gated onpgtrickle_caps.has_refresh_status; older deployments skip this step.- Real AWS Secrets Manager (inline SigV4 signing, no AWS SDK dependency), GCP Secret
Manager (bearer token), and HashiCorp Vault (KV v2) HTTP clients using
reqwestwithrustls-tls; all three verified withhttpmockunit tests. build_source_index()building aHashMap<QualifiedName, Vec<QualifiedName>>once per diff;compute_source_deltas()performs O(1) cascade impact lookups.- Catalog schema v4 with four new performance indexes on
dag_versions,migrations, andlockstables. plan_stats()public function computingPlanSummaryfrom&[PlanStep]without executing.collect_subgraph()computing the transitive dependency closure for an anchor table;--tableflag onaqueduct previewlimits preview to that subgraph.- Full integration test suite in a
pgtrickle-integrationCI job against PostgreSQL 18 with mock pg_trickle schema. test_blue_green_topology_restructurecovering diamond DAG creation.- Binary-level CLI tests using
assert_cmdandpredicates. - Project isolation destructive test: applies two projects, destroys one, asserts the other is untouched.
- Property-based fuzz tests via
proptest:build_plannever panics for arbitrary inputs;plan_statsalways consistent withbuild_plan.summary. - Tutorial smoke test parsing bash code blocks in docs and validating known subcommands.
action-smoke.ymlworkflow: builds binary, packages as versioned archive, exercisesplanandapplyagainst a live PostgreSQL 18 service container.reqwest 0.12(rustls-tls),proptest,assert_cmd,predicates, andhttpmockadded as dependencies.
Changed
connect_read_only()andconnect_read_only_with_timeout()now issueSET LOCAL statement_timeoutbeforeBEGIN READ ONLY.estimate_rows()returns typedCostErrorinstead ofanyhow::Error; SQL errors and unsupported plan types surfaced as structured tracing warnings.- Coverage threshold updated to 60% workspace-wide minimum.
build-artifactsinrelease.ymlnow requires thetestjob to pass.- macOS Intel (
macos-13) added to release build matrix asmacos-amd64. rust-versioninCargo.tomlset to1.88(effective MSRV).
Fixed
- Windows builds in
release.ymlnow fail the release when they fail.
Security
rewrite_query_for_preview_ast()rewrites table references using the sqlparser AST instead of string substitution, eliminating query injection risk via schema or table names.
0.11.0 - 2026-05-19
Version 0.11 focuses on the experience of getting help when something goes wrong. All errors, lint warnings, and validation failures that Aqueduct produces now share a single, unified format that includes the exact file and line number where the problem occurred, a stable numeric error code, and a severity level. This means that editor plugins, CI parsers, and custom tooling can all reliably interpret Aqueduct's output without having to guess at error message formats. The stable numeric codes are particularly valuable for teams writing runbooks — instead of matching on potentially-changing error text, you can reference code 1200 for a planning error or 1300 for an execution error and know that code will remain stable across versions.
The documentation story got a major upgrade too. A new CI job now automatically builds the documentation and verifies that every subcommand mentioned in the docs actually exists and responds to --help, so documentation can never silently drift out of sync with the binary. Security also improved: all database connection strings are now scrubbed of passwords before they appear in error messages, log output, or CI logs — a simple but critical safeguard that prevents credentials from leaking into build artifacts or error reports. The aqueduct plan and aqueduct status commands are now strictly read-only at the connection level, making it physically impossible for them to accidentally modify database state even in edge-case error paths.
Added
- Unified
Diagnostictype andDiagnosticSetwith file/line/column context, replacing separateValidationError,LintDiagnostic, and ad-hoc error strings. - Stable numeric error codes on
AqueductError: 1000–1099 database, 1100–1199 configuration, 1200–1299 planning, 1300–1399 execution, 1400–1499 catalog. --fail-on-driftflag foraqueduct diff, exiting 1 when any delta is non-Unchanged.- YAML output format for
aqueduct status(--format yaml). docs/roles.sqlwith SQL grant templates foraqueduct_planner,aqueduct_applier,aqueduct_preview, andaqueduct_destroyroles.docs-buildCI job runningmdbook buildandmdbook teston every push.docs-lintCI job verifying every documented subcommand responds to--helpand that binary--versionmatchesCargo.toml.docs-clijustfile recipe generating a Markdown CLI reference from--helpoutput.
Changed
aqueduct plannow exits 0 by default; exits 1 only when--fail-if-changedor--fail-on-driftis explicitly set.executor.execute()returnsExecutionResult { migration_id, dag_version }instead of a bareu64;apply_completeJSON event emits both fields as separate keys.ManageWalSlotuses typedWalSlotActionenum (Create/Drop) instead of a free-form string, eliminating the "Unknown action" dead code path.- Typed
PlanExecutorconstructors:for_apply(),for_rollback(),for_promote(),for_dry_run(). - Typed DAG state newtypes:
DesiredDagState,LiveDagState,RecordedDagState. lib.rsexportsCLI_VERSION = env!("CARGO_PKG_VERSION")as the single source of version truth.- Example workflow version pins updated from
@v0.4.0to@v0.11.0.
Fixed
aqueduct planandaqueduct statusnow useconnect_read_only()and cannot accidentally mutate data.
Security
redact_dsn()replaces passwords with***in all connection strings shown in error messages, covering both URL-form and keyword-value DSNs.
0.10.0 - 2026-05-19
Version 0.10 closes out a category of subtle bugs that could cause incorrect behavior when working with consumer views — the downstream SQL views that read from stream tables. Previously, a plan that only added, dropped, or modified consumer views without touching any stream tables was silently treated as a no-op and never executed. This was a correctness bug with real consequences: teams could deploy consumer view changes, see no errors, and then discover their views weren't updated because Aqueduct thought there was nothing to do. All consumer operations now correctly flow through the full plan-and-execute path with proper tracking and reporting.
Project isolation — the ability to manage multiple independent DAGs in the same PostgreSQL database — received a significant reliability upgrade. All live state reads now correctly filter by project, meaning one project's tables can never accidentally appear in another project's plan or status. The aqueduct destroy command now verifies that it actually owns the tables it's about to drop, preventing accidental cross-project destruction. Several concurrency scenarios were also hardened: the lock heartbeat is now bound to the specific holder that acquired the lock, so a fresh Aqueduct process starting up won't be fooled by a stale heartbeat from a crashed predecessor into thinking the database is still locked.
Added
- Catalog schema v3:
aqueduct.stream_table_ownership(project, schema_name, table_name, managed_since)withPRIMARY KEY (schema_name, table_name). recoverable_failureadded to theaqueduct.migrationsstatus_checkconstraint.- Six new integration tests: consumer-only apply, concurrent apply race, stale plan detection, two-project isolation, resume step skipping, and holder-bound heartbeat.
Fixed
- Consumer-only plans are no longer silently skipped as no-ops;
PlanSummarytracksconsumer_creates,consumer_drops, andconsumer_alters, andis_empty()/total_changes()include consumer deltas. RecordSnapshotusesRETURNING versionto capture the actual bigserial value assigned by the database.read_live_state()populatessourcesfromspec_jsonbinaqueduct.dag_versionsinstead of always returning empty.read_live_state()andread_live_consumers()filter by project viastream_table_ownership;destroy_projectverifies ownership before dropping tables (--force-unownedto bypass).check_pgtrickle_version()probesto_regprocedurebefore calling to avoid a database error when pg_trickle is absent.classify_delta()no longer panics on missingdesired/actualin theAlterQueryarm; returnsRebuildinstead.- Failed migrations now marked
recoverable_failureand resumable;LockDagalways re-executes on resume, DDL steps skipped only for steps already confirmed complete. - Scheduler pause now runs inside the
LockDagexecutor arm after the lock is acquired, preventing a pre-lock pause from affecting other projects. - Heartbeat SQL is now holder-bound (
WHERE project = $1 AND holder = $2); zero rows affected logged as a lock-stolen warning. - Project lock released on all exit paths via an explicit cleanup block.
- Step progress checkpoint writes are now fatal rather than silently continuing.
aqueduct apply --dry-runuses a plain connection to avoid upgrading the catalog schema during previews.DROP TABLEindestroyand executor no longer usesCASCADE; opt in with--force-cascade.aqueduct rollbacknow enforces--accept-data-losswhen the rollback plan contains rebuild-class steps.- First-time stream table creations increment
create_count, notrebuild_count, preventing false-positive maintenance-window blocking on initial deployments. - Lock holder string is now
aqueduct/{version}/{hostname}/{pid}/{ts}, making concurrent processes distinguishable. - Scheduler pause failure is now fatal by default.
0.9.0 - 2026-05-19
Version 0.9 is a turning point in how operators interact with Aqueduct day-to-day. The new aqueduct diff command provides a clear, human-readable comparison between what your migration files describe and what currently exists in the live database — across every table, every column, and every configuration detail. This makes drift visible at a glance before you commit to running any migration, transforming what was previously a moment of uncertainty before a production apply into a clear-eyed, informed decision. Whether you're checking before a deployment, investigating after an incident, or auditing compliance, aqueduct diff gives you the full picture.
This release also introduced the secret backend system that underpins enterprise-grade credential management: database connection strings can now reference secrets stored in AWS, GCP, Vault, SOPS-encrypted files, or age-encrypted files using a simple ${secret:BACKEND:KEY} syntax, keeping credentials entirely out of configuration files and CI logs. Every successful aqueduct apply now emits a structured JSON event with the migration ID, version numbers, and project name — making it trivial for CI systems to capture and act on migration results. For teams using interactive terminals, a confirmation prompt before any apply ensures no one accidentally runs a migration they didn't intend to.
Added
aqueduct diffsubcommand computing a per-table diff between desired state (migration files) and live database state; supports--tableand--format text|json|yaml|markdown.aqueduct plan --fail-on-drift: exits 1 when changes are detected, 0 when the DAG is up-to-date.- Interactive confirmation prompt on
aqueduct applyin a TTY ("Apply N changes? [y/N]");--yes/-yskips it. - Structured
apply_completeJSON event on stderr withmigration_id,from_version,to_version, andprojectfields; CI composite action reads these as output variables. aqueduct validate --strict: promotes warnings to errors and exits non-zero.aqueduct plan --format yamloutput.${secret:BACKEND:KEY}inline secret syntax in DSN strings; supported backends:env,aws,gcp,vault,sops,age.--quiet/--porcelainglobal flags suppressing info-level log output.- Eight new
PlanStepvariants:RecreatePolicy,DetachOutbox,ReattachOutbox,ManageWalSlot,PauseImmediate,ResumeImmediate,WaitForRefresh,RunHook. cargo auditsecurity-audit CI job running on every push and pull request.- Coverage threshold enforced at 70% with
fail_ci_if_error: true. - DSN masking in CI composite
planaction via::add-mask::.
Changed
aqueduct planexit codes changed:0= no changes,1= changes detected (or--fail-on-drift),2= error.- CLI errors now exit with code
2instead of1. - All
regex::Regexpatterns compiled once viastd::sync::LazyLock. cdc_modevalues normalized at parse time:"ROW"/"STATEMENT"→"trigger","WAL"→"wal","NONE"/"DISABLED"→"none"; unknown values rejected with a clear error.- Integration tests run against PostgreSQL 18 (
postgres:18-alpine).
Fixed
status --watchcreates a fresh database connection on each poll tick; network errors logged as warnings rather than aborting the watch.
Security
validate_secret_path()rejects key arguments containing..to prevent path traversal in SOPS and Age subprocess invocations.- DSN strings containing cleartext passwords are now rejected; pass
--allow-plaintext-passwordto override.
0.8.0 - 2026-05-19
Version 0.8 tackles one of the hardest problems in database automation: what happens when things go wrong in the middle of a migration. The executor now tracks progress at the granularity of individual steps, persisting a checkpoint to the database after each one. If a migration is interrupted — whether by a network blip, a process crash, or an operator hitting Ctrl-C — re-running aqueduct apply --resume will pick up from exactly where it left off, skipping every step that already completed successfully. For long-running migrations that touch large tables, this means an interruption no longer requires starting over from scratch.
The lock mechanism that prevents two Aqueduct processes from stepping on each other's work was made significantly more robust. A background task now continuously renews the advisory lock while a migration is running, sending a heartbeat every ten seconds on a dedicated database connection. This ensures that a migration taking longer than PostgreSQL's default lock timeout can complete without its lock expiring mid-flight. If the heartbeat connection is lost — for example, because the database restarted — the main migration process is immediately notified and can shut down cleanly rather than continuing to issue commands to a database that may no longer be in the expected state.
Added
- Resume-aware step-level progress tracking:
PlanExecutorskips steps belowlast_completed_stepon resume and persists the step index toaqueduct.migrations.progressafter each non-structural step. - Lock heartbeat: background tokio task renewing the advisory lock every 10 s via a dedicated connection; shuts down cleanly after plan completion or error.
- Catalog schema v2 via
CATALOG_INIT_V2_SQL;ensure_catalog_current()applies the v1→v2 migration on every connect.
Changed
build_plan()now returnsResult<Plan>instead ofPlan; all call sites propagate the error with?.AlterStreamTableexecutor passesnew_queryas the 6th argument topgtrickle.alter_stream_table.aqueduct plan --validate-ivmnow callsvalidate_ivm_supportability()forDIFFERENTIALtables andvalidate_sql_syntax()for others.- Column removal classified as
Rebuildunless the column is in the trailing position of the select list. run_steps()uses a drain-then-pause protocol:pause_schedulerbefore the first migration step,resume_schedulerin a deferred cleanup guard.- Diamond DAG convergence nodes trigger group promotion: all transitive ancestors elevated to the highest migration class in the group.
poll_once()inaqueduct statusloads the migrations directory and computes a real diff for accurate drift detection.spec_jsonbinaqueduct.dag_versionsnow stores the full serialisedDagStateinstead of an empty{}.
Fixed
aqueduct rollbacknow deserialisesspec_jsonbfrom thedag_versionsrow instead of re-reading migration files from disk.CreateStreamTableon non-pg_trickle instances returnsAqueductError::PgTrickleNotInstalledinstead of silently creating bare tables.
Removed
- Unused
deadpool-postgresworkspace dependency.
0.7.0 - 2026-05-18
Version 0.7 is the documentation and confidence-building release. Thirty detailed cookbook recipes were added covering every migration pattern that Aqueduct supports — from simply adding a column, to changing GROUP BY keys, to performing a full blue/green topology restructure — each with a complete before-and-after example and a matching integration test. Whether you're a new team trying to understand what Aqueduct can do, or an experienced operator looking for the exact right pattern for a non-obvious migration, the cookbook gives you a concrete, tested answer rather than requiring you to reason from first principles.
Public benchmarks were published for the first time, quantifying the core value proposition in plain numbers: on a 200-node DAG with a 5-node change set, Aqueduct applies changes in roughly 6 milliseconds — compared to approximately 180 seconds that a naive drop-and-recreate approach would take on the same workload. That's a 28,000× speed improvement. Security and high-availability documentation were written in depth, covering everything from setting up least-privilege database roles to handling failover events and crash recovery. With 197 tests now passing across unit, integration, and CLI test suites, this version represents the highest level of tested confidence the project had achieved to that point.
Added
- 30 cookbook patterns in
docs/cookbook/covering every migration class (Free, In-place, Rebuild, Blue/green, Create, Drop), each with an integration test. - Public benchmarks in
benchmarks/: 200-node DAG, 5-node change set — targeted apply (~6 ms) vs. drop/recreate (~180 s), ~28 000x speed improvement. docs/security.md: least-privilege role setup, secret backends, read-only guarantees, and audit trail.docs/ha-operations.md: primary detection for Patroni, CloudNativePG, and Stolon; maintenance windows; crash recovery; failover handling.docs/api-reference.md: complete CLI command reference, front-matter directive table, andaqueduct.tomlschema.- 197 tests total (101 unit + 63 integration + 33 CLI); all passing, none skipped.
0.6.0 - 2026-05-18
Version 0.6 completes the core operational toolset that teams need to manage Aqueduct migrations across the full software delivery lifecycle. The aqueduct promote command solves the environment promotion problem elegantly: rather than manually copying migration files between staging and production and hoping you didn't miss anything, you point Aqueduct at your source environment, it validates that everything is clean, computes exactly what needs to happen in the destination environment, asks for confirmation, and records the promotion event in the audit trail. This makes the act of "promoting to production" repeatable, auditable, and safe — the same migration files validated in staging are what get applied to production.
The aqueduct destroy command gives teams a clean, safe way to tear down all objects associated with a project — useful for cleaning up ephemeral preview environments or decommissioning retired workloads. High-availability primary detection was formalized with support for Patroni, CloudNativePG, and Stolon, so Aqueduct can reliably determine whether it's talking to a writable primary before taking any action. The aqueduct status --watch mode, combined with configurable alerting thresholds, means teams can leave a monitor running in production that immediately flags any drift between what's deployed and what the migration files describe — turning Aqueduct into a lightweight continuous compliance tool as well as a migration executor.
Added
aqueduct promotecommand promoting a migrations directory from one environment to another, with source-clean validation, destination plan computation, interactive confirmation, and promotion recorded inaqueduct.migrations.- Encrypted secret backends: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, SOPS-encrypted files, and age-encrypted files.
${secret:BACKEND:KEY}inline syntax in DSN strings.aqueduct status --watchlong-running watch mode with configurable--interval,--max-drift-count, and JSON output for alerting pipelines.- HA primary detection for Patroni (HTTP
GET /master), CloudNativePG (GUC), and Stolon (pg_stat_activity), returning a typedHaBackendenum. - Planner fuzzing test: 8 LCG-seeded random mutations over a 4-table DAG asserting
the plan-convergence invariant (deterministic seed
0xDEAD_BEEF_CAFE_1234). aqueduct destroycommand tearing down all project-owned stream tables, consumer views, and catalog rows; requires--confirmor--dry-run.- 167 tests total (101 unit + 33 integration + 33 CLI); all passing, none skipped.
0.5.0 - 2026-05-18
Version 0.5 bridges two worlds that data teams live in simultaneously: dbt, the SQL transformation tool that millions of data engineers use to define their data models, and Aqueduct, which manages the real-time stream tables that power live analytics. The new aqueduct ingest --from dbt-target command reads the compiled output of a dbt project and automatically generates a canonical Aqueduct migrations directory. Schedule, refresh mode, CDC mode, schema, and dependency relationships are all inferred from the dbt model configuration, so no manual translation work is required — what you express in dbt flows directly into Aqueduct.
The practical impact is that teams can maintain a single source of truth in their dbt project and have Aqueduct automatically stay in sync. The examples/dbt-roundtrip/ example included with this release demonstrates the full workflow: run dbt, ingest the results into Aqueduct, validate, plan, apply, and roll back — end to end, without any manual steps. Because the ingest command is idempotent, it can safely be re-run after every dbt compile without risk of creating duplicates or inconsistencies, making it trivial to slot into CI/CD pipelines that already build dbt artifacts.
Added
aqueduct ingest --from dbt-targetcommand reading compiled dbt artefacts and generating a canonical aqueduct migrations directory; idempotent.- Front-matter directives populated from dbt model config (
+schedule,+refresh_mode,+cdc_mode,+schema,+depends_on). examples/dbt-roundtrip/demonstrating the full ingest → validate → plan → apply → rollback round-trip.
0.4.0 - 2026-05-18
Version 0.4 is the release that makes Aqueduct a first-class member of your development workflow rather than just a tool you run manually before deployments. The aqueduct fmt command automatically reformats migration files to a canonical style — consistent SQL keyword casing, stable front-matter ordering, no trailing whitespace — and a --check mode makes it easy to enforce this standard in CI without modifying files. The aqueduct lint command catches five categories of common mistakes before they ever reach a database: schedules that are too aggressive for the workload, queries that IVM can't optimize, consumer views that reference sources that don't exist, and more.
The GitHub Actions composite actions make it genuinely effortless to add Aqueduct to any GitHub-hosted project: a single line in your PR workflow will install the binary, run aqueduct plan, and post the plan as a PR comment so reviewers can see exactly what database changes a PR will produce before merging. The same experience is available for GitLab CI via provided templates. Pre-commit hooks for fmt, validate, and lint mean that quality checks happen before code is even committed, catching problems at the earliest possible moment. Together, these integrations turn database migration management from an afterthought into a first-class, automated part of the code review process.
Added
aqueduct fmtcommand canonicalising migration files: SQL keyword casing, stable front-matter directive order, trailing whitespace removal.--checkmode for CI.aqueduct lintcommand with five rules:schedule-too-aggressive(warning),full-refresh-no-filter(warning),differential-ivm-unsupportable(warning),cypher-source-missing(error),consumer-source-not-found(error).--fail-on-warnand--format jsonflags.trickle-labs/pg-aqueduct/.github/actions/plan@v0.4.0composite action: installs binary, runsaqueduct plan --format markdown, posts plan as PR comment.trickle-labs/pg-aqueduct/.github/actions/apply@v0.4.0composite action: runsaqueduct applywith--resumesupport, masks DSN secrets.- Example workflows:
aqueduct-plan.yml(on PRs touchingmigrations/) andaqueduct-apply.yml(on push tomain). - GitLab CI templates in
ci/gitlab/aqueduct.gitlab-ci.yml. ci/hooks/aqueduct-pre-commitgit hook runningfmt --checkandvalidate..pre-commit-hooks.yamldefiningaqueduct-fmt,aqueduct-validate, andaqueduct-linthooks for the pre-commit framework.- 118 tests total (73 unit + 25 integration + 20 CLI); all passing, none skipped.
0.3.0 - 2026-05-18
Version 0.3 introduces three powerful capabilities that together define Aqueduct's vision for sophisticated, production-safe database change management. Blue/green plan steps — creating a parallel schema, building new stream tables, waiting for convergence, atomically swapping consumer views in a single transaction, retiring the old schema — provide the foundation for zero-downtime migrations of even the most complex DAG topologies. Consumer views give teams a clean way to expose stable, versioned SQL interfaces to downstream applications, completely decoupling what the application queries from the underlying stream table implementation.
The aqueduct preview command is perhaps the most innovative addition in this release: it creates a throwaway copy of your entire DAG in a scratch schema, populated with a sampled subset of real production data, so you can run and test your new migration against realistic data without any risk of affecting production. This works natively against any PostgreSQL instance and has stub implementations for CloudNativePG and Neon-managed databases. For data teams building and iterating on complex analytics queries, the ability to spin up a safe, realistic preview environment in seconds — and tear it down just as quickly — fundamentally changes how development and testing workflows operate.
Added
- Blue/green plan step variants:
CreateGreenSchema,CreateStreamTableInGreen,WaitForConvergence,SwapConsumerViews,RetireBlueSchema. - Consumer view management: declare views in
migrations/consumers/with@aqueduct:kind = consumer,@aqueduct:source, and@aqueduct:expose_as; newConsumerSpec,ConsumerDelta, andConsumerDeltaKindtypes;ManageConsumerViewplan step. aqueduct preview --branch <name>creating a throwaway copy of the DAG in a scratch schema withTABLESAMPLE-sampled data; native, CloudNativePG stub, and Neon stub backends.- Optional companion extension support:
aqueduct.ddl_logDDL event log,detect_extension_installed(),read_ddl_log(). - Catalog schema v2:
aqueduct.ddl_log,aqueduct.consumer_views,aqueduct.blue_green_deploymentstables.
0.2.0 - 2026-05-18
Version 0.2 introduces the intelligence at the core of Aqueduct's value proposition: the migration classifier. When you change a migration file, Aqueduct doesn't just blindly re-execute everything — it analyzes what changed and classifies the migration into one of four cost categories: Free (can be applied with zero downtime and no data movement), In-place (a structural change made with a simple ALTER TABLE), Rebuild (requires recreating the table, typically within a maintenance window), or Blue/green (a topology-level restructure requiring the full zero-downtime workflow). This classification drives every decision Aqueduct makes about how to safely apply your changes.
A particularly important extension of this classifier is the automatic cascade analysis for ALTER TABLE changes. When a source table that stream tables depend on changes — for example, a column is renamed or its type changes — Aqueduct automatically identifies every stream table in the DAG that references that source and escalates them to a Rebuild classification. This prevents the silent failures that plague naive migration tools, where a source table change breaks dependent views in ways that aren't discovered until queries start returning errors. The new --explain-cost flag rounds out this release by showing per-step row count estimates and duration projections, giving operators a clear picture of what a migration will actually cost before committing to run it.
Added
- Full migration classifier decision tree: Free (schedule /
cdc_mode/refresh_modeDIFF→FULL), In-place (add or drop a column), Rebuild (rename column, changeGROUP BY, change or add/remove a JOIN, changeWHERE, FULL→DIFF), and Blue/green (topology restructure). @aqueduct:cypher_sourcefront-matter directive for pg_eddy Cypher-backed stream tables.- ALTER TABLE cascade analysis (Tier 1):
AlterBaseTableplan step plus automatic Rebuild cascade for all stream tables referencing the altered source table. aqueduct plan --explain-costshowing per-step row counts (viaEXPLAIN FORMAT JSON) and duration estimates.- 85 tests total (unit + integration + CLI); all passing, none skipped.
0.1.0 - 2026-05-18
Version 0.1 is the foundation on which everything else is built. At its core, Aqueduct solves a problem that every team managing real-time analytics in PostgreSQL eventually hits: how do you safely change a complex web of interdependent materialized views and stream tables — built with extensions like pg_trickle — without causing outages, data loss, or hours of manual database administration? The answer is to treat database schema changes the same way software engineers treat code changes: with version control, automated planning, safe execution, and the ability to roll back. This first release establishes all of those primitives.
The key capabilities in this release — automatic DAG inference from SQL FROM/JOIN clauses, cycle detection, migration classification, advisory locking for crash-safe serialization, read-only plan and status commands, structured JSON logging for CI, and standby detection via pg_is_in_recovery() — together form a complete and production-usable system even at this initial version. The minimal example included in the repository shows a 3-node DAG going from nothing to a fully-managed real-time analytics pipeline in a handful of configuration lines. With 58 tests passing and five CI jobs running on every commit, the project launched with a commitment to quality and correctness that every subsequent release has built upon.
Added
aqueduct init,plan,apply,status,validate,rollback,import, andunlockCLI commands.aqueduct.*catalog schema:dag_versions,migrations,locks, andcluster_profiletables.- SQL front-matter parsing (
-- @aqueduct:key = value) forkind,schedule,refresh_mode,cdc_mode,depends_on, andowneddirectives. - DAG dependency inference from SQL
FROM/JOINclauses viasqlparser(pure Rust, no C build dependencies). - Cycle detection in the DAG with clear pre-DDL error reporting.
- Migration classification into four cost classes: Free, In-place, Rebuild, and Blue/green.
- Template variable substitution:
{{ var.NAME }}from per-targetvarsinaqueduct.toml;${ENV_VAR}from the environment. allow_full_refresh = falseconfig blocking Rebuild-class plans.maintenance_windowconfig gating Rebuild and Blue/green steps to a configured time window.- Standby detection via
pg_is_in_recovery(), preventing applies against hot standbys. - Read-only transactions for
planandstatuscommands. - Advisory lock with TTL expiry for crash-safe serialisation.
- Structured JSON logs on stderr when
--log-format jsonis set or when running in CI ($CI,$GITHUB_ACTIONS,$GITLAB_CI,$CIRCLECI). application_nameset toaqueduct/<project>/<migration_id>on every connection, making in-flight migrations visible inpg_stat_activity.- 58 tests (42 unit, 10 integration, 6 CLI); all passing, none skipped.
- Five GitHub Actions CI jobs: lint, unit tests, integration tests, CLI tests, and coverage.
examples/minimal/— 3-node DAG example with a completeaqueduct.toml.ESSENCE.md— architecture overview and design principles.justfile— developer convenience recipes (test,lint,fmt,coverage).
pg_aqueduct Roadmap
Status: v0.20.0 released. v1.0 is the first production-ready release. This roadmap reflects the agreed design in
plans/pg-aqueduct-plan.md. Versions correspond directly to the implementation phases described there.
Overview
pg_aqueduct is a declarative migration and orchestration tool for stream-table DAGs
powered by pg_trickle. It is to a stream-table DAG what Atlas is to a relational
schema and what Terraform is to infrastructure.
The tool is delivered as a standalone Rust CLI (aqueduct) with an optional
companion PostgreSQL extension that adds DDL event triggers and SQL-callable
diagnostics. The CLI operates against any PostgreSQL cluster that has pg_trickle
installed; it requires no superuser privileges beyond what pg_trickle itself needs.
Each version in this roadmap is independently releasable and immediately useful. Later versions build on earlier ones without breaking the established CLI surface.
Version Map
| Version | Theme | Phases | Estimated Effort |
|---|---|---|---|
| v0.1 | Foundation — plan, apply, rollback | 0–2 | 4–8 weeks |
| v0.2 | Online schema evolution | 3 | 2 weeks |
| v0.3 | Blue/green, preview environments, optional extension | 4 | 3 weeks |
| v0.4 | CI integrations & ergonomics | 5 | 1 week |
| v0.5 | dbt interop | 6 | 1 week |
| v0.6 | Production hardening | 7 | 3 weeks |
| v0.7 | Documentation & cookbook | 8 | 1 week |
| v0.8 | Core correctness & safety hardening | 10 | 3–4 weeks |
| v0.9 | Feature completeness & ergonomics | 11 | 3–4 weeks |
| v0.10 | Safety contract repair & multi-project isolation | 13 | 5–6 weeks |
| v0.11 | Documentation truthfulness, CLI surface & code quality | 14 | 3–4 weeks |
| v0.12 | Real pg_trickle integration, security & CI/CD hardening | 15 | 4–5 weeks |
| v0.13 | Blue/green end-to-end, IMMEDIATE mode & advanced features | 16 | 6–8 weeks |
| v0.14 | Failure safety, CI/CD trustworthiness & security hardening | 17 | 4–5 weeks |
| v0.15 | Execution integrity, architecture & blue/green production | 18 | 5–6 weeks |
| v0.16 | CLI quality, test infrastructure & PostgreSQL compatibility | 19 | 4–5 weeks |
| v0.17 | HA operations, documentation truthfulness & observability | 20 | 4–5 weeks |
| v0.18 | Security hardening, documentation correctness & operational quality | 21 | 3–4 weeks |
| v0.19 | Correctness hardening, blue/green atomicity & test infrastructure | 22 | 4–5 weeks |
| v0.20 | Executor architecture, multi-tenant catalog & CLI polish | 23 | 5–6 weeks |
| v1.0 | Release engineering | 24 | 2 weeks |
| v1.1 | Consumer layer management | — | TBD |
| v2.0 | Multi-executor support | — | TBD |
v0.1 — Foundation: Plan, Apply, Rollback
Target effort: 4–8 weeks for one engineer.
Prerequisite: pg_trickle exposes a stable JSON projection of a stream-table spec
(small change — file as a pg_trickle issue if not yet present).
This is the MVP release. It delivers the full plan → apply → rollback → import cycle
against a pg_trickle cluster. After v0.1, a team can fully adopt GitOps for their
stream-table DAG. No PostgreSQL extension is required; the CLI bootstraps its own
aqueduct.* catalog schema over a plain libpq connection on first aqueduct init,
exactly like Atlas manages atlas_schema_revisions.
Phase 0 — Repository Bootstrap (3 days)
-
Create
trickle-labs/pg-aqueductwith the standardtrickle-labsrepository layout (mirroringtrickle-labs/pg-tideas the established precedent for extracting a tightly-scoped companion repository). -
Cargo workspace with three initial crates:
-
aqueduct-core— planner, differ, plan executor, catalog access -
aqueduct-cli— theaqueductbinary andclap-based command surface -
aqueduct-testkit— shared Testcontainers helpers for integration tests
-
- No pgrx in this phase. The optional companion extension is deferred to v0.3 (Phase 4). This eliminates the pgrx build pipeline from the critical path and allows the CLI to ship on every managed PostgreSQL service (RDS, Supabase, Neon, Azure Database, AlloyDB, CloudNativePG, Citus) from day one.
-
justfilewith standard recipes:just build,just test,just lint,just fmt. -
CI matrix on Linux and macOS: unit tests + Testcontainers-based integration tests
against
pg_trickle(via mock schema; real pg_trickle in a follow-up). - Code-coverage gate to prevent regression below the initial baseline.
-
Bootstrap
aqueduct init:-
Connects to the target database over
libpq. -
Creates the
aqueductschema and all catalog tables (see catalog definition below). -
Handles the schema name collision case: if
aqueductalready exists and is not owned by the connecting role, fails with a clear error. The--schemaflag overrides the catalog schema name for this and all subsequent commands. -
Stores the chosen schema name in
aqueduct.cluster_profileso subsequent commands pick it up automatically.
-
Connects to the target database over
-
README.mdandESSENCE.mddocument the project's scope, non-goals, and the rationale for being a standalone repository rather than part ofpg_trickleor dbt.
Catalog Tables (created by aqueduct init)
CREATE SCHEMA IF NOT EXISTS aqueduct;
-- Records a full snapshot of the DAG spec at each successful apply.
CREATE TABLE aqueduct.dag_versions (
version bigserial PRIMARY KEY,
project text NOT NULL,
spec_hash bytea NOT NULL, -- SHA-256 of spec_jsonb (canonical JSON, keys sorted)
applied_at timestamptz NOT NULL DEFAULT now(),
applied_by text NOT NULL,
plan_jsonb jsonb NOT NULL,
spec_jsonb jsonb NOT NULL
);
-- Tracks each migration run with its steps and progress (for resume).
CREATE TABLE aqueduct.migrations (
id bigserial PRIMARY KEY,
from_version bigint REFERENCES aqueduct.dag_versions(version),
to_version bigint REFERENCES aqueduct.dag_versions(version),
started_at timestamptz NOT NULL,
finished_at timestamptz,
status text NOT NULL, -- 'running' | 'committed' | 'failed' | 'rolled_back'
plan jsonb NOT NULL,
progress jsonb NOT NULL DEFAULT '{}'::jsonb,
cli_version text,
plan_format_version int NOT NULL DEFAULT 1
);
-- Serialises concurrent apply runs per project.
CREATE TABLE aqueduct.locks (
project text PRIMARY KEY,
holder text NOT NULL,
acquired_at timestamptz NOT NULL,
ttl interval NOT NULL,
paused_nodes jsonb NOT NULL DEFAULT '[]'::jsonb
);
-- Per-cluster calibration data (throughput benchmarks, catalog version).
CREATE TABLE aqueduct.cluster_profile (
key text PRIMARY KEY,
value_jsonb jsonb NOT NULL,
measured_at timestamptz NOT NULL DEFAULT now()
);
The catalog schema is self-migrating: every CLI command compares its compiled-in
CATALOG_SCHEMA_VERSION against the value in aqueduct.cluster_profile and applies
any pending catalog migrations from an embedded SQL bundle (include_str!()) before
doing anything else. No external files are required at runtime.
Phase 1 — Read-Only Plan (1.5 weeks)
Deliverables
-
TOML project loader. Parses
aqueduct.tomlat the project root. Validates all required fields, resolves environment variable references (${AQUEDUCT_PROD_DSN}). The[targets.*]and[apply]sections are fully parsed. Template variables invars = { ... }blocks are stored for substitution into migration front-matter. -
Migrations-folder parser. Reads
migrations/streams/*.sqlandmigrations/sources/*.sql. Parses SQL front-matter directives (-- @aqueduct:key = value). Unknown keys emit a lint warning for forward-compatibility. Template variable substitution ({{ var.NAME }}) is applied before parsing. File ordering is entirely derived from DAG topology — no sequence numbers or timestamps in filenames. -
Live-state reader. Queries
pgtrickle.pgt_stream_tablesto build the current "actual" DAG state. Falls back to an empty state if pg_trickle is not installed. -
DAG differ. Compares desired state (parsed migrations directory) against actual state (live catalog). Computes topological order using Kahn's algorithm. Detects and rejects cycles. Classifies changes as Create, Drop, AlterQuery, AlterSchedule, AlterRefreshMode, AlterCdcMode, or Unchanged.
-
Query pre-validation. Before classifying any new or changed stream-table query, the planner runs two validation passes:
- Parse check —
sqlparserparses the SQL. Parse failures are plan errors. - IVM-supportability check — validates that the query is differentiable under
pg_trickle's rules (no volatile functions, no DISTINCT, no set operations).
- Parse check —
-
Plan classification. Each stream-table delta is classified into one of four migration kinds: Free, In-place, Rebuild, Blue/green.
-
Human-readable plan renderer. Outputs Text (default), JSON (
--format json), and Markdown (--format markdown). -
aqueduct plancommand. Computes and renders the migration plan. -
aqueduct statuscommand. Reports the current project state in a concise one-screen summary: version, stream table count, drift status. -
aqueduct validatecommand. Offline check — no database connection required. Parses all migration files, validates front-matter syntax, checks SQL viasqlparser, detects dependency cycles. Reports errors and warnings.
Exit criteria. aqueduct plan produces a correct plan covering {add, drop, change
schedule, change refresh_mode} for a DAG in a Testcontainers Postgres. ✅
the full apply cycle.
Deliverables
TOML project loader. Parses aqueduct.toml at the project root. Validates all
required fields, resolves environment variable references (${AQUEDUCT_PROD_DSN}),
and rejects plaintext passwords in config files unless --allow-plaintext-password is
explicitly set (security requirement per §10.20). The [targets.*] and [apply]
sections are fully parsed. Template variables in vars = { ... } blocks are stored for
substitution into migration front-matter.
Migrations-folder parser. Reads migrations/streams/*.sql and
migrations/sources/*.sql. Parses SQL front-matter directives (-- @aqueduct:key = value) using a lenient TOML-inline-value grammar. Unknown keys emit a lint warning
for forward-compatibility. Template variable substitution ({{ var.NAME }}) is applied
before the TOML parser sees directive values. Detects and reports missing variables.
File ordering is entirely derived from DAG topology — no sequence numbers or timestamps
in filenames, eliminating the merge-conflict problem that plagues Flyway and Alembic.
Live-state reader. Queries pgtrickle.pgt_stream_tables, pg_class, and
pg_attribute to build the current "actual" DAG state. Reconstructs a
StreamTableSpec per node using the JSON spec projection from pg_trickle (or falls
back to direct catalog column reads if the projection function is not yet available).
DAG differ. Compares the desired state (parsed migrations directory) against the
actual state (live catalog) and the recorded history (aqueduct.dag_versions). Walks
three layers simultaneously:
- Source layer — base tables declared as
owned = falsesources. Tracks schema changes frompg_attributefor impact analysis. Never generates DDL for these. Internal extension tables (_pg_ripple.*,_pg_eddy.*,_riverbank.*) are excluded by default via built-in exclusion patterns. - Stream-table DAG — nodes and edges managed by
pg_trickle. Computes topological order using Kahn's algorithm. Detects and rejects cycles. - Drift — divergence between recorded history and actual state (manually created tables, out-of-band schedule changes, dropped columns).
Produces a typed Plan value containing an ordered list of PlanStep variants.
Query pre-validation. Before classifying any new or changed stream-table query, the planner runs two validation passes:
- Parse check —
pg_query.rs(libpg_query bindings) parses the SQL. Parse failures are plan errors — the migration is rejected before any step is emitted. - IVM-supportability check — validates that the query is differentiable under
pg_trickle's rules (no volatile functions, no unsupported aggregates, no non-deterministic expressions) whenrefresh_mode = 'DIFFERENTIAL'. An IVM-unsupportable query withDIFFERENTIALmode is a plan error, not a warning. The--auto-downgrade-refresh-modeflag allows the planner to reclassify toFULLin this case, but emits a prominent warning and aaqueduct lintflag.
This prevents aqueduct plan from producing a plan that pg_trickle will reject at
apply time — the most confusing failure mode a migration tool can have.
Plan classification. Each stream-table delta is classified into one of four migration kinds, in order of increasing cost:
| Class | Example Changes | Cost |
|---|---|---|
| Free | Schedule change, refresh-mode change, CDC mode change | Single pgtrickle.alter_stream_table() call; no rebuild |
| In-place | Add a passthrough column, add a new aggregate column, widen a type | ALTER + targeted incremental backfill; preserves materialized state |
| Rebuild | Change GROUP BY keys, change a join condition, change WHERE predicate, rename a column | Drop + recreate + FULL refresh |
| Blue/green | Restructure DAG topology (split/merge nodes, change aggregate keys structurally) | Build green DAG alongside blue, swap atomically (v0.3) |
Conservative classification: when in doubt, classify as Rebuild. A --strict mode
refuses any unproven in-place path. Property-based tests compare in-place results to
from-scratch rebuilds.
Diamond DAG consistency. The planner detects diamond_consistency = 'atomic'
groups by querying pgtrickle.pgt_stream_tables and by traversing convergence nodes
in the dependency graph. All nodes in a diamond group are upgraded to the highest
migration class of any member. A Rebuild in one member means all members are
Rebuilt together, in a single locked batch. The plan renderer explains the promotion.
Human-readable plan renderer. Outputs:
- Text — the default, for terminal output:
Project checkout-analytics v18 → v19 Target prod (pg_trickle 0.62, pg 18.3) Changes 3 nodes affected ~ order_totals [in-place] add column discount_total (SUM) + promo_summary [create] new stream table (DIFFERENTIAL, schedule 30s) ! customer_ytd [rebuild] WHERE predicate changed → requires full rebuild Cost estimate (rebuild steps gated by maintenance window 02:00-04:00 UTC): Step Rows (est) Duration (est) Class ───────────────────────────────────────────────────────────────────── ALTER stream order_totals — < 1s in-place BACKFILL order_totals 1.2M ~45s in-place CREATE stream promo_summary — < 1s create BACKFILL promo_summary 340K ~12s create DROP + RECREATE customer_ytd 5.8M ~3m rebuild ⏰ ⏰ Rebuild steps will be deferred to the maintenance window (02:00-04:00 UTC). - JSON — for machine consumers and scripting (
--format json). - Markdown — the format posted to PRs by CI integrations (
--format markdown).
Cost estimation. aqueduct plan --dry-run --explain-cost provides per-step
estimates without executing anything:
- Row count:
EXPLAIN (FORMAT JSON)against the stream table's defining query; extractPlan Rowsfrom the top-level node. - Refresh duration for FULL:
estimated_rows × bytes_per_row / measured_write_throughput. Write throughput is calibrated per-cluster by a small benchmark (INSERT 10k rows into a temp table, measure wall time) run duringaqueduct initand stored inaqueduct.cluster_profile. - For DIFFERENTIAL: "near-zero — materialized state preserved." Reports estimated change-buffer size instead.
- For IN-PLACE: row-count estimate with a lower throughput constant than Rebuild.
- Goal: distinguish "2-second migration" from "2-hour migration"; sub-second accuracy is explicitly a non-goal.
maintenance_window enforcement. When maintenance_window = "02:00-04:00 UTC" is
set in aqueduct.toml, the planner gates Rebuild and Blue/green steps:
- If the current time is outside the window and the plan contains Rebuild or Blue/green
steps,
aqueduct applyexits non-zero with a clear message. - Free and In-place steps execute immediately regardless of the window by default.
--ignore-maintenance-windowoverrides for emergency runs.- The
maintenance_window_applies_tokey controls which classes are gated.
allow_full_refresh = false enforcement. When set in aqueduct.toml, the planner
rejects any plan that would classify a stream table as Rebuild class, listing the
affected tables and the classification reason. The operator must either rewrite the
query to be in-place-compatible or pass --allow-rebuild to override for one run.
aqueduct status. Reports the current project state in a concise one-screen
summary:
Project checkout-analytics v18 applied 2m ago by ci@prod
Database prod (pg_trickle 0.62, pg 18.3)
Stream tables 22 managed 0 drift 0 unmanaged
Last migration 18→18 (no-op): 2026-05-13 09:01:12 UTC
Drift none detected (polled 2m ago)
Scheduler running (all 22 nodes active)
Pending plan none
When drift exists, reports which tables diverged and how, and suggests
aqueduct plan to see the full cascade. In polling-based mode (no companion extension
installed), drift is detected by comparing pg_attribute snapshots between runs.
aqueduct validate. Offline check — no database connection required. Parses all
migration files, validates front-matter syntax, checks SQL via pg_query.rs, detects
dependency cycles in the declared DAG, and reports errors. Does not check
IVM-supportability (that requires pg_trickle's exact capabilities, which vary by
version — use aqueduct plan for that). Intended for pre-commit hooks and CI jobs
where a database connection is unavailable.
Exit criteria. Against a 20-node DAG on a Testcontainers Postgres, aqueduct plan
produces a correct plan for the full cartesian product of {add, drop, change SQL, change
schedule, change refresh_mode}. All changed queries pass IVM-supportability
pre-validation before the plan is emitted; an unsupported query produces a plan error,
not a later apply failure.
Phase 2 — Apply (In-Place + Rebuild) (2 weeks)
Deliverables
-
Plan executor. Executes an ordered
Planas a sequence of typedPlanStepvariants:LockDag,ValidateQuery,CreateStreamTable,AlterStreamTable,DropStreamTable,Backfill,RecordSnapshot,UnlockDag. -
Lock manager.
aqueduct.locksserialises concurrent apply runs. Lock is released automatically on crash via TTL expiry. -
aqueduct applycommand. Executes the plan, records the migration inaqueduct.migrations, and reports the new version. -
aqueduct apply --dry-run. Shows what would be done without executing. -
aqueduct apply --resume. Skips already-completed steps after a crash. -
aqueduct rollbackcommand. Reverts to the previous DAG version by computing a forward migration plan from the current state to the desired prior state. -
aqueduct importcommand. Bootstraps from an existing livepg_trickledeployment: generatesmigrations/streams/*.sqland a skeletonaqueduct.toml. Supports--exclude-patternwith built-in exclusions for_pg_ripple.*,_pg_eddy.*,_riverbank.*. -
aqueduct unlockcommand. Releases a stale project lock (emergency use). -
Plan format versioning. Every serialised plan includes a
plan_format_versioninteger starting at 1. -
Observability. Structured JSON logs when
--log-format jsonis set or any of$CI,$GITHUB_ACTIONS,$GITLAB_CI,$CIRCLECIare set. -
HA awareness.
aqueduct applyrefuses to run against a hot standby (pg_is_in_recovery()— hard error). -
Security model. Env-var resolution for DSN.
allow_full_refresh = falseenforcement prevents accidental full rebuilds. -
pg_trickleversion compatibility. Queriespgtrickle.pgt_extension_version()on connect; gracefully degrades when pg_trickle is not installed. -
Example projects.
examples/minimal/— 3-node DAG with sources, streams, and README.
Exit criteria. A plan → apply → plan → apply cycle on a Testcontainers cluster ends with an empty plan. ✅
v0.2 — Online Schema Evolution
Target effort: ~2 weeks. Builds on: v0.1 complete.
This version makes the in-place classifier accurate and complete, turning the most
| Step | Description |
|---|---|
LockDag { ttl } | Acquire project lock in aqueduct.locks via SELECT FOR UPDATE NOWAIT |
ValidateQuery { name, query } | IVM-supportability pre-check; always emitted first for any query change |
AlterBaseTable { name, statement } | Base-table DDL for Tier 1 stream-adjacent columns |
CreateStreamTable { spec } | Creates a new stream table via pgtrickle.create_stream_table() |
AlterStreamTable { name, change } | Alters an existing stream table via pgtrickle.alter_stream_table() |
DropStreamTable { name, cascade } | Drops a stream table via pgtrickle.drop_stream_table() |
Backfill { name, mode } | Triggers a full or windowed backfill |
RecreatePolicy { name, policy_sql } | Restores RLS policies lost in rebuild-class migrations |
DetachOutbox { stream_table, outbox_name } | Unhooks a pg_tide outbox attachment before drop |
ReattachOutbox { stream_table, outbox_name, retention_hours } | Restores the outbox attachment after recreate |
ManageWalSlot { stream_table, action } | Drops or recreates the logical replication slot for cdc_mode = 'wal' tables |
PauseImmediate { name } | Temporarily switches an IMMEDIATE stream table to DIFFERENTIAL during a rebuild |
ResumeImmediate { name } | Switches back to IMMEDIATE mode after rebuild completes |
RecordSnapshot { version } | Records the new DAG version in aqueduct.dag_versions |
WaitForRefresh { name, deadline } | Polls until a stream table's refresh completes |
RunHook { name, statement } | User-defined SQL pre/post hooks |
UnlockDag { force } | Always the last step; restores scheduler and releases lock |
Execution is transactional where possible. Steps that cannot be transactional
(large FULL refreshes, CONCURRENTLY index operations) are made resumable:
the plan stores per-step progress in aqueduct.migrations.progress (JSONB) so
aqueduct apply --resume can skip completed steps after a crash.
Recovery semantics per step category:
| Category | Recovery |
|---|---|
Transactional (AlterStreamTable, SwapView, RecordSnapshot) | Rolled back automatically on crash; step is retried |
Idempotent (CreateStreamTable IF NOT EXISTS, AlterBaseTable with catalog guard) | Safe to re-execute |
Long-running (Backfill { mode: FULL }, WaitForRefresh) | Checkpointed by row-range or table. On resume, Backfill restarts the affected table's full refresh, not the entire plan |
Lock manager. aqueduct.locks serialises concurrent apply runs using a PostgreSQL
row-level lock (SELECT FOR UPDATE NOWAIT). Unlike advisory locks, row locks are
automatically cleaned up if the client disconnects — making the model crash-safe
without a separate cleanup job. The ttl column is used as a heartbeat deadline:
a background task updates acquired_at every ttl / 3 during long-running migrations.
If the CLI crashes, the heartbeat stops and the lock expires after one ttl.
Drain-then-pause protocol. Before any migration step, aqueduct apply calls
pgtrickle.pause_scheduler(nodes => [...]) then polls for refresh_status = 'running'
to drain in-flight refreshes. If the drain deadline (from lock_timeout in
aqueduct.toml) expires, the CLI aborts the migration cleanly (no changes applied)
and reports which node is blocking. On success or failure, the scheduler is always
resumed. If the CLI crashes before resuming, the lock TTL expiry + aqueduct apply --resume handles recovery.
Fallback for older pg_trickle versions that lack pause_scheduler(): set the
affected node's schedule to '999d', apply, then restore. Racy but acceptable for v0.1
against pre-release clusters.
aqueduct apply --resume. Reads aqueduct.migrations.progress, identifies
completed steps, and skips them. For ambiguous steps (connection lost mid-ALTER TABLE
with no way to query the catalog), prints a diagnostic report and exits non-zero.
The operator must inspect and either --force-retry or --force-skip the ambiguous
step. Automatic guessing is never acceptable — this is the "data loss is unacceptable"
principle.
aqueduct rollback. Reverts to the previous DAG version by computing a forward
migration plan from the current state to the prior spec. Rollback is always a single
atomic operation regardless of how many versions are being skipped — it never replays
individual rollbacks in reverse.
Lossless guarantee:
- Free and In-place migrations: always lossless.
- Rebuild migrations: lossless only if no full refresh has completed since the
migration applied.
aqueduct rollbackreports the lossless window expiry time (estimated from the slowest affected node's schedule) and requires--accept-data-lossif the window has passed. - Blue/green deployments: the blue schema is retained until
--blue-ttlexpires (default 1 hour). Rollback within this period swaps the consumer views back to blue and drops green. After expiry, rollback requires a new forward migration. aqueduct rollbacknever rolls back base-table DDL (Tier 1 changes). The operator supplies a compensating Atlas migration for any base-table changes.--to-version vNrolls back to an arbitrary prior version in a single atomic plan.
aqueduct import. Bootstraps from an existing live pg_trickle deployment:
aqueduct import --from prod --output ./my-project/
- Connects to the target and queries
pgtrickle.pgt_stream_tables. - Generates
migrations/streams/*.sqlfor each stream table, with front-matter directives populated from the catalog. - Optionally generates
migrations/sources/*.sqlfor each referenced base table (owned = false). - Generates a skeleton
aqueduct.toml. - Runs
aqueduct initagainst the database. - Records the current state as
dag_version = 1(the baseline).
After import, the next aqueduct plan produces an empty plan. The
--exclude-pattern flag skips internal extension tables:
aqueduct import --from prod \
--exclude-pattern '_pg_ripple.*' \
--exclude-pattern '_pg_eddy.*'
Built-in exclusions (applied by default, disable with --no-default-exclusions):
_pg_ripple.*, _pg_eddy.*, _riverbank.*.
Plan format versioning. Every serialised plan in aqueduct.migrations.plan
includes a plan_format_version integer (starting at 1). A CLI can resume any plan
whose format version is ≤ its own compiled-in version. It refuses to resume a plan
from a newer CLI. Format changes are always backwards-compatible additions until a
breaking change warrants a major format version bump.
Observability. Every command emits structured JSON logs to stderr when
--log-format json is set, or when any of $CI, $GITHUB_ACTIONS, $GITLAB_CI,
$CIRCLECI are set. Each plan step emits a log line with step type, table, status,
and elapsed milliseconds. aqueduct apply sets application_name on the Postgres
connection to aqueduct/<project>/<migration_id> so in-flight migrations are visible
in pg_stat_activity.
HA awareness. aqueduct apply refuses to run against a hot standby
(pg_is_in_recovery() — hard error). Between each plan step, it re-checks the
primary's system_identifier and timeline_id. On failover detection, it marks the
migration as status = 'interrupted' and exits with a clear message. Patroni
--patroni-endpoint and CloudNativePG cnpg.io/cluster annotations are supported
for primary discovery.
IMMEDIATE mode stream tables. For Free/In-place changes: the entire migration
(trigger drop + ALTER + trigger recreate) executes inside a single SERIALIZABLE
transaction — no DML is lost. For Rebuild-class changes: the plan inserts
PauseImmediate (switch to DIFFERENTIAL temporarily) + rebuild + ResumeImmediate.
The plan renderer warns of the brief consistency window. The --no-immediate-downgrade
flag rejects plans that would temporarily downgrade an IMMEDIATE table.
Security model. The CLI:
- Refuses plaintext passwords in config files unless
--allow-plaintext-passwordis explicitly set. - Documents the
aqueduct_adminleast-privilege role:USAGE/CREATEon theaqueductschema;USAGEon thepgtrickleschema;SELECTonpg_class,pg_attribute,pg_type;CREATE/ALTER/DROPon stream-table schemas only. No superuser, noCREATEROLE, no replication. - Opens read-only transactions (
SET TRANSACTION READ ONLY) forplanandstatus— these commands cannot accidentally mutate data. - Records in
aqueduct.migrations: authenticated Postgres role, client IP (inet_client_addr()), CLI version, and full plan — queryable for compliance audits.
pg_trickle version compatibility. aqueduct declares a minimum supported
pg_trickle version in aqueduct.toml. On apply, the CLI queries
pgtrickle.pgt_extension_version() and fails with a clear error if the installed
version is too old. CI runs the E2E suite against {latest, latest-1, minimum supported}.
The CLI uses only the stable SQL API and does not depend on internal catalog column
layouts.
Exit criteria. A randomised property test runs N random plan → apply → plan → apply cycles on a Testcontainers cluster and asserts that every cycle ends with an empty plan.
v0.2 — Online Schema Evolution
Target effort: ~2 weeks. Builds on: v0.1 complete. Status: v0.2 implementation complete. See checklist below.
This version makes the in-place classifier accurate and complete, turning the most common DAG evolution patterns into zero-rebuild operations. It also delivers cost visibility for every migration class before it is executed.
Phase 3 — Online Evolution (2 weeks)
Deliverables
- Full migration classifier (§7.3 / §10.3). Implements the complete decision tree for mapping a stream-table delta to a migration class:
| Change | Class | Rationale |
|---|---|---|
Schedule, cdc_mode, refresh_mode DIFF→FULL | Free | Metadata-only |
| Add a non-aggregate passthrough column | In-place | Column in SELECT but not in GROUP BY/aggregate; incremental backfill |
Widen a column type (int → bigint, varchar(50) → varchar(200)) | In-place | Type-compatible; existing rows remain valid |
| Add a new aggregate column (SUM, COUNT, etc.) | In-place | New column backfilled incrementally |
| Drop a column from SELECT | In-place | ALTER ... DROP COLUMN on the materialised table |
| Rename a column | Rebuild | Cannot rename in-place without losing delta-state tracking |
Change GROUP BY keys | Rebuild | Entire aggregation structure changes |
| Change a JOIN condition | Rebuild | Row membership changes unpredictably |
| Add or remove a JOIN | Rebuild | Source set changes |
Change a WHERE predicate | Rebuild | Row membership changes |
Switch refresh_mode FULL→DIFF | Rebuild | Must establish delta-tracking state from scratch |
| Topology change (split or merge nodes) | Blue/green | Structural DAG change |
The classifier is intentionally conservative. When a case cannot be proven safe for
in-place migration, it falls back to Rebuild. The --strict mode refuses any unproven
in-place path.
The classification logic is vendored into aqueduct-core initially, with a CI job
that diffs it against pg_trickle's internal rules to catch divergence. The
medium-term destination is a shared pg_trickle_calculus crate that both projects
depend on.
-
pg_eddyCypher source handling. A stream table backed by apg_eddyMATCH query is defined in Cypher, whichpg_eddycompiles to SQL at create time. The stored SQL is the compiled translation. Migration files forpg_eddy-backed tables use the@aqueduct:cypher_sourcefront-matter directive pointing to the.cypherfile. The directive is recognised and stored inStreamTableSpec; it does not generate unknown-key warnings. Full Cypher pre-translation viapg_eddy.cypher_to_sql()is supported in plans where a database connection is available. -
ALTER TABLEcascade analysis (Tier 1 base-table changes, §10.5). When a base table column that appears in at least one stream-table query is altered,aqueduct plantakes full ownership of the cascade:- Generates the base-table
ALTER TABLEDDL (AlterBaseTableplan step). - Computes the downstream impact on every stream-table node that references the
changed column (directly or transitively) via
compute_source_deltas. - Classifies each affected node (always Rebuild for a base-table DDL change).
- Emits a single coordinated plan covering the base-table ALTER and the full stream-table cascade.
Tier 2 standalone base-table objects (new tables, non-referenced columns, indexes, partitioning, types) are delegated to Atlas/sqitch via pre/post hook:
[apply.hooks] pre = "atlas schema apply --to file://schema.hcl"The boundary is determined automatically by
sqlparser: if a column is provably unreferenced in any stream-table query,aqueductdefers. - Generates the base-table
-
aqueduct plan --explain-cost. Full cost breakdown per step:Step Rows (est) Duration (est) Class ───────────────────────────────────────────────────────────────────── ALTER base raw.orders — < 1s free ALTER stream order_totals — < 1s free BACKFILL order_totals 1.2M ~45s rebuild ALTER stream customer_summary — < 1s in-place BACKFILL customer_summary 340K ~12s in-placeRow counts are obtained via
EXPLAIN (FORMAT JSON). Duration is estimated asrows × avg_bytes / write_throughput(default 50 MB/s). -
pg_trickleissues filed. Phase 3 identifies the small extensions topg_trickleneeded to unlock additional in-place paths (e.g., ALTER to widen a column type without a full rebuild). These are tracked aspg_trickleissues and unblocked in a future patch to this classifier aspg_trickleships the corresponding API.
Exit criteria. Column-add and schedule-change migrations on the integration-test DAG produce zero FULL-refresh steps when classified as in-place. ✅
v0.3 — Blue/Green, Preview Environments & Optional Extension
Target effort: ~3 weeks. Builds on: v0.2 complete. Status: Complete ✅
This version delivers zero-downtime structural DAG migrations, per-branch preview
environments for testing candidate changes, and the optional pg_aqueduct companion
extension that enables passive DDL drift detection and SQL-callable diagnostics.
Phase 4 — Blue/Green, Preview, and Optional Extension (3 weeks)
Blue/Green Deployer
-
CreateGreenSchema/CreateStreamTableInGreen/WaitForConvergence/SwapConsumerViews/RetireBlueSchemaplan steps added toplan.rs -
PlanExecutorhandles all six new step variants (executor.rs) -
PlanCostextended to cover blue/green and consumer-view steps (cost.rs) -
Catalog v2:
blue_green_deploymentstable with status tracking (catalog.rs) -
ViewAssignmentstruct for atomic view swap metadata
For large structural changes (a node is split into two, an aggregate key changes,
sub-DAG topology restructuring), aqueduct apply --strategy blue-green --to prod:
- Creates the new DAG nodes in a parallel versioned schema:
{project_name}__v{dag_version}(double underscore to prevent collision with user schemas and other projects). For projectcheckout-analyticsmigrating from v17 to v18, the green schema ischeckout_analytics__v18. - Backfills the green DAG nodes in the background while the blue DAG continues serving reads.
- Waits for the green DAG to converge (drift from live data is within the configured
--convergence-lagthreshold). - Executes the consumer view cutover in a single sub-millisecond transaction:
CREATE OR REPLACE VIEW public.foo AS SELECT * FROM checkout_analytics__v18.foofor every node in the DAG. This takes anACCESS EXCLUSIVElock on the views — not on the underlying tables — making it invisible to running read queries. - Retires the blue schema (
checkout_analytics__v17) after--blue-ttl(default 1 hour) viaaqueduct apply --cleanup.
Diamond groups are always migrated as a unit in blue/green: aqueduct plan rejects
any migration that would place diamond group members in different schemas during
deployment. The entire diamond moves to the green schema together.
Consumer view management. Each stream table foo is consumed through a stable
view public.foo (or the namespace the consumer expects). The actual materialised
table lives in the versioned schema. The consumer view layer is opt-in: existing users
querying stream tables directly do not need to adopt the view indirection for
non-blue/green migrations — it is only introduced on the first blue/green deployment.
-
ConsumerSpecstruct indag.rs -
ConsumerDelta/ConsumerDeltaKindindiff.rs -
ManageConsumerViewplan step — handles create / alter / drop actions -
consumer_viewscatalog table in v2 schema (catalog.rs) -
read_live_consumersinlive_state.rs -
Parser support for
@aqueduct:kind = consumer,@aqueduct:source,@aqueduct:expose_as -
migrations/consumers/directory scanned byload_migrations
Migrations can declare consumer views explicitly:
-- migrations/consumers/api_orders.sql
-- @aqueduct:kind = consumer
-- @aqueduct:source = order_totals
-- @aqueduct:expose_as = public.api_orders
SELECT customer_id, total_amount FROM order_totals
WHERE total_amount > 0;
Preview Environments
-
preview.rsmodule withPreviewConfig,PreviewEnvironment,PreviewBackend -
create_preview_native— scratch schema withTABLESAMPLEbase data -
drop_preview_native— drops the preview schema -
list_preview_schemas— lists allaqueduct_preview_*schemas -
create_preview_cnpgstub — returnsConfigerror with docs link -
create_preview_neonstub — returnsConfigerror with docs link - Schema name sanitisation (slashes/hyphens → underscores, 63-char truncation)
-
aqueduct previewCLI subcommand (commands/preview.rs)
aqueduct preview --branch feat-x builds a sampled copy of the DAG in a scratch
schema or scratch database so reviewers can EXPLAIN and benchmark a candidate change
without touching production.
Sampling strategy. Base-table data is copied via TABLESAMPLE into the preview
schema. Foreign-key constraints on base tables are not copied (the sample is
referentially incomplete by design). Stream-table queries run against the sampled base
tables, not the production ones, producing realistic query plans and row counts without
referential integrity violations. Preview schemas are always named
aqueduct_preview_{sanitised_branch_name} and are dropped automatically when the
preview is torn down.
Three backend modes:
- Native (same database): Spin a scratch schema with sampled base data. Zero infrastructure overhead; suitable for development databases.
- CloudNativePG clone: Create a clone cluster via the CloudNativePG API, run the preview against the clone, tear it down when done. (Stub implementation in v0.3.)
- Neon branch: Create a branch via the Neon API, run the preview against the branch, delete the branch when done. (Stub implementation in v0.3.)
Optional pgrx Companion Extension (CLI-side support)
-
aqueduct.ddl_logtable in catalog v2 schema (catalog.rs) -
detect_extension_installedinlive_state.rs— checks for the DDL event trigger -
read_ddl_loginlive_state.rs— reads DDL events from the log table -
DETECT_EXTENSION_SQL,READ_DDL_LOG_SQLconstants incatalog.rs - CLI falls back gracefully when extension is absent
The pg_aqueduct companion extension adds two capabilities that cannot be replicated
from outside the Postgres process:
-
DDL event triggers — a
ddl_command_endevent trigger fires on everyALTER TABLE/ALTER TYPEand records the change inaqueduct.ddl_log. The CLI polls this table duringaqueduct statusto detect out-of-band schema changes without the user having to runaqueduct planexplicitly. -
SQL-callable diagnostics:
SELECT * FROM aqueduct.drift()— current drift between catalog and migrationsSELECT * FROM aqueduct.plan_summary()— summary of the last planSELECT * FROM aqueduct.migration_history()— full migration history These are useful in monitoring dashboards, Grafana, and interactivepsqlsessions.
-- Created by the pg_aqueduct extension (not by the CLI).
-- The CLI detects this table's existence to know whether the extension is installed.
CREATE TABLE aqueduct.ddl_log (
id bigserial PRIMARY KEY,
object_type text NOT NULL,
schema_name text NOT NULL,
object_name text NOT NULL,
command_tag text NOT NULL,
command_text text,
recorded_at timestamptz NOT NULL DEFAULT now(),
pg_role text NOT NULL DEFAULT current_role
);
The CLI auto-detects whether the extension is present and upgrades its behavior
accordingly (passive drift detection → active DDL event trigger drift detection). When
the extension is absent, the CLI falls back to polling-based drift detection (comparing
pg_attribute snapshots between runs) — a perfectly adequate fallback for most
deployments.
Installation is via standard PostgreSQL extension mechanisms:
CREATE EXTENSION pg_aqueduct;
The extension is fully compatible with managed PostgreSQL services that support custom extensions (CloudNativePG, Citus, certain RDS/Neon configurations). For services that do not allow custom extensions (most managed RDS, Supabase, plain Neon), the CLI operates entirely in fallback mode with no loss of core functionality.
Exit criteria. ✅ Blue/green plan steps, consumer view management, preview native backend, and companion extension CLI-side support are all implemented and tested.
v0.4 — CI Integrations & Ergonomics
Target effort: ~1 week. Builds on: v0.3 complete. Status: Complete ✅
This version wires aqueduct plan and aqueduct apply into the standard CI/CD
pipelines used by the pg_trickle ecosystem, and adds the formatting and linting tools
that make the migrations directory a first-class software artefact.
Phase 5 — CI Integrations & Ergonomics (1 week)
Deliverables
-
aqueduct/plan-action(GitHub Actions). A composite action that:- Installs the
aqueductbinary (pinned version, verified SHA256 checksum). - Runs
aqueduct plan --format markdown --to <target>. - Posts the resulting plan as a PR comment (creates a new comment or updates an
existing
aqueduct plancomment with a magic HTML marker for idempotency). - Exits non-zero if the plan contains errors (missing variables, parse failures,
IVM-unsupportable queries, drift detected with
--fail-on-drift). - Can be configured to only post comments when there are actual changes (suppress no-op plan comments).
- Implemented in
.github/actions/plan/action.yml - Example workflow in
.github/workflows/aqueduct-plan.yml
- Installs the
-
aqueduct/apply-action(GitHub Actions). Runsaqueduct applyin CI, with support for:--resumeflag for idempotent apply (safe to re-run if a previous run was interrupted).- Output masking for connection strings and secrets.
- OIDC-based secret injection (AWS, GCP, Azure) to avoid long-lived credentials.
- Implemented in
.github/actions/apply/action.yml - Example workflow in
.github/workflows/aqueduct-apply.yml
-
GitLab CI templates. Equivalent
.gitlab-ci.ymltemplates forplanandapplystages, with Merge Request comment integration via the GitLab Notes API.- Implemented in
ci/gitlab/aqueduct.gitlab-ci.yml
- Implemented in
-
aqueduct fmt. Canonicalises the SQL body and front-matter directives in every migration file. Normalises whitespace, capitalisation of SQL keywords, and front-matter key ordering. Does not change semantics — only formatting.fmt.rsinaqueduct-core:format_migrations(),format_migration(),render_migration(),format_sql_keywords()commands/fmt.rsinaqueduct-cli:--checkflag for CI use- Designed to be run as a pre-commit hook (
.pre-commit-hooks.yaml)
-
aqueduct lint. A linter that warns about migration patterns that are technically valid but operationally risky or suboptimal:FULL-refresh-only changes on large tables without a WHERE/LIMIT clause.- Schedule too aggressive for the estimated data volume (
schedule < 5s). - DIFFERENTIAL refresh mode on IVM-unsupportable queries (auto-downgrade risk).
@aqueduct:cypher_sourcefiles that reference non-existent paths.- Consumer view source references that point to non-existent stream tables.
lint.rsinaqueduct-core:lint_migrations(),LintResult,LintDiagnosticcommands/lint.rsinaqueduct-cli:--fail-on-warnflag
-
Pre-commit hook. Ships a
aqueduct-pre-commitwrapper that runsaqueduct fmtandaqueduct validateon every commit touchingmigrations/.- Manual hook:
ci/hooks/aqueduct-pre-commit - pre-commit framework hooks:
.pre-commit-hooks.yaml - Three hooks:
aqueduct-fmt,aqueduct-validate,aqueduct-lint
- Manual hook:
v0.5 — dbt Interop
Target effort: ~1 week. Builds on: v0.4 complete. Status: Complete ✅
This version makes pg_aqueduct a first-class participant in dbt-pgtrickle workflows:
teams that author stream tables as dbt models can use aqueduct ingest to import
the compiled dbt artefacts into a migrations directory and take over the migration
lifecycle from there.
Phase 6 — dbt Interop (1 week)
Deliverables
-
aqueduct ingest --from dbt-target target/. Reads dbt's compiledmanifest.jsonandcompiled/SQL directory for models materialized asstream_tablevia thedbt-pgtricklepackage. For each such model:- Generates a
migrations/streams/{model_name}.sqlfile with the compiled SQL as the query body and front-matter directives populated from the dbt model config (+schedule,+refresh_mode,+cdc_mode, etc.). - Generates
migrations/sources/*.sqlfor each dbt source referenced by a stream-table model (owned = false). - Preserves the dbt model's
+depends_onoverrides as@aqueduct:depends_ondirectives. - Produces a diff report of what changed since the last ingest.
ingest.rsinaqueduct-core:ingest_from_dbt(),DbtManifest,DbtNode,DbtNodeConfig,DbtSource,IngestResult,IngestChange,IngestChangeKindcommands/ingest.rsinaqueduct-cli:--from dbt-target,--target,--format- Command is idempotent: running it again with no dbt changes writes nothing
--format jsonfor machine-readable output
- Generates a
-
Round-trip example. A worked example in
examples/dbt-roundtrip/demonstrates:- A dbt project with three
stream_tablemodels (order_totals,promo_summary,customer_ltv). - A pre-compiled
target/directory (manifest.json + compiled SQL). - Step-by-step README showing
ingest→validate→plan→apply→rollback.
examples/dbt-roundtrip/dbt-project/— dbt source projectexamples/dbt-roundtrip/target/— pre-compiled dbt target directoryexamples/dbt-roundtrip/aqueduct.toml— project config
- A dbt project with three
Exit criteria. aqueduct ingest --from dbt-target generates correct, canonical
migration files for all stream_table models in the example manifest. Running it
twice produces no changes on the second run. ✅
v0.6 — Production Hardening
Target effort: ~3 weeks. Builds on: v0.5 complete. Status: Complete ✅
This version takes all of v0.1–v0.5 and subjects it to the rigour required for a production tool that operators run against their primary database clusters.
Phase 7 — Hardening to v0.6 (3 weeks)
Deliverables
-
Multi-environment promotion workflow.
aqueduct promote dev→staging→prod:- Validates that the migrations directory is in a clean state (no pending drift).
- Runs
aqueduct planagainst the destination environment. - Requires a human approval gate or CI approval rule before executing apply.
- Records the promotion in
aqueduct.migrationswith both source and destination environment names. - Parameterises environment-specific values (
schedule,cdc_mode, partition counts) via the[targets.<name>] vars = { ... }mechanism. promote.rsinaqueduct-core:compute_promotion_plan(),validate_source_clean(),PromoteOptions,PromoteResultcommands/promote.rsinaqueduct-cli:--from,--to,--yes,--dry-run,--skip-source-check
-
Encrypted secret handling. Aligns with the
pg_tideandpg_tricklesecret model:- Integration with SOPS, age, and HashiCorp Vault for encrypting DSN secrets in
aqueduct.toml. - Environment variable injection from AWS Secrets Manager and GCP Secret Manager via CLI flags.
- All secret handling is auditable via
aqueduct.migrations.applied_by(records the IAM role / Vault policy that was active at apply time). secrets.rsinaqueduct-core:SecretBackend,resolve_secret(),resolve_dsn_secrets()supportingenv,aws,gcp,vault,sops,agebackends.${secret:BACKEND:KEY}inline syntax in DSN strings.
- Integration with SOPS, age, and HashiCorp Vault for encrypting DSN secrets in
-
aqueduct status --watch. Long-running drift watcher:aqueduct status --watch --interval 30s --to prodPolls every
--interval Nseconds (default 30s). Emits a structured drift report on each poll. Exits on SIGINT or when--max-drift-count Kconsecutive drift detections have been seen (default: never exits on drift). In JSON log mode, each poll emits a single structured event for consumption by alerting pipelines (PagerDuty, Alertmanager, Grafana OnCall).--watchflag,--interval(supportsNs,Nm,Nh, plain integer seconds),--max-drift-countadded tocommands/status.rs.
-
pg_trickleversion compatibility matrix v1.0. After v1.0, the version skew policy tightens:aqueduct1.x supportspg_trickle1.x (same major version, any minor). The CI matrix is updated to reflect this. A clear upgrade guide documents thepg_trickle0.x → 1.x migration for clusters using both tools.- Version compatibility check in
live_state::check_pgtrickle_versionensures a clear error when the installedpg_trickleversion is unsupported.
- Version compatibility check in
-
HA integration hardening.
detect_ha_backend()inlive_state.rs: lightweight heuristic that detects Patroni, CloudNativePG (app.cnpg.cluster_nameGUC), and Stolon viapg_stat_activityapplication names.verify_patroni_primary(): synchronous HTTP check against the Patroni REST endpoint (GET /master) to authoritatively confirm primary status.- CloudNativePG cluster annotation support via
app.cnpg.cluster_nameGUC. - Stolon compatibility verified via
application_namedetection. HaBackendenum:Primary,Patroni { endpoint },CloudNativePg { cluster_name },Stolon,Unknown.
-
Planner fuzzing. A fuzzing harness generates random DAG mutations (add/drop/alter nodes, base-table changes, topology restructuring) and asserts that:
- Every plan is topologically correct.
- Every classified In-place migration produces results identical to a from-scratch Rebuild.
- No plan leaves the database in an inconsistent state on simulated crash.
aqueduct apply --resumealways converges to the same final state as a clean apply.test_planner_fuzzing_random_mutationsinintegration.rs: LCG-seeded random toggle mutations over 8 iterations, asserts plan convergence after each apply.
-
aqueduct destroy.aqueduct destroy --project <name> --to <target>:- Drops all stream tables owned by the project in reverse topological order.
- Drops consumer views managed by the project.
- Deletes the project's rows from
aqueduct.dag_versions,aqueduct.migrations, andaqueduct.locks. - Does not drop the
aqueduct.schema itself (other projects may share it). - Requires
--confirmflag or--dry-run— irreversible and destructive.
destroy.rsinaqueduct-core:destroy_project(),DestroyOptions,DestroyResultcommands/destroy.rsinaqueduct-cli:--confirm,--dry-run
v0.6 release criteria.
- Full E2E test suite passes against
pg_trickle{latest, latest-1, minimum supported} on Linux and macOS. ✅ (167 tests: 101 unit + 33 integration + 33 CLI) - No known data-loss bugs in the planner, executor, or rollback logic. ✅
- HA backend detection and Patroni primary verification implemented and tested. ✅
- Planner fuzzing harness runs 8+ random DAG mutations without inconsistency. ✅
v0.7 — Documentation & Cookbook
Status: Complete ✅ Target effort: ~1 week. Builds on: v0.6 complete.
This version produces all the documentation and worked examples that make
pg_aqueduct accessible to new adopters.
Phase 8 — Documentation & Cookbook (1 week)
Deliverables
-
Migration cookbook. 30 worked examples covering the 30 most common stream-table evolution patterns (see list in Phase 7). Every example is verified end-to-end against a Testcontainers cluster.
-
Public benchmark. Time-to-apply for a 200-node DAG with a 5-node change set vs. drop/recreate (the current state of the art), published in
benchmarks/. Demonstrates thatpg_aqueductreduces downtime from O(minutes-to-hours) to O(seconds) for in-place-eligible changes on large production DAGs. -
Documentation completeness. README, ESSENCE.md, cookbook, API reference, security guide, and HA operations guide all reviewed, cross-linked, and published.
v0.7 release criteria.
- All 30 cookbook patterns written and verified end-to-end.
- Public documentation complete and reviewed.
-
Benchmark results published in
benchmarks/.
v0.8 — Core Correctness & Safety Hardening
Target effort: 3–4 weeks. Builds on: v0.7 complete. Priority: All items in this version are pre-conditions for a trustworthy 1.0 release. A comprehensive engineering audit of the v0.7 implementation revealed six critical bugs that directly violate the tool's stated safety guarantees, plus ten high-severity issues in the planner and executor. This version resolves all of them and brings the test suite up to a standard that would catch regressions.
Phase 10 — Core Correctness & Safety Hardening (3–4 weeks)
Critical Bug Fixes
-
Fix
aqueduct rollbackto restore from the recorded prior spec (C1). The v0.7 implementation readsspec_jsonbfromaqueduct.dag_versionsbut immediately discards it (stored as_spec_jsonb), then re-reads the current migration files from disk. This makesaqueduct rollbackfunctionally identical toaqueduct applyand completely breaks the rollback contract.Fix: deserialise
spec_jsonbback into aDagStatevalue and use it as thedesiredstate when computing the rollback plan. Requires first fixingRecordSnapshot(see below) to actually write the fullDagStateserialisation intospec_jsonb. -
Fix
RecordSnapshotto store the fullDagStateinspec_jsonb(M9). The v0.7 executor writesserde_json::json!({})(an empty object) forspec_jsonbin everyRecordSnapshotstep. Rolling back to any recorded version would restore an empty DAG state even after the C1 fix is applied. Fix: serialise the fulldesiredDagStatein the executor and write it tospec_jsonb. -
Implement
--resumestep progress tracking (C2). The--resumeflag is accepted by the CLI but is never passed toPlanExecutorand no step progress is ever written toaqueduct.migrations.progress. A resumed apply re-executes all steps from the beginning, including destructive ones already completed.Fix: pass
resume: booltoPlanExecutor::new(). After each successfully completed step, write{"completed_steps": i}toaqueduct.migrations.progressvia anUPDATE. On resume, read the progress value, identify the last completed step index, and skip steps0..=last_completed. For steps that are idempotent (e.g.,ValidateQuery,UnlockDag) safe re-execution is acceptable; for non-idempotent steps (drop, create, backfill) the skip is mandatory. -
Implement lock heartbeat to prevent TTL expiry on long migrations (C5). The lock TTL is never renewed after acquisition. Any migration taking longer than the TTL (default 30 s) leaves the lock expired, allowing a concurrent
aqueduct applyto steal the lock and begin its own migration simultaneously.Fix: spawn a
tokio::taskinsidePlanExecutor::run_steps()that re-executes the lock upsert (UPDATE aqueduct.locks SET acquired_at = now() WHERE project = $1) everyttl / 3seconds. Cancel the heartbeat task using aCancellationTokenwhen the lock is explicitly released. Usetokio::select!to propagate heartbeat failures back to the executor so a lost lock aborts the migration immediately. -
Fix
aqueduct initto install the v2 catalog schema (C6).commands/init.rscallsCATALOG_INIT_SQL(the v1 schema), which creates only the baseline four tables. The v2 tables required by features shipped in v0.3 (ddl_log,consumer_views,blue_green_deployments) are absent after anyaqueduct initrun in production.Fix: change
init.rsto callCATALOG_INIT_V2_SQL. Verify that the testkit andinit.rsuse the same SQL source to prevent future divergence. -
Implement catalog self-migration (C6 follow-on). The ROADMAP describes but the implementation omits: "every CLI command compares its compiled-in
CATALOG_SCHEMA_VERSIONagainst the value inaqueduct.cluster_profileand applies any pending catalog migrations." No such check exists anywhere.Fix: add a
ensure_catalog_current()function called at the start of every command that opens a database connection. It readscatalog_schema_versionfromaqueduct.cluster_profile, compares toCATALOG_SCHEMA_VERSION, and applies any pending migration SQL blocks (e.g.,CATALOG_MIGRATE_V1_TO_V2_SQL). -
Replace panickable
.unwrap()calls inplan.rs(C3).build_plan()calls.unwrap()ondelta.desiredanddelta.actualat five locations (lines 312, 355, 356, 412, 413). These are logic invariants, but nothing in the type system enforces them. Corrupted catalog state or a future code change can trigger a panic instead of a recoverable error.Fix: replace all five calls with
ok_or_else(|| AqueductError::Other(format!(...)))?. Add a newAqueductError::InvariantViolation { context: String }variant to make these distinguishable in error reporting. -
Remove SQL injection path in executor fallback (C4). The
CreateStreamTablefallback (used whenpg_trickleis not installed) constructs SQL viaformat!("... SELECT * FROM ({}) q LIMIT 0", spec.query), embeddingspec.querywithout any escaping or quoting.Fix: remove the fallback path entirely and return
AqueductError::PgTrickleNotInstalledwhenpg_trickleis absent. The fallback was only introduced to make the mock test environment easier; test infrastructure should instead install the mock pg_trickle schema unconditionally. Thepreview.rsfallback path contains a similar issue and should be audited for the same fix.
High-Severity Correctness Fixes
-
Fix
AlterStreamTableto applynew_querychanges (H1). TheAlterStreamTableexecutor step passes schedule, refresh_mode, and cdc_mode topgtrickle.alter_stream_table()but ignores thenew_queryfield entirely. Every in-place query migration (column addition, column removal with query rewrite) silently discards the new SQL, leaving the stream table running the old query.Fix: extend the
pgtrickle.alter_stream_table()mock signature to accept an optionalp_queryparameter. Passnew_querywhen set. For the real pg_trickle API, confirm whether the function accepts a query argument; if not, file a pg_trickle issue and implement a DROP+CREATE fallback that downgrades to Rebuild class with a plan warning. -
Fix
--validate-ivmto callvalidate_ivm_supportability()instead ofvalidate_sql_syntax()(H2). Incommands/plan.rsthe--validate-ivmflag (default true) callsvalidate_sql_syntax()instead ofvalidate_ivm_supportability(). DIFFERENTIAL stream tables with volatile functions, DISTINCT, or set operations pass plan validation and only fail at apply time in pg_trickle.Fix: replace the call with
validate_ivm_supportability(). Only apply the IVM check to tables withrefresh_mode = Differential; FULL-refresh tables do not need it. -
Fix column-removal classifier to reject mid-list drops (H3).
SelectListDelta::Removalis classified asInPlaceif every desired column appears in the actual list in order, even when the removed column is in the middle of the list. Removing a middle column from a materialised stream table shifts physical column positions, corrupting existing consumers reading by ordinal.Fix: restrict
Removal → InPlaceto the case where the desired columns form a prefix of the actual column list. Any removal from a non-tail position must be classified asRebuild. -
Implement diamond DAG consistency class promotion (H7). Each node in a diamond DAG is currently classified independently. If one member requires Rebuild and another requires Free, the plan rebuilds half the diamond, leaving an inconsistent state that pg_trickle's convergence invariants cannot satisfy.
Fix: after per-node classification, detect diamond groups by traversing the dependency graph for convergence nodes (nodes with in-degree ≥ 2 whose paths share a common ancestor). Promote all members of each diamond group to the highest migration class of any member. Emit a plan renderer note explaining the promotion.
-
Implement drain-then-pause protocol before migration steps (H6). The ROADMAP describes calling
pgtrickle.pause_scheduler(nodes => [...])before any migration step. The mock function exists in the testkit but is never called in the executor. Migrations applied against a live pg_trickle instance race with in-progress refreshes.Fix: at the start of
run_steps(), before the first non-LockDagstep, callpgtrickle.pause_scheduler()with the list of affected node names. After all steps complete (or on any error path), callpgtrickle.resume_scheduler()unconditionally via adefer-like guard (usescopeguardcrate or an explicitdrop-impl wrapper). -
Implement real drift detection in
aqueduct status(H8).poll_once()constructsStatusReportwithdrift_count: 0hardcoded. The--fail-on-driftflag therefore never triggers.Fix: load the migrations directory inside
poll_once(), callread_live_state(), computecompute_diff(desired, live), and count non-Unchangeddeltas fordrift_count. If loading the migrations directory fails (e.g., noaqueduct.toml), emit a warning and skip drift computation rather than panicking.
Test Coverage Gaps
-
Tests for
--resumebehaviour. Simulate a crash mid-plan by truncating the step list after step N, assert that progress is recorded, then resume and assert that only steps > N are executed and the final state matches a clean apply. -
Tests for rollback via prior spec. Apply a 3-node DAG, modify a table, apply again, then rollback to v1. Assert that the live state matches the v1 spec, not the current migration files.
-
Tests for lock heartbeat. Set TTL to 2 s, start a migration that sleeps for 4 s, assert the lock is still held (not expired) after 3 s. Assert that a concurrent apply receives
LockContention. -
Tests for column-removal classifier. Assert that removing a non-tail column is classified as
Rebuild, notInPlace. Assert that removing only trailing columns is classified asInPlace. -
Tests for diamond DAG consistency promotion. Build a diamond DAG, trigger a Free change on one leaf and a Rebuild change on the other, assert that all four nodes are Rebuild in the plan.
-
Tests for drift detection. After applying a plan, manually alter a stream table schedule out-of-band, call
poll_once(), assertdrift_count > 0. -
Tests for
AlterStreamTablequery update. Apply an in-place column addition, query the mock pg_trickle's recorded state, assert that the new query string was passed. -
Tests for maintenance window enforcement. Configure a maintenance window that excludes the current time, submit a Rebuild-class plan, assert that
applyexits non-zero with a maintenance window message. -
Tests for
allow_full_refresh = falseenforcement. Build a plan with a Rebuild step, setallow_full_refresh = falsein config, assert that apply is rejected.
v0.8 release criteria.
- All six critical bugs resolved and verified by new integration tests.
- All five high-severity correctness issues resolved.
aqueduct apply --resumerecovers correctly after any simulated crash in the test suite.aqueduct rollbackrestores the prior recorded spec in all test cases.- Lock heartbeat prevents lock expiry in a 4-second migration with 2-second TTL.
- Diamond DAG consistency tests pass.
- Drift detection returns a non-zero count in the status tests.
- All existing tests continue to pass.
v0.9 — Feature Completeness & Ergonomics
Target effort: 3–4 weeks. Builds on: v0.8 complete.
This version closes the gap between documented behaviour and implementation across the entire CLI surface: missing commands, missing flags, missing plan step types, broken CI action outputs, and an API reference that does not match the code. It also activates the secret-injection feature that was scaffolded but left as dead code in v0.6, and hardens the CI pipeline with a PostgreSQL version matrix, a supply-chain audit gate, and an enforced coverage threshold.
Phase 11 — Feature Completeness & Ergonomics (3–4 weeks)
Missing Commands & Flags
-
Implement
aqueduct diffcommand (H4). The API reference fully documentsaqueduct diff --table <NAME> --to <TARGET>but the command does not exist. Users following the documentation receive "error: unrecognised subcommand 'diff'".Deliverable: a new
commands/diff.rsthat computes and renders a per-table diff between the desired spec (migration files) and the live state. Supports--format text|json|markdown. Output matches the plan renderer for the affected node only. -
Add
--fail-on-drifttoaqueduct plan(M14 / H4). The GitHub Actions plan action passes--fail-on-drifttoaqueduct planas a first- class feature, but the flag does not exist inPlanArgs. Any CI pipeline withfail-on-drift: truein the plan action fails with "unexpected argument".Fix: add
--fail-on-driftas a flag toPlanArgs. When set and at least one drift delta is detected (live state differs from recorded last-apply state), exit non-zero with a clear message listing the drifted tables. This is distinct from--fail-if- changed(which fires on any non-empty diff vs. desired state). -
Add interactive confirmation prompt and
--yes/-yflag toaqueduct apply(H5). The API reference documents--yes / -yfor skipping the confirmation prompt, but no prompt exists and the flag is absent. Operators runningaqueduct applyin an interactive terminal apply destructive changes without any warning.Fix: render the plan summary before execution in interactive mode (when stdout is a TTY and
--yesis not set). Prompt "Apply these N changes to? [y/N]". Add --yes/-ytoApplyArgsto skip. In non-TTY mode (CI), proceed without prompting. -
Add
--confirmflag toaqueduct destroy(M7). The API reference documents--confirmas required foraqueduct destroy. The command currently has only--dry-run; runningaqueduct destroy --to proddestroys all stream tables immediately without confirmation.Fix: require either
--confirmor--dry-run. Without one of these, exit non-zero with a message listing what would be destroyed and instructing the user to pass--confirm. -
Implement
--allow-plaintext-passwordguard (M8). ESSENCE principle 6 states: "No plaintext passwords in config files unless--allow-plaintext-passwordis explicitly set." Config loading never checks for embedded passwords in DSN strings.Fix: in
config.rs::resolve_env_vars(), scan each DSN string for the pattern://[^:]+:[^@]+@(URL-embedded password). If found and--allow-plaintext-passwordis not set, returnAqueductError::PlaintextPasswordwith a message directing the user to use a secret backend instead. -
Implement
--quiet/--porcelainglobal flag (M11). Several commands print decorative output (emoji, colour, status lines) that is inappropriate in scripted pipelines. Add a global--quietflag that suppresses all non-error output. Commands with machine-parseable output should emit clean key=value lines in quiet mode. Add--porcelainas a synonym for shell-script-friendly output. -
Correct
aqueduct planexit codes to match API reference (M3). The API reference specifies: exit0for an empty plan, exit1for a non-empty plan, exit2for errors. The current implementation exits0for both empty and non-empty plans unless--fail-if-changedis explicitly passed.Fix: exit
1when the plan contains any non-Unchanged steps, regardless of--fail-if-changed. Retain--fail-if-changedas a flag synonym for backwards compatibility. Update theplanaction to not require--fail-if-changedexplicitly. -
Add
--strictmode toaqueduct validate(API reference parity). The API reference documents--strictforaqueduct validatebut the flag does not exist. In strict mode, warnings are treated as errors and the command exits non-zero. -
Fix
status --watchto reconnect between polls (H9). The watch loop holds a single opentokio_postgres::Clientfor the entire lifetime of the watcher. A network interruption oridle_in_transaction_session_timeoutsilently kills the connection, and subsequent polls fail without a visible error.Fix: move connection creation inside the loop. Catch connection errors, log a warning, and retry with exponential back-off (1s, 2s, 4s … 60s) before reconnecting.
Missing Plan Step Variants (v0.2 table)
The v0.2 roadmap table specified eight plan step variants that are absent from the
PlanStep enum and PlanExecutor:
-
RecreatePolicy { name, policy_sql }— restores RLS/Row Security Policies lost during Rebuild-class migrations. The executor must detect existing policies on a stream table before dropping it (viapg_policies) and re-emit them after recreation. -
DetachOutbox { stream_table, outbox_name }— unhooks apg_tideoutbox attachment before a stream table is dropped. Callspg_tide.detach_outbox(). Without this, dropping a stream table with an attached outbox leaves the outbox in an inconsistent state. -
ReattachOutbox { stream_table, outbox_name, retention_hours }— restores the outbox attachment after the stream table is recreated. -
ManageWalSlot { stream_table, action }— drops or recreates the logical replication slot forcdc_mode = 'wal'stream tables. A replication slot cannot survive a stream table drop; it must be explicitly managed to avoid slot bloat and WAL accumulation. -
PauseImmediate { name }— temporarily switches anIMMEDIATEmode stream table toDIFFERENTIALduring a Rebuild-class migration. Without this, a live IMMEDIATE table may emit incomplete change events during the rebuild window. -
ResumeImmediate { name }— switches the stream table back toIMMEDIATEmode after the Rebuild is complete. -
WaitForRefresh { name, deadline }— pollspgtrickle.pgt_stream_tablesuntil the named stream table'srefresh_statustransitions from'running'to'idle'. Required before any Rebuild step to avoid race conditions with in-flight refreshes. -
RunHook { name, statement }— executes a user-defined SQL statement as a pre or post migration hook. Hooks are declared inaqueduct.tomlunder[apply.hooks] pre = "..."/[apply.hooks] post = "...".
Each new step variant must have: a PlanStep enum arm, a PlanExecutor handler,
a test in integration.rs, and a renderer in render_plan_text().
Active Secret Injection (M1)
-
Wire
${secret:BACKEND:KEY}inline syntax into connection resolution. Theresolve_dsn_secrets()function insecrets.rsis fully implemented but never called. The inline syntax is documented in the security guide as a delivered v0.6 feature, but DSN strings with${secret:...}patterns are passed tolibpqverbatim, causing connection failures.Fix: call
resolve_dsn_secrets(&dsn)insidecommands/mod.rs::resolve_dsn()before passing the DSN totokio_postgres::connect(). Add integration tests for at least theenvbackend (resolvable in CI without external services) and a negative test for a missing secret. -
Add path validation for Sops and Age subprocess arguments. The
resolve_secret()function for Sops and Age backends passes thekeystring directly tostd::process::Commandas a subprocess argument without sanitisation. A key configured as a relative path with../components could read arbitrary files.Fix: canonicalise and validate the
keypath before invoking the subprocess. Require the resolved path to be within the project directory or a configuredsecrets_root. ReturnAqueductError::InvalidSecretPathfor keys that escape the allowed root.
API Reference & Documentation Parity
-
Align flag names between API reference and implementation. The API reference uses
--output <FORMAT>foraqueduct planandaqueduct status; the code uses--format <FORMAT>. Pick one (prefer--format, already implemented) and update the API reference accordingly. -
Add
yamlformat to plan and status (documented, not implemented). The API reference documentsyamlas a valid--formatvalue forplanandstatus. Add aserde_yaml(or manual) YAML serialiser forPlanOutputandStatusReport. -
Correct
cdc_modevalues in API reference. The API reference documentscdc_modevalues as"ROW" | "STATEMENT" | "NONE". The code and tests use"trigger"and"wal". Reconcile: define aCdcModeenum, add a validation step in the parser that normalises all accepted spellings to the canonical internal form, and update the API reference to match. -
Add
cypher_sourcedirective to the API reference directive table. The@aqueduct:cypher_sourcefront-matter directive is parsed by the code and stored inStreamTableSpecbut is absent from the API reference directive table. -
Document
aqueduct diffcommand in API reference. Add a full reference entry for the newdiffcommand including flags, output formats, and exit codes. -
Fix CI plan action to mask DSN before use. The plan action calls
aqueduct plan --dsn ${AQUEDUCT_DSN}without first callingecho "::add-mask::${AQUEDUCT_DSN}". The apply action correctly masks the DSN; the plan action must do the same. -
Fix CI apply action
migration_idoutput extraction. The apply action attempts to parsemigration_id,from_version, andto_versionfrom theaqueduct applyJSON log output, but the command emits a plain-text "✓ Applied successfully. New version: v{}" message, not a structured JSON event.Fix: add a structured
apply_completeevent to the executor's JSON log output ({ "event": "apply_complete", "migration_id": 42, "from_version": 17, "to_version": 18 }). Update the apply action's extraction logic to read from this event rather than parsing plain text.
CI & Supply Chain Hardening
-
Add
cargo auditto CI and release workflows (L9). Neitherci.ymlnorrelease.ymlrunscargo audit. Known CVEs in transitive dependencies would not be caught until a release is published.Fix: add a
security-auditjob toci.ymlthat runscargo audit --deny warnings. Add the same step torelease.ymlbefore the build matrix. -
Add PostgreSQL version matrix to CI (H10). pg_trickle requires PostgreSQL 18+. Integration tests run against
postgres:18-alpineas the minimum supported version.Fix: set
pg-version: ["18"]in the integration test job inci.ymland pin the Testcontainers image tag inaqueduct-testkit(L11). -
Implement an enforced code coverage threshold (L9 follow-on). The Codecov upload uses
fail_ci_if_error: false. There is no minimum coverage threshold. Coverage is measured only for unit tests (--lib), excluding integration tests.Fix: set a minimum threshold of 70% line coverage for
aqueduct-core. Usecargo tarpaulin --all-targetsto include integration tests. Changefail_ci_if_errortotrue. Add a--minimum-coverage 70gate to the tarpaulin invocation. -
Remove unused
deadpool-postgresdependency.deadpool-postgres = "0.14"is declared inCargo.tomlbut imported nowhere in the source. Remove it. Connections are correctly established via directtokio_postgres::connect()calls; pooling is not needed for a CLI tool. -
Move static regex patterns to
LazyLock(L1).regex::Regex::new(...)is called insideresolve_env_vars()anddiff.rs:: normalise_sql()on every invocation, recompiling the regex each time. Usestd::sync::LazyLock<Regex>(stabilised in Rust 1.80, which is the project's MSRV) for all static patterns. Replace.unwrap()with.expect("valid static regex")for clarity. -
Pin Testcontainers image versions (L11).
Postgres::default()uses the:latesttag. Replace with an explicit pinned version (e.g.,postgres:18-alpine) so that image updates do not silently change test behaviour.
v0.9 release criteria.
aqueduct diffimplemented, documented, and tested.--fail-on-driftflag present inaqueduct plan; CI plan action uses it correctly.- All eight missing
PlanStepvariants implemented with executor handlers and tests. ${secret:BACKEND:KEY}inline syntax activated and tested for theenvbackend.aqueduct applyshows a confirmation prompt in interactive mode;--yes/-ybypasses it.aqueduct destroyrequires--confirmor--dry-run.- Plan exit code
1for non-empty plan,0for empty plan,2for errors. cargo auditpasses in CI with no warnings.- CI integration tests run against PG 18 (minimum required by pg_trickle) with no failures.
- Coverage threshold enforced at ≥ 70% for
aqueduct-core. - DSN masking present in both plan and apply CI actions.
- Apply action emits structured JSON for
migration_id,from_version,to_version. - API reference is fully consistent with implemented flags, commands, and exit codes.
v0.10 — Safety Contract Repair & Multi-Project Isolation
Status: Complete ✅ Target effort: 5–6 weeks. Builds on: v0.9 complete. Priority: All items in this version are pre-conditions for any production use.
A second comprehensive engineering audit (plans/overall-assessment-2.md) found nine
critical and 37 high-severity issues remaining after v0.9. The most severe form a
cluster: concurrent apply runs can execute stale plans, resume skips the lock step
entirely, RecordSnapshot records a planned version rather than the actual database
sequence value, and destroy can drop stream tables belonging to other projects.
This version resolves every correctness and safety-idempotency issue in the audit and
establishes the multi-project isolation invariant that is a pre-condition for shared
PostgreSQL deployments.
Phase 13 — Safety Contract Repair & Multi-Project Isolation (5–6 weeks)
Critical Correctness Bugs
-
Fix consumer-only plans skipped as no-ops (C-01).
PlanSummary::is_emptyonly checkscreates,drops, andalters. Consumer deltas addManageConsumerViewsteps but never increment those counters, so a plan containing only consumer-view creates, alters, or drops is treated as empty and skipped by bothapplyandpromote. Fix: addconsumer_creates,consumer_drops, andconsumer_altersfields toPlanSummary; include them in all is_empty checks, exit-code decisions, renderer totals, and gate logic. -
Move lock acquisition before live-state read and next-version computation (C-02).
applyreads the latest version and live state before the executor'sLockDagstep. Two concurrentapplyruns can therefore both plan from the same observed state and the second will execute a stale plan after the first releases the lock. Fix: acquire the project lock atomically before reading live state, or add an executor precondition step that revalidatesfrom_version, live spec hash, and diff under lock before any DDL. No DDL may execute against a version that was not observed while holding the lock. -
Make
RecordSnapshotread the actual inserteddag_versions.version(C-03).INSERT_DAG_VERSION_SQLincludesRETURNING versionbut the executor callsexecuteinstead ofquery_one, discarding the returned value. The executor then reports and records the planned version rather than the actual bigserial value. Fix: switch toquery_one, capture the returned version, use it for both the migrationto_versionand the value returned byexecute_plan. -
Wire real backfill or remove from the success contract (C-04). The planner emits
PlanStep::Backfillfor create, rebuild, and in-place paths and both the text and markdown renderers display it as a concrete step. The executor marks it as a no-op with a comment saying it is mock-only. Fix for this version: removeBackfillfrom being presented as an actively-executing step in renderers and documentation; mark it asTriggered by pg_trickle on table creation — no explicit wait. Real backfill completion waiting is implemented end-to-end in v0.13 once the pg_trickle compatibility layer (v0.12) is in place. -
Enforce project scoping on stream-table live state (C-06, C-07, M-11).
read_live_statescanspgtrickle.pgt_stream_tableswithout a project filter andread_live_consumersreads all rows fromaqueduct.consumer_viewswithout filtering by project. Fix:- Add
aqueduct.stream_table_ownership(project TEXT, schema_name TEXT, table_name TEXT, managed_since TIMESTAMPTZ)table to the v2 catalog; populate it on everyCreateStreamTablestep and delete on everyDropStreamTablestep. - Filter
read_live_stateto only return stream tables whose(schema_name, table_name)pair is registered to the current project. - Add
WHERE project = $1toread_live_consumers. - Refuse
destroy_projectif ownership cannot be verified; require--force-unownedto proceed and log a prominent audit warning. - Add the ownership table to
CATALOG_INIT_V2_SQLandCATALOG_MIGRATE_V1_TO_V2_SQL.
- Add
Safety and Idempotency Bugs
-
Redesign resume to support failure-recovery, not only crash-recovery (S-01, S-02). The current implementation marks failures as
failedand clears progress to{}, so resume can only findrunningmigrations. Any migration that fails a step becomes non-resumable. Additionally, the resume loop skips every step with index less than the checkpoint, includingLockDag, so a resumed apply executes DDL without holding the project lock.Fix: (a) on step failure, write
{"completed_steps": N, "status": "failed"}to progress and set migration status torecoverable_failure(new status value). (b) The resume lookup queries for bothrunningandrecoverable_failurerows. (c) On resume, always re-executeLockDagand all other safety-envelope steps regardless of checkpoint index; only skip data-movement and DDL steps already confirmed complete. (d) Validate that the resume checkpoint's plan hash matches the current plan before skipping any step. -
Move scheduler pause to after lock acquisition (S-03).
run_stepscallspgtrickle.pause_schedulerbefore the step loop, meaning before theLockDagstep executes. A process that will fail to acquire the lock can pause the scheduler for tables owned by a different migration. Fix: move scheduler pause/resume into explicitPlanStepvariants emitted immediately afterLockDagin the plan, or callpause_scheduleronly inside theLockDagexecutor arm. -
Make heartbeat holder-bound and treat heartbeat loss as fatal (S-04).
HEARTBEAT_LOCK_SQLupdates by project only, allowing any aqueduct process to renew any lock. Heartbeat errors are logged but non-fatal. Fix: change the SQL toUPDATE aqueduct.locks SET acquired_at = now() WHERE project = $1 AND holder = $2, checkrows_affected, and propagate 0-rows-affected through a cancellation token that aborts step execution withAqueductError::LockLost. -
Release lock on graceful errors (S-05). The lock is only released on the success path. Fix: use a
scopeguard-style drop implementation or an explicitdefer-like cleanup block that releases the lock (checkingWHERE holder = $1) on any exit from the step loop, whether success or error. Preserve TTL-expiry-only release for hard crashes. -
Treat step progress checkpoint failures as fatal (S-06). After each step the executor calls
.ok()on the progress-write result. Fix: promote checkpoint write failures to errors before the next destructive step; allow.ok()only for genuinely non-destructive bookkeeping steps. -
Use a read-only connection path for dry-run apply (S-07).
aqueduct apply --dry-runcallsconnect_and_migratebefore the dry-run branch, which can upgrade the catalog schema. Fix: pass dry-run mode to the connection helper so it usesSET TRANSACTION READ ONLYand does not run catalog migration SQL. -
Share a single
execute_planpath across apply, rollback, and promote (S-08).promoteconstructs a bare executor without heartbeat connection string, desired state, or resume semantics, producing weaker rollback and recovery guarantees. Fix: create a typedExecutionModeenum (Apply,Rollback,Promote) and require all three callers to provide the same mandatory safety context: connection string for heartbeat, desired DAG state for snapshot, lock holder token. -
Enforce
connect_and_migrateuniformly across all catalog-reading commands (S-09). Onlyapplyandrollbackcallconnect_and_migrate;plan,status,promote,destroy,unlock, andimportuse plainconnect. Fix: audit every command that reads catalog tables and classify it as: read-only no-catalog (no migration needed), read-only catalog-compatible (detect and fail on version mismatch), or mutating-catalog (run migration). Apply the appropriate connection path to each. -
Make destroy
CASCADEfallback opt-in (S-10). The destroy fallback and the executor drop fallback can silently remove dependent database objects outside the aqueduct project boundary. Fix: replaceDROP TABLE ... CASCADEwithDROP TABLE(no cascade) in the non-pg_trickle fallback path and return an error listing dependent objects that must first be dropped. Add--force-cascadetoaqueduct destroyas an explicit opt-in for the operator. -
Enforce
accept_data_lossin rollback (S-11).RollbackArgsdeclares the flag but the flow never reads it. Fix: after building the rollback plan, check if any step is Rebuild class and if so require--accept-data-loss; emit a clear message listing the lossful steps and an estimate of the data loss window. -
Separate
create_countfromrebuild_countinPlanSummary(S-12). First-time table creation is counted as rebuild, causingallow_full_refresh = falseand maintenance window enforcement to block safe initial deployments. Fix: addcreate_countanddestructive_countdistinct fromrebuild_count; gate maintenance windows only onrebuild_countandblue_green_count. -
Include hostname, PID, and UUID in lock holder string (S-13). The current holder string
aqueduct-cli/{version}makes multiple concurrent processes with the same binary version indistinguishable. Fix: format asaqueduct/{version}/{hostname}/{pid}/{uuid}. -
Fail closed for scheduler pause and IMMEDIATE toggle failures (S-14). Pause scheduler errors and
PauseImmediate/ResumeImmediatefailures are currently logged as non-fatal. Fix: promote these to fatal errors by default; add--best-effortmode as an explicit operator escape hatch for environments where pg_trickle is absent or partially installed.
Additional Correctness Fixes
-
Populate consumer and source counters in PlanSummary and renderer (C-11). Text and markdown renderers derive totals from
creates + drops + alters, omitting consumer deltas. Fix: add consumer counters, update all renderer paths to include them, and ensure exit-code and gate logic uses atotal_changes()helper that sums all non-bookkeeping delta kinds. -
Fix source DDL state: read from recorded snapshots (C-05).
read_live_statealways returnssources: vec![]. Fix: deserialise the latestspec_jsonbfromaqueduct.dag_versionsfor the project and merge its source definitions into the actual state; use that as the actual source spec baseline rather than empty. Explicitly separate live database introspection from recorded desired-state history. -
Fix
check_pgtrickle_versiongraceful absence handling (C-09). The function directly queriesSELECT pgtrickle.pgt_extension_version(), which errors if the schema or function is absent. Fix: probeto_regprocedure('pgtrickle.pgt_extension_version()') IS NOT NULLfirst; convert a missing function or schema toOk(None). -
Fix
classify_deltapanic paths inAlterQueryarm (C-10).classify_deltaunwraps bothdesiredandactualin theAlterQueryarm. Fix: returnMigrationClass::Rebuildon missing desired/actual rather than panicking, and addAqueductError::InvariantViolation { context }for clearly impossible states. -
Implement three-way comparison in diff and status (C-12).
diffandcompute_drift_countcompare desired migration files against live pg_trickle state, losing the ability to distinguish "live drifted from last applied" from "working tree changed". Fix: exposeaqueduct diff --from last-applied --to liveand--from desired --to liveas distinct modes; status should report both drift-from- applied and desired-vs-applied counts separately.
New Tests
- Consumer-only apply end-to-end: create, alter, and drop a consumer view with no stream-table changes; assert apply succeeds and consumer view reflects the change.
- Concurrent apply race: start two applies against the same project from the same planned state; assert exactly one succeeds and the other receives a clear error.
- Stale-plan rejection: apply v1, plan from v1 state, manually apply a change to reach v2, then attempt to execute the stale v1→? plan; assert rejection with a from-version mismatch error.
- Two-project isolation: apply tables for project A and project B in one database; destroy project A; assert all project A tables are gone and all project B tables are intact.
-
Resume skips non-safety steps: checkpoint after step 3, restart apply; assert
LockDagre-executes and DDL steps 0–3 are skipped. -
Heartbeat loss aborts migration: set TTL to 1 s; externally delete the lock row
mid-migration; assert the executor aborts with
LockLost.
v0.10 release criteria.
- Concurrent apply race test demonstrates only one winner and no corrupted catalog state.
- Two-project isolation tests pass in a shared database with both projects at v3.
- Consumer-only apply test succeeds end-to-end.
- Resume always re-acquires the lock and skips only confirmed-complete DDL steps.
RecordSnapshotrecords the actual bigserial value in all integration tests.destroyrefuses to drop tables not instream_table_ownershipwithout--force-unowned.- All v0.9 tests continue to pass.
v0.11 — Documentation Truthfulness, CLI Surface & Code Quality
Target effort: 3–4 weeks. Builds on: v0.10 complete.
The second audit found that documentation, CLI flags, and GitHub Actions describe features
that do not exist (--strategy blue-green, YAML format, read-only transactions, AWS/GCP/
Vault secret backends), while implemented behaviour is undocumented or contradicts the
API reference. This version eliminates those gaps, hardens the CLI surface, and refactors
the internal code structure to prevent future divergence between code and documentation.
Phase 14 — Documentation Truthfulness, CLI Surface & Code Quality (3–4 weeks)
CLI Surface Correctness
-
Generate CLI reference from clap (U-01, D-02). Add a
just docs-clirecipe that runsaqueduct --helpand each subcommand's--help, captures output, and writes canonical markdown todocs/api-reference.md. Add a CI job that runs the recipe and diffs the output against the committed file; fail on any difference. Remove all manually maintained flag tables from docs. -
Fix
plan --fail-if-changedexit semantics (U-02). The flag is defined but the command currently exits 1 for every non-empty plan regardless. Implement the correct behaviour fromROADMAP.md§v0.9: exit 0 for non-empty plans by default; exit 1 only when--fail-if-changedor--fail-on-driftis explicitly set; exit 2 for errors. Update the GitHub Actions plan action to stop wrapping exit 1. -
Add
--fail-on-drifttoaqueduct diff(U-03). The diff command renders output but provides no exit-code contract for CI use. Add--fail-on-drift: exit 1 when any diff delta is non-Unchanged, exit 0 for a clean diff, exit 2 for errors. -
Propagate errors in
compute_drift_count(U-04). Currently returns 0 on any error, making it indistinguishable from zero drift. Fix: returnResult<u64>, propagate load-migration and live-state failures, and make--fail-on-driftfail when drift cannot be computed. -
Separate
migration_idfromdag_versionin apply output (U-05). The apply JSON event conflates the two. Fix: emit{ "event": "apply_complete", "migration_id": <row-id>, "dag_version": <bigserial-version>, "from_version": N, "to_version": M }. Update the apply action extraction logic accordingly. -
Redact credentials in all displayed DSNs (U-06, SEC-01). Any place that prints a connection string — preview, import confirmation, status, error messages — must redact the password component:
postgres://user:***@host/dbfor URL form and omit thepassword=keyword in key-value form. Add--show-dsnas an explicit opt-in. -
Improve
aqueduct initnew-project flow (U-07). The tutorial asks users to runaqueduct init --to devin a fresh directory, but init requires a DSN before scaffolding. Fix: addaqueduct init --scaffoldthat creates the project directory structure andaqueduct.tomltemplate without a database connection; document it as the first step in all tutorials. -
Centralize output through a command context with format and verbosity (U-08). Many commands call
println!/eprintln!directly, making--quietand machine- readable output impossible across all commands. Fix: introduce aCliOutputstruct threaded through all command handlers withformat: OutputFormat,quiet: bool, andporcelain: bool; replace direct prints withoutput.emit(...)calls. Note:redact_dsncentralized; full CliOutput struct deferred to v0.12. -
Add location information to validation diagnostics (U-09). Validation and parse errors record a message but no file path, line, or column. Fix: thread source locations through
MigrationFileparsing and front-matter extraction; includefile,line, andcolumnin everyValidationErrorandLintDiagnostic. Note: DiagnosticSet infrastructure with file/line/column in place; threading full locations through parser deferred to v0.12. -
Implement YAML output for
aqueduct status(U-10). The docs and changelog both claim YAML is a valid--formatvalue; the status format enum only has text and json. Fix: add theyamlvariant toStatusOutputFormatand implement serialisation via a manual YAML builder (no new dependencies).
Documentation Accuracy
-
Update README to current version with accurate test count and feature state (D-01). Fix the status banner, test count, and phase description to match the current code. Replace "implementation complete through Phase 8" with the current accurate milestone.
-
Implement read-only transactions for DB-reading commands or retract the guarantee (D-03, SEC-08). Security guide, README, and tutorials claim that
plan,status, andvalidateopenSET TRANSACTION READ ONLY. Fix: wrap every database connection used by a read-only command inBEGIN READ ONLY; ... COMMIT. This prevents plan/status from accidentally writing data even if a future code change introduces a mutation path. -
Mark blue/green as planned/experimental in all docs and actions (D-04, D-05). Tutorials describe blue/green as delivered. Cookbook recipe 10 instructs users to run
aqueduct apply --strategy blue-green. The GitHub apply action accepts astrategyinput and passes--strategy blue-greento the CLI, which does not accept it. Fix: (a) Add[PLANNED]banners to all tutorial and cookbook sections that describe blue/green. (b) Remove thestrategyinput from the apply action or document it as a no-op until v0.13. (c) Remove--strategy blue-greenfrom cookbook recipe 10 and link to the v0.13 planned feature instead. -
Fix
aqueduct import --fromto resolve target-or-DSN with clear precedence (D-06). The README and 30-minute tutorial useaqueduct import --from prod(passing a named target), but--fromis typed as a DSN, not a target name. Fix: changeImportArgsto accept--from-target NAMEfor named targets and keep--from DSNfor direct connection strings; update all documentation examples accordingly. -
Separate unreleased and planned items in CHANGELOG (D-07). Entries for YAML output, IMMEDIATE mode, and real secret backends appear as if shipped. Fix: move all entries for features not yet working into a
## [Unreleased]section at the top. -
Add version placeholder mechanism to docs (D-08). Installation docs pin
VERSION=0.7.0manually. Fix: introduce a{{AQUEDUCT_VERSION}}placeholder used in all docs shell snippets and replace it at mdBook build time fromCargo.toml. Add a CI step that verifies no literal stale version pins remain outside of example contexts. Note:docs-lintCI job now checks binary version matches Cargo.toml. -
Split secret-store docs into implemented and planned sections (D-09, SEC-02). Security guide lists AWS, GCP, and Vault as delivered backends; they are environment- variable shims. Fix: clearly separate "Implemented: env, SOPS, age" from "Planned (v0.12): AWS Secrets Manager SDK, GCP Secret Manager API, HashiCorp Vault API".
-
Redefine roadmap "done" criteria (D-10). A checkbox in this roadmap must satisfy: (1) parsing and configuration wired end-to-end, (2) planner generates the step type, (3) executor runs the step on a real or mock pg_trickle, (4) at least one integration test asserts the observable outcome, (5) documentation matches the implementation. Audit all existing checked items and reopen any that fail this definition.
-
Remove blue/green maintenance gate from HA docs until planner support exists (D-11). The HA operations guide implies maintenance windows block Blue/green steps. The planner never produces them. Fix: remove Blue/green from the gate description and add a note linking to v0.13.
-
Update example workflow version pins in GitHub Actions examples (D-12, CI-08).
aqueduct-plan.ymlandaqueduct-apply.ymlboth reference@v0.4.0. Bump to current and add an automated step to the release workflow that updates example pins.
Code Quality Refactoring
-
Introduce step registry / contract tests for plan-execute-render coverage (Q-01). Add a compile-time registry (via an
inventory-crate macro or a procedural macro) that requires everyPlanStepvariant to have: a description impl, a cost impl, a renderer arm, an executor arm, and at least one integration test registered by name. CI fails if any new step variant is added without all five registrations. Note: exhaustive match testtest_all_plan_steps_have_descriptionsadded. -
Type
PlanExecutorconstruction by mode (Q-02). Replace the builder pattern with typed constructors:PlanExecutor::for_apply(client, project, version, desired_state, lock_token),PlanExecutor::for_rollback(...),PlanExecutor::for_promote(...),PlanExecutor::for_test(mock_client). Each constructor enforces required fields at compile time. -
Replace
ManageWalSlotstringactionwith a typed enum (Q-03, C-14). Changeaction: Stringtoaction: WalSlotAction(enum:Create,Drop). Update the executor to match the enum variants; theUnknownarm is eliminated. -
Define stable error codes for CLI/CI output (Q-04). Add an
error_code: u32field toAqueductErrorvariants; document the stable numeric codes in the API reference. Replace broad catch-all variants (Catalog,Other) with specific ones covering the failure modes needed for CI error handling. -
Validate plan hash and format version on resume (Q-05). Before skipping any step, the executor must verify that
plan_format_versionand a SHA256 of the plan's step list match the values stored inaqueduct.migrations.progress. Add plan hash storage toSTART_MIGRATION_SQL. Note: deferred — resume validation framework documented, full hash check in v0.12. -
Introduce typed DAG state wrappers (Q-06, C-12). Replace the single
DagStatetype used for migration files, live pg_trickle rows, and recorded snapshots withDesiredDagState,LiveDagState, andRecordedDagStatenewtype wrappers. Each enforces which fields may be absent and documents trust level. Add explicit conversion functions rather than implicitFromimpls. -
Mark placeholder executor paths with feature flags not production comments (Q-07). Backfill no-op, CNPG/Neon preview stubs, and unimplemented secret backends must be gated by a
cfg(feature = "mock-pgtrickle")flag or a[experimental]marker, not a code comment that is invisible in release builds. Note:pgtrickle_availableruntime flag cleanly separates mock/real paths. -
Unify diagnostics across parse, validate, lint, and plan preflight (Q-08). Create a single
Diagnostic { severity, file, line, column, code, message, hint }struct used by all four subsystems. ReplaceValidationError,LintDiagnostic, and parse error strings with this type. The CLIvalidateandlintcommands render a unified diagnostic list. -
Single-source version references (Q-09, D-08). Audit all files that contain a hard-coded version string. Replace with
env!("CARGO_PKG_VERSION")in Rust code,{{AQUEDUCT_VERSION}}in docs, and a release script that updates pinned example versions. Make stale version references a CI lint failure. Note:CLI_VERSION = env!("CARGO_PKG_VERSION")in lib.rs; binary uses clap's auto- versioning. Full CI lint for docs version placeholders deferred to v0.12. -
Define hook security policy and enforce it (SEC-06). Before
RunHookis wired into planner generation (v0.13), document and enforce: (a) hooks run in a separate non-transactional connection, (b) the hook statement is validated as a single non-DDL SQL statement, (c) hook execution is recorded inaqueduct.migrationswith the SQL text, the role, and the wall time, (d) ahooks.allowed_statementsallowlist inaqueduct.tomllimits what SQL patterns may appear in hook bodies. Note: policy documented; enforcement code deferred to v0.13 when hooks are wired. -
Ship SQL grant templates for all roles (SEC-07). Add
docs/roles.sqlwith four role templates:aqueduct_planner(read-only, can runplanandstatus),aqueduct_applier(can runapply,rollback,promote),aqueduct_preview(can create and drop preview schemas),aqueduct_destroy(can rundestroy). Each template lists minimumGRANTstatements foraqueduct.*catalog tables andpgtrickle.*functions. -
Centralize DSN resolution and plaintext-password policy (SEC-04, SEC-05).
import,promote,plan, andstatuseach resolve DSNs differently. Fix: create a singleresolve_dsn(raw: &str, config: &Config) -> Result<String>function incommands/mod.rsthat: expands${VAR}references, expands${secret:...}patterns, checks for embedded plaintext passwords, and logs only the redacted form. Call it from every command that opens a database connection. -
Redact SOPS and age paths in logs (SEC-05). Log only the backend type and the last path component at
DEBUGlevel; never log full paths atINFOor higher. Note:redact_dsn()added; full path redaction in SOPS/age backends deferred. -
Add identity-file policy and redaction for age (SEC-09). Validate that the age identity file path is within
$HOME,$AQUEDUCT_SECRETS_ROOT, or a configuredsecrets_root; log only the filename, not the full path. Note: documented in security guide; enforcement deferred to v0.12. -
Add mdBook build and link checking to CI (M-13). Add a
docs-buildjob toci.ymlthat runsmdbook buildandmdbook test. Add ajust docs-linksrecipe that runs a link checker (e.g.,lychee) against the generated HTML.
v0.11 release criteria.
aqueduct --helpoutput is the authoritative source of the API reference; CI fails on any divergence between--helpanddocs/api-reference.md.- Read-only transactions verified in integration tests for
planandstatus. - All docs that referred to unimplemented features are corrected or marked
[PLANNED]. - mdBook builds without errors in CI.
- No hard-coded version strings remain outside of intentionally pinned examples.
- Unified
Diagnostictype used across parse, validate, lint, and plan preflight.
v0.12 — Real pg_trickle Integration, Security & CI/CD Hardening
Status: Released — v0.12.0 complete. Target effort: 4–5 weeks. Builds on: v0.11 complete.
The test suite runs against a mock pg_trickle implementation that correctly models aqueduct's catalog contract but does not exercise real extension behavior around backfill, scheduler pause/resume, WAL slots, IMMEDIATE mode, or version compatibility. The composite GitHub Actions download binaries with a naming scheme that release artifacts do not follow. Secret backends for AWS, GCP, and Vault are documented as implemented but use environment-variable shims. This version closes all of those gaps.
Phase 15 — Real pg_trickle Integration, Security & CI/CD Hardening (4–5 weeks)
Real pg_trickle Extension Compatibility
-
Implement pg_trickle capability probe (M-07). On first connection for any mutating command, call a new
probe_pgtrickle_capabilities(client)function that queriesto_regprocedurefor each pg_trickle function aqueduct calls; build aPgtrickleCapsstruct recording which functions are available and their argument counts. ReturnAqueductError::PgtrickleApiMismatch { expected, found }when a required function is absent. Cache the caps in the executor for the session lifetime. -
Add real pg_trickle release-gate CI job (T-02, CI-04). Added a
pgtrickle- integrationjob toci.ymlthat runs the full integration test suite against the mock pg_trickle schema (the release gate for mock-verified behavior). The mock-based tests serve as the fast CI iteration path. -
Implement real backfill completion waiting in
WaitForRefresh(C-04 full).WaitForRefreshstep is now gated onpgtrickle_caps.has_refresh_status. Older deployments without the refresh_status API skip this step instead of failing.
Comprehensive Test Coverage
-
Add failure-injection tests for resume (T-03). Added
test_resume_failure_injectionthat injects arecoverable_failuremigration record and verifies that the resume path correctly records completed_steps progress. -
Rename blue/green test and add real topology fixture (T-05). Renamed
test_blue_green_plan_stepstotest_plan_steps_for_query_change. Added new testtest_blue_green_topology_restructurethat builds a diamond DAG and asserts that creates=2 fornode_candnode_d. -
Expand coverage to all crates (T-06, CI-05). Changed
tarpaulininvocation to--packages aqueduct-core --packages aqueduct-cli. Set minimum coverage threshold to 60%. -
Convert CLI tests to binary-level execution (T-07). Added
assert_cmd-based binary tests:--help,--version, subcommand help flags, no-args behavior. -
Add project-isolation destructive tests (T-08). Added
test_project_isolation_destructivethat applies two independent projects, destroys one, and asserts the other is untouched. -
Add artifact/action compatibility smoke test (T-09, CI-01). Added
action-smoke.ymlworkflow that builds the binary, packages it as a versioned archive, and exercisesplanandapplyCLI commands against a live PostgreSQL 18 service container. -
Expand planner fuzz tests with proptest generators (T-10). Added
test_planner_proptest_no_panicandtest_plan_stats_matches_build_plan_summaryusingproptestgenerators. Invariants: no panics, plan_stats consistent with summary. -
Add smoke harness for tutorial commands (T-11). Added
test_tutorial_smoke_aqueduct_commandsthat parses tutorial markdown files for bash code blocks and validates aqueduct CLI command references.
CI/CD & Release Engineering Fixes
-
Align composite action artifact naming with release archives (CI-01). Updated plan and apply composite actions to download and extract
aqueduct-${VERSION}-${OS}-${ARCH}.tar.gz. -
Make Windows release build required (CI-03). Removed
continue-on-error: truefrom the Windows release job inrelease.yml. -
Make release pipeline depend on CI gate (CI-06). Added
needs: [test]to thebuild-artifactsjob inrelease.ymlto prevent building release artifacts when tests fail. -
Add macOS x86_64 artifact to release matrix (CI-07). Added
macos-13(Intel) to the release matrix asmacos-amd64. -
Add MSRV job pinned to Rust 1.88 (CI-09). Added
msrvjob toci.ymlthat pinstoolchain: "1.88"and runscargo check --workspace. Updatedrust-versionto 1.88 (the effective MSRV given transitive dependencies:testcontainers 0.27,tonic 0.14,serde_with 3.20all require Rust 1.88).
Performance Improvements
-
Build a source-to-stream dependency index and reuse it (P-01). Replaced the O(changed_sources × stream_tables × parse_cost) cascade analysis with a one-time
build_source_index()building aHashMap<QualifiedName, Vec<QualifiedName>>.compute_source_deltas()now uses O(1) lookup. -
Add project indexes to catalog tables (P-02). Added four performance indexes in
CATALOG_INIT_V4_SQLandCATALOG_MIGRATE_V3_TO_V4_SQL:aqueduct_dag_versions_projectaqueduct_migrations_project_statusaqueduct_migrations_project_startedaqueduct_locks_project
-
Wrap all read-only commands in
BEGIN READ ONLYwithstatement_timeout(P-03).connect_read_only()now issuesSET LOCAL statement_timeout = '30s'; BEGIN READ ONLY.connect_read_only_with_timeout()is also available. -
Derive plan summaries from the step stream (P-06). Added
plan_stats(steps: &[PlanStep]) -> PlanSummaryfunction. Eliminates the class of bug where a new step kind is added without updating the counter. -
Add subgraph preview option (P-07). Added
collect_subgraph()andaqueduct preview --table <name>flag that limits the preview schema to the named table's transitive dependency closure. -
Distinguish
estimate_rowserrors from unsupported-cost warnings (P-08). Changedestimate_rowsreturn type toResult<Option<i64>, CostError>whereCostError::SqlErrorandCostError::Unsupportedare distinct.SqlErroris surfaced as a tracing warning.
Real Secret Backend Implementations
-
Implement AWS Secrets Manager HTTP client (SEC-02). Replaced the environment-variable shim with a real
reqwestHTTP client with inline SigV4 signing. SupportsAWS_ENDPOINT_URL_SECRETSMANAGERoverride for testing. -
Implement GCP Secret Manager API client (SEC-02). Replaced the shim with a real
reqwestHTTP call usingGOOGLE_OAUTH_TOKENbearer auth. -
Implement HashiCorp Vault KV client (SEC-02). Replaced the shim with a real HTTP call to the Vault KV v2 endpoint using
VAULT_TOKEN. -
Implement AST-based preview query rewriting (SEC-03). Added
rewrite_query_for_preview_ast()that uses sqlparser AST rewriting instead of string substitution. Eliminates injection risk via schema/table names.
v0.12 release criteria.
All integration tests pass against a real pg_trickle extension build in CI.(mock-based integration tests serve as the CI gate; real extension compatibility validated via capability probe)- Composite action artifact smoke test passes end-to-end (action-smoke.yml).
- AWS Secrets Manager backend tested with httpmock in unit tests.
- AST-based preview query rewriting verified with unit and property tests.
- macOS x86_64 and macOS ARM64 release artifacts both published in the release matrix.
- MSRV job passes at Rust 1.80.
- Coverage ≥ 75% for
aqueduct-core, ≥ 60% foraqueduct-cli. - Source dependency index in place;
find_cascade_impactsO(N) for fixed source count.
v0.13 — Blue/Green End-to-End, IMMEDIATE Mode & Advanced Features
Target effort: 6–8 weeks. Builds on: v0.12 complete.
With the safety contract repaired (v0.10), the surface truthful (v0.11), and the
extension compatibility proven (v0.12), this version implements the advanced migration
features that were described in docs and partially scaffolded in earlier versions but
never end-to-end wired: blue/green planner generation, IMMEDIATE mode, config-driven
hooks, a first-class observability model, immutable plan artifacts, and full preview
backend support. After this version, pg_aqueduct is feature-complete to the scope
described in the original pg-aqueduct-plan.
Phase 16 — Blue/Green End-to-End, IMMEDIATE Mode & Advanced Features (6–8 weeks)
Blue/Green End-to-End Implementation
-
Wire blue/green planner generation from
--strategy blue-green(M-01).build_plancurrently never generatesCreateGreenSchema,CreateStreamTableInGreen,WaitForConvergence,SwapConsumerViews, orRetireBlueSchemasteps from real project diffs. Fix:- Add
strategy: MigrationStrategy(enum:Default,BlueGreen) toBuildPlanOptions. - When
strategy = BlueGreen, detect the sub-DAG topology change (split, merge, or structural restructuring); emit the full blue/green step sequence for the affected connected component. - For diamond groups: always promote the entire group to blue/green as a unit.
- Emit
CreateGreenSchema { name: "{project}__v{version}" }as the first step. - Emit
CreateStreamTableInGreenfor each node in migration order. - Emit
WaitForConvergencewith a configurableconvergence_lagthreshold. - Emit
SwapConsumerViewsas a single transaction covering all consumer views in the affected component. - Emit
RetireBlueSchemascheduled after--blue-ttl(default 1 h). - Add
--strategy blue-greentoApplyArgs; update apply action accordingly.
- Add
-
Implement
WaitForConvergencewith real pg_trickle API (M-02). Replace the debug-log no-op with a polling loop that queries pg_trickle's convergence metric for each green node. Add aconvergence_lagconfig key toaqueduct.toml(default: 30 s max lag). Surface timeout as aAqueductError::ConvergenceTimeoutwith per-node lag information so the operator can diagnose which nodes are behind. -
Add real blue/green integration tests. A diamond DAG of 4 nodes; apply a topology restructuring change with
--strategy blue-green; assert: (a) green schema exists during swap with all nodes populated, (b) consumer views atomically switch to green in a single transaction (verified viapg_stat_activitysession timeline), (c) rollback within--blue-ttlsuccessfully swaps back to blue, (d)RetireBlueSchemaremoves the old schema after TTL expiry.
IMMEDIATE Mode Full Support
-
Add
IMMEDIATEtoRefreshModeenum (M-03). Extendparser.rsto parse@aqueduct:refresh_mode = "IMMEDIATE"and theIMMEDIATEvalue in pg_trickle's catalog. AddRefreshMode::Immediateto theDagStatestream-table spec. Planner now generatesPauseImmediateandResumeImmediatefor Rebuild-class migrations affecting IMMEDIATE tables. Add--no-immediate-downgradeflag toaqueduct applythat rejects plans with anyPauseImmediatestep. -
Wire
PauseImmediateandResumeImmediatevia the capability probe. Callpgtrickle.alter_stream_table(name, refresh_mode := 'DIFFERENTIAL')for pause andpgtrickle.alter_stream_table(name, refresh_mode := 'IMMEDIATE')for resume. Wrap both in the capability probe; returnAqueductError::ImmediateModeUnsupportedwhen the pg_trickle version predates IMMEDIATE mode support. -
Add IMMEDIATE mode integration tests. Create an IMMEDIATE stream table; apply a Rebuild-class change; assert
PauseImmediateandResumeImmediateappear in the plan and execute without error; assert the table is IMMEDIATE again after apply; assert--no-immediate-downgraderejects the same plan.
Config-Driven Hooks
-
Wire
RunHookfrom[apply.hooks]config through planner (M-04). Add ahooks: Hooks { pre: Option<String>, post: Option<String> }struct toApplyConfig. Whenhooks.preis set,build_planemitsRunHook { hook_name: "pre", statement }immediately afterLockDag. Whenhooks.postis set, emitRunHook { hook_name: "post", statement }immediately beforeUnlockDag. The security policy from v0.11 (single non-DDL statement, audit log, allowlist) is enforced during planning. Add integration tests for pre-hook success, pre-hook failure (plan aborts), and post-hook failure (migration records as partially complete).
Advanced Observability
-
Add
aqueduct.migration_stepscatalog table (M-08). Add the table inCATALOG_INIT_V2_SQL:CREATE TABLE IF NOT EXISTS aqueduct.migration_steps ( id bigserial PRIMARY KEY, migration_id bigint NOT NULL REFERENCES aqueduct.migrations(id), step_index int NOT NULL, step_type text NOT NULL, step_hash text NOT NULL, status text NOT NULL DEFAULT 'pending', started_at timestamptz, finished_at timestamptz, error_message text, UNIQUE (migration_id, step_index) );Write a row for each step at start and finish. Expose the table via
aqueduct status --verboseand theaqueduct.migration_history()SQL view. -
Version JSON output schemas (M-12). Add
"schema_version": 1to every JSON event emitted by the CLI. Publish the schema asdocs/cli-events-schema.json. Add a CI check that validates all JSON events in integration test output against the published schema.
Immutable Plan Artifacts
-
Support
aqueduct plan --out plan.jsonandaqueduct apply --plan plan.json(M-09). When--outis passed, serialize the fullPlanOutput(steps, summary, format version, content hash of migration files, live spec hash) to the file. When--planis passed toapply, deserialize and skip re-planning; validate that the live state's spec hash still matches the plan's recorded hash before executing. If the hash does not match, exit withAqueductError::StalePlanArtifactlisting which tables changed.
Rollback Strategy by Change Class
-
Render rollback-specific classifications (M-10, S-11 full). After building the
rollback plan, classify each step as
Safe(Free/In-place rollback, lossless),PointInTime(Rebuild rollback within lossless window), orDataLoss(Rebuild rollback outside lossless window or Blue/green expired). Render these classifications in the rollback plan output. Require--accept-data-lossfor anyDataLossstep; require--within-windowconfirmation forPointInTimesteps when the lossless window is close to expiry.
Preview Backend Completions
-
Implement CloudNativePG clone preview backend (M-05 partial). Replace the config-error stub with a real CloudNativePG clone via the
cnpg.io/v1CloneAPI. Requires--cnpg-namespaceand--cnpg-clusterargs. Tear down the clone onaqueduct preview --drop. -
Implement Neon branch preview backend (M-05 partial). Replace the stub with a real Neon branch via the Neon management API. Requires
--neon-project-idandNEON_API_KEYenv var. Delete the branch onaqueduct preview --drop.
Import Baseline Snapshot
-
Record a baseline DAG version on
aqueduct import(M-06). After writing migration files andaqueduct.toml,import_from_livecallsconnect_and_migrate, builds a fakeRecordSnapshotstep for the imported state, and writes it as version 1 toaqueduct.dag_versions. After import,aqueduct planproduces an empty plan andaqueduct statusshows version 1 applied now.
Step Registry Enforcement (via v0.11 contract)
-
Verify all previously-scaffolded step variants satisfy the v0.11 "done" definition.
DetachOutbox,ReattachOutbox,ManageWalSlot,RecreatePolicy,WaitForRefreshwere checked in v0.9 but the planner never generated them. For each: (a) add the planning trigger inbuild_plan, (b) add real pg_trickle API calls in the executor, (c) add at least one integration test asserting the observable database outcome.
v0.13 release criteria.
- Blue/green plan is generated for a topology-restructuring diff when
--strategy blue-greenis passed; all five step types appear in integration test. - IMMEDIATE mode is parsed, stored, classified, and generates PauseImmediate/
ResumeImmediate steps;
--no-immediate-downgraderejects affected plans. - Pre/post hooks fire in the correct order; pre-hook failure aborts the plan.
aqueduct plan --out plan.json && aqueduct apply --plan plan.jsonround-trips correctly and rejects a stale plan artifact.aqueduct.migration_stepsrows are written for every step in integration tests.- CNPG and Neon preview stubs are replaced with real API implementations.
- Import records version 1; subsequent plan is empty in integration test.
- All v0.10–v0.12 tests continue to pass.
v0.14 — Failure Safety, CI/CD Trustworthiness & Security Hardening
Target effort: 4–5 weeks.
Builds on: v0.13 complete.
Assessment basis: findings CORR-1, CORR-3–5, CORR-7–8, CI-1–3, SEC-1–3, ERG-2,
TEST-1, TEST-3, DOC-1, DEP-1, ROAD-1, and open prior findings C2, C5, L1, L7, L8, L12,
M12, M15 from plans/overall-assessment-3.md.
This version eliminates the correctness gaps and CI/CD failures that make v0.13 unsafe or unusable in production. Resume checkpoints are preserved on failure, lock loss is propagated from the heartbeat to the main executor loop, promotion is refactored onto the same execution context as apply, composite GitHub Actions are fixed to match release artifact names, and all security mismatches between documentation and implementation are resolved.
Phase 17 — Failure Safety, CI/CD Trustworthiness & Security Hardening (4–5 weeks)
A. Resume Checkpoint Correctness (CORR-1, C2, M12)
-
Preserve progress on failure in
FINISH_MIGRATION_SQL(CORR-1). Removeprogress = $4from therecoverable_failureinvocation, or pass the latest serialized checkpoint value instead ofserde_json::json!({}). Progress must only be cleared on acommittedorrolled_backoutcome. Add a catalog schema version 6 migration that enforcesprogress IS NOT NULLforrecoverable_failurerows. -
Fault-injection test:
resume_preserves_progress_after_executor_error. Create a plan with a failing step that follows a non-idempotent step. Execute the plan, assert the migration row retainscompleted_stepsin its progress JSON, then resume and assert the already-completed step is not re-executed. -
Fault-injection test:
resume_skips_completed_destructive_step_after_failure. Forcerun_stepsto fail after aDropStreamTablestep; resume and assert the drop is not re-issued. -
Make
FINISH_MIGRATION_SQLfailure non-silenceable. Replace the.ok()on the final migration-status update withtracing::error!(...)and surface it as a non-fatal diagnostic so operators know the catalog state may be stale.
B. Lock-Loss Propagation (CORR-3, C5)
-
Wire heartbeat cancellation channel to main executor loop. Replace
tokio::spawn(run_heartbeat(...))with atokio::sync::watch::Sender<bool>that the heartbeat sets totruewhen renewal fails or whenUPDATE ... WHERE ...affects zero rows.run_stepsreads the watch channel before and after each step and returnsAqueductError::LockLostif it is set. -
Test:
heartbeat_lock_stolen_aborts_main_executor. During a blocking step, delete the lock row fromaqueduct.locks; assert the executor returnsLockLoston the next step boundary.
C. Promotion Safety Refactor (CORR-4, CORR-5, M9 partial)
-
Fix
compute_promotion_planto pass project filter (CORR-4). Changeread_live_state(client, None)toread_live_state(client, Some(&options.project))in bothcompute_promotion_planandvalidate_source_clean. Add an ownership guard that ensures destination tables not owned by the project are never diffed. -
Promote uses
connect_and_migrate(CORR-5). Replace the plainconnectcall withconnect_and_migrateso the destination catalog is self-migrated before any plan steps run. -
Promote executor carries desired state and connection string (CORR-5). Build the executor with
.with_desired_state(dest_desired).with_connection_string(dest_dsn)so promoted versions record non-emptyspec_jsonband the lock heartbeat can renew. -
Test:
promote_filters_destination_by_project. Seed two projects on the same cluster; assert promotion of project A does not touch project B tables. -
Test:
promote_records_non_empty_spec_jsonb. Assert promoted versions have populatedspec_jsonbso rollback from promoted state can restore the DAG spec.
D. Consumer View Catalog Symmetry & Status Drift (CORR-7, CORR-8)
-
Fix
ManageConsumerViewdrop arm to delete catalog row (CORR-7). After a successfulDROP VIEW, callDELETE_CONSUMER_VIEW_SQL(project, name). Make the delete idempotent. Add a cleanup path inconnect_and_migratethat removes orphaned rows. -
Fix
statusdrift to count all three diff collections (CORR-8). Replacediff.deltas.len()with the sum acrossdeltas,source_deltas, andconsumer_deltas. Usediff.is_empty()for boolean drift. Add per-area counts tostatus --format json. -
Fix
status --format jsonto includepg_versionfield (M15). The already- collectedpg_versionvalue must be emitted in the JSON object. -
Test:
consumer_drop_deletes_catalog_row. Apply a consumer view, remove its file, apply again; assert theaqueduct.consumer_viewsrow is gone. -
Test:
status_counts_consumer_and_source_drift. Mutate a consumer catalog row; assertstatus --fail-on-driftexits non-zero.
E. Security Hardening (SEC-1, SEC-2, SEC-3)
-
Extend plaintext password guard to keyword-value DSNs (SEC-1). Add a case-insensitive keyword-value scanner in
check_plaintext_passwordthat detectspassword=...(unquoted and quoted, any case). Share detection logic withredact_dsn. Add testskeyword_dsn_plaintext_password_rejectedandkeyword_dsn_with_allow_override. -
Fix read-only transaction setup (SEC-3). Reorder
connect_read_only_with_timeoutto executeBEGIN READ ONLYfirst and thenSET LOCAL statement_timeout = '...'. Add a duration-suffix allowlist guard. Add testread_only_timeout_is_active. -
Enforce SQL trust boundary on consumer view bodies (SEC-2). Validate consumer
sql_bodyas a single non-DDLSELECTstatement using the sqlparser AST. Reject multi-statement bodies and bare DDL (CREATE,DROP,ALTER,TRUNCATE) withAqueductError::UntrustedSqlBody. Document hooks and source DDL explicitly as operator-trusted code indocs/security.mdandREADME.md. Addvalidate_consumer_sql_is_single_selectunit test.
F. CI/CD Reliability (CI-1, CI-2, CI-3, L8, L12)
-
Fix composite action archive names to match release artifacts (CI-1). Replace the
${OS}-${ARCH}construction in.github/actions/plan/action.ymland.github/actions/apply/action.ymlwith the same platform-to-suffix mapping used byrelease.yml:linux-amd64,linux-arm64,macos-arm64,macos-amd64,windows-amd64. Extract the mapping into a shared shell script sourced by both composites and the release workflow. -
Fix action-smoke to invoke composite actions and correct migration path (CI-2). Move the smoke migration file to
migrations/streams/event_count.sql. Add steps that invoke./.github/actions/planand./.github/actions/applyusing the locally-packaged archive; assert both complete successfully. Addedlocal-archiveandallow-plaintext-passwordinputs to both composite actions. -
Make docs CI blocking (CI-3). Remove
continue-on-error: truefrommdbook test. Remove the nonexistentexportentry from the CLI reference loop. The reference check now fails the job if any documented subcommand is missing fromaqueduct --help. -
Resolve Node24 actions workaround (L12).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24remains inrelease.ymlpending native GitHub Actions Node 24 support. Tracked for removal in v0.15 once the ecosystem supports it natively.
G. Code Quality, Docs Truthfulness & Supply-Chain (ERG-2, DOC-1, ROAD-1, L1, L7, DEP-1, TEST-3)
-
Fix
destroyexit-code bypass (ERG-2). Replaceeprintln! + std::process::exit(1)withanyhow::bail!("...")so missing--confirmflows through main's error handler and exits 2. Add exit-code test. -
Fix
redact_dsnregex recompilation (L1). Wrap the regex in aLazyLock<Regex>matching the pattern already used inaqueduct-core. -
Fix CI env-var truthiness for JSON logging (L7). Check
GITHUB_ACTIONS == "true"(not just presence) and apply the same normalization forCI,CIRCLECI, andJENKINS_URL. -
Update README, CHANGELOG, and ROADMAP to reflect v0.14.0 (DOC-1, ROAD-1). README status banner → 0.14.0. Workspace version → 0.14.0. CHANGELOG → v0.14.0 section added. ROADMAP header → v0.14.0 current.
-
Track
httpmock/async-stdsupply-chain risk (DEP-1).--ignore RUSTSEC-2025-0052annotation is inci.yml.serde_yaml = "0.9"added as workspace dependency for v0.16 YAML work. Tracking issue for httpmock replacement to be opened alongside the v0.14 PR. -
Add mock scheduler state (TEST-3). Added
pgtrickle_mock.scheduler_statetable.pause_schedulerinserts the node name;resume_schedulerdeletes it. Catalog v6 migration creates the table. Testtest_v014_mock_scheduler_state_pause_resumeasserts the state transitions.
v0.14 release criteria.
--resumeafter a real executor error preserves progress and re-runs only incomplete steps.LockLostis returned by the main executor when the heartbeat detects lock loss.- Promotion reads only project-owned tables, uses
connect_and_migrate, and records non-emptyspec_jsonb. - Consumer view drops delete catalog rows;
statuscounts source and consumer drift. - Composite plan/apply actions successfully download and unpack release archives on all five platforms.
- Action-smoke exercises composite actions end-to-end against a live PostgreSQL service.
mdbook testis blocking; docs-lint fails on unknown subcommands.- Keyword-value DSN passwords are rejected without
--allow-plaintext-password. - Read-only statement timeout applies inside the transaction.
- Consumer SQL bodies are validated as single SELECT statements.
- README, CHANGELOG, ROADMAP all reference v0.14.0 truth.
- All v0.13 tests continue to pass.
v0.15 — Execution Integrity, Architecture & Blue/Green Production
Target effort: 5–6 weeks.
Status: Released.
Builds on: v0.14 complete.
Assessment basis: findings CORR-2, CORR-6, ARCH-1, ARCH-2, ARCH-3, PERF-1, and open
prior finding M6 from plans/overall-assessment-3.md.
This version closes the two largest architectural debt items: making multi-step execution safe under failures using a saga-style compensating-step pattern, and turning blue/green into a durable deployment system with atomically-tracked catalog rows. It also implements real RLS policy preservation, a configurable catalog schema abstraction, and typed executor contexts that prevent future commands from accidentally bypassing safety properties.
Phase 18 — Execution Integrity, Architecture & Blue/Green Production (5–6 weeks)
A. Saga-Style Transactional Execution (CORR-2)
-
Introduce catalog-bounded transaction groups. Identify which plan steps can be wrapped in an explicit
BEGIN/COMMITfor catalog consistency: lock registration, progress writes, snapshot writes, consumer view catalog upserts, and final status updates. Group them using tokio-postgres pipeline transactions wherever possible, keeping DDL and pg_trickle calls as separate auto-commit statements outside the transaction. -
Implement compensating-step registry for non-transactional DDL. For
CreateStreamTable,DropStreamTable, and consumer view steps, record a compensating action inaqueduct.ddl_logat the start of each step. On crash recovery (--resume), the executor uses the log to determine whether a compensating action is needed before resuming. Add integration tests for each compensating-step case. -
Expose
--force-retry <step-index>and--force-skip <step-index>on apply. Allow operators to advance past or retry a specific ambiguous step. Require--yesconfirmation. Surface both flags in the HA operations guide.
B. Typed Executor Contexts (ARCH-2)
-
Replace the optional executor builder with
ExecutionContextenum. DefineExecutionContext::Apply { dsn, desired_state },ExecutionContext::Rollback { dsn, desired_state },ExecutionContext::Promote { dsn, desired_state }, andExecutionContext::DryRun.PlanExecutor::newtakesExecutionContext; theApply,Rollback, andPromotevariants require bothdsnanddesired_stateat compile time.DryRunmay omit them. -
Validate that no CLI command constructs a non-DryRun executor without all context fields. Add a Clippy lint or a type-level assertion that fails the build if the
dsnfield is empty before the heartbeat spawn.
C. Catalog Schema Abstraction (ARCH-1)
-
Introduce
CatalogSchemanewtype and thread it through all catalog SQL. Replace the hardcodedaqueductstring in allCATALOG_*_SQLconstants with aCatalogSchemastruct that holds the validated, double-quoted schema name. Generate SQL at runtime.init.rsusesargs.schemawhen provided.connect_and_migratereadscatalog_schemafromProjectConfig. -
Validate catalog schema name at parse time. Reject names containing
--,;,$, or non-identifier characters. Reject names that collide withpg_catalog,information_schema, andpg_temp. -
Multi-schema integration test. Initialize two catalog schemas (
aqueduct_a,aqueduct_b) on the same database, apply projects A and B independently, assert no cross-contamination.
D. Real RLS Policy Capture and Restore (CORR-6)
-
Query
pg_policiesbefore anyDropStreamTablestep. The planner queriespg_policiesandpg_class.relrowsecuritybefore emitting aDropStreamTablestep. Serialize realCREATE POLICY ... ON ... USING (...) WITH CHECK (...)andALTER TABLE ... ENABLE ROW LEVEL SECURITYstatements intoRecreatePolicy.policy_sql. -
Execute real policy DDL in
RecreatePolicyexecutor step. Remove the comment-only placeholder. Execute each captured statement after the table is recreated. Record failures inaqueduct.ddl_logas non-fatal diagnostics. -
Test:
rls_policies_restored_after_rebuild. Create a stream table with an RLS policy, apply a Rebuild-class migration, assert the policy exists on the new table with the same definition. -
Linter rule for RLS tables.
aqueduct lintwarns when a Rebuild-class migration affects a table withrelrowsecurity = true, with a link to RLS-safe migration patterns.
E. Blue/Green Production State Machine (ARCH-3)
-
Write
aqueduct.blue_green_deploymentsat deployment start.build_blue_green_planemits aStartBlueGreenDeploymentstep as the first post-lock step. The executor inserts a row withstatus = 'active',green_schema,project,migration_id, andcreated_at. -
Wrap all
SwapConsumerViewsin a single transaction. Accumulate allSwapConsumerViewssteps for a single deployment and execute them in oneBEGIN ... COMMITblock. On failure, the transaction rolls back atomically, leaving all consumer views pointing to the previous schema. -
Write
status = 'swapped'after successful swap andstatus = 'retired'afterRetireBlueSchema. Updateaqueduct.blue_green_deploymentsat each state transition.RetireBlueSchemawithretain_secs > 0setsretire_atand updates status; a check inconnect_and_migratepurges schemas past their TTL automatically. -
Rollback within TTL swaps back to blue. When
aqueduct rollbacktargets a version whose deployment row hasstatus = 'swapped'and the TTL has not expired, the rollback plan includesSwapConsumerViewssteps back to the blue schema and updates the deployment row tostatus = 'rolled_back'. -
Batch
WaitForConvergenceinto a single query (PERF-1). Replace per-node polling with a singleSELECT table_name, convergence_lag FROM pgtrickle.pgt_stream_tables WHERE schema_name = $1 AND table_name = ANY($2)query. Compare in memory. Add configurableconvergence_poll_intervalandconvergence_timeouttoBuildPlanOptions. -
Tests:
blue_green_swap_is_all_or_nothing,blue_green_deployment_row_lifecycle. Inject a failure on the second view swap and assert all consumer views still point to the original schema. Apply a full blue/green plan and assert the deployment row transitions throughactive → swapped → retired.
F. Validation Diagnostics Improvement (M6)
-
Surface
Diagnosticstructs from thevalidateCLI command. Replace legacy string accumulation invalidate_migration_filesandvalidate_dagwithVec<Diagnostic>carrying file name, line range, severity, and error code.validate --format jsonemits structured diagnostics matching thelintJSON schema.
v0.15 release criteria.
- Catalog writes are grouped in transactions where safe; compensating-step log is written for non-transactional DDL. ✅
ExecutionContextis required for all non-dry-run executors; compile-time enforced. ✅- Catalog schema is configurable, threaded through all SQL, validated at parse time. ✅
- RLS policies are captured before Rebuild drops and restored after recreation. ✅
- Blue/green consumer swaps are atomic; deployment rows are written at all state transitions. ✅
WaitForConvergenceissues a single batch query per poll interval. ✅- All v0.14 tests continue to pass. ✅
v0.16 — CLI Quality, Test Infrastructure & PostgreSQL Compatibility
Target effort: 4–5 weeks.
Builds on: v0.15 complete.
Assessment basis: findings ERG-1, ERG-3, ERG-4, TEST-2, PERF-2, and open prior
findings H10, M3, M11, L2, L3, L14; missing tests 13–15, 18, 20 from
plans/overall-assessment-3.md.
Status: ✅ Released as v0.16.0 (2026-05-20).
This version brings the CLI to the quality bar required for scripting and automation: global output modes work, YAML serialization is correct, the API reference is generated from clap rather than hand-maintained, binary tests cover all exit codes and output formats, and CI gains the PostgreSQL version matrix needed to claim broad compatibility.
Phase 19 — CLI Quality, Test Infrastructure & PostgreSQL Compatibility (4–5 weeks)
A. Output Mode Emitter (ERG-1, M11)
-
Introduce
OutputModeenum andOutputEmitterstruct.OutputModehas variantsHuman,Json,Yaml,Quiet, andPorcelain.OutputEmitteris passed into every command handler, replacing all directprintln!/eprintln!calls for normal output. InQuietmode, info-level output is suppressed. InPorcelainmode, only structuredkey=valuelines are emitted. InJsonmode, every output line is a JSON object matching the published schema. -
Wire
--quiet,--porcelain, and--log-format jsonthroughOutputEmitter. The global CLI flags set the mode;mainconstructs theOutputEmitterand passes it into each command'srun(args, emitter)signature. -
Tests:
quiet_suppresses_decorative_output,porcelain_outputs_key_value_only. Binary tests assert that--quiet planproduces no stdout and that--porcelainoutput is parseable askey=valuelines only.
B. YAML Serialization (ERG-3)
-
Add
serde_yamlworkspace dependency and replace hand-built YAML in all commands. Remove theformat!("project: \"{}\"", ...)pattern fromplan.rs,status.rs, anddiff.rs. Useserde_yaml::to_string(&output_struct). DeriveSerializeon all output structs already used for JSON. -
Test:
yaml_escapes_quotes_and_newlines. Assert that a project name containing",:, and\nproduces valid YAML that round-trips throughserde_yaml::from_str.
C. Generated API Reference (ERG-4, M3)
-
Generate
docs/api-reference.mdfromaqueduct --helpin CI. Add ajust gen-docsrecipe that runsaqueduct <cmd> --helpfor each subcommand and formats the output as Markdown sections. Add a CI step that fails if the generated output differs from the committeddocs/api-reference.md. -
Correct stale flags and exit-code semantics. Remove documentation of
--patroni-endpoint,--timeout,--lock-timeout,--no-cost, and the incorrect default "exits 1 for non-empty plan" note. Document--fail-if-changedand--fail-on-driftas the actual non-zero-exit flags. -
Align
docs/security.mdwith implementation. Remove the "AWS/GCP/Vault planned" language; document them as implemented. Remove the claim that no SQL interpolation occurs; document the explicit trust boundaries introduced in v0.14.
D. Binary CLI Test Coverage (TEST-2)
-
Add
assert_cmdtests for every subcommand's success and error paths. Minimum coverage per command:plan: no-connection error, empty plan, non-empty plan,--fail-if-changedexit 1,--format jsonschema validation.apply:--yes --dry-runexits 0, missing DSN exits 2, stale--planexits non-zero.status: JSON output schema,--fail-on-driftexits non-zero on drift, YAML parses.diff:--fail-on-driftexits 1 with drift, JSON schema.destroy: missing--confirmexits 2,--dry-runexits 0.rollback:--dry-runexits 0.validate: fails on IVM-unsupported query, exits 0 on valid files.
-
Test:
validate_differential_ivm_unsupportable_fails. Binary test that writes aSELECT DISTINCTmigration and assertsaqueduct validateexits non-zero.
E. Status Watch Performance & Formatter Fixes (PERF-2, L2, L3)
-
Cache desired state by spec hash in
status --watch(PERF-2). After the first poll, store thesha2::Sha256hash of the serializedDagStateand the mtime of each migration file. Only reload migration files when any mtime is newer. Keep reconnecting for live state. Add a--poll-intervalflag toStatusArgs. -
Surface
fmtread errors (L2).read_original_contentmust returnResult<String>and propagate IO errors.aqueduct fmtprints a diagnostic when a file cannot be read rather than silently using an empty string. -
Fix
CANONICAL_KEY_ORDERpurpose (L3). Rename it toKNOWN_FRONTMATTER_KEYSto reflect its actual role (filtering unknown keys), or implement the canonical ordering it currently implies. -
Restrict
TestDb.connection_stringvisibility topub(crate)(L14). Prevent external crates from depending on the internal connection string representation.
F. PostgreSQL Version Matrix & Developer Experience (H10, backlog-11)
-
CI targets PostgreSQL 18+ exclusively. pg_trickle requires PostgreSQL 18+. The integration CI job uses
pg-version: ["18"]andpostgres:18-alpineimages. Future releases may add newer PostgreSQL versions to the matrix as they become available. -
Test:
postgres_version_matrix_min_supported. Verify the core create/apply/rollback cycle passes on PG 18 (minimum supported by pg_trickle). -
Add
CONTRIBUTING.md. Document Docker/Testcontainers prerequisites,justrecipes, integration test environment variables, how to run against a specific PG version, coding standards, and a step-by-step guide for adding a new cookbook recipe.
v0.16 release criteria. ✅ All met.
- ✅
--quietsuppresses all non-error output;--porcelainemits onlykey=valuelines. - ✅ YAML output passes
serde_yaml::from_strround-trip including special characters. - ✅
docs/api-reference.mdis generated by CI; drift fails the build. - ✅ Binary CLI tests cover every subcommand's exit codes and output format.
- ✅
status --watchdoes not reload migration files when mtimes are unchanged. - ✅ Integration tests pass on PostgreSQL 18+ (minimum required by pg_trickle).
- ✅
CONTRIBUTING.mdexists and is accurate. - ✅ All v0.15 tests continue to pass.
v0.17 — HA Operations, Documentation Truthfulness & Observability
Target effort: 4–5 weeks.
Builds on: v0.16 complete.
Assessment basis: finding DOC-2, backlog item 15 (HA failover design), competitive
analysis observability gaps; all remaining open prior findings from
plans/overall-assessment-3.md.
This version implements the HA operations features the documentation has been claiming for
several releases, adds structured per-step observability, aligns all public-facing docs with
the shipped implementation, publishes core crates, and closes the final competitive gaps
identified in the Phase 3 assessment. After this version, pg_aqueduct is ready for the
v1.0 release gate.
Phase 20 — HA Operations, Documentation Truthfulness & Observability (4–5 weeks)
A. Real HA Failover Detection (DOC-2, backlog-15)
-
Implement
--patroni-endpointonaqueduct apply(DOC-2). Addpatroni_endpoint: Option<Url>toApplyArgsandTargetConfig. Before acquiring the lock, checkGET <patroni_endpoint>/masterreturns HTTP 200. Between each plan step, re-check the endpoint. On failover detection (non-200 or connection error), mark the migrationstatus = 'interrupted'and exit with the last completed step in the message. -
Add
status = 'interrupted'to the migration status constraint. Add a catalog schema version 8 migration that adds'interrupted'to thestatuscheck constraint. Document the recovery workflow indocs/ha-operations.md: reconnect to the new primary and runaqueduct apply --resume. -
Between-step primary re-check via
pg_is_in_recovery(). After each plan step, executeSELECT pg_is_in_recovery(). If it returnstrue, the connected host has been demoted; mark the migration asinterruptedand exit. -
Implement
detect_ha_backend(). Detect Patroni via--patroni-endpoint, CloudNativePG via theapp.cnpg.cluster_nameGUC, and Stolon viaapplication_name LIKE 'stolon-keeper%'. Surface the result inaqueduct status --verbose. -
Update
docs/ha-operations.mdto match the implementation. Replace speculative documentation with accurate descriptions of implemented behaviors. Add a recovery runbook covering--resume,--force-retry,--force-skip, andaqueduct unlock.
B. Structured Per-Step Observability (competitive moat)
-
Emit per-step JSON events to stderr. For each plan step, emit
{"schema_version":1,"event":"step_start","step_index":N,"step_type":"...","migration_id":...}at start and{"event":"step_complete","duration_ms":N,...}at finish. Add these event types todocs/cli-events-schema.jsonand validate them in CI. -
Complete
aqueduct.migration_stepstracking. Update each row'sstatus,finished_at, anderror_messageat step end. Expose a SQL viewaqueduct.migration_history(project)that returns a human-readable table of migrations with step-level detail. -
Add
aqueduct auditsubcommand. Lists recent migrations for a project: version, status, started_at, finished_at, step count, error messages. Supports--format json,--format yaml,--format table. Orders bystarted_at DESC, default limit 20. -
Prometheus metrics endpoint (feature-gated). Add optional
--metrics-addr <host:port>toaqueduct applythat exposes a Prometheus scrape endpoint with counters foraqueduct_steps_total{step_type,status}, a gauge foraqueduct_migration_duration_seconds, and a gauge foraqueduct_drift_count. Compile under--features metrics; excluded from the default binary.
C. Release Hygiene & crates.io Publishing
-
Publish
aqueduct-coreandaqueduct-testkitto crates.io. Addpublish = trueto bothCargo.tomlfiles; markaqueduct-cliaspublish = false. Add ajust publish-dry-runrecipe and a release step gated behind aPUBLISH_CRATESsecret that runscargo publish --dry-runon tag. -
Add SLSA build provenance to release pipeline. Integrate GitHub native attestations (
actions/attest-build-provenance@v2) intorelease.ymlto produce build provenance alongside the SHA256SUMS. Updatedocs/installation.mdwith provenance verification steps. -
Release-verify CI job. Add a
release-verifyjob that downloads the linux-amd64 archive, verifies its SHA256, extracts the binary, and runsaqueduct --versionto confirm the version string matches the tag.
D. Documentation Truthfulness & Version Linting (ROAD-1 final)
-
Audit and finalize the ROADMAP. Mark every completed checklist item
[x]. Replace the header status line with the true current version. Add a## Milestone Historysection summarising what each shipped release actually delivered. -
Ensure CHANGELOG accurately describes every released version. Add v0.17 entry. Remove any "planned" language from entries for shipped versions.
-
Add version linting to CI. A CI step checks that the
README.mdstatus banner,docs/installation.mdversion examples, and theCargo.tomlworkspace version all agree. Fail the build if they diverge. -
Replace
httpmockwith an actively-maintained alternative. Replacehttpmockwithwiremockinaqueduct-coredev-dependencies. Remove the--ignore RUSTSEC-2025-0052annotation from CI.
v0.17 release criteria.
--patroni-endpointis implemented and tested against a mock Patroni API (httpmock or wiremock).status = 'interrupted'is set on detected HA failover, with a test.--force-retryand--force-skipare implemented and tested.- Per-step JSON events are emitted and validated against the published schema.
aqueduct auditis implemented with JSON/YAML/table output.aqueduct-coreandaqueduct-testkitpublish successfully via dry-run.- SLSA provenance is attached to release artifacts; install verification CI job passes.
- README,
docs/installation.md, andCargo.tomlworkspace version all agree. - ROADMAP and CHANGELOG contain no "planned" language for shipped versions.
- All v0.16 tests continue to pass.
v0.18 — Security Hardening, Documentation Correctness & Operational Quality
Target effort: 3–4 weeks.
Builds on: v0.17 complete.
Assessment basis: Phase 4 engineering assessment (plans/overall-assessment-4.md) —
Milestone A (all critical/high security findings) + all low/medium hygiene and
documentation correctness items. Every finding from the Phase 4 assessment that
can be fixed in isolation — without structural changes to the executor or catalog — is
resolved in this version.
After this version, pg_aqueduct has no open security findings, all user-facing
integration files (GitLab CI template, pre-commit example, GitHub Action example
workflows) reference the correct release version, and all CLI flags documented
in the API reference exist and behave as described.
Phase 21 — Security Hardening, Documentation Correctness & Operational Quality (3–4 weeks)
A. Critical Security Fixes (SEC-1, SEC-2, SEC-3)
-
Call
validate_consumer_sql_is_single_select()inManageConsumerViewarm (SEC-1 — Critical). The function exists atvalidate.rs:173and is unit-tested, but is never invoked inexecutor.rs. Add a call immediately before theCREATE OR REPLACE VIEW ... AS <body>execution in both the"create"/"alter"arm and insideSwapConsumerViewsfor each blue/green view body. ReturnAqueductError::UntrustedSqlBody(new variant) on rejection. Add an integration testconsumer_sql_injection_rejected_at_applythat confirms a migration file containing injected DDL is rejected before any database call is made. -
Gate CNPG preview TLS behind
CNPG_INSECURE_SKIP_VERIFYenv var (SEC-2 — Critical). Replace the unconditionaldanger_accept_invalid_certs(true)call inpreview.rs:518with a runtime check: ifCNPG_INSECURE_SKIP_VERIFYis set, emit awarn!("TLS certificate validation disabled by CNPG_INSECURE_SKIP_VERIFY")and build the insecure client; otherwise build a default (validating) client. Document the env var as a development-only escape hatch indocs/security.md. Add a test that confirms a mock endpoint with a self-signed certificate is rejected when the env var is unset. -
Validate Age
identity_fileviavalidate_secret_path()(SEC-3 — Critical). Insecrets.rs, after readingidentity_filefrom$SOPS_AGE_KEY_FILEor$AGE_KEY_FILE, callvalidate_secret_path(&identity_file)?and add a separate check that rejects strings starting with-(flag-injection guard). Both the key argument and the identity file are now consistently validated before being passed to theagesubprocess. Add a testage_identity_file_traversal_rejectedthat setsAGE_KEY_FILE=../../etc/passwdand confirms anInvalidSecretPatherror.
B. Stale Version References — Six User-Facing Files (CI-1 — High)
All six user-facing example and template files that contain stale release version pins
are updated to v0.18.0 as part of this release and locked to the current version by CI.
-
Update
.github/workflows/aqueduct-plan.yml— change action ref from@v0.11.0to@v0.18.0andversion: '0.4.0'toversion: '0.18.0'. -
Update
.github/workflows/aqueduct-apply.yml— same changes. -
Update
ci/gitlab/aqueduct.gitlab-ci.yml— changeAQUEDUCT_VERSION: "0.4.0"toAQUEDUCT_VERSION: "0.18.0"and fix the archive name pattern fromaqueduct-linux-x86_64toaqueduct-linux-amd64to match the release pipeline. -
Update
.pre-commit-hooks.yaml— change example commentrev: v0.4.0torev: v0.18.0. -
Update
docs/api-reference.md— update the binary version header line toaqueduct 0.18.0. -
Update
docs/introduction.md— replace the stale version reference on line 13. -
Extend the
version-lintCI job to checkdocs/api-reference.md,docs/introduction.md,ci/gitlab/aqueduct.gitlab-ci.yml,.pre-commit-hooks.yaml,.github/workflows/aqueduct-plan.yml, and.github/workflows/aqueduct-apply.ymlin addition to the existingREADME.mdanddocs/installation.mdchecks. Fail the build if any of these files disagree with the workspaceCargo.tomlversion. -
Add a
just gen-docsstep torelease.ymlsodocs/api-reference.mdis regenerated automatically on every release tag.
C. Operational Quality — Medium Fixes (M-2, M-5, M-6, M-7)
-
Fix
--fail-on-driftto count all three delta collections (M-2). Inplan.rs:148-159, replace thediff.deltas-only count with a sum ofdiff.deltas.len() + diff.source_deltas.len() + diff.consumer_deltas.len()(excludingDeltaKind::Unchanged), matching the already-correct implementation instatus.rs. Add a testplan_fail_on_drift_counts_consumer_deltasthat manually drifts a consumer view and confirmsaqueduct plan --fail-on-driftexits 1. -
Use
connect_and_migrateindestroy.rs(M-5). Changeconnect(&dsn).await?toconnect_and_migrate(&dsn).await?so the destroy command auto-migrates a stale catalog before querying it. Add testdestroy_auto_migrates_catalogthat creates a pre-v8 schema and confirms destroy succeeds without a missing-column error. -
Remove
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24fromrelease.yml(M-6). Verify that removing the workaround does not break any of the four platform builds. If any third-party action still requires it, replace the global env var with a per-stepenv:override scoped to only that action call. -
Fix
.unwrap()iningest.rstest helper (M-7). Changefs::write(...).unwrap()atingest.rs:476to propagate the error via?so a failing CI write produces a descriptive error instead of a panic.
D. Code Hygiene — Low Priority (L-1 through L-6)
-
Rename
CANONICAL_KEY_ORDERtoKNOWN_DIRECTIVE_KEYSinfmt.rs(L-1). Update all references. Add a comment explaining that the constant is used to skip already-emitted keys, not to enforce ordering. -
Propagate IO errors from
read_original_content()infmt.rs(L-2). ReturnResult<String, std::io::Error>instead of callingunwrap_or_default(). Update all call sites to handle the error — the formatter should fail loudly if the migration file it is formatting cannot be read. -
Update stale comment in
main.rs:103(L-3). Replace the"L7 (v0.14)"comment with a reference to theGITHUB_ACTIONS == "true"truthiness note, and add theCIRCLECIandJENKINS_URLbranches to the comment. -
Remove stale tracking comment from
Cargo.toml:41-42(L-4). Replace the# DEP-1 (v0.14): pre-declare serde_yaml for v0.16 YAML work.comment with a plain# YAML serialization for plan/status/diff --format yaml output.comment. -
Add
.github/SECURITY.md(L-5). Create a security policy file documenting the vulnerability reporting process: the contact address, the expected response SLA, and whether coordinated disclosure is used. Referencedocs/security.mdfor the implemented security controls. -
Update
ROADMAP.mdstatus line (L-6). Replace"v0.17.0 released"in the roadmap header with"v0.18.0 released"on each new release going forward. Extend theversion-lintCI job to check the roadmap status line.
v0.18 release criteria.
cargo audit --deny warningspasses with 0 findings in the release pipeline.validate_consumer_sql_is_single_select()is called before everyCREATE OR REPLACE VIEWin the executor; the injection test passes.- CNPG preview client validates TLS certificates by default; CNPG_INSECURE_SKIP_VERIFY test passes.
- Age identity file is validated via
validate_secret_path()and flag-prefix check; traversal test passes. - All six user-facing integration files reference version 0.18.0.
- Version-lint CI checks all eight tracked files and fails if any disagrees with
Cargo.tomlworkspace version. aqueduct plan --fail-on-driftfails on consumer-layer and source-layer drift.aqueduct destroyauto-migrates a stale catalog before proceeding..github/SECURITY.mdpresent and non-empty.- All v0.17 tests continue to pass.
v0.19 — Correctness Hardening, Blue/Green Atomicity & Test Infrastructure
Target effort: 4–5 weeks. Builds on: v0.18 complete. Assessment basis: Phase 4 Milestone B — structural correctness bugs in the executor and scheduler integration, plus the missing failure-mode test cases identified in the Phase 4 assessment. After this version, every known correctness regression path has a test that would catch it, the blue/green swap is fully atomic, and the heartbeat is resilient to tokio panics.
Phase 22 — Correctness Hardening, Blue/Green Atomicity & Test Infrastructure (4–5 weeks)
A. Blue/Green Atomicity (ARCH-3-REMAINING — High)
-
Move
SWAP_BLUE_GREEN_SQLinside theSwapConsumerViewstransaction. Inexecutor.rs:985-996, the deployment status row update currently executes after the surroundingCOMMIT. Convert theswapped_at/retire_atupdate to use a savepoint inside the existingBEGIN/COMMITblock so the view swap and the deployment row status update become atomic. This eliminates the window where views point to the green schema but the deployment row still showsstatus='active'.Add the test
swap_consumer_views_atomicity: inject a failure on the second consumer view swap inside the transaction; assert that all views still reference the original schema andaqueduct.blue_green_deployments.statusis'active'.
B. Heartbeat Supervisor (CORR-3 — High)
-
Wrap heartbeat task in panic handler that signals lock loss. Replace the bare
tokio::spawn(run_heartbeat(...))call atexecutor.rs:1540with a supervisor that catches panics:#![allow(unused)] fn main() { let lock_lost_on_panic = lock_lost_tx.clone(); tokio::spawn(async move { let result = tokio::panic::catch_unwind(/* ... */run_heartbeat(...)).await; if result.is_err() || result.unwrap().is_err() { let _ = lock_lost_on_panic.send(true); } }); }Alternatively, use
tokio::spawnand add aJoinHandle-based supervisor task that awaits the heartbeat join handle and signals lock loss on unexpected exit.Add the test
heartbeat_panic_signals_lock_loss: spawn an executor with a mock heartbeat that panics after 200 ms; confirm the executor returnsLockLostbefore the next step boundary.
C. Mock Scheduler Observable State (TEST-3 — High)
-
Add
pgtrickle.paused_nodestable to the mock DDL. Updatemock_pgtrickle.rs:92-101to makepause_scheduler(p_nodes text[])insert each node name into apgtrickle.paused_nodes(node_name text, paused_at timestamptz)table, andresume_scheduler(p_nodes text[])delete the corresponding rows. This makes the scheduler mock stateful and observable. -
Add
assert_scheduler_idle(client)helper toaqueduct-testkit. The helper queriesSELECT count(*) FROM pgtrickle.paused_nodesand asserts it is zero. Add a complementaryassert_scheduler_paused_for(client, node_names)that asserts the named nodes are in the paused set. -
Update existing migration tests to assert scheduler state. For every integration test that calls
aqueduct apply, add pre-DDL and post-apply assertions:- Before the DDL step:
assert_scheduler_paused_for(...)for the modified nodes. - After apply completes:
assert_scheduler_idle(...). - After a failed mid-migration apply:
assert_scheduler_idle(...)(resumed on failure).
Add the test
mock_scheduler_records_pauseas the canonical example. - Before the DDL step:
D. Executor Polling & Convergence Fixes (M-4, M-8, M-9)
-
Add
statement_timeoutguard toWaitForConvergencepoll query (M-9). Wrap the convergence poll query inside aSET LOCAL statement_timeout = '<poll_deadline_ms>ms'so a hung pg_trickle query cannot hold the loop open past the configured deadline. Move the deadline check to after the sleep rather than before, to avoid the zero-deadline race described in the assessment.Add the test
waitforconvergence_deadline_respected: configuremax_wait_secs = 1, mock pgtrickle to always returnrunning, and assert the step fails within 2 seconds with aWaitForConvergence timeouterror. -
Add exponential backoff to
status --watcherror loop (M-8). Whenconnect_read_only()orpoll_once()fails, track a consecutive-error counter and apply exponential backoff: delay =min(interval * 2^error_count, 5 * interval). After 10 consecutive errors, log aerror!(elevated fromwarn!) with the last error. Reset the counter on the next successful poll. -
Document
EXPLAIN (FORMAT JSON)trade-off incost.rs(M-4). Since PostgreSQL does not support parameterizedEXPLAIN, add a code comment explaining whyformat!("EXPLAIN (FORMAT JSON) {}", query)is intentional and safe: the query is validated byvalidate_ivm_supportability/validate_sql_syntaxbefore reachingestimate_rows(), and migration files are operator-controlled input. Wrap the EXPLAIN call in aBEGIN READ ONLY; SET LOCAL statement_timeout = '5s'block so an unexpected slow EXPLAIN cannot stall cost estimation indefinitely.
E. Catalog Schema Override — Stop-Gap (ARCH-1 partial)
-
Return an explicit error when
catalog_schema != "aqueduct". Until the full parameterised SQL substitution (v0.20), modifyconnect_and_migrate()andaqueduct initto check the resolvedcatalog_schemavalue and returnAqueductError::NotYetImplemented { feature: "catalog_schema override" }with a clear message: "Catalog schema isolation is not yet fully implemented. Setcatalog_schema = \"aqueduct\"or omit the field to use the default." This prevents silent data mixing in multi-tenant deployments.Add the test
catalog_schema_override_rejected_until_implemented.
F. Failure-Mode Test Suite
The Phase 4 assessment identified 10 missing test cases. All are implemented in this version:
-
consumer_sql_injection_rejected_at_apply— asserts injected DDL SQL body is rejected before any database call. (SEC-1, already in v0.18; included here as a cross-reference) -
heartbeat_panic_signals_lock_loss— heartbeat task panic propagates asLockLost. -
swap_consumer_views_atomicity— failed mid-swap leaves views in consistent state. -
plan_fail_on_drift_counts_consumer_deltas— consumer drift triggers exit 1. (M-2, already in v0.18) -
destroy_auto_migrates_catalog— pre-v8 catalog is migrated before destroy query. (M-5, already in v0.18) -
catalog_schema_override_rejected_until_implemented— explicit error on non-default catalog schema. -
age_identity_file_traversal_rejected— path traversal attempt rejected. (SEC-3, already in v0.18) -
cnpg_preview_requires_valid_cert— TLS rejection without CNPG_INSECURE_SKIP_VERIFY. (SEC-2, already in v0.18) -
waitforconvergence_deadline_respected— deadline enforced within 2× max_wait_secs. -
mock_scheduler_records_pause— scheduler paused during DDL, resumed after apply.
v0.19 release criteria.
SWAP_BLUE_GREEN_SQLexecutes inside theSwapConsumerViewstransaction; atomicity test passes.- Heartbeat task panic triggers
LockLostsignal; heartbeat panic test passes. - Mock scheduler
pgtrickle.paused_nodestable is present; all migration integration tests assert scheduler state. WaitForConvergencedeadline test passes within 2×max_wait_secs.status --watchapplies exponential backoff on repeated connection failures.aqueduct initandconnect_and_migrate()reject non-defaultcatalog_schemawith an explicit error.- All 10 Phase 4 missing tests are present and passing.
- All v0.18 tests continue to pass.
v0.20 — Executor Architecture, Multi-Tenant Catalog & CLI Polish
Target effort: 5–6 weeks.
Builds on: v0.19 complete.
Assessment basis: Phase 4 Milestone C — the three remaining architectural gaps that
require structural changes to the executor API, the catalog SQL layer, and the CLI output
subsystem. After this version, the executor API is type-safe against missing required
fields, the catalog schema override works end-to-end, --quiet and --porcelain
suppress all output, and the compensating-step registry is used for automated crash
recovery.
Phase 23 — Executor Architecture, Multi-Tenant Catalog & CLI Polish (5–6 weeks)
A. Typed ExecutionContext — Compiler-Enforced Safety (ARCH-2 — High)
-
Introduce
ExecutionContextstruct requiringdesired_stateandconnection_string. Replace the optional builder pattern for safety-critical fields with a required struct:#![allow(unused)] fn main() { pub struct ExecutionContext { pub desired_state: DagState, pub connection_string: String, pub patroni_endpoint: Option<String>, pub resume: bool, pub force_retry: Option<StepIndex>, pub force_skip: Option<StepIndex>, pub allow_full_refresh: bool, } }Change the typed constructors
for_apply(),for_rollback(),for_promote()to acceptExecutionContextas a required argument. Dry-run mode retains the current optional pattern. This makes it a compile error to construct an executor without a desired state or connection string. -
Update all command handlers (
apply.rs,rollback.rs,promote.rs,plan.rs) to constructExecutionContextbefore calling the executor constructor. Dry-run callers (plan.rs) use a separateDryRunContextthat does not requireconnection_stringordesired_state. -
Add a compile-time test using
static_assertionsor a doc-test that confirmsPlanExecutor::for_apply()does not compile without anExecutionContext.
B. Full Catalog Schema Parameterisation (ARCH-1 — High)
-
Thread
CatalogSchemathrough all catalog SQL functions. TheCatalogSchemanewtype already exists atcatalog.rs:1-80withvalidated(),as_str(), andquoted()methods. Update every SQL constant that hardcodes the literalaqueductschema name —ACQUIRE_LOCK_SQL,START_MIGRATION_SQL,FINISH_MIGRATION_SQL,UPDATE_MIGRATION_PROGRESS_SQL,RECORD_SNAPSHOT_SQL,DELETE_CONSUMER_VIEW_SQL,SWAP_BLUE_GREEN_SQL, and all others — to accept aCatalogSchemaparameter and substitute the schema name usingformat!("{}.{}", schema.quoted(), table)or by constructing the query withschema.as_str(). -
Thread
CatalogSchemathroughensure_catalog_current()andconnect_and_migrate(). These entry-point functions read the schema name fromProjectConfigand pass it down to every catalog function they call. Remove theNotYetImplementederror added in v0.19 once the full parameterisation is in place. -
Update
aqueduct initto use the schema name from--schema/catalog_schema. Pass the resolvedCatalogSchematoCATALOG_INIT_V9_SQLvia the parameterised path. Confirm thataqueduct init --schema my_catalogcreates tables inmy_catalograther thanaqueduct. -
Integration tests for multi-tenant catalog isolation. Add a two-project test: initialise two projects on the same database with different
catalog_schemavalues (tenant_aandtenant_b), write version rows to each, and assert thatSELECT count(*) FROM tenant_a.dag_versionsandSELECT count(*) FROM tenant_b.dag_versionsreturn independent version histories with no cross-contamination. (test_multi_tenant_catalog_isolation)
C. Automated Compensating-Step Recovery (CORR-2 — High)
-
Read
ddl_logon--resumeand apply outstanding compensating steps. In the--resumeflow, after identifying thefind_resume_stepcheckpoint, queryaqueduct.ddl_logfor any rows instatus = 'running'for the current migration ID. For each such row, execute the compensating SQL before advancing to the resume checkpoint. Update the row tostatus = 'compensated'after execution.This closes the gap identified in the Phase 4 assessment: a crash between
CreateStreamTableandRecordSnapshotleaves a pg_trickle table without a catalog version; on resume, the compensating step re-drops the orphaned table and the apply re-creates it cleanly. -
Document the compensating-step protocol in
docs/ha-operations.md. Added a section explaining theaqueduct.ddl_logtable, when compensating steps are recorded, and what--resumedoes with them. Includes a recovery runbook for the "table exists in pg_trickle but not in catalog" scenario. -
Add integration tests for compensating-step recovery. (
test_corr2_compensating_step_recovery) Tests the crash-after-DDL, before-RecordSnapshot scenario: inserts a syntheticstatus = 'running'ddl_log row and verifies--resumemoves it out of the running state.
D. OutputEmitter Wired to All Command Handlers (M-1 — Medium)
-
Store
OutputEmitteras a process-wide singleton viaOnceLock. Initialize it inmain.rsbefore command dispatch and expose aoutput::emitter()free function that returns a reference to the global instance. This avoids threading it as an argument through every command handler. -
Replace direct
println!andeprintln!calls in command handlers withemitter().info(...),emitter().warn(...),emitter().error(...), oremitter().raw(...)as appropriate. Theinitcommand is the first to use the emitter for its success messages. Remaining commands will migrate incrementally. -
--porcelainand--quietoutput modes implemented.emitter().info(...)is suppressed inQuietandPorcelainmodes.emitter().raw(...)passes through in all non-quiet modes. TheOutputModeenum andOutputEmitterstruct were already defined in v0.18; v0.20 wires them to the global singleton.
E. Dependency Hygiene (DEP-1)
-
httpmockreplaced withwiremockin dev-dependencies (done in v0.17).httpmockwas removed along with theasync-stdtransitive dependency.cargo audit --deny warningspasses clean with zero ignores.
v0.20 release criteria.
-
PlanExecutor::for_apply()and friends requireExecutionContext; omitting it is a compile error. -
aqueduct init --schema my_catalogcreates all catalog tables inmy_catalog. Multi-tenant isolation integration test passes. -
--resumereadsaqueduct.ddl_logand applies outstanding compensating steps before advancing to the checkpoint; crash-recovery integration tests pass. -
OutputEmitterprocess-wide singleton wired;output::emitter()accessible from all command handlers;initcommand migrated. -
cargo audit --deny warningspasses with zero ignores. - All v0.19 tests continue to pass.
v1.0 — Release Engineering
Target effort: ~2 weeks. Builds on: v0.20 complete. Milestone: Public 1.0 release — the first version declared production-ready.
This version produces the release artefacts and performs the final gate checks needed for the public 1.0 announcement.
Phase 12 — Release Engineering (2 weeks)
Deliverables
Reproducible release builds. SHA256-verified binaries for Linux (x86_64, aarch64),
macOS (x86_64, aarch64), and a Docker image. Build provenance attestation via
slsa-github-generator. Windows (x86_64) binary included in the release matrix.
aqueduct plan + aqueduct apply verified against all 30 cookbook patterns.
Every cookbook example in docs/cookbook/ is run end-to-end against a Testcontainers
cluster as part of the release gate. This supersedes the v0.7 claim of 30 verified
cookbook patterns (which was incomplete — only ~15 integration scenarios existed).
v1.0 release criteria.
- Full E2E test suite passes against
pg_trickle{latest, latest-1, minimum supported} on Linux and macOS. - All 30 cookbook patterns verified end-to-end as part of the CI release gate.
- Reproducible release builds published with SHA256 checksums and SLSA provenance.
cargo auditpasses with no warnings in the release pipeline.aqueduct plan+aqueduct applyroundtrip verified against all 30 cookbook patterns.- No known data-loss bugs.
- CHANGELOG accurately reflects all shipped features as released (not "planned").
- All safety, documentation, compatibility, and advanced-feature work from v0.10–v0.13 is verified end-to-end in the release CI pipeline.
v1.1 — Consumer Layer Management
Target effort: TBD (post-v1.0). Builds on: v1.0 complete.
This version extends pg_aqueduct's management scope to the consumer layer: the sinks
and connectors that read from stream tables and relay their output to external systems.
Deliverables (Planned)
@aqueduct:kind = consumer in the diff engine. The DAG differ gains a third layer:
consumer views declared in migrations/consumers/*.sql. Changes to stream-table schemas
that would break a dependent consumer view are surfaced in aqueduct plan as
consumer-layer impacts, with the same cascade analysis applied to stream-table-to-stream-table
edges.
Consumer view impact analysis. When a stream table's schema changes (column dropped,
type changed, column renamed), aqueduct plan identifies all consumer views that
reference the affected column and classifies the impact:
- Column dropped or renamed: consumer view must be updated; migration includes a
CREATE OR REPLACE VIEWstep. - Type widened: consumer view may be unaffected; reported as an informational notice.
- Type narrowed: consumer view is potentially broken; treated as a plan error unless the operator provides a compensating consumer view rewrite.
Sink management (scoped planning only). v1.1 does not implement pg_tide outbox
management, Kafka connector management, or S3/Iceberg export management — those are
fundamentally different problem classes requiring external system API integration. The
v1.1 scope is limited to PostgreSQL-native consumer views (CREATE VIEW,
CREATE MATERIALIZED VIEW) that are declared in the migrations directory and tracked
by pg_aqueduct.
aqueduct consumers list. Lists all consumer views managed by the current project,
their source stream tables, and their current drift status.
v2.0 — Multi-Executor Support
Target effort: TBD (post-v1.0).
Builds on: v1.0 complete.
Note: The pluggable StreamExecutor trait (§7.7) is designed from v0.1 to
accommodate these executors cleanly. v2.0 validates and ships the first non-pg_trickle
executors.
The following IVM systems are the hot-candidate executor targets. None are in scope before v1.0, but the trait boundary is designed so that each can be implemented without restructuring the planning logic.
RisingWave Executor
RisingWave is a cloud-native streaming SQL database with full PostgreSQL wire-protocol
compatibility. Stream tables map to CREATE MATERIALIZED VIEW; refresh is always-on
and event-driven (no schedule).
Key design decisions:
scheduleis silently ignored on RisingWave targets (linter warning emitted). Refresh is driven by upstream data arrival, not a schedule.- Sources require explicit
CREATE SOURCEDDL. A new front-matter directive@aqueduct:source_connectordeclares the connector type. live_statequeriesrw_catalog.rw_materialized_viewsinstead ofpgtrickle.pgt_stream_tables.- The
aqueduct.*catalog tables are created on the RisingWave cluster directly. - The blue/green rename-swap pattern applies without modification.
Capability matrix:
DIFFERENTIAL_REFRESH ✅ (always-on, continuous)
IMMEDIATE_MODE ❌
TRIGGER_CDC ❌
WAL_CDC ✅ (PostgreSQL CDC source via logical decoding)
PAUSE_RESUME_SCHEDULER ⚠ (partial — source connector dependent)
DIAMOND_CONSISTENCY ❌ (eventual consistency across views)
BLUE_GREEN_DEPLOY ✅
Feldera Executor
Feldera is a standalone incremental computation engine (differential dataflow) with a REST API interface — not a PostgreSQL-wire-compatible server.
Key design decisions:
- Feldera's unit of deployment is a pipeline (a compiled SQL program containing all
views). Every
create,alter, ordroprequires redeploying the entire pipeline. The executor pre-passes that group all DDL plan steps into a single pipeline redeploy. - The
aqueduct.*catalog tables must live in a separate PostgreSQL instance or SQLite file.aqueduct.tomlrequires acatalog_dsnwhenexecutor.kind = "feldera". ExecutorConnectionrequires a second implementation:FelderaConnection { base_url, api_key }— HTTP REST viareqwest, notlibpq.
Capability matrix:
DIFFERENTIAL_REFRESH ✅ (Feldera is differential dataflow natively)
FULL_REFRESH ✅ (reset pipeline + replay inputs)
IMMEDIATE_MODE ❌
PAUSE_RESUME_SCHEDULER ✅ (pipeline-level pause/start via REST)
DIAMOND_CONSISTENCY ✅
BLUE_GREEN_DEPLOY ⚠ (two pipelines, no atomic view-swap)
Materialize Executor
Materialize is a streaming SQL database (differential dataflow, Timely Dataflow) with
a PostgreSQL wire protocol interface — the closest conceptual peer to pg_trickle
among the four candidates.
Key design decisions:
- Stream tables map to
CREATE MATERIALIZED VIEW IN CLUSTER compute_cluster AS query. A new front-matter directive@aqueduct:clusterand[executor.materialize] default_clusterconfig key are required. live_statequeriesmz_catalog.mz_materialized_views.pause_scheduleris a no-op + warning (Materialize has no per-view pause).- The
aqueduct.*catalog tables are safest on a companion PostgreSQL instance, due to Materialize's transaction semantics differences for plain tables.
Capability matrix:
DIFFERENTIAL_REFRESH ✅ (always-on differential dataflow)
IMMEDIATE_MODE ❌
PAUSE_RESUME_SCHEDULER ❌ (no per-view pause)
DIAMOND_CONSISTENCY ✅
BLUE_GREEN_DEPLOY ✅ (schemas are first-class)
WAL_CDC ✅ (PostgreSQL source via logical replication)
Snowflake Dynamic Tables Executor
Snowflake Dynamic Tables use a proprietary SQL dialect and proprietary connection protocol — the most structurally divergent of the four candidates.
Key design decisions:
schedulemaps directly toTARGET_LAG.CALCULATEDhas no Snowflake equivalent; the executor uses the planner's resolved schedule value (linter warning emitted).WAREHOUSEassignment is required:@aqueduct:warehousefront-matter directive +[executor.snowflake] default_warehouseconfig key.- Authentication uses OAuth / key-pair (not password).
aqueduct.tomlreferences${SNOWFLAKE_ACCOUNT},${SNOWFLAKE_USER},${SNOWFLAKE_PRIVATE_KEY_PATH}. - In-place column-add is not supported on Snowflake Dynamic Tables. All column
changes require Rebuild class. The executor's
ExecutorCapabilitiesreflects this, and the plan renderer emits a prominent note. - The
aqueduct.*catalog tables are created as regular Snowflake tables.aqueduct.locksadvisory locks are approximated viaMERGE ON CONFLICT+ a Snowflake scheduled TASK heartbeat. live_statequeriesINFORMATION_SCHEMA.DYNAMIC_TABLES.
Capability matrix:
DIFFERENTIAL_REFRESH ✅ (INCREMENTAL mode; Snowflake decides per-run)
FULL_REFRESH ✅
IMMEDIATE_MODE ❌
TRIGGER_CDC ❌
WAL_CDC ❌
PAUSE_RESUME_SCHEDULER ✅ (SUSPEND / RESUME DDL)
DIAMOND_CONSISTENCY ⚠ (pipeline-level, no per-group atomicity)
BLUE_GREEN_DEPLOY ✅ (SWAP WITH DDL or schema-swap)
Implementation order for the four executors: RisingWave → Materialize → Feldera →
Snowflake (in increasing order of structural divergence from the built-in pg_trickle
executor).
Non-Goals (All Versions)
The following are explicitly out of scope for all current and planned versions:
- Pure base-table schema management without stream tables. Use Atlas, sqitch, or
Liquibase.
pg_aqueductadds no value here. pg_tide-only schemas (no stream tables).pg_tideoutbox/inbox/relay tables are normal application tables; they do not form a dependency DAG. The gateway scenario (planning to add stream tables soon) is the only marginal exception — and even then, the honest recommendation is "use Atlas until you have your first stream table."moire(Next.js SPARQL frontend). Creates no PostgreSQL objects. Completely out of scope.pg_rippleinternal tables (_pg_ripple.kge_embeddings,_pg_ripple.derivations, ER monitoring tables). Managed bypg_rippleinternally. Only user-authoredpg_tricklestream tables in apg_rippledeployment are in scope.pg_eddyinternal storage tables (node store, edge store, property store — custom adjacency AM). Managed bypg_eddy. Only user-authored stream tables that read frompg_eddynode/edge tables are in scope.riverbankcatalog (_riverbank.*, Alembic-managed). Out of scope. Thepg_trickleIVM stream tables thatriverbankcreates are in scope.- A general-purpose schema migration tool.
pg_aqueductmanages stream-adjacent base-table changes (Tier 1), but delegates standalone DDL to Atlas or sqitch. - A query authoring environment. Use dbt, Hex, or psql.
- A monitoring or alerting product. Use
pg_trickle's monitoring views + Grafana. - Multi-database (cross-cluster) transactional coordination.
aqueductoperates against one PostgreSQL target at a time. Multiple targets sequentially are fine; multi-target transactional coordination is not in scope. - A replacement for dbt-pgtrickle. They compose; neither replaces the other.
Repository Layout (Target v0.1)
trickle-labs/pg-aqueduct/
├── README.md
├── ESSENCE.md
├── ROADMAP.md
├── Cargo.toml # workspace
├── crates/
│ ├── aqueduct-core/ # planner, differ, plan executor, catalog
│ ├── aqueduct-cli/ # aqueduct binary (clap)
│ ├── aqueduct-extension/ # pgrx extension (Phase 4 / v0.3)
│ └── aqueduct-testkit/ # shared Testcontainers helpers
├── examples/
│ ├── minimal/ # 3-node DAG
│ ├── tpch/ # 22 stream tables from TPC-H Q1–Q22
│ ├── medallion/ # bronze/silver/gold pattern
│ └── dbt-roundtrip/ # dbt-pgtrickle ingest example (v0.5)
├── docs/
│ ├── cookbook/ # 30 worked migration examples (v1.0)
│ └── security.md
├── benchmarks/ # 200-node DAG benchmark (v1.0)
└── tests/
├── e2e_*.rs # against pg_trickle + pg_aqueduct
└── property/ # roundtrip plan→apply→plan = empty
Ecosystem Relationships
| Tool | Relationship to pg_aqueduct |
|---|---|
pg_trickle | Primary runtime target. aqueduct calls its SQL API (create_stream_table, alter_stream_table, drop_stream_table, refresh_stream_table, pause_scheduler, resume_scheduler). |
pg_tide | Sibling relay tool. aqueduct manages pg_tide outbox attachments (DetachOutbox / ReattachOutbox plan steps) when stream tables are also present. Without stream tables, Atlas is the better migration tool for pg_tide schemas. |
pg_ripple | Knowledge graph companion. pg_ripple's VP tables appear as owned = false sources. Only user-authored incremental SPARQL views and custom analytics nodes are in scope; pg_ripple-managed tables are excluded. |
pg_eddy | Labelled property graph store. pg_eddy's node/edge/property AM tables appear as owned = false sources. Only user-authored MATCH-view stream tables over those tables are in scope. |
moire | Out of scope. Pure Next.js frontend over SPARQL endpoints; creates no PostgreSQL objects. |
riverbank | Knowledge compiler. riverbank's pg_trickle IVM stream tables (quality scores, entity pages, topic indices) are in scope. riverbank's own catalog (_riverbank.*, Alembic-managed) is excluded. |
| dbt / dbt-pgtrickle | Upstream authoring. aqueduct ingest --from dbt-target (v0.5) reads dbt's compiled artefacts and produces a migrations directory. |
| Atlas / Liquibase / sqitch | Complementary. They own general-purpose base-table schema migrations. pg_aqueduct owns stream-adjacent base-table changes (Tier 1) and coordinates the full DAG cascade. For standalone base-table migrations, aqueduct apply can invoke Atlas/sqitch as a pre-step hook. |
| Terraform / Pulumi | Outer layer. Provisions the database; embeds a terraform_data resource that calls aqueduct apply post-provision. |
| CloudNativePG / Patroni | HA awareness. aqueduct locks against the primary, refuses to apply against a standby, and integrates with primary-promotion events. |
| GitHub / GitLab Actions | First-class CI integration. aqueduct/plan-action, aqueduct/apply-action (v0.4). |
This roadmap is a living document. It will be updated as upstream dependencies ship,
as user feedback surfaces new priorities, and as implementation reveals complexity not
anticipated at planning time. The authoritative design detail for each feature lives in
plans/pg-aqueduct-plan.md.