fix: harden DEV runtime migration transactions
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user