mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
refactor(freight-api): split migration histories between IAM and freight
This commit is contained in:
@@ -1,24 +1,179 @@
|
||||
import "dotenv/config";
|
||||
import { AppDataSource } from "../data-source";
|
||||
import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas";
|
||||
import { buildDataSourceOptions } from "../config/database.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";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
/**
|
||||
* 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<unknown> })
|
||||
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
|
||||
.catch(() => {
|
||||
/* connection will be validated on first real query */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initialize(options: DataSourceOptions): Promise<DataSource> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await ensurePostgresSchemas(buildDataSourceOptions());
|
||||
|
||||
await AppDataSource.initialize();
|
||||
const iam = await initialize(buildIamMigrationDataSourceOptions());
|
||||
try {
|
||||
const applied = await AppDataSource.runMigrations();
|
||||
for (const migration of applied) {
|
||||
console.log(`applied: ${migration.name}`);
|
||||
}
|
||||
if (applied.length === 0) console.log("no pending migrations");
|
||||
const iamMigrationNames = iam.migrations.map(
|
||||
(migration) => migration.name ?? migration.constructor.name,
|
||||
);
|
||||
await adoptLegacyHistory(iam, iamMigrationNames);
|
||||
await runMigrations("iam", iam);
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
await iam.destroy();
|
||||
}
|
||||
|
||||
const freight = await initialize(buildFreightMigrationDataSourceOptions());
|
||||
try {
|
||||
await runMigrations("freight", freight);
|
||||
} finally {
|
||||
await freight.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
runAllMigrations().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { runAllMigrations } from './migrate';
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
console.log('Initializing datasource...');
|
||||
await AppDataSource.initialize();
|
||||
console.log('Datasource initialized. Running migrations...');
|
||||
const migrations = await AppDataSource.runMigrations();
|
||||
console.log(`Applied ${migrations.length} migrations.`);
|
||||
await AppDataSource.destroy();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
// Alias for `migrate.ts` — the real logic lives there. IAM and freight now run as
|
||||
// two separate migration histories, so this must not go back to
|
||||
// `AppDataSource.runMigrations()`: the runtime DataSource carries no migrations.
|
||||
runAllMigrations()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('Migration run failed:', err);
|
||||
try {
|
||||
await AppDataSource.destroy();
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user