Skip to content

Replacing Atlas CE in Scooter to Generate Function and Trigger Migrations

We tested Ptah Compat in Scooter's existing migration workflow, adding PostgreSQL functions and triggers to desired SQL while preserving Atlas history.

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

Scooter is an agent platform with a live conversation list. PostgreSQL notifies its conversation router when a conversation is inserted, deleted, or changed in a way the list should show. A function builds the notification payload; triggers decide when to call it.

Those objects were outside Scooter’s desired schema. A migration comment explains the reason: “Atlas Community does not diff FUNCTION/TRIGGER objects.” The migration became the source of truth for that behavior, while schema.sql continued to describe the tables and indexes.

We tested a replacement that keeps Scooter’s authoring and deployment workflow. Ptah Compat is installed as atlas, and the existing function and trigger declarations join schema.sql. Changes to those objects can then become generated migrations alongside table changes.

Our upstream PR is open. This is a tested integration proposal, not an announcement that Scooter has adopted Ptah. The examples below ran with Ptah Compat 0.12.0 and PostgreSQL 16.15; the supporting files contain the frozen schema, original migration history, generated SQL, and recorded output.

The database decides which changes need an event

Section titled “The database decides which changes need an event”

The function conversations_notify() sends a small JSON payload with a conversation ID and an operation: upsert or delete. The router reads the row again to assemble the event, so the notification does not need to carry the whole conversation.

The triggers split the work:

  • conversations_notify_ins_del runs after an INSERT or DELETE.
  • conversations_notify_upd runs after an UPDATE only when title, starred, user_titled, or owner changes.

That filter matters. Scooter updates last_activity_at frequently. Those writes should not flood the live conversation list with notifications.

The migration already creates the correct function and triggers. The proposal copies those definitions into the desired schema unchanged. It does not rewrite the historical migration or change which production events fire.

Replace the executable in the existing Nix environment

Section titled “Replace the executable in the existing Nix environment”

Scooter uses Nix for both its development shell and migration image. The PR adds a package for the published Ptah release, pins each supported platform’s archive hash, and installs the compatibility binary under its existing name. This is the relevant part of the Nix install phase:

examples/nix-install.excerpt.nix
install -Dm755 ptah-compat "$out/bin/ptah-compat"
ln -s ptah-compat "$out/bin/atlas"
install -Dm644 LICENSE "$out/share/licenses/ptah/LICENSE"

The same package goes into the development shell and migration image. The migration image’s entry point still invokes atlas migrate apply; the executable behind that name is Ptah Compat.

Part of the workflow Integration change
atlas commands and generated atlas.hcl Kept in place.
Historical SQL and atlas.sum Kept unchanged.
schema.sql Gains the existing function and trigger definitions.
Nix shell and migration image Use the pinned Ptah Compat package.
Application code and generated ORM bindings Kept unchanged.

Scooter’s existing atlas-dev.sh starts a private PostgreSQL server for each invocation and removes its data directory on exit. The PR declares that fact with PTAH_DEV_SERVER_DISPOSABLE=1, allowing procedural migration replay on that owned server. A database inside a shared server is not the same isolation boundary. The dev-server reference explains the distinction. Deployment applies do not need this declaration.

Adding the declarations does not create a new migration

Section titled “Adding the declarations does not create a new migration”

For the recorded example, we invoke ptah-compat explicitly so the tool is visible. The fork installs the same binary as atlas. Commands run from a copy of Scooter’s SQL directory, with ATLAS_DEV_URL pointing to a separate database on a disposable test server and the disposable-server setting enabled for replay.

With the original migration history and the complete desired schema:

examples/commands/baseline.txt
ptah-compat migrate diff baseline_check --env agent_host
examples/measured/baseline.txt
The migration directory is synced with the desired state, no changes to be made

The function and triggers are already represented in the historical migration. Adding their declarations to schema.sql therefore requires no deployment change. Ptah compares the same objects on both sides and produces no file.

We also applied the original history to a fresh target database for the next step. DATABASE_URL names that target; it is separate from the dev database.

Generate a function and trigger change together

Section titled “Generate a function and trigger change together”

To test the next edit, we made a separate probe.sql from the complete desired schema. It changes the notification channel to conversations_changed_probe and adds phase to the UPDATE trigger’s condition. These are test-only edits; the integration PR preserves the current application behavior.

The unchanged atlas.hcl selects the migration directory and retains Scooter’s SQL output format. Only the desired source is overridden for the probe:

examples/commands/generate.txt
ptah-compat migrate diff notify_probe --env agent_host --to file://probe.sql

Ptah generated this migration without manual SQL additions:

examples/measured/generated.sql
-- Modify function conversations_notify: body
CREATE OR REPLACE FUNCTION "conversations_notify"() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('conversations_changed_probe',
json_build_object('id', COALESCE(NEW.id, OLD.id),
'op', CASE WHEN TG_OP = 'DELETE' THEN 'delete' ELSE 'upsert' END)::text);
RETURN NULL; -- AFTER trigger: the return value is ignored.
END;
$$
LANGUAGE plpgsql SECURITY INVOKER VOLATILE;
CREATE OR REPLACE TRIGGER "conversations_notify_upd" AFTER UPDATE ON "conversations" FOR EACH ROW WHEN (OLD."title" IS DISTINCT FROM NEW."title" OR
OLD."starred" IS DISTINCT FROM NEW."starred" OR
OLD."user_titled" IS DISTINCT FROM NEW."user_titled" OR
OLD."owner" IS DISTINCT FROM NEW."owner" OR
OLD."phase" IS DISTINCT FROM NEW."phase") EXECUTE FUNCTION "conversations_notify"();

The migration changes both the function body and the trigger condition. The INSERT/DELETE trigger remains unchanged because it already calls the same function. Ptah 0.12.0 also preserves the quoted function body while formatting the surrounding SQL, so indentation does not introduce another body change.

Apply the generated file through the same migration directory:

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

Then compare the replayed history with the probe schema again:

examples/commands/settled.txt
ptah-compat migrate diff no_change --env agent_host --to file://probe.sql
examples/measured/settled.txt
The migration directory is synced with the desired state, no changes to be made

The second diff produces no migration. Generating SQL is only useful if the result reaches the declared state and stays there on the next run.

After applying the generated migration, a PostgreSQL session listened on the probe channel. We inserted a conversation, changed its phase, updated only its activity timestamp, and deleted it:

Operation Notifications received
INSERT One upsert.
UPDATE phase One upsert.
UPDATE only last_activity_at None.
DELETE One delete.

The generated condition makes the phase change visible while retaining the activity-update filter. The function still sends the conversation ID and the expected operation. The test SQL and full LISTEN output are in the example files.

The integration checks covered all five Scooter databases: agent_host, broker, byoc, scheduler, and webhooks.

For each one, Atlas applied the original migrations first. Ptah then read that history and continued without changing its revision rows. Applying the same history to a fresh database with Ptah produced the same schema.

We also checked the migration image’s adoption path for the databases with a baseline migration: an existing unmanaged database first reports not clean, then the existing deployment script retries with --baseline. The resulting schema matched a fresh installation. The runner did not need a new deployment sequence.

Scooter’s type checks, ORM regeneration check, unit tests, and Nix migration image build passed. The broader cluster and browser end-to-end suites remain outside this validation; the full test entry point stopped at cluster setup because k3s was unavailable in the isolated runner.

The proposed change is available in PR #693, with context in Issue #692. For the CLI, see the migration reference, or try schema commands in the browser playground.

Put Ptah to work

Example files