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
+26
View File
@@ -163,6 +163,32 @@ database or cryptographic guarantee. Phase 5D is still not complete** — passes
migration correctness) and 5 (deployment/supply chain plus a final independent re-audit) remain
outstanding, and Phase 6 remains blocked.
Phase 5D pass 4 of 5 hardens the module runtime and the migration commands. **Two routes of one
module that could both match the same request path are now rejected at registration**
(`MODULE_ROUTE_OVERLAP`) instead of being resolved by declaration order — with different permissions
on `/events/:id` and `/events/new`, registration order alone previously decided which permission
guarded `/events/new`. Decoded route parameters are documented as untrusted, module-owned input that
handlers must validate against their own domain grammar; no generic validator or route field was
added. A module whose `start` hook fails is now rolled back through **its own** `stop` hook first,
so partially acquired resources are released, and every stop/rollback hook runs under a fixed
10-second deadline while HTTP shutdown drains for at most 30 seconds before force-closing
connections through Node's supported public APIs — shutdown is finite at every stage, reports a
forced drain or a hook failure as an unsuccessful shutdown, and always still reaches pool closure.
`migrations:plan`/`migrations:apply` now connect with fixed 10-second connection, query, and
`statement_timeout` defaults that a `DATABASE_URL` cannot weaken (the pinned driver lets a
connection string override explicit options — verified), and each migration's SQL is sent to
PostgreSQL **intact, as one parsed batch** inside the existing transaction rather than split on the
literal `--> statement-breakpoint` bytes, which corrupted any migration containing those bytes in a
string literal, dollar-quoted body, quoted identifier, or comment. See
[Module development](docs/module-development/README.md#http-routes),
[API](docs/development/api.md#graceful-shutdown), and
[ADR 0020](docs/architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md).
**Deadlines bound how long the platform waits; they do not cancel or sandbox trusted in-process
module code, and closing a socket does not prove a handler promise ended. No custom SQL parser
exists. Modules remain trusted compiled backend code with no process, role, schema, or tenant
isolation. Phase 5D is still not complete** — pass 5 (deployment/supply chain plus an independent
whole-repository re-audit) remains outstanding, and Phase 6 remains blocked.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.