Two problems in the migration commands: no timeouts, and SQL split on literal bytes. `migrations:plan` and `migrations:apply` constructed their connection with neither a connection deadline nor an operation deadline — a real server reported `statement_timeout = 0` on the migration connection. Both now default to 10 seconds for the connection deadline, the client query timeout, and PostgreSQL's own statement_timeout, applied at the one shared construction boundary so a future call site cannot silently omit them. History reads, advisory-lock queries, transaction control, migration SQL, and history writes are all covered. The existing lock-wait deadline, the Pass 2 TLS policy, and Doctor's and the bootstrap CLI's own timeouts are untouched, and no environment variable or flag was added. Explicit client fields do not win under the pinned driver. [email protected].0 builds parameters as `Object.assign({}, config, parse(config.connectionString))`, so the URL overrides passed options — verified directly, where an explicit statement_timeout of 10000 alongside `?statement_timeout=0` produced a server-reported 0, while a clean URL produced 10s. A DATABASE_URL carrying connect_timeout, query_timeout, statement_timeout, or an `options` value setting statement_timeout is therefore refused with the existing fixed configuration error, before any client is constructed and without echoing the URL, parameter, or value. Unrelated parameters are left alone. The runner split each migration on the literal `--> statement-breakpoint` bytes. Those bytes inside a string literal, a dollar-quoted function body, a quoted identifier, or a comment tore the migration into fragments that were no longer valid SQL — each case verified to produce two fragments where one was required. Each migration's exact SQL is now passed to the client once, with no values and no prepared-statement name, inside the existing transaction, and its history row inserted only after that batch succeeds. One transaction still spans all pending migrations; advisory locking, checksum validation, approval preflight, ordering, rollback, cleanup, and sanitized error identity are unchanged. The port method is renamed executeStatement to executeMigrationSql so the contract says what it receives. PostgreSQL parses the batch because it owns the only correct parser. A homegrown splitter would have to track single quotes, dollar quoting with arbitrary tags, quoted identifiers, line comments, nested block comments, and escape syntax to answer a question the server already answers, and a parser dependency would add supply-chain surface for the same reason. Drizzle's marker lines are ordinary `--` comments when the batch is sent whole. No custom SQL parser exists here. Co-Authored-By: Claude Opus 5 <[email protected]>
11 KiB
0009: Transactional module migration execution
- Status: Accepted
- Date: 2026-07-21
Context
ADR 0008 defines deterministic, module-owned Drizzle migration artifacts and offline validation. It deliberately stops before connecting to PostgreSQL. Ekklesia now needs one Core-controlled path that compares that immutable inventory with applied database history and either reports a plan or applies every pending migration safely.
Migration execution is a platform-wide coordination problem. Independently installed modules can share one database, their dependencies determine current execution order, and a concurrent deploy must not race another runner. Destructive declarations also need approval from outside the module artifact so editing SQL invalidates an earlier approval. These invariants cannot be delegated to module entrypoints or Drizzle Kit configuration.
Decision
Core owns connectivity and execution
Only Core opens the PostgreSQL connection, reads applied migration history, acquires the migration lock, executes SQL, and records success. Modules contribute only the validated artifacts defined by ADR 0008. Migration execution is not a module lifecycle hook and does not occur automatically when the runtime starts.
Core uses the pinned pg 8.22.0 driver. A run uses one explicitly managed client session because
PostgreSQL transactions and session-level advisory locks must remain on the same connection. The
client is released in every success and failure path.
Fixed applied-history contract
Core owns the fixed table public.ekklesia_core_migrations. Modules may neither migrate nor query
it as private module state. Its columns are:
| Column | PostgreSQL type | Contract |
|---|---|---|
identity |
text |
Unique exact <module-id>:<tag> identity |
module_id |
text |
Owning module ID |
migration_tag |
text |
Drizzle journal tag |
migration_index |
integer |
Module-local contiguous journal index |
checksum |
text |
Lowercase SHA-256 of the exact packaged SQL bytes |
applied_order |
bigint identity |
Primary key and database-assigned insertion order |
applied_at |
timestamp with time zone |
Database application time, defaulting to clock_timestamp() |
The table also has unique constraints for (module_id, migration_index) and
(module_id, migration_tag). A missing table means no migrations have been applied. Planning does
not create it. Applying creates it inside the migration transaction only when at least one
migration is pending, so a no-op apply does not mutate the database.
Packaged SQL remains the source of migration identity and exact contents; the database history is the authority for what was successfully applied. Every history row must identify a migration in the current validated inventory and match its module, tag, index, and checksum. Unknown identities, duplicate or inconsistent metadata, and checksum mismatches stop planning and applying.
Within each module, applied history must be an exact prefix of its current Drizzle journal. A gap
or a later migration without all predecessors is invalid. Historical rows are not required to be
one prefix of today's global aggregate: dependencies and installed module sets can change, so
valid historical migrations from different modules may interleave. applied_order records that
historical interleaving for audit rather than redefining module-local order. Its values must be
unique and increasing but need not be contiguous because PostgreSQL identity sequences can retain
gaps after a rollback. An applied migration
which is absent from the current inventory is still rejected; removing an installed history
requires a future explicit platform policy.
Migrations remain forward-only. There are no downgrade or uninstall operations.
Read-only planning and locked applying
migrations:plan completes static module and migration discovery, reads history, validates it,
and reports each migration as applied, pending, or awaiting destructive approval. It does not
acquire the apply lock or write to PostgreSQL. Its result is informational and can become stale as
soon as another process applies migrations.
migrations:apply completes the same atomic discovery before opening a database session. It then
acquires a session advisory lock and rereads history while holding that lock before it calculates
the authoritative pending set. The lock uses PostgreSQL's two-key advisory-lock namespace with the
stable signed 32-bit keys 1162562380 (EKKL) and 1296648018 (MIGR). Core polls
pg_try_advisory_lock for at most ten seconds rather than waiting indefinitely. A timeout fails
without executing SQL. Core explicitly releases the lock in cleanup and closes the session as a
second release guarantee, including after transaction, approval, or execution failures.
One transaction for the whole pending batch
After history and approval preflight succeed, Core applies the complete dependency-first pending aggregate in one PostgreSQL transaction. This matches Drizzle ORM 0.45.2's PostgreSQL migration transaction boundary. The history table is created if needed, each migration is executed in aggregate order, and its history row is inserted only after that migration succeeds. A later failure rolls back every schema change and history insert from the current invocation. Migrations committed by earlier invocations remain applied.
Partial correction — see ADR 0020. Splitting on the literal marker, described in the paragraph immediately below, split a migration whose SQL legitimately contained those bytes inside a string literal, dollar-quoted body, quoted identifier, or comment. Core now sends each migration's complete SQL to PostgreSQL in one call inside the same transaction, so the server does all SQL parsing and the marker lines are ordinary
--comments. Everything else recorded here — one transaction across the pending batch, history rows inserted only after their migration succeeds, no semicolon splitting, no parsing, classifying, reordering, or repairing SQL, and no non-transactional fallback — is unchanged and still accepted. This section is left as originally accepted, as the historical record.
Core matches Drizzle's generated statement-breakpoint behavior: it splits only on the exact
literal --> statement-breakpoint marker. It never splits SQL on semicolons or tries to parse,
classify, reorder, or repair arbitrary SQL. Transaction-incompatible SQL fails the transaction;
there is no non-transactional fallback and no regular-expression safety claim. A module author
must express a compatible forward migration or wait for a future explicitly designed execution
mode.
Destructive approval contract
Before beginning the transaction, Core resolves approvals from an externally supplied approval
provider and preflights the entire pending batch. Every migration declared
approval-required needs an exact approval triple:
module ID + migration identity + exact SHA-256 checksum
All three values must match. Wildcards, module-wide grants, a command-line force flag, an
approve-all mode, and an approved value in the module sidecar are not accepted. The default
provider supplies no approvals, so destructive migrations are blocked until a deployment composes
a trusted external provider. Because all approvals are checked before the transaction begins, a
missing or stale approval causes no migration mutation.
Deterministic and sanitized reporting
The runner consumes only the fully validated, frozen migration aggregate and publishes frozen, deterministically ordered plans or results. Errors use stable categories and safe inventory identifiers. They do not expose SQL contents, connection URLs, credentials, driver exception messages, machine-specific paths, causes, stacks, or partial execution results. Cleanup failures are reported without hiding the primary failure and without copying underlying exception text.
The developer commands read DATABASE_URL from the environment. A local uncommitted .env may
supply it, but commands never print it. This decision's implementation slice verified database
behavior through a narrow in-memory database port and unit tests; it deliberately deferred live
PostgreSQL evidence to a follow-up slice rather than claiming it at acceptance time.
Implementation follow-up (2026-07-21): pnpm test:migrations:integration now runs the production
adapter and migration services against disposable PostgreSQL for lock contention, DDL rollback,
history creation, approvals, and sanitized driver failures. The Docker-backed command remains
separate from the code-only pnpm verify gate.
The transaction and single-client choices follow node-postgres's transaction guidance. The non-blocking lock and explicit release behavior follow PostgreSQL's advisory-lock functions. The batch transaction and exact breakpoint delimiter match Drizzle ORM 0.45.2's PostgreSQL dialect and migration reader.
Alternatives
- Running migrations during module startup was rejected because database mutation must remain an explicit deployment operation and complete discovery must precede all execution.
- A transaction per module or migration was rejected because a failure would publish a partially applied invocation and diverge from the pinned Drizzle PostgreSQL runner.
- Transaction-level advisory locks were rejected because the pending set must be protected while history is reread and approvals are checked before the transaction begins.
- An indefinitely blocking advisory lock was rejected because deploy automation needs a bounded, deterministic failure mode.
- Trusting only a timestamp or the newest history row was rejected because installed modules can interleave and exact identity and checksum comparison is required.
- A source-controlled approval flag or force option was rejected because the migration author could grant their own approval and an edited artifact could reuse it.
- Parsing SQL with regular expressions was rejected for the reasons in ADR 0008 and because it would imply safety guarantees the runner does not provide.
- Automatically falling back to non-transactional execution was rejected because it would weaken rollback guarantees exactly when a migration is least compatible with them.
Consequences
Operators receive an explicit read-only plan and a serialized transactional apply command. A successful invocation records exact immutable history, while a failed invocation leaves none of that invocation's schema or history changes committed. Destructive migrations are unusable until a trusted approval provider is composed, by design.
The all-or-nothing batch can hold locks for longer than per-migration transactions and excludes operations PostgreSQL forbids in a transaction. The narrow database port continues to provide fast, deterministic unit coverage; the dated implementation follow-up above records the separate live PostgreSQL evidence added after this decision was accepted.