Skip to content

Replacing Atlas with Ptah: Preserve History and Reject Invalid Indexes

We tested Akashi's TimescaleDB migrations with Ptah Compat: the Atlas history stays readable, while an invalid concurrent index blocks migration success.

Ptah helps you plan, review, and apply database migrations. Try in your browser

Akashi records decisions made by AI agents. Its PostgreSQL database uses TimescaleDB and pgvector, and its migration history already has an Atlas runner, a checksum file, and deployed version identifiers. Trying another CLI should not require rewriting that history.

We tested Ptah Compat against the 91 migration files in our proposed integration. Ptah applied the history, Atlas read it, and each tool could continue from the other’s last migration. We also reproduced an index failure suggested by Akashi’s migration comments: a failed concurrent index can keep its name while remaining unusable. In that state, Atlas reported a successful migration; Ptah refused to record success and named the repair needed.

These measurements use Ptah Compat 0.11.4, Atlas 1.1.0 as pinned by Akashi’s CI, and PostgreSQL 18.6 with TimescaleDB 2.30.2 and pgvector 0.8.1. The supporting files contain the frozen migration history and recorded results.

The upstream proposal and draft PR remain open. The PR adds an optional CI comparison and a runbook section; it does not announce that Akashi has adopted Ptah. It still pins Ptah 0.8.1, while this article repeats the checks with the newer released binary.

Akashi’s Makefile already accepts an ATLAS override. Its existing atlas.hcl resolves DATABASE_URL and selects file://migrations; the original SQL files and atlas.sum remain unchanged.

We used a fresh disposable database from the same TimescaleDB image line as Akashi’s CI and bootstrapped its extensions the same way:

examples/commands/bootstrap.txt
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS vector;"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS timescaledb;"

We validated the original checksum file with make migrate-validate ATLAS=ptah-compat. With ptah-compat on PATH, the existing apply target becomes:

examples/commands/apply.txt
make migrate-apply ATLAS=ptah-compat

That target runs ptah-compat migrate apply --env local. It applied all 91 files. Their last version is 111: the version token is not the number of files, and early versions retain their leading zeros, such as 001 and 022.

To check interoperability, we asked Atlas to read the history Ptah had written:

examples/commands/status.txt
atlas migrate status --dir file://migrations --url "$DATABASE_URL"
examples/measured/history-status.txt
Migration Status: OK
-- Current Version: 111
-- Next Version: Already at latest version
-- Executed Files: 91
-- Pending Files: 0

Both tools use the same Atlas revision table. The result shows that Atlas recognizes all the applied files, rather than treating the database as a new migration target.

We repeated the test in reverse: Atlas applied the original history to another fresh database, then Ptah inspected it:

examples/commands/ptah-status.txt
ptah-compat migrate status --dir file://migrations --url "$DATABASE_URL"

Ptah reported the same version and zero pending files. On copies of the migration directory, we then added a small version 112 that creates a probe table. Atlas applied it after Ptah’s history; Ptah applied it after Atlas’s. The other tool recognized the new version in each case. The original directory was not edited for this continuation test.

Akashi’s make verify-exit-criteria also passed after Ptah’s apply. In this fresh-database run it checked orphan integrity and the configured dead-letter and outbox-age thresholds. The optional strict-retention and Qdrant checks were disabled, matching the proposed CI job.

Concurrent indexes need more than a transaction switch

Section titled “Concurrent indexes need more than a transaction switch”

Migration 022_full_text_search.sql explains why it uses ordinary index creation: the application’s migration transaction prevents CREATE INDEX CONCURRENTLY. It suggests creating the index manually for large production tables. Migration 037_conflict_scored_at.sql gives the same transaction reason for its partial index.

PostgreSQL requires concurrent index creation to run outside a transaction block. Both Atlas and Ptah recognize -- atlas:txmode none. That makes the statement executable, but it does not make a failed build safe to ignore.

For new work, a migration can declare the transaction mode explicitly. This small fixture isolates the failure behavior; it is not an edit to Akashi’s shipped history:

examples/index-migrations/001_unique_email.sql
-- atlas:txmode none
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS members_email_key
ON members (email);

The IF NOT EXISTS clause only tests whether the name is already present. It does not establish that an existing index is valid.

A successful statement can leave the constraint unenforced

Section titled “A successful statement can leave the constraint unenforced”

We created a disposable members table with duplicate email addresses, then attempted a unique concurrent index. PostgreSQL rejected the duplicate keys and left members_email_key in the catalog with both indisvalid and indisready false.

After removing the duplicate row, we ran the fixture migration against the remaining invalid index. This models retrying index creation after correcting the data. The database is deliberately nonempty, so the test explicitly allows that starting state:

examples/commands/index-apply.txt
ptah-compat migrate apply --dir file://index-migrations --url "$DATABASE_URL" --allow-dirty

On identical starting states, the results differed:

Check after the migration attempt Atlas 1.1.0 Ptah Compat 0.11.4
Command result Success Refused
Index valid and ready No No
Successful migration revision recorded Yes No
Diagnostic names the invalid index and repair No Yes

Atlas saw IF NOT EXISTS, skipped the existing name, and recorded the file as applied. A subsequent insert with the duplicate email succeeded. The migration record said the change was complete, but the database did not enforce its intended uniqueness.

Ptah inspected the existing index and refused to record the migration as successful. Its error names public.members_email_key and explains that the statement would skip the unusable index. It directs the operator to rebuild or remove that index before retrying.

With the duplicate data already corrected, we rebuilt it:

examples/sql/index-repair.sql
REINDEX INDEX CONCURRENTLY members_email_key;

The same Ptah apply command then succeeded. The catalog reported a valid, ready index, and a duplicate insert was rejected. Ptah does not repair the index automatically: the operator controls that database change.

Concurrent creation avoids the ordinary index build’s write-blocking lock, but it can still wait for other sessions. Ptah can apply a PostgreSQL lock wait limit from the migration header:

examples/lock-migrations/001_lock_probe.sql
-- atlas:txmode none
-- +ptah lock_timeout=2s
CREATE INDEX CONCURRENTLY IF NOT EXISTS lock_probe_id_idx ON lock_probe (id);

We held an ACCESS EXCLUSIVE lock on the test table in another session and ran the migration. The command failed at the configured lock timeout:

examples/measured/lock-refusal.txt
Error: error applying migrations: failed to apply migration 001: failed to execute migration SQL: ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)
SQL: CREATE INDEX CONCURRENTLY IF NOT EXISTS lock_probe_id_idx ON lock_probe (id)

This directive configures PostgreSQL’s statement lock wait. It is different from the CLI’s --lock-timeout, which controls acquisition of the migration runner’s advisory lock. It also does not cap the total duration of an index build once the needed locks are available.

Atlas treats the Ptah directive as a comment. The file remains readable by both tools, but the PostgreSQL lock-wait setting in this header is a Ptah behavior. Teams switching executables should account for that difference.

The Akashi PR copies the existing migration-verification job, substitutes Ptah for the apply step, then asks Atlas to check the resulting history. The runbook explains the executable override. That gives the maintainers a way to evaluate the handover before choosing whether to change their normal path.

Akashi also has an embedded migration runner. Selecting ATLAS=ptah-compat changes the Makefile command, not the application’s startup runner. The concurrent-index examples here exercise the CLI path; a deployment needs to choose which runner owns migration execution.

To explore Ptah, see the documentation or use the browser playground without installing a binary.

Put Ptah to work

Example files