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:
co-authored by
Claude Opus 5
parent
490b85b07c
commit
2983789779
@@ -77,8 +77,72 @@ export class PostgresMigrationDatabaseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's fixed migration connection deadline, applied when a caller does not narrow it.
|
||||
* Lives here, at the one construction boundary every migration command shares, so a future CLI
|
||||
* call site cannot silently omit it.
|
||||
*/
|
||||
export const MIGRATION_CONNECTION_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* The platform's fixed migration operation deadline: both `pg`'s client-side `query_timeout` and
|
||||
* PostgreSQL's own server-side `statement_timeout`. The client-side value bounds a query whose
|
||||
* response never arrives; the server-side value bounds work the server is actually doing. Applied
|
||||
* to every statement the session issues — history reads, advisory-lock queries, transaction
|
||||
* control, migration SQL, and history writes.
|
||||
*/
|
||||
export const MIGRATION_OPERATION_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Connection-string parameters the pinned `[email protected]` would let silently override the timeouts
|
||||
* selected above. Its `ConnectionParameters` merges as
|
||||
* `Object.assign({}, config, parse(config.connectionString))`, so the **URL wins** over explicitly
|
||||
* passed options — verified directly against the pinned driver, where an explicit
|
||||
* `statement_timeout: 10000` alongside `?statement_timeout=0` produced a server-reported
|
||||
* `statement_timeout` of `0`. Rejecting these is therefore not stylistic: without it the policy
|
||||
* above is advisory.
|
||||
*
|
||||
* Deliberately narrow — only settings that actually conflict with the timeouts this boundary
|
||||
* selects. `options` is included solely because it can carry `-c statement_timeout=...`, and is
|
||||
* rejected only when it does.
|
||||
*/
|
||||
const CONFLICTING_TIMEOUT_PARAMETERS = Object.freeze([
|
||||
"connect_timeout",
|
||||
"query_timeout",
|
||||
"statement_timeout",
|
||||
]);
|
||||
|
||||
function assertNoConflictingTimeoutParameters(connectionString: string): void {
|
||||
let search: URLSearchParams;
|
||||
try {
|
||||
search = new URL(connectionString).searchParams;
|
||||
} catch {
|
||||
throw databaseError(
|
||||
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
|
||||
"The PostgreSQL database URL is invalid.",
|
||||
);
|
||||
}
|
||||
for (const [rawKey, rawValue] of search.entries()) {
|
||||
const key = rawKey.toLowerCase();
|
||||
if (CONFLICTING_TIMEOUT_PARAMETERS.includes(key)) {
|
||||
throw databaseError(
|
||||
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
|
||||
"The PostgreSQL database URL must not set a connection or statement timeout.",
|
||||
);
|
||||
}
|
||||
if (key === "options" && rawValue.toLowerCase().includes("statement_timeout")) {
|
||||
throw databaseError(
|
||||
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
|
||||
"The PostgreSQL database URL must not set a connection or statement timeout.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostgresMigrationDatabaseOptions {
|
||||
/** Omitted means `MIGRATION_CONNECTION_TIMEOUT_MS`. Tests may narrow it; nothing disables it. */
|
||||
readonly connectionTimeoutMs?: number;
|
||||
/** Omitted means `MIGRATION_OPERATION_TIMEOUT_MS`. Tests may narrow it; nothing disables it. */
|
||||
readonly operationTimeoutMs?: number;
|
||||
/** The validated transport policy (`core/src/database/transport.js`). Every production executable
|
||||
* that plans or applies migrations passes one; omitting it keeps the pre-existing plaintext
|
||||
@@ -177,14 +241,17 @@ function createClientConfig(
|
||||
throw invalidConfiguration("The PostgreSQL migration database options are invalid.");
|
||||
}
|
||||
|
||||
const validatedConnectionTimeoutMs = validateTimeout(
|
||||
connectionTimeoutMs,
|
||||
"The PostgreSQL connection timeout is invalid.",
|
||||
);
|
||||
const validatedOperationTimeoutMs = validateTimeout(
|
||||
operationTimeoutMs,
|
||||
"The PostgreSQL operation timeout is invalid.",
|
||||
);
|
||||
// Defaults are applied here, at the shared construction boundary, so every migration command
|
||||
// inherits a finite deadline without having to remember to ask for one.
|
||||
const validatedConnectionTimeoutMs =
|
||||
validateTimeout(connectionTimeoutMs, "The PostgreSQL connection timeout is invalid.") ??
|
||||
MIGRATION_CONNECTION_TIMEOUT_MS;
|
||||
const validatedOperationTimeoutMs =
|
||||
validateTimeout(operationTimeoutMs, "The PostgreSQL operation timeout is invalid.") ??
|
||||
MIGRATION_OPERATION_TIMEOUT_MS;
|
||||
|
||||
// The URL must not be able to weaken what was just selected.
|
||||
assertNoConflictingTimeoutParameters(connectionString);
|
||||
|
||||
// Also rejects a URL carrying its own TLS parameters, so `pg`'s connection-string precedence
|
||||
// can never silently replace this policy with a weaker one.
|
||||
@@ -195,16 +262,10 @@ function createClientConfig(
|
||||
|
||||
return {
|
||||
connectionString,
|
||||
connectionTimeoutMillis: validatedConnectionTimeoutMs,
|
||||
query_timeout: validatedOperationTimeoutMs,
|
||||
...(ssl === undefined ? {} : { ssl }),
|
||||
...(validatedConnectionTimeoutMs === undefined
|
||||
? {}
|
||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||
...(validatedOperationTimeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
query_timeout: validatedOperationTimeoutMs,
|
||||
statement_timeout: validatedOperationTimeoutMs,
|
||||
}),
|
||||
statement_timeout: validatedOperationTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -402,7 +463,7 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession {
|
||||
|
||||
const transaction = Object.freeze<MigrationTransaction>({
|
||||
ensureHistoryTable: async () => this.#ensureHistoryTable(),
|
||||
executeStatement: async (sql) => this.#executeStatement(sql),
|
||||
executeMigrationSql: async (sql) => this.#executeMigrationSql(sql),
|
||||
insertAppliedMigration: async (migration) => this.#insertAppliedMigration(migration),
|
||||
});
|
||||
|
||||
@@ -479,15 +540,17 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession {
|
||||
}
|
||||
}
|
||||
|
||||
async #executeStatement(sql: string): Promise<void> {
|
||||
/**
|
||||
* Sends one migration's complete SQL to PostgreSQL in a single simple-query call — no values, no
|
||||
* prepared-statement name, no rewriting — so the server performs all SQL parsing and the packaged
|
||||
* bytes are what actually execute.
|
||||
*/
|
||||
async #executeMigrationSql(sql: string): Promise<void> {
|
||||
this.#assertTransactionActive("POSTGRES_MIGRATION_EXECUTION_FAILED");
|
||||
try {
|
||||
await this.#client.query(sql);
|
||||
} catch {
|
||||
throw databaseError(
|
||||
"POSTGRES_MIGRATION_EXECUTION_FAILED",
|
||||
"A module migration statement failed.",
|
||||
);
|
||||
throw databaseError("POSTGRES_MIGRATION_EXECUTION_FAILED", "A module migration failed.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
} from "../modules/migration-discovery.js";
|
||||
|
||||
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
||||
const DRIZZLE_STATEMENT_BREAKPOINT = "--> statement-breakpoint";
|
||||
|
||||
export type MigrationRunnerErrorCode =
|
||||
| "MIGRATION_APPROVAL_LOOKUP_FAILED"
|
||||
@@ -40,7 +39,12 @@ export interface MigrationHistoryInsert {
|
||||
|
||||
export interface MigrationTransaction {
|
||||
ensureHistoryTable(): Promise<void>;
|
||||
executeStatement(sql: string): Promise<void>;
|
||||
/**
|
||||
* Executes one migration's **complete** SQL exactly as packaged, in a single call, inside the
|
||||
* caller's transaction. Never a fragment: the runner does not split migration SQL, so the
|
||||
* implementation must pass the string through to PostgreSQL unmodified.
|
||||
*/
|
||||
executeMigrationSql(sql: string): Promise<void>;
|
||||
insertAppliedMigration(migration: MigrationHistoryInsert): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -484,9 +488,14 @@ export async function applyModuleMigrations(
|
||||
await transaction.ensureHistoryTable();
|
||||
for (const migration of pending) {
|
||||
executingIdentity = migration.identity;
|
||||
for (const statement of migration.sql.split(DRIZZLE_STATEMENT_BREAKPOINT)) {
|
||||
await transaction.executeStatement(statement);
|
||||
}
|
||||
// PostgreSQL parses the batch, not this runner. Drizzle's `--> statement-breakpoint`
|
||||
// lines are ordinary `--` comments to the server, so sending the migration whole keeps
|
||||
// the marker's bytes meaningful wherever they legitimately appear — inside a string
|
||||
// literal, a dollar-quoted function body, a quoted identifier, or a comment — while a
|
||||
// literal `split` on those bytes tore such a migration into fragments that were no
|
||||
// longer valid SQL. Deliberately not a homegrown SQL lexer and not a new parser
|
||||
// dependency: the database already has the only correct parser.
|
||||
await transaction.executeMigrationSql(migration.sql);
|
||||
await transaction.insertAppliedMigration(freezeHistoryInsert(migration));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ class FakeMigrationDatabaseSession implements MigrationDatabaseSession {
|
||||
}
|
||||
stagedTableExists = true;
|
||||
},
|
||||
executeStatement: async (sql) => {
|
||||
executeMigrationSql: async (sql) => {
|
||||
this.#database.events.push("sql:execute");
|
||||
this.#database.executedStatements.push(sql);
|
||||
const statementIndex = this.#statementCount;
|
||||
@@ -655,7 +655,7 @@ describe("module migration apply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves exact Drizzle breakpoints without naively splitting semicolons", async () => {
|
||||
it("sends each migration's complete SQL to PostgreSQL in exactly one call, byte for byte", async () => {
|
||||
const sql =
|
||||
'CREATE TABLE "people_records" ("id" integer);\n--> statement-breakpoint\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;';
|
||||
const initial = migration("people", 0, { sql });
|
||||
@@ -666,15 +666,54 @@ describe("module migration apply", () => {
|
||||
database,
|
||||
});
|
||||
|
||||
expect(database.executedStatements).toEqual([
|
||||
'CREATE TABLE "people_records" ("id" integer);\n',
|
||||
'\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;',
|
||||
]);
|
||||
// One call, unmodified: the runner never splits, trims, normalizes, or rewrites migration SQL.
|
||||
// PostgreSQL parses the batch, and Drizzle's marker lines are ordinary `--` comments to it.
|
||||
expect(database.executedStatements).toEqual([sql]);
|
||||
expect(database.events.indexOf(`history:insert:${initial.identity}`)).toBeGreaterThan(
|
||||
database.events.lastIndexOf("sql:execute"),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a string literal containing the marker bytes",
|
||||
`INSERT INTO "notes" ("body") VALUES ('keep this: --> statement-breakpoint intact');`,
|
||||
],
|
||||
[
|
||||
"a dollar-quoted function body containing the marker and semicolons",
|
||||
`CREATE FUNCTION touch() RETURNS void AS $$\nBEGIN\n -- --> statement-breakpoint\n PERFORM 1;\n PERFORM 2;\nEND;\n$$ LANGUAGE plpgsql;`,
|
||||
],
|
||||
["a line comment containing the marker", `-- note: --> statement-breakpoint\nSELECT 1;`],
|
||||
["a block comment containing the marker", `/* --> statement-breakpoint */ SELECT 1;`],
|
||||
[
|
||||
"a quoted identifier containing the marker",
|
||||
`CREATE TABLE "odd--> statement-breakpoint name" ("id" integer);`,
|
||||
],
|
||||
])("never fragments %s", async (_label, sql) => {
|
||||
const initial = migration("people", 0, { sql });
|
||||
const database = new FakeMigrationDatabase();
|
||||
|
||||
await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [initial] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(database.executedStatements).toEqual([sql]);
|
||||
});
|
||||
|
||||
it("sends several generated migrations as one call each, in order", async () => {
|
||||
const first = migration("people", 0, { sql: `SELECT 1;\n--> statement-breakpoint\nSELECT 2;` });
|
||||
const second = migration("people", 1, { sql: `SELECT 3;` });
|
||||
const database = new FakeMigrationDatabase();
|
||||
|
||||
await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [first, second] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(database.executedStatements).toEqual([first.sql, second.sql]);
|
||||
});
|
||||
|
||||
it("rolls back the whole pending batch on a later SQL failure and preserves prior history", async () => {
|
||||
const prior = migration("people", 0);
|
||||
const pendingFirst = migration("people", 1);
|
||||
|
||||
@@ -6,6 +6,8 @@ vi.mock("pg", () => ({ Client: clientConstructor }));
|
||||
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
MIGRATION_CONNECTION_TIMEOUT_MS,
|
||||
MIGRATION_OPERATION_TIMEOUT_MS,
|
||||
PostgresMigrationDatabaseError,
|
||||
} from "../src/migrations/postgres-database.js";
|
||||
|
||||
@@ -110,3 +112,110 @@ describe("PostgreSQL migration database composition", () => {
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 4: finite migration timeouts live at this one construction boundary, and the pinned driver's
|
||||
* connection-string precedence cannot weaken them.
|
||||
*/
|
||||
describe("PostgreSQL migration timeout policy", () => {
|
||||
function mockClient() {
|
||||
const client = {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
};
|
||||
clientConstructor.mockImplementation(
|
||||
class {
|
||||
readonly connect = client.connect;
|
||||
readonly end = client.end;
|
||||
readonly on = client.on;
|
||||
} as unknown as () => typeof client,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("applies the fixed 10-second defaults when a caller omits them", async () => {
|
||||
mockClient();
|
||||
|
||||
const database = createPostgresMigrationDatabase("postgresql://example.test/ekklesia");
|
||||
const session = await database.openSession();
|
||||
|
||||
// This is exactly what `migrations:plan` and `migrations:apply` construct: no timeout options.
|
||||
expect(clientConstructor).toHaveBeenCalledWith({
|
||||
connectionString: "postgresql://example.test/ekklesia",
|
||||
connectionTimeoutMillis: MIGRATION_CONNECTION_TIMEOUT_MS,
|
||||
query_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
|
||||
statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
|
||||
});
|
||||
expect(MIGRATION_CONNECTION_TIMEOUT_MS).toBe(10_000);
|
||||
expect(MIGRATION_OPERATION_TIMEOUT_MS).toBe(10_000);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("still honours an explicit narrower test override", async () => {
|
||||
mockClient();
|
||||
|
||||
const database = createPostgresMigrationDatabase("postgresql://example.test/ekklesia", {
|
||||
connectionTimeoutMs: 250,
|
||||
operationTimeoutMs: 500,
|
||||
});
|
||||
const session = await database.openSession();
|
||||
|
||||
expect(clientConstructor).toHaveBeenCalledWith({
|
||||
connectionString: "postgresql://example.test/ekklesia",
|
||||
connectionTimeoutMillis: 250,
|
||||
query_timeout: 500,
|
||||
statement_timeout: 500,
|
||||
});
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"postgresql://example.test/ekklesia?statement_timeout=0",
|
||||
"postgresql://example.test/ekklesia?STATEMENT_TIMEOUT=0",
|
||||
"postgresql://example.test/ekklesia?query_timeout=0",
|
||||
"postgresql://example.test/ekklesia?connect_timeout=0",
|
||||
"postgresql://example.test/ekklesia?options=-c%20statement_timeout%3D0",
|
||||
"postgresql://example.test/ekklesia?statement_timeout=900000",
|
||||
])("refuses a connection string that would override the policy (%s)", (databaseUrl) => {
|
||||
// The pinned [email protected] merges as `Object.assign({}, config, parse(connectionString))`, so the
|
||||
// URL wins over the explicit values above — verified against the real driver, where an explicit
|
||||
// `statement_timeout: 10000` alongside `?statement_timeout=0` produced a server-reported `0`.
|
||||
const constructionsBefore = clientConstructor.mock.calls.length;
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPostgresMigrationDatabase(databaseUrl);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
|
||||
// The rejection never echoes the URL, the offending parameter, or its value.
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("example.test");
|
||||
expect(publicText).not.toContain("statement_timeout=");
|
||||
expect(publicText).not.toContain("900000");
|
||||
// Rejected before any client is constructed, so no connection is ever attempted.
|
||||
expect(clientConstructor.mock.calls.length).toBe(constructionsBefore);
|
||||
});
|
||||
|
||||
it("leaves unrelated connection-string parameters alone", async () => {
|
||||
mockClient();
|
||||
|
||||
const database = createPostgresMigrationDatabase(
|
||||
"postgresql://example.test/ekklesia?application_name=ekklesia&lock_timeout=5000",
|
||||
);
|
||||
const session = await database.openSession();
|
||||
|
||||
expect(clientConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS }),
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
MigrationRunnerError,
|
||||
type MigrationRunnerErrorCode,
|
||||
planModuleMigrations,
|
||||
PostgresMigrationDatabaseError,
|
||||
} from "../src/migrations/index.js";
|
||||
import type {
|
||||
DiscoveredModuleMigration,
|
||||
@@ -1457,3 +1459,179 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 4: the migration commands' finite timeout defaults, and whole-batch SQL execution, proven
|
||||
* against real PostgreSQL and a real TCP endpoint rather than a fake.
|
||||
*
|
||||
* Every listener, client, and disposable database here is closed in `finally`/`afterAll`, including
|
||||
* after a failed assertion.
|
||||
*/
|
||||
describe.sequential("live PostgreSQL migration command hardening", () => {
|
||||
it("bounds a connection to an endpoint that accepts TCP but never completes PostgreSQL startup", async () => {
|
||||
// A real blackhole listener bound only to loopback: it accepts the socket and then says
|
||||
// nothing, which is exactly the case `connectionTimeoutMillis` exists for.
|
||||
const sockets: net.Socket[] = [];
|
||||
const listener = net.createServer((socket) => {
|
||||
sockets.push(socket);
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
listener.once("error", reject);
|
||||
listener.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = listener.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The blackhole listener has no bound address.");
|
||||
}
|
||||
|
||||
// A short, test-only override; production keeps MIGRATION_CONNECTION_TIMEOUT_MS unchanged.
|
||||
const blackholeDatabase = createPostgresMigrationDatabase(
|
||||
`postgresql://ekklesia:[email protected]:${address.port}/ekklesia`,
|
||||
{ connectionTimeoutMs: 300, operationTimeoutMs: 300 },
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await blackholeDatabase.openSession();
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONNECTION_FAILED" });
|
||||
// Finite, and nowhere near an unbounded wait.
|
||||
expect(Date.now() - startedAt).toBeLessThan(10_000);
|
||||
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`;
|
||||
expect(publicText).not.toContain("127.0.0.1");
|
||||
expect(publicText).not.toContain("ekklesia:ekklesia");
|
||||
expect(publicText).not.toContain(String(address.port));
|
||||
} finally {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
listener.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts a migration that exceeds the operation deadline and commits no history row", async () => {
|
||||
await withIsolatedDatabase("operation_timeout", async (database) => {
|
||||
const slow = migration("people", 0, {
|
||||
sql: "SELECT pg_sleep(5);",
|
||||
tag: "0000_slow",
|
||||
});
|
||||
// The operation deadline is the behavior under test, so a bounded server-side delay is the
|
||||
// only way to exercise it.
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url, {
|
||||
connectionTimeoutMs: 5_000,
|
||||
operationTimeoutMs: 250,
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [slow] }),
|
||||
database: migrationDatabase,
|
||||
}),
|
||||
"MIGRATION_EXECUTION_FAILED",
|
||||
);
|
||||
|
||||
expect(error.migrationIdentity).toBe(slow.identity);
|
||||
const publicText = `${String(error)}\n${error.stack ?? ""}`;
|
||||
expect(publicText).not.toContain("pg_sleep");
|
||||
expect(publicText).not.toContain(database.name);
|
||||
|
||||
// Nothing was committed: the transaction rolled back with its history insert.
|
||||
const history = await safeQuery<{ identity: string }>(
|
||||
database.client,
|
||||
`select identity from public.ekklesia_core_migrations`,
|
||||
).catch(() => undefined);
|
||||
expect(history?.rows ?? []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("executes marker bytes inside literals, dollar-quoted bodies, and comments intact", async () => {
|
||||
await withIsolatedDatabase("intact_sql", async (database) => {
|
||||
const marker = "--> statement-breakpoint";
|
||||
const sql = [
|
||||
`CREATE TABLE "people_notes" ("id" integer primary key, "body" text not null);`,
|
||||
marker,
|
||||
`INSERT INTO "people_notes" ("id", "body") VALUES (1, 'keep this: ${marker} intact');`,
|
||||
marker,
|
||||
`CREATE FUNCTION people_touch() RETURNS integer AS $$`,
|
||||
`BEGIN`,
|
||||
` -- ${marker}`,
|
||||
` PERFORM 1;`,
|
||||
` RETURN 42;`,
|
||||
`END;`,
|
||||
`$$ LANGUAGE plpgsql;`,
|
||||
marker,
|
||||
`/* block comment with ${marker} */`,
|
||||
`CREATE TABLE "odd${marker}name" ("id" integer);`,
|
||||
].join("\n");
|
||||
const intact = migration("people", 0, { sql, tag: "0000_intact" });
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [intact] }),
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
expect(result.appliedCount).toBe(1);
|
||||
|
||||
// The literal survived byte-for-byte, so the marker was never treated as a split point.
|
||||
const stored = await safeQuery<{ body: string }>(
|
||||
database.client,
|
||||
`select body from "people_notes" where id = 1`,
|
||||
);
|
||||
expect(stored.rows[0]?.body).toBe(`keep this: ${marker} intact`);
|
||||
|
||||
// The dollar-quoted body executed as one function, not as fragments.
|
||||
const called = await safeQuery<{ result: number }>(
|
||||
database.client,
|
||||
`select people_touch() as result`,
|
||||
);
|
||||
expect(Number(called.rows[0]?.result)).toBe(42);
|
||||
|
||||
// The quoted identifier kept the marker in its name.
|
||||
const relations = await safeQuery<{ table_name: string }>(
|
||||
database.client,
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_name like 'odd%'`,
|
||||
);
|
||||
expect(relations.rows.map((row) => row.table_name)).toEqual([`odd${marker}name`]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the whole multi-statement migration when a later statement fails", async () => {
|
||||
await withIsolatedDatabase("intact_rollback", async (database) => {
|
||||
const failing = migration("people", 0, {
|
||||
sql: [
|
||||
`CREATE TABLE "people_partial" ("id" integer primary key);`,
|
||||
`--> statement-breakpoint`,
|
||||
`INSERT INTO "people_partial" ("id") VALUES (1);`,
|
||||
`--> statement-breakpoint`,
|
||||
`INSERT INTO "people_partial" ("id") VALUES (1);`,
|
||||
].join("\n"),
|
||||
tag: "0000_rollback",
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [failing] }),
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
}),
|
||||
"MIGRATION_EXECUTION_FAILED",
|
||||
);
|
||||
expect(error.migrationIdentity).toBe(failing.identity);
|
||||
|
||||
// The earlier statements in the same batch rolled back with it.
|
||||
const relations = await safeQuery(
|
||||
database.client,
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_name = 'people_partial'`,
|
||||
);
|
||||
expect(relations.rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user