Files
wyndham-Condon/backend/scripts/migrate-up.mjs
2026-08-02 13:33:13 +08:00

205 lines
6.1 KiB
JavaScript

import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import pg from "pg";
const { Client } = pg;
const EXPECTED_DATABASE = "booking_test";
const TARGET_SCHEMA = "condon";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const migrationsDirectory = path.resolve(scriptDirectory, "..", "migrations");
async function readStandardInput() {
const lines = createInterface({ input: process.stdin, terminal: false });
for await (const line of lines) {
lines.close();
return line.trim();
}
throw Object.assign(new Error("Missing connection input"), {
code: "MISSING_CONNECTION_INPUT"
});
}
function sanitizeError(error) {
const errorCode = typeof error?.code === "string" ? error.code : "MIGRATION_FAILED";
const connectionErrorCodes = new Set([
"ECONNREFUSED",
"ECONNRESET",
"ENOTFOUND",
"ETIMEDOUT"
]);
return {
ok: false,
errorCode,
errorMessage: connectionErrorCodes.has(errorCode)
? "Database connection failed"
: typeof error?.message === "string"
? error.message.replace(/\s+/g, " ").slice(0, 240)
: "Migration failed"
};
}
async function loadMigrations() {
const names = (await readdir(migrationsDirectory))
.filter(name => name.endsWith(".up.sql"))
.sort();
if (names.length === 0) {
throw Object.assign(new Error("No up migrations found"), {
code: "NO_MIGRATIONS_FOUND"
});
}
return Promise.all(names.map(async fileName => {
const sql = await readFile(path.join(migrationsDirectory, fileName), "utf8");
return {
fileName,
version: fileName.slice(0, -".up.sql".length),
sql,
checksum: createHash("sha256").update(sql).digest("hex")
};
}));
}
let client;
let transactionStarted = false;
try {
const [rawInput, migrations] = await Promise.all([
readStandardInput(),
loadMigrations()
]);
const input = JSON.parse(rawInput);
client = new Client({
host: input.host,
port: input.port,
user: input.user,
password: input.password,
database: input.database,
ssl: input.ssl ? { rejectUnauthorized: false } : undefined,
application_name: "condon_guarded_migration",
connectionTimeoutMillis: 10_000,
query_timeout: 60_000,
options: "-c statement_timeout=60000 -c lock_timeout=2000"
});
await client.connect();
await client.query("BEGIN");
transactionStarted = true;
const identityResult = await client.query(`
SELECT
current_database() AS database_name,
current_user AS role_name,
current_setting('transaction_read_only') AS transaction_read_only
`);
const identity = identityResult.rows[0];
if (identity.database_name !== EXPECTED_DATABASE) {
throw Object.assign(new Error("Unexpected database"), {
code: "UNEXPECTED_DATABASE"
});
}
if (identity.role_name !== input.user) {
throw Object.assign(new Error("Unexpected database role"), {
code: "UNEXPECTED_DATABASE_ROLE"
});
}
if (identity.transaction_read_only !== "off") {
throw Object.assign(new Error("Migration transaction is read-only"), {
code: "MIGRATION_TRANSACTION_READ_ONLY"
});
}
const schemaResult = await client.query(`
SELECT EXISTS (
SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1
) AS exists
`, [TARGET_SCHEMA]);
const schemaExists = schemaResult.rows[0].exists === true;
if (schemaExists) {
const migrationTableResult = await client.query(`
SELECT to_regclass('condon.schema_migrations') IS NOT NULL AS exists
`);
if (!migrationTableResult.rows[0].exists) {
throw Object.assign(new Error("Migration table is missing"), {
code: "MIGRATION_TABLE_MISSING"
});
}
}
const applied = [];
const skipped = [];
for (const migration of migrations) {
let existing = null;
if (schemaExists || migration !== migrations[0]) {
const result = await client.query(
"SELECT checksum FROM condon.schema_migrations WHERE version = $1",
[migration.version]
);
existing = result.rows[0] ?? null;
}
if (existing) {
if (existing.checksum !== migration.checksum) {
throw Object.assign(new Error(`Checksum mismatch for ${migration.version}`), {
code: "MIGRATION_CHECKSUM_MISMATCH"
});
}
skipped.push(migration.version);
continue;
}
await client.query(migration.sql);
await client.query(
`INSERT INTO condon.schema_migrations (version, checksum) VALUES ($1, $2)`,
[migration.version, migration.checksum]
);
applied.push(migration.version);
}
const verificationResult = await client.query(`
SELECT
(SELECT count(*)::integer FROM condon.room_types) AS room_type_count,
(SELECT count(*)::integer FROM condon.owner_accounts) AS owner_account_count,
(SELECT count(*)::integer FROM condon.entitlement_periods) AS entitlement_period_count,
(SELECT count(*)::integer FROM condon.usage_records) AS usage_record_count,
(SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger_count,
(SELECT count(*)::integer FROM condon.schema_migrations) AS migration_count,
(SELECT count(*)::integer FROM condon.bookings) AS booking_count
`);
const counts = verificationResult.rows[0];
await client.query("COMMIT");
transactionStarted = false;
process.stdout.write(`${JSON.stringify({
ok: true,
databaseVerified: true,
targetSchema: TARGET_SCHEMA,
applied,
skipped,
migrations: migrations.map(migration => ({
version: migration.version,
checksum: migration.checksum
})),
counts
}, null, 2)}\n`);
} catch (error) {
if (client && transactionStarted) {
try {
await client.query("ROLLBACK");
transactionStarted = false;
} catch {
// Keep the sanitized migration failure authoritative.
}
}
process.stdout.write(`${JSON.stringify(sanitizeError(error), null, 2)}\n`);
process.exitCode = 1;
} finally {
if (client) {
await client.end().catch(() => {
process.exitCode = 1;
});
}
}