Ptah in practice
24 runs of Ptah, recorded at the terminal. Nothing here was written for the page. Open one to read it; Play types it out.
-
# Write the database's own schema into a file.$ ptah db read --db-url sqlite://app.db | tee schema.sqlCREATE TABLE "users" ( "id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL); # Drift compares that file with the database it came from.$ ptah schema drift --schema-file schema.sql \ --db-url sqlite://app.dbNo schema drift detected. # Now ask for a column, by rewriting the schema you want.$ cat > schema.sql <<'SQL'CREATE TABLE "users" ( "id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL, "created_at" TEXT);SQL # The same check now has something to report.$ ptah schema drift --schema-file schema.sql \ --db-url sqlite://app.dbSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: sqlite://app.db Findings:- columns_added: 1 (warning) # Ask what it would take to close it. A dry run executes nothing.$ ptah schema apply --schema-file schema.sql \ --db-url sqlite://app.db --dry-runPlanned schema changes:ALTER TABLE "users" ADD COLUMN "created_at" TEXT; # Run the reviewed plan. --auto-approve suits a disposable file.$ ptah schema apply --schema-file schema.sql \ --db-url sqlite://app.db --auto-approvePlanned schema changes:ALTER TABLE "users" ADD COLUMN "created_at" TEXT;Auto-approval enabled; applying schema changes.Schema apply completed successfully. # And the same check, a third time.$ ptah schema drift --schema-file schema.sql \ --db-url sqlite://app.dbNo schema drift detected.
-
# Turn the declaration into a versioned migration.$ ptah migrations generate --root-dir ./entities \ --db-url sqlite://app.db --migrations-dir ./migrations \ --name create_users_and_postsGenerated migration files for sqlite://app.db:UP: /tmp/app/migrations/1788809709_create_users_and_posts.up.sqlDOWN: /tmp/app/migrations/1788809709_create_users_and_posts.down.sql # A down file too, written at the same time as the up.$ ptah migrations up --db-url sqlite://app.db \ --migrations-dir ./migrations=== MIGRATE UP ===Current version: 0Total migrations: 1Pending migrations: 1 ✅ Migrations completed successfully!Database is now at version: 1788809709 # And the revision table agrees.$ ptah migrations status --db-url sqlite://app.db \ --migrations-dir ./migrations=== MIGRATION STATUS ===Database: ***Current Version: 1788809709Applied Migrations: 1Pending Migrations: 0
-
# Every generated migration came with a down file.$ ls migrations1788810699_create_users.down.sql1788810699_create_users.up.sql # Ask what rolling back would do. Nothing runs.$ ptah migrations down --db-url sqlite://app.db \ --migrations-dir ./migrations --dry-run=== DRY RUN MODE ===No actual changes will be made to the database === MIGRATE DOWN ===Target version: 0Current version: 1788810699Migrations to roll back: 1 ✅ Dry run completed successfully!Would have rolled back to version: 0Would have rolled back these migrations: [1788810699] # The plan first, the rollback second. Same rule as forward.
-
# What is the SQL between these two files?$ ptah schema diff --from file://schema.sql \ --to file://schema-next.sql \ --dev-url "sqlite://dev.db?mode=memory"ALTER TABLE "users" ADD COLUMN "created_at" TEXT;CREATE INDEX IF NOT EXISTS "idx_users_email" ON "users" ("email"); # Two arbitrary states, neither of them a live database.
-
# A new embedding model. Plan first: what would this take?$ ptah inference plansource.estimated_rows = 3 (measured)target.capability.vector_type = true (measured)[backfill] embed 3 in-scope source rowsConsistency mode: outbox # Build the new generation beside the one being served.$ ptah inference prepareprepared run quick-start for generation b7cc9eb5c7d2 - snapshot boundary: 753$ ptah inference backfill --batch-rows 10backfill finished: 3 scanned, 3 embedded, 0 skipped # Rows that changed while it ran are caught up, then indexed.$ ptah inference catchup --batch-rows 10caught up to transaction 761: 0 changed rows, 0 tombstoned$ ptah inference indexgeneration b7cc9eb5c7d2 has a valid index # Check it before anything reads it.$ ptah inference verify3 source rows, 3 target rowsevery deterministic layer passed # Queries still read the old vectors. Cutover is its own step.$ ptah inference cutoverplan 5bbecb32c113cutover refused: - this policy requires an approval and none was givenerror: cutover refused # Approve that plan by its digest, and only that one.$ export PTAH_APPROVE=5bbecb32c113$ ptah inference cutover --approver 'quick-start check'queries now read generation b7cc9eb5c7d2ee13ad46ba3d5da8a479ec57b128d771e4151fab5bf6d1da0866 (plan 5bbecb32c113) - this is the first generation over this target, so there is no previous one to keep current and nothing to roll back to # The pointer the queries follow now names it.$ psql -c 'SELECT target_table, active_generation FROM ptah_embedding_pointer;' target_table | active_generation--------------+------------------------------------------------------------------ docs | b7cc9eb5c7d2ee13ad46ba3d5da8a479ec57b128d771e4151fab5bf6d1da0866(1 row)
-
# A pull request adds this migration.$ cat migrations/1700000100_drop_email.up.sqlALTER TABLE "users" DROP COLUMN "email"; # Check it before it reaches a database.$ ptah migrations lint --dir ./migrations \ --dialect postgresmigrations/1700000100_drop_email.up.sql:1 [warning] BC104: dropping a column retires a name application versions already deployed against the old schema still select and insert, so each of them starts failing the moment this migration commits, whether or not the column held any rows; deploy readers that no longer use the column first, then drop it in a later release (dropped column breaks deployed code)migrations/1700000100_drop_email.up.sql:1 [error] DS102: DROP COLUMN permanently deletes the column's data; deploy readers that no longer use the column first, then drop it in a later release (column dropped) 2 finding(s).warning: DS110P ran without the baseline schema it reads, so this analysis is thinner than the same directory would get against a dev database the run can read # The exit code is what fails the build.$ echo $?1
-
# Save the plan, so what is reviewed is what runs.$ ptah schema plan --schema-file schema.sql \ --db-url sqlite://app.db --name add-active-users \ --output plan.jsonPlanned schema changes:CREATE TABLE "users" ( "id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL, "legacy_ref" TEXT);Plan saved to file://plan.json # Sign it. Ptah never reads the key; ssh-keygen does.$ ptah schema approve --plan plan.json --key ./release_keyApproved plan.jsonSignature: plan.json.sig$ ptah schema verify-approval --plan plan.jsonApproved by release@example.comPlan digest: cb74d5837d3efb8c1a8c93bd930fd0321d12abc5e13fd731ff129c43bf0bfc41 # Now change one identifier in the approved plan.$ sed -i 's/legacy_ref/legacy_id/g' plan.json$ ptah schema verify-approval --plan plan.jsonerror: approval does not verify against .ptah/allowed_signers: either the plan changed after it was approved, or it was signed by a key that file does not list # Exit 2. An edited plan is a plan nobody approved.
-
# Seed files carry the environment in the name.$ ls seeds001_demo_accounts.dev.sql002_reference_data.prod.sql$ ptah seed --db-url sqlite://app.db --seeds-dir ./seeds --env dev=== SEED ===Environment: dev Matching seeds: 1Applied seeds: 1Seeds completed successfully. # Run it again. Applied seeds are recorded, so this is a no-op.$ ptah seed --db-url sqlite://app.db --seeds-dir ./seeds --env devMatching seeds: 1Applied seeds: 0Skipped seeds: 1Database seed data is already up to date. # And prod is not an environment you reach by typing it.$ ptah seed --db-url sqlite://app.db --seeds-dir ./seeds --env proderror: refusing to seed protected environment "prod" without --allow-prod
-
# The schema is a Go struct. The annotations are the declaration.$ cat entities/user.gopackage entities //ptah:schema:table name="users"type User struct { //ptah:schema:field name="id" type="SERIAL" primary="true" ID int64 //ptah:schema:field name="email" type="VARCHAR(255)" not_null="true" Email string //ptah:schema:field name="plan" type="ENUM" enum="free,team,enterprise" Plan string} # Render it for PostgreSQL.$ ptah schema render --root-dir . --dialect postgres-- Statement 1/2CREATE TYPE "enum_user_plan" AS ENUM ('free', 'team', 'enterprise'); -- Statement 2/2-- POSTGRES TABLE: users --CREATE TABLE "users" ( "id" SERIAL PRIMARY KEY NOT NULL, "email" VARCHAR(255) NOT NULL, "plan" enum_user_plan); # The same file, one flag apart.$ ptah schema render --root-dir . --dialect sqlite-- Statement 1/1CREATE TABLE "users" ( "id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL, "plan" TEXT CHECK (plan IN ('free', 'team', 'enterprise'))); # No enum type in SQLite, so it is a CHECK. Nothing was dropped.
-
# A database nobody ever wrote a schema file for.$ ptah introspect --db-url sqlite://app.db --out ./modelsGenerated 2 Go file(s) in /tmp/app/modelsImported 2 table(s), 5 field(s), 0 enum(s) # What it wrote is the annotated Go you would have written.$ cat models/posts.go// Code generated by ptah introspect; DO NOT EDIT. package models //ptah:schema:table name="posts"type Posts struct { //ptah:schema:field name="id" type="INTEGER" primary="true" Id *int //ptah:schema:field name="user_id" type="INT" not_null="true" foreign="users(id)" foreign_key_name="fk_posts_user_id" on_delete="NO ACTION" on_update="NO ACTION" UserId int //ptah:schema:field name="title" type="TEXT" not_null="true" Title string} # The constraint came back with its name and its actions.
-
# Ask what Ptah resolved for the server it is talking to.$ ptah db capabilities --db-url sqlite://app.dbDialect: sqliteServer version: 3.53.4Capability preset: SQLite3 (sqlite)Preset source: version-ladderSupport level: certified Behavior: identifier_limit unlimited enum_modeling unsupported foreign_key_reference unique Capabilities: advisory_locks unsupported catalog_partitions supported # enum_modeling is why a declared enum arrives here as a CHECK.
-
# Read the database back in a shape another tool reads.$ ptah schema inspect --db-url sqlite://app.db --format dbmlTable "users" { "id" INTEGER [pk] "email" TEXT [not null]} # Or as JSON, for something that is not a person.$ ptah schema inspect --db-url sqlite://app.db --format json{"schemas":[{"name":"main","tables":[{"name":"users","columns":[{"name":"id","type":"INTEGER","null":true},{"name":"email","type":"TEXT"}],"primary_key":{"parts":[{"column":"id"}]}}]}]} # hcl and sql are the other two, and --out-dir writes files.
-
# Which view columns depend on which base columns?$ ptah schema lineage --schema-file schema.sql \ --dialect sqliteSOURCE FEEDS KINDusers.email active_users.email viewusers.id active_users.id view # The analysis is static: without --db-url it contacts nothing.
-
# Schema shape, in a format a metrics pipeline already reads.$ ptah schema stats --db-url sqlite://app.db# HELP ptah_schema_tables Tables.# TYPE ptah_schema_tables gaugeptah_schema_tables{dialect="sqlite"} 2# HELP ptah_schema_columns Columns across all tables.# TYPE ptah_schema_columns gaugeptah_schema_columns{dialect="sqlite"} 5# HELP ptah_schema_indexes Indexes.# TYPE ptah_schema_indexes gaugeptah_schema_indexes{dialect="sqlite"} 0 # Counts of objects, never of rows. Shape, not contents.
-
# The tables already describe the API that reads them.$ ptah schema export --to graphql --root-dir ./entities \ --out schema.graphqlExported GraphQL schema to /tmp/app/schema.graphqlFound 2 table(s), 5 field(s), 0 enum(s)$ cat schema.graphql# Code generated by ptah; DO NOT EDIT. type User { id: ID! email: String!} type Post { id: ID! user_id: Int! title: String! user: User!} # The last field is the foreign key, read as a relation.
-
# The tables also describe the payloads that carry them.$ ptah schema export --to openapi-v3 --from sql \ --schema-file schema.sql --out openapi.yamlExported OpenAPI schema to /tmp/app/openapi.yamlFound 2 table(s), 5 field(s), 0 enum(s)$ head -20 openapi.yamlopenapi: 3.0.3info: title: Ptah Exported Schema version: 1.0.0paths: {}components: schemas: users: type: object required: - id - email properties: id: type: integer format: int32 # NOT NULL became required. The two cannot disagree.
-
# The same schema as a wire contract.$ ptah schema export --to protobuf --from sql \ --schema-file schema.sql --out schema.proto \ --proto-package acme.app.v1Exported Protobuf schema to /tmp/app/schema.protoExported 2 message(s), 5 field(s), 0 enum(s)bootstrapped new compatibility history1 export warning(s) reported # The warning is the point: field numbers are a promise.$ head -14 schema.protoedition = "2023"; package acme.app.v1; message Post { int32 id = 1; int32 user_id = 2; string title = 3;} message User { int32 id = 1; string email = 2;} # A later export reuses these numbers, or refuses to write.
-
# A diagramming tool wants DBML instead. Change one flag.$ ptah schema export --to dbml --root-dir ./entities \ --out schema.dbmlExported DBML schema to /tmp/app/schema.dbmlFound 2 table(s), 5 field(s), 0 enum(s)$ cat schema.dbmlTable "posts" { "id" SERIAL [pk] "user_id" INT [not null] "title" VARCHAR(200) [not null]} Table "users" { "id" SERIAL [pk] "email" VARCHAR(255) [not null]} Ref "posts"."user_id" > "users"."id" # Nothing was described twice to get this.
-
# The reference page nobody keeps up to date by hand.$ ptah schema export --to markdown --from sql \ --schema-file schema.sql --out SCHEMA.mdExported schema documentation to /tmp/app/SCHEMA.mdFound 2 table(s), 5 field(s), 0 enum(s)$ head -13 SCHEMA.md# Schema reference - [posts](#posts)- [users](#users) ## posts | Column | Type | Null | Default | Key | Comment || --- | --- | --- | --- | --- | --- || id | INTEGER | yes | — | PK | — || user_id | INTEGER | no | — | FK → users(id) | — || title | TEXT | no | — | — | — | # HTML is the same command with a different --to.
-
# The declaration is also a diagram.$ ptah viz --root-dir ./entities --format mermaid \ --include-columnserDiagram users { SERIAL id PK VARCHAR_255 email } posts { SERIAL id PK INT user_id FK VARCHAR_200 title } users ||--o{ posts : "fk_posts_user_id" # The relationship is read off the foreign key, not a second file.
-
# Publish the schema itself, as an artifact with a digest.$ ptah schema push oci://registry.example/acme/schema:v1 \ --schema-file schema.sql --dialect sqlite --latestPushed schema as oci://registry.example/acme/schema:v1Digest: sha256:66fed834333daafa24448c7c6b5fe548c4f429d036977194f5b417c505fb933fVersion: Tags: [v1 latest] # Two tags now point at it, and only the two that were asked for.$ ptah oci tags oci://registry.example/acme/schemalatestv1 # A moving tag resolves to the digest that cannot move.$ ptah oci resolve oci://registry.example/acme/schema:latestoci://registry.example/acme/schema@sha256:66fed834333daafa24448c7c6b5fe548c4f429d036977194f5b417c505fb933f
-
# What does that artifact declare? Ask without downloading it.$ ptah oci inspect oci://registry.example/acme/schema:v1Reference: oci://registry.example/acme/schema:v1Digest: sha256:66fed834333daafa24448c7c6b5fe548c4f429d036977194f5b417c505fb933fMedia type: application/vnd.oci.image.manifest.v1+jsonSize: 843Artifact type: application/vnd.stokaro.ptah.schema.v1Discovery: none Annotations: io.stokaro.ptah.kind application/vnd.stokaro.ptah.schema.v1 io.stokaro.ptah.schema-format hcl org.opencontainers.image.revision 112a72244a81db149899df0ce43afc704a152bc4 Files: NAME MEDIA TYPE SIZE DIGEST schema.hcl application/vnd.stokaro.ptah.schema.hcl.v1 483 sha256:11719c3690781f43aadc70a236e5494dd5435e9418b6197e4048fc3a5fd23366 # 843 bytes of manifest answered that. The payload stayed put.
-
# A registry reference is a schema source like a file is.$ ptah schema render \ --schema-file oci://registry.example/acme/schema:v1 \ --dialect postgres-- Statement 1/3-- POSTGRES TABLE: users --CREATE TABLE "users" ( "id" INTEGER PRIMARY KEY NOT NULL, "email" TEXT NOT NULL); # Or take the artifact down as the canonical HCL it was stored as.$ ptah schema pull oci://registry.example/acme/schema:v1 \ --out pulled.hclPulled oci://registry.example/acme/schema:v1 to /tmp/app/pulled.hclDigest: sha256:66fed834333daafa24448c7c6b5fe548c4f429d036977194f5b417c505fb933f$ head -12 pulled.hcl// Code generated by ptah; DO NOT EDIT. table "posts" { column "id" { type = INTEGER } primary_key { columns = [column.id] } foreign_key "fk_posts_user_id" { columns = [column.user_id] ref_columns = [table.users.column.id] }}
-
# An existing Atlas pipeline, and the same flags.$ ptah-compat migrate diff add_users --dir file://migrations \ --to file://schema.sql \ --dev-url "sqlite://dev.db?mode=memory"Created migration file: /tmp/app/migrations/20260907193826_add_users.sqlUpdated migration checksum: /tmp/app/migrations/atlas.sum # It wrote an Atlas directory: the timestamped file, and atlas.sum.$ cat migrations/atlas.sumh1:9q+8PcnP03kKT2aZzOj4MHlWEilRmbPXaScNvs2FJzI=20260907193826_add_users.sql h1:eXKVFuXvHFnneZKiWkpyJSHeF5q8KlZCCXJqM7JNymE= # And apply reads that same directory.$ ptah-compat migrate apply --dir file://migrations \ --url sqlite://app.dbMigrating to version 20260907193826 from 1 pending migrations.Migration complete. Current version: 20260907193826 # No script changed. The native surface is still there underneath.