import "dotenv/config"; import { DataSource, DataSourceOptions } from "typeorm"; import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "../config/ensure-postgres-schemas"; import { buildDataSourceOptions, buildIamMigrationDataSourceOptions, buildFreightMigrationDataSourceOptions, FREIGHT_MIGRATIONS, // IAM_MIGRATIONS, LEGACY_MIGRATIONS, } from "../config/database.config"; /** * Migrations run as two independent histories: * * 1. IAM — migrations shipped by `@tria-plc/iamapi-common`, recorded in * `iam.typeorm_migrations`. * 2. Freight — this app's `src/migrations/*`, recorded in `freight.migrations`. * * They used to share `public.migrations`, which meant a single ordered history * across two independently versioned sources: dropping in a new IAM package * release interleaved its migrations with freight's by timestamp, and * `migration:revert` could not tell the two apart. `adoptLegacyHistory()` below * moves the existing rows into the two tables on the first run after this change, * so nothing re-runs. */ /** * The pooler in front of the remote DB rejects the Postgres `options` startup * parameter, so search_path is set per physical connection instead. pg queues the * SET on the client before the pool hands it to a caller, so every query a * migration issues already sees the full schema search order. */ function applySearchPath(dataSource: DataSource): void { const pool = (dataSource.driver as { master?: unknown }).master as | { on?: (event: string, cb: (client: unknown) => void) => void } | undefined; pool?.on?.("connect", (client) => { (client as { query: (sql: string) => Promise }) .query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`) .catch(() => { /* connection will be validated on first real query */ }); }); } async function initialize(options: DataSourceOptions): Promise { const dataSource = new DataSource(options); await dataSource.initialize(); applySearchPath(dataSource); return dataSource; } function qualified({ schema, table, }: { schema: string; table: string; }): string { return `"${schema}"."${table}"`; } /** Same shape TypeORM's MigrationExecutor creates for a Postgres history table. */ async function createHistoryTable( dataSource: DataSource, target: { schema: string; table: string }, ): Promise { await dataSource.query( `CREATE TABLE IF NOT EXISTS ${qualified(target)} ( "id" SERIAL NOT NULL, "timestamp" bigint NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_${target.schema}_${target.table}" PRIMARY KEY ("id") )`, ); } async function countRows( dataSource: DataSource, target: { schema: string; table: string }, ): Promise { const [{ count }] = (await dataSource.query( `SELECT count(*)::int AS count FROM ${qualified(target)}`, )) as [{ count: number }]; return count; } /** * Copy the pre-split `public.migrations` rows into the per-owner tables. * * Only fills a table that is still empty — once a history is live it is the * source of truth, and re-copying would resurrect rows that a deliberate * `migration:revert` removed. `public.migrations` is intentionally left in place: * it is the rollback path if an older build of this app is redeployed. */ async function adoptLegacyHistory( dataSource: DataSource, iamMigrationNames: string[], ): Promise { const legacyExists = (await dataSource.query( `SELECT to_regclass($1) IS NOT NULL AS present`, [`${LEGACY_MIGRATIONS.schema}.${LEGACY_MIGRATIONS.table}`], )) as [{ present: boolean }]; if (!legacyExists[0].present) return; // await createHistoryTable(dataSource, IAM_MIGRATIONS); await createHistoryTable(dataSource, FREIGHT_MIGRATIONS); const adopt = async ( target: { schema: string; table: string }, /** true → rows whose name is an IAM migration; false → everything else. */ isIam: boolean, ): Promise => { if ((await countRows(dataSource, target)) > 0) return; const inserted = (await dataSource.query( `INSERT INTO ${qualified(target)} ("timestamp", "name") SELECT legacy."timestamp", legacy."name" FROM ${qualified(LEGACY_MIGRATIONS)} legacy WHERE legacy."name" ${isIam ? "= ANY" : "<> ALL"}($1::text[]) ORDER BY legacy."timestamp" RETURNING "name"`, [iamMigrationNames], )) as unknown[]; console.log( `adopted ${inserted.length} row(s) from ${qualified(LEGACY_MIGRATIONS)} into ${qualified(target)}`, ); }; // await adopt(IAM_MIGRATIONS, true); await adopt(FREIGHT_MIGRATIONS, false); } async function runMigrations( label: string, dataSource: DataSource, ): Promise { const applied = await dataSource.runMigrations(); for (const migration of applied) { console.log(`applied [${label}]: ${migration.name}`); } if (applied.length === 0) console.log(`no pending ${label} migrations`); } export async function runAllMigrations(): Promise { await ensurePostgresSchemas(buildDataSourceOptions()); const iam = await initialize(buildIamMigrationDataSourceOptions()); try { const iamMigrationNames = iam.migrations.map( (migration) => migration.name ?? migration.constructor.name, ); await adoptLegacyHistory(iam, iamMigrationNames); // IAM migrations are owned by @tria-plc/iamapi-common's own CLI, not the // freight deploy — do not apply them here. // await runMigrations("iam", iam); } finally { await iam.destroy(); } const freight = await initialize(buildFreightMigrationDataSourceOptions()); try { await runMigrations("freight", freight); } finally { await freight.destroy(); } } if (require.main === module) { runAllMigrations().catch((err) => { console.error(err); process.exit(1); }); }