refactor(freight-api): split migration histories between IAM and freight

This commit is contained in:
ghost2023
2026-08-05 10:26:25 +03:00
parent 9485e7e9cf
commit 321f3612ec
5 changed files with 278 additions and 53 deletions

View File

@@ -118,30 +118,91 @@ const iamMigrationsGlob = join(
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export function buildDataSourceOptions(): DataSourceOptions {
/**
* Migration history is split per owner instead of sharing one `public.migrations`
* table:
*
* - IAM migrations ship with `@tria-plc/iamapi-common`, target the `iam` schema
* and are recorded in `iam.typeorm_migrations` — the same table the package's
* own CLI (`pnpm iam:migration:run|show|revert`) uses, so both paths agree on
* what has been applied.
* - Freight migrations are recorded in `freight.migrations`.
*
* `public.migrations` is the pre-split table; `src/scripts/migrate.ts` adopts its
* rows into the two tables above on first run and then leaves it untouched.
*/
export const IAM_MIGRATIONS = {
schema: "iam",
table: "typeorm_migrations",
} as const;
export const FREIGHT_MIGRATIONS = {
schema: "freight",
table: "migrations",
} as const;
export const LEGACY_MIGRATIONS = {
schema: "public",
table: "migrations",
} as const;
function buildConnectionOptions() {
return {
type: "postgres",
type: "postgres" as const,
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
schema: "public",
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
// proxies fronting the remote edr_dev DB) reject with
// `08P01 unsupported startup parameter in options: search_path`.
// The search_path is instead applied per-connection via a pool `connect`
// handler in app.module.ts (see setPoolSearchPath).
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
migrations: [
iamMigrationsGlob,
freightMigrationsGlob,
],
migrationsTransactionMode: "each",
// handler (app.module.ts `setPoolSearchPath`, migrate.ts `applySearchPath`).
synchronize: false,
logging:
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
process.env.TYPEORM_LOGGING === "true"
? true
: (["error", "warn"] as DataSourceOptions["logging"]),
};
}
/**
* Runtime options for the API (and the seed scripts using `AppDataSource`).
* Carries no migrations: migrations run only through `src/scripts/migrate.ts`,
* which uses the two dedicated DataSources below.
*/
export function buildDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: "public",
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
migrations: [],
};
}
/** IAM migrations only, recorded in `iam.typeorm_migrations`. */
export function buildIamMigrationDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: IAM_MIGRATIONS.schema,
entities: [],
migrations: [iamMigrationsGlob],
migrationsTableName: IAM_MIGRATIONS.table,
migrationsTransactionMode: "each",
};
}
/** Freight migrations only, recorded in `freight.migrations`. */
export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: FREIGHT_MIGRATIONS.schema,
entities: [],
migrations: [freightMigrationsGlob],
migrationsTableName: FREIGHT_MIGRATIONS.table,
migrationsTransactionMode: "each",
};
}

View File

@@ -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);
});
}

View File

@@ -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();
});