fix: harden migration command execution

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]>
This commit is contained in:
ekklesia
2026-07-26 23:13:52 -05:00
co-authored by Claude Opus 5
parent 490b85b07c
commit 2983789779
12 changed files with 721 additions and 38 deletions
+16 -1
View File
@@ -68,7 +68,7 @@ under the reserved source identifier `"core"`, and
aggregate, **Core first**. An installed module manifest that ever declares `id: "core"` is rejected
at discovery time so Core and module migration identities can never collide.
The transactional runner, advisory lock, breakpoint splitter, and the fixed
The transactional runner, advisory lock, whole-batch SQL execution, and the fixed
`public.ekklesia_core_migrations` history table (ADR 0009) are unchanged: Core's migration honestly
populates the existing `module_id` column with the literal value `"core"`, exactly like a module
populates it with its own module ID. There is exactly one migration system and one executor.
@@ -82,6 +82,21 @@ pnpm migrations:apply # locked, transactional application of the pending bat
pnpm doctor # includes Core's migration in its discovery/plan checks
```
Both `migrations:plan` and `migrations:apply` connect with fixed, finite defaults — a 10-second
connection deadline, a 10-second client query timeout, and a 10-second PostgreSQL
`statement_timeout` — applied at the one shared construction boundary so no command can omit them.
A `DATABASE_URL` that tries to set `connect_timeout`, `query_timeout`, `statement_timeout`, or an
`options` value carrying `statement_timeout` is refused with a sanitized configuration error,
because the pinned driver lets the connection string override explicitly passed options. This bounds
each individual operation; it does **not** promise that a migration completes within ten seconds
across retries or cleanup. Migrations remain explicit operator work and never run at application
startup.
Each pending migration's SQL is sent to PostgreSQL as **one complete batch**, inside the existing
transaction, so the server does all SQL parsing — Drizzle's `--> statement-breakpoint` lines are
ordinary `--` comments to it. No SQL parser exists in this repository, and the marker's bytes keep
their ordinary meaning inside a string literal, dollar-quoted body, quoted identifier, or comment.
No new commands are required to include identity: it flows through the existing migration
commands automatically. A fresh database applies the Core identity migration through
`pnpm migrations:apply` like any other pending migration; a second application is a genuine no-op.
+11 -2
View File
@@ -163,8 +163,17 @@ Pass 4 introduced several deadlines, and their tests deliberately differ in kind
- **Real raw-HTTP force-close** (`apps/api/tests/api-http.integration.ts`) binds the real listener,
opens a real socket, writes a deliberately incomplete request, and proves shutdown force-closes
it at a short `drainTimeoutMsOverride` and still reaches pool closure.
Both use a short **test-only override**; the production defaults (10 s per module hook, 30 s HTTP
drain) are asserted separately as constants and are never waited out.
- **Real TCP connection timeout** (`core/tests/postgres-migration.integration.ts`) binds a loopback
blackhole listener that accepts the socket and never completes PostgreSQL startup, proving the
configured connection deadline; the listener and every accepted socket close in `finally`.
- **Real PostgreSQL operation timeout and SQL batching** (same file) use a bounded server-side
delay only because the deadline itself is what is under test, and disposable databases to prove
that marker bytes inside literals, dollar-quoted bodies, comments, and quoted identifiers execute
intact, and that a failure rolls the whole batch back.
Every one of these uses a short **test-only override**; the production defaults (10 s per module
hook, 30 s HTTP drain, 10 s migration connection/query/statement) are asserted separately as
constants and are never waited out.
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection: