fix: close postgres migration review gaps
This commit is contained in:
@@ -69,4 +69,7 @@ SELECT format(
|
|||||||
'GRANT CONNECT ON DATABASE %I TO identity_runtime, app_runtime, admin_runtime, migration_runner, backup_reader',
|
'GRANT CONNECT ON DATABASE %I TO identity_runtime, app_runtime, admin_runtime, migration_runner, backup_reader',
|
||||||
:'database_name'
|
:'database_name'
|
||||||
) \gexec
|
) \gexec
|
||||||
|
|
||||||
|
ALTER SCHEMA public OWNER TO schema_owner;
|
||||||
|
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||||
SQL
|
SQL
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test": "tsx --test --test-concurrency=1 tests/*.test.ts",
|
"test": "tsx --test tests/*.test.ts",
|
||||||
"test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts",
|
"test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts",
|
||||||
"db:migrate": "node scripts/db-migrate.mjs",
|
"db:migrate": "node scripts/db-migrate.mjs",
|
||||||
"db:migrate:check": "node scripts/db-migrate.mjs --check",
|
"db:migrate:check": "node scripts/db-migrate.mjs --check",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
appendFileSync,
|
appendFileSync,
|
||||||
copyFileSync,
|
copyFileSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
|
unlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -17,6 +18,8 @@ import { startPostgresFixture } from "./helpers/postgres-fixture";
|
|||||||
|
|
||||||
const migrationFilename = "20260720000100_backend_foundation.sql";
|
const migrationFilename = "20260720000100_backend_foundation.sql";
|
||||||
const pendingFilename = "20260720000200_pending_check.sql";
|
const pendingFilename = "20260720000200_pending_check.sql";
|
||||||
|
const concurrentFilename = "20260720000300_concurrent_lock.sql";
|
||||||
|
const failingFilename = "20260720000400_atomic_rollback.sql";
|
||||||
const runnerPath = fileURLToPath(
|
const runnerPath = fileURLToPath(
|
||||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||||
);
|
);
|
||||||
@@ -33,26 +36,72 @@ const fixturePasswords = [
|
|||||||
"postgres-test-password",
|
"postgres-test-password",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type MigrationResult = {
|
||||||
|
status: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function migrationInvocation(
|
||||||
|
connectionString: string,
|
||||||
|
options: { check?: boolean; migrationsDirectory?: string },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
arguments: [runnerPath, ...(options.check ? ["--check"] : [])],
|
||||||
|
environment: {
|
||||||
|
SCHEMA_DATABASE_URL: connectionString,
|
||||||
|
...(options.migrationsDirectory
|
||||||
|
? { MIGRATIONS_DIRECTORY: options.migrationsDirectory }
|
||||||
|
: {}),
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function runMigration(
|
function runMigration(
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
options: { check?: boolean; migrationsDirectory?: string } = {},
|
options: { check?: boolean; migrationsDirectory?: string } = {},
|
||||||
): SpawnSyncReturns<string> {
|
): MigrationResult {
|
||||||
return spawnSync(
|
const invocation = migrationInvocation(connectionString, options);
|
||||||
|
const result = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[runnerPath, ...(options.check ? ["--check"] : [])],
|
invocation.arguments,
|
||||||
{
|
{
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: {
|
env: invocation.environment,
|
||||||
SCHEMA_DATABASE_URL: connectionString,
|
|
||||||
...(options.migrationsDirectory
|
|
||||||
? { MIGRATIONS_DIRECTORY: options.migrationsDirectory }
|
|
||||||
: {}),
|
|
||||||
} as NodeJS.ProcessEnv,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSafeOutput(result: SpawnSyncReturns<string>): void {
|
function runMigrationAsync(
|
||||||
|
connectionString: string,
|
||||||
|
migrationsDirectory: string,
|
||||||
|
): Promise<MigrationResult> {
|
||||||
|
const invocation = migrationInvocation(connectionString, {
|
||||||
|
migrationsDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, invocation.arguments, {
|
||||||
|
env: invocation.environment,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk;
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk;
|
||||||
|
});
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("close", (status) => resolve({ status, stdout, stderr }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeOutput(result: MigrationResult): void {
|
||||||
const output = `${result.stdout}${result.stderr}`;
|
const output = `${result.stdout}${result.stderr}`;
|
||||||
for (const password of fixturePasswords) {
|
for (const password of fixturePasswords) {
|
||||||
assert.doesNotMatch(output, new RegExp(password));
|
assert.doesNotMatch(output, new RegExp(password));
|
||||||
@@ -66,7 +115,7 @@ test("readDatabaseUrl requires APP_DATABASE_URL", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("migration runner applies once, detects drift, and checks read-only", () => {
|
test("migration runner is serialized, atomic, drift-safe, and read-only in check mode", async () => {
|
||||||
const fixture = startPostgresFixture();
|
const fixture = startPostgresFixture();
|
||||||
const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-migrations-"));
|
const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-migrations-"));
|
||||||
const copiedMigration = join(temporaryDirectory, migrationFilename);
|
const copiedMigration = join(temporaryDirectory, migrationFilename);
|
||||||
@@ -74,7 +123,7 @@ test("migration runner applies once, detects drift, and checks read-only", () =>
|
|||||||
"schema_owner",
|
"schema_owner",
|
||||||
"schema-owner-test-password",
|
"schema-owner-test-password",
|
||||||
);
|
);
|
||||||
const results: SpawnSyncReturns<string>[] = [];
|
const results: MigrationResult[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
copyFileSync(migrationPath, copiedMigration);
|
copyFileSync(migrationPath, copiedMigration);
|
||||||
@@ -93,7 +142,9 @@ test("migration runner applies once, detects drift, and checks read-only", () =>
|
|||||||
"f",
|
"f",
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstRun = runMigration(schemaUrl);
|
const firstRun = runMigration(schemaUrl, {
|
||||||
|
migrationsDirectory: temporaryDirectory,
|
||||||
|
});
|
||||||
results.push(firstRun);
|
results.push(firstRun);
|
||||||
assert.equal(firstRun.status, 0, firstRun.stderr);
|
assert.equal(firstRun.status, 0, firstRun.stderr);
|
||||||
assert.match(firstRun.stdout, new RegExp(`applied ${migrationFilename}`));
|
assert.match(firstRun.stdout, new RegExp(`applied ${migrationFilename}`));
|
||||||
@@ -106,7 +157,9 @@ test("migration runner applies once, detects drift, and checks read-only", () =>
|
|||||||
assert.equal(filename, migrationFilename);
|
assert.equal(filename, migrationFilename);
|
||||||
assert.equal(checksum.length, 64);
|
assert.equal(checksum.length, 64);
|
||||||
|
|
||||||
const secondRun = runMigration(schemaUrl);
|
const secondRun = runMigration(schemaUrl, {
|
||||||
|
migrationsDirectory: temporaryDirectory,
|
||||||
|
});
|
||||||
results.push(secondRun);
|
results.push(secondRun);
|
||||||
assert.equal(secondRun.status, 0, secondRun.stderr);
|
assert.equal(secondRun.status, 0, secondRun.stderr);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -121,7 +174,10 @@ test("migration runner applies once, detects drift, and checks read-only", () =>
|
|||||||
ledgerRow,
|
ledgerRow,
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentCheck = runMigration(schemaUrl, { check: true });
|
const currentCheck = runMigration(schemaUrl, {
|
||||||
|
check: true,
|
||||||
|
migrationsDirectory: temporaryDirectory,
|
||||||
|
});
|
||||||
results.push(currentCheck);
|
results.push(currentCheck);
|
||||||
assert.equal(currentCheck.status, 0, currentCheck.stderr);
|
assert.equal(currentCheck.status, 0, currentCheck.stderr);
|
||||||
|
|
||||||
@@ -161,8 +217,19 @@ test("migration runner applies once, detects drift, and checks read-only", () =>
|
|||||||
),
|
),
|
||||||
"f",
|
"f",
|
||||||
);
|
);
|
||||||
|
unlinkSync(pendingPath);
|
||||||
|
|
||||||
appendFileSync(copiedMigration, " ");
|
appendFileSync(copiedMigration, " ");
|
||||||
|
const driftRun = runMigration(schemaUrl, {
|
||||||
|
migrationsDirectory: temporaryDirectory,
|
||||||
|
});
|
||||||
|
results.push(driftRun);
|
||||||
|
assert.equal(driftRun.status, 1);
|
||||||
|
assert.match(
|
||||||
|
driftRun.stderr,
|
||||||
|
new RegExp(`migration checksum mismatch: ${migrationFilename}`),
|
||||||
|
);
|
||||||
|
|
||||||
const driftCheck = runMigration(schemaUrl, {
|
const driftCheck = runMigration(schemaUrl, {
|
||||||
check: true,
|
check: true,
|
||||||
migrationsDirectory: temporaryDirectory,
|
migrationsDirectory: temporaryDirectory,
|
||||||
@@ -173,15 +240,75 @@ test("migration runner applies once, detects drift, and checks read-only", () =>
|
|||||||
driftCheck.stderr,
|
driftCheck.stderr,
|
||||||
new RegExp(`migration checksum mismatch: ${migrationFilename}`),
|
new RegExp(`migration checksum mismatch: ${migrationFilename}`),
|
||||||
);
|
);
|
||||||
|
copyFileSync(migrationPath, copiedMigration);
|
||||||
|
|
||||||
const driftRun = runMigration(schemaUrl, {
|
writeFileSync(
|
||||||
|
join(temporaryDirectory, concurrentFilename),
|
||||||
|
"select pg_sleep(1);\ncreate schema concurrent_lock_probe;\n",
|
||||||
|
);
|
||||||
|
const concurrentResults = await Promise.all([
|
||||||
|
runMigrationAsync(schemaUrl, temporaryDirectory),
|
||||||
|
runMigrationAsync(schemaUrl, temporaryDirectory),
|
||||||
|
]);
|
||||||
|
results.push(...concurrentResults);
|
||||||
|
assert.deepEqual(
|
||||||
|
concurrentResults.map((result) => result.status),
|
||||||
|
[0, 0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
concurrentResults
|
||||||
|
.flatMap((result) => result.stdout.trim().split("\n"))
|
||||||
|
.filter((line) => line.endsWith(concurrentFilename))
|
||||||
|
.sort(),
|
||||||
|
[
|
||||||
|
`already applied ${concurrentFilename}`,
|
||||||
|
`applied ${concurrentFilename}`,
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select count(*)
|
||||||
|
from migration.schema_migrations
|
||||||
|
where filename = '${concurrentFilename}'
|
||||||
|
`),
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(temporaryDirectory, failingFilename),
|
||||||
|
`create schema atomic_rollback_probe authorization schema_owner;
|
||||||
|
create table atomic_rollback_probe.parent (id integer primary key);
|
||||||
|
create table atomic_rollback_probe.child (
|
||||||
|
parent_id integer references atomic_rollback_probe.parent(id)
|
||||||
|
deferrable initially deferred
|
||||||
|
);
|
||||||
|
insert into atomic_rollback_probe.child (parent_id) values (1);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const failingRun = runMigration(schemaUrl, {
|
||||||
migrationsDirectory: temporaryDirectory,
|
migrationsDirectory: temporaryDirectory,
|
||||||
});
|
});
|
||||||
results.push(driftRun);
|
results.push(failingRun);
|
||||||
assert.equal(driftRun.status, 1);
|
assert.equal(failingRun.status, 1);
|
||||||
assert.match(
|
assert.match(
|
||||||
driftRun.stderr,
|
failingRun.stderr,
|
||||||
new RegExp(`migration checksum mismatch: ${migrationFilename}`),
|
new RegExp(`migration failed: ${failingFilename}`),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select exists (
|
||||||
|
select from pg_namespace where nspname = 'atomic_rollback_probe'
|
||||||
|
)
|
||||||
|
`),
|
||||||
|
"f",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select count(*)
|
||||||
|
from migration.schema_migrations
|
||||||
|
where filename = '${failingFilename}'
|
||||||
|
`),
|
||||||
|
"0",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -33,6 +33,38 @@ test("database roles have no cluster privileges", () => {
|
|||||||
"schema_owner:f:f:f:f:f:f",
|
"schema_owner:f:f:f:f:f:f",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(
|
||||||
|
"select nspowner::regrole::text from pg_namespace where nspname = 'public'",
|
||||||
|
),
|
||||||
|
"schema_owner",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select has_schema_privilege('schema_owner', 'public', 'create')
|
||||||
|
and has_schema_privilege('schema_owner', 'public', 'usage')
|
||||||
|
`),
|
||||||
|
"t",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select coalesce(string_agg(privilege_type, ',' order by privilege_type), '')
|
||||||
|
from pg_namespace,
|
||||||
|
aclexplode(coalesce(nspacl, acldefault('n', nspowner)))
|
||||||
|
where nspname = 'public'
|
||||||
|
and grantee = 0
|
||||||
|
and privilege_type in ('CREATE', 'USAGE')
|
||||||
|
`),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.psql(`
|
||||||
|
select pg_get_userbyid(datdba)
|
||||||
|
from pg_database
|
||||||
|
where datname = current_database()
|
||||||
|
`),
|
||||||
|
"postgres",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
fixture.stop();
|
fixture.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { execFileSync, spawnSync } from "node:child_process";
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
chmodSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -43,21 +49,43 @@ server.listen({ host: "127.0.0.1", port: Number(process.argv[1]) }, () =>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findAvailablePort(): number {
|
type PortReservation = { port: number; directory: string };
|
||||||
|
|
||||||
|
function reserveAvailablePort(): PortReservation {
|
||||||
for (let port = 55432; port <= 55531; port += 1) {
|
for (let port = 55432; port <= 55531; port += 1) {
|
||||||
if (isPortAvailable(port)) {
|
const directory = join(tmpdir(), `jyotisha-postgres-port-${port}.lock`);
|
||||||
return port;
|
try {
|
||||||
|
mkdirSync(directory);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPortAvailable(port)) {
|
||||||
|
return { port, directory };
|
||||||
|
}
|
||||||
|
rmSync(directory, { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("no available PostgreSQL test port in 55432..55531");
|
throw new Error("no available PostgreSQL test port in 55432..55531");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releasePort(reservation: PortReservation): void {
|
||||||
|
rmSync(reservation.directory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
export function startPostgresFixture(): PostgresFixture {
|
export function startPostgresFixture(): PostgresFixture {
|
||||||
const projectName = `jyotisha-postgres-${process.pid}-${Date.now()}`;
|
const projectName = `jyotisha-postgres-${process.pid}-${Date.now()}`;
|
||||||
const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-postgres-"));
|
const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-postgres-"));
|
||||||
const databaseEnvFile = join(temporaryDirectory, "database.env");
|
const databaseEnvFile = join(temporaryDirectory, "database.env");
|
||||||
const hostPort = findAvailablePort();
|
let portReservation: PortReservation;
|
||||||
|
try {
|
||||||
|
portReservation = reserveAvailablePort();
|
||||||
|
} catch (error) {
|
||||||
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const hostPort = portReservation.port;
|
||||||
const composeArguments = [
|
const composeArguments = [
|
||||||
"compose",
|
"compose",
|
||||||
"--project-name",
|
"--project-name",
|
||||||
@@ -75,10 +103,9 @@ export function startPostgresFixture(): PostgresFixture {
|
|||||||
POSTGRES_HOST_PORT: String(hostPort),
|
POSTGRES_HOST_PORT: String(hostPort),
|
||||||
};
|
};
|
||||||
|
|
||||||
writeFileSync(databaseEnvFile, databaseEnvironment, { mode: 0o600 });
|
|
||||||
chmodSync(databaseEnvFile, 0o600);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
writeFileSync(databaseEnvFile, databaseEnvironment, { mode: 0o600 });
|
||||||
|
chmodSync(databaseEnvFile, 0o600);
|
||||||
execFileSync(
|
execFileSync(
|
||||||
"docker",
|
"docker",
|
||||||
[...composeArguments, "up", "-d", "--wait", "postgres"],
|
[...composeArguments, "up", "-d", "--wait", "postgres"],
|
||||||
@@ -86,12 +113,17 @@ export function startPostgresFixture(): PostgresFixture {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
execFileSync(
|
try {
|
||||||
"docker",
|
execFileSync(
|
||||||
[...composeArguments, "down", "-v", "--remove-orphans"],
|
"docker",
|
||||||
{ env: environment, stdio: "inherit" },
|
[...composeArguments, "down", "-v", "--remove-orphans"],
|
||||||
);
|
{ env: environment, stdio: "inherit" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Preserve the original startup failure.
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
releasePort(portReservation);
|
||||||
rmSync(temporaryDirectory, { force: true, recursive: true });
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -121,6 +153,7 @@ export function startPostgresFixture(): PostgresFixture {
|
|||||||
{ env: environment, stdio: "inherit" },
|
{ env: environment, stdio: "inherit" },
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
releasePort(portReservation);
|
||||||
rmSync(temporaryDirectory, { force: true, recursive: true });
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user