Schema migrations
Schema changes have exactly one paved path: declare a migration command per
database in fluffy-chainsaw.yaml, keep your migration files in the repo, and let
fluffy-chainsaw migrate run them — locally and deployed — as a dedicated migrator
identity that the app's runner identity never holds.
databases:
posts:
instance: serving-db
migrations:
run: npx node-pg-migrate -m migrations up
runis any shell command — bring your own migration tool (node-pg-migrate, dbmate, golang-migrate, Prisma, …). fluffy-chainsaw owns who runs it, against what, and when; the tool owns ordering, history, and rollbacks.- The command executes in your app directory with
DATABASE_URLset to the migrator connection for exactly that database. One database per run — an app with several databases declaresmigrationson each, and each command sees only its own URL. - Never run DDL from app code. A
CREATE TABLEat boot is exactly what this replaces — once a database declaresmigrations, its runner grants dropCREATEand boot-time DDL will fail withpermission denied.
What declaring migrations changes
| Identity | Without migrations |
With migrations |
|---|---|---|
| Runner (serves traffic) | CONNECT, CREATE, TEMPORARY on the db |
CONNECT, TEMPORARY + DML on tables (no DDL) |
Migrator (fluffy-chainsaw migrate only) |
— | owns the schema: CREATE on db + schema, runs your tool |
After each run, fluffy-chainsaw migrate also grant-syncs: as the migrator it grants
SELECT/INSERT/UPDATE/DELETE on all tables (and sequence usage) to every runner
that uses the database, and sets default privileges so the next migration's
tables are granted automatically. A runner added later gets its grants on the
next deploy — fluffy-chainsaw deploy always runs migrate after provision.
Running
# local — against the fluffy-chainsaw local postgres (the m_<db> role):
fluffy-chainsaw local # full and --deps-only modes migrate automatically before anything reads the db
fluffy-chainsaw migrate --local # re-run by hand after adding a migration
# deployed — against the Cloud SQL database, no password anywhere:
fluffy-chainsaw deploy # build -> provision -> migrate -> firebase
fluffy-chainsaw migrate # just the migrate stage (reads the stack's `migrations` output)
Deployed, fluffy-chainsaw migrate impersonates the database's migrator service account
(dbmig-<db>) through a cloud-sql-proxy it starts for you with IAM auth — your
own gcloud identity must be allowed to impersonate it, which the stack grants to
its deployerPrincipal. Install the proxy once:
brew install cloud-sql-proxy.
Rules and edges
existing: truedatabases can't declaremigrations([E_MIGRATIONS_EXISTING]). A referenced database's schema belongs to the app that owns it — run migrations there.- Local volumes created before the
migrationsblock lack the migrator role;fluffy-chainsaw migrate --localwill tell you ([E_MIGRATE_CONNECT]) — runfluffy-chainsaw local-pruneand start again. - Adopting a database whose tables were created by the runner (the old
boot-DDL pattern): grant-sync can only grant on tables the migrator owns.
One-time fix, as any role that owns them:
ALTER TABLE <t> OWNER TO "dbmig-<db>@<project>.iam";(locally:OWNER TO "m_<db>"). - Failures are coded like everything else — see errors.md
(
E_MIGRATE_*).
Deploy ordering and boot rules
During deployment (fluffy-chainsaw deploy), the stages run in the following sequence:
build ➔ scan ➔ provision ➔ migrate ➔ firebase (hosting rewrites).
Because Pulumi provisions both the Cloud SQL database and the Cloud Run service in the same pulumi up call (the provision stage), the application container will boot and execute its startup checks before the migrations have run (the migrate stage).
To prevent deployment rollbacks due to missing tables or temporary permission lags:
- No startup database calls: The emitted Cloud Run service does not configure a database startup probe; it relies strictly on Cloud Run's default TCP check on the container's
PORT. Your service passes deployment simply by listening onPORT. - Lazy SDK connections: The fluffy-chainsaw SDK is lazy by design. Calling
db.connect(...)only parses the environment descriptor; it does not open a socket. - The Contract: Never perform database queries on startup (e.g., at the global module level or during server initialization before binding to
PORT). Always open connections lazily on the first incoming HTTP request. - Traffic is safe: Public traffic (routed via Firebase Hosting rewrites) is only enabled in the
firebasestage — which runs after migrations have successfully completed.
Example
The auth example is the reference: examples/auth/app/migrations/ holds the
schema, databases.posts.migrations.run points node-pg-migrate at it, and the
server contains no DDL.