From 852ec3dbc70341a39f7c761843a529c250bc99c0 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 00:14:13 +0000 Subject: [PATCH] fix: harden DEV runtime migration transactions --- scripts/src/dev-runtime-migration.mjs | 38 ++++++++-- scripts/src/dev-runtime-migration.test.mjs | 83 ++++++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/scripts/src/dev-runtime-migration.mjs b/scripts/src/dev-runtime-migration.mjs index b770500a..7696da44 100644 --- a/scripts/src/dev-runtime-migration.mjs +++ b/scripts/src/dev-runtime-migration.mjs @@ -250,7 +250,14 @@ async function verifyRuntimeReadiness(report, env, options) { queryClient: options.queryClient, pgModuleLoader: options.pgModuleLoader }); - const runtime = await runtimeStore.readiness(); + let runtime; + try { + runtime = await runtimeStore.readiness(); + } finally { + if (!options.queryClient && typeof runtimeStore.pool?.end === "function") { + await runtimeStore.pool.end(); + } + } report.runtime = summarizeRuntime(runtime); report.actions.readinessVerified = runtime.ready === true; report.gates = gatesFromRuntime(runtime); @@ -277,11 +284,9 @@ async function applyMigration(report, sql, env, options) { if (options.queryClient) { client = options.queryClient; } else { - const pool = await createPgPool(env, options); - client = pool; - closeClient = async () => { - await pool.end(); - }; + const migrationClient = await createMigrationClient(env, options); + client = migrationClient.client; + closeClient = migrationClient.close; } await client.query("BEGIN"); await client.query(sql); @@ -300,6 +305,27 @@ async function applyMigration(report, sql, env, options) { } } +async function createMigrationClient(env, options) { + const pool = await createPgPool(env, options); + let client; + try { + client = await pool.connect(); + } catch (error) { + await pool.end(); + throw error; + } + return { + client, + close: async () => { + try { + client.release(); + } finally { + await pool.end(); + } + } + }; +} + async function createPgPool(env, options) { let pg; try { diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs index cf2f3575..f4a006da 100644 --- a/scripts/src/dev-runtime-migration.test.mjs +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -104,6 +104,55 @@ test("apply mode records ledger and verifies durable runtime readiness", async ( assert.equal(JSON.stringify(report).includes("redacted@example.invalid"), false); }); +test("live dry-run closes pg pool after readiness verification", async () => { + const backingClient = createFakeQueryClient({ migrationReady: true }); + const pools = []; + const report = await buildDevRuntimeMigrationReport( + parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), + { + env: { + HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", + HWLAB_CLOUD_DB_SSL_MODE: "require" + }, + pgModuleLoader: async () => createFakePgModule({ backingClient, pools }), + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "ready"); + assert.equal(pools.length, 1); + assert.ok(pools[0].calls.some((call) => call.target === "pool" && call.sql.includes("information_schema.columns"))); + assert.ok(pools[0].calls.some((call) => call.type === "end")); +}); + +test("apply mode uses a dedicated pg client transaction", async () => { + const backingClient = createFakeQueryClient({ migrationReady: false }); + const pools = []; + const report = await buildDevRuntimeMigrationReport( + parseArgs(["--apply", "--confirm-dev", "--confirmed-non-production"]), + { + env: { + HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", + HWLAB_CLOUD_DB_SSL_MODE: "require" + }, + pgModuleLoader: async () => createFakePgModule({ backingClient, pools }), + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "ready"); + assert.equal(pools.length, 2); + assert.ok(pools[0].calls.some((call) => call.type === "connect")); + assert.ok(pools[0].calls.some((call) => call.target === "client" && call.sql === "BEGIN")); + assert.ok(pools[0].calls.some((call) => call.target === "client" && call.sql.includes("INSERT INTO hwlab_schema_migrations"))); + assert.ok(pools[0].calls.some((call) => call.target === "client" && call.sql === "COMMIT")); + assert.equal(pools[0].calls.some((call) => call.target === "pool"), false); + assert.ok(pools[0].calls.some((call) => call.type === "release")); + assert.ok(pools[0].calls.some((call) => call.type === "end")); + assert.ok(pools[1].calls.some((call) => call.target === "pool" && call.sql.includes("information_schema.columns"))); + assert.ok(pools[1].calls.some((call) => call.type === "end")); +}); + function createFakeQueryClient({ migrationReady }) { const state = { migrationReady, @@ -145,6 +194,40 @@ function createFakeQueryClient({ migrationReady }) { }; } +function createFakePgModule({ backingClient, pools }) { + return { + Pool: class FakePool { + constructor(config) { + this.config = config; + this.calls = []; + pools.push(this); + } + + async connect() { + this.calls.push({ type: "connect" }); + return { + query: async (sql, params = []) => { + this.calls.push({ target: "client", sql, params }); + return backingClient.query(sql, params); + }, + release: () => { + this.calls.push({ type: "release" }); + } + }; + } + + async query(sql, params = []) { + this.calls.push({ target: "pool", sql, params }); + return backingClient.query(sql, params); + } + + async end() { + this.calls.push({ type: "end" }); + } + } + }; +} + function schemaRows() { return Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS).flatMap(([table, columns]) => columns.map((column) => ({