mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
refactor(freight-api): split migration histories between IAM and freight
This commit is contained in:
@@ -11,7 +11,7 @@ One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight
|
|||||||
node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output
|
node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output
|
||||||
node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched)
|
node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched)
|
||||||
node .claude/skills/edr-db/query.cjs columns <table> # freight.<table> column list
|
node .claude/skills/edr-db/query.cjs columns <table> # freight.<table> column list
|
||||||
node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first)
|
node .claude/skills/edr-db/query.cjs migrations [like] # migration rows, newest first (freight.migrations + iam.typeorm_migrations)
|
||||||
node .claude/skills/edr-db/query.cjs drift <table> # bare column names, for diffing vs the entity
|
node .claude/skills/edr-db/query.cjs drift <table> # bare column names, for diffing vs the entity
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,7 +31,11 @@ defaulting to the shared dev database (`edr_dev`).
|
|||||||
(`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`,
|
(`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`,
|
||||||
and non-idempotent statements have double-run here before.
|
and non-idempotent statements have double-run here before.
|
||||||
- Timestamps for new migrations: must be unique across `src/migrations/` AND
|
- Timestamps for new migrations: must be unique across `src/migrations/` AND
|
||||||
higher than `SELECT max(timestamp) FROM public.migrations`.
|
higher than `SELECT max(timestamp) FROM freight.migrations`.
|
||||||
|
- Freight and IAM keep separate histories: `freight.migrations` for
|
||||||
|
`apps/edr-freight-api/src/migrations/*`, `iam.typeorm_migrations` for the
|
||||||
|
`@tria-plc/iamapi-common` migrations. `public.migrations` is the pre-split
|
||||||
|
table, left in place for rollback — never write to it.
|
||||||
|
|
||||||
## Diagnosing a pasted 400/500 (the recurring loop)
|
## Diagnosing a pasted 400/500 (the recurring loop)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table)
|
* node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table)
|
||||||
* node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only
|
* node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only
|
||||||
* node .claude/skills/edr-db/query.cjs columns <table> list freight.<table> columns
|
* node .claude/skills/edr-db/query.cjs columns <table> list freight.<table> columns
|
||||||
* node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows
|
* node .claude/skills/edr-db/query.cjs migrations [like] freight/iam migration rows
|
||||||
* node .claude/skills/edr-db/query.cjs drift <table> columns vs entity check helper
|
* node .claude/skills/edr-db/query.cjs drift <table> columns vs entity check helper
|
||||||
*
|
*
|
||||||
* Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling
|
* Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling
|
||||||
@@ -52,12 +52,27 @@ async function main() {
|
|||||||
console.table(r.rows);
|
console.table(r.rows);
|
||||||
} else if (first === 'migrations') {
|
} else if (first === 'migrations') {
|
||||||
const like = rest[0] ? `%${rest[0]}%` : '%';
|
const like = rest[0] ? `%${rest[0]}%` : '%';
|
||||||
const r = await c.query(
|
// Histories are split per owner: freight.migrations (this app) and
|
||||||
`SELECT id, timestamp, name FROM public.migrations
|
// iam.typeorm_migrations (@tria-plc/iamapi-common). public.migrations is the
|
||||||
WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
|
// pre-split table, kept for rollback — read it only if the split has not
|
||||||
[like],
|
// been applied to this DB yet.
|
||||||
);
|
const sources = [
|
||||||
console.table(r.rows);
|
['freight', 'freight.migrations'],
|
||||||
|
['iam', 'iam.typeorm_migrations'],
|
||||||
|
['legacy', 'public.migrations'],
|
||||||
|
];
|
||||||
|
const rows = [];
|
||||||
|
for (const [owner, table] of sources) {
|
||||||
|
const present = await c.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [table]);
|
||||||
|
if (!present.rows[0].ok) continue;
|
||||||
|
const r = await c.query(
|
||||||
|
`SELECT id, timestamp, name FROM ${table}
|
||||||
|
WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
|
||||||
|
[like],
|
||||||
|
);
|
||||||
|
rows.push(...r.rows.map((row) => ({ owner, ...row })));
|
||||||
|
}
|
||||||
|
console.table(rows);
|
||||||
} else if (first === 'drift') {
|
} else if (first === 'drift') {
|
||||||
// Quick drift signal: DB columns for the table. Compare by eye against
|
// Quick drift signal: DB columns for the table. Compare by eye against
|
||||||
// the entity's @Column names; a recorded-but-absent column = drift.
|
// the entity's @Column names; a recorded-but-absent column = drift.
|
||||||
|
|||||||
@@ -118,30 +118,91 @@ const iamMigrationsGlob = join(
|
|||||||
);
|
);
|
||||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
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 {
|
return {
|
||||||
type: "postgres",
|
type: "postgres" as const,
|
||||||
host: process.env.DB_HOST ?? "localhost",
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||||
username: process.env.DB_USER ?? "postgres",
|
username: process.env.DB_USER ?? "postgres",
|
||||||
password: process.env.DB_PASSWORD ?? "",
|
password: process.env.DB_PASSWORD ?? "",
|
||||||
database: process.env.DB_NAME ?? "edr_freight",
|
database: process.env.DB_NAME ?? "edr_freight",
|
||||||
schema: "public",
|
|
||||||
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
|
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
|
||||||
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
|
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
|
||||||
// proxies fronting the remote edr_dev DB) reject with
|
// proxies fronting the remote edr_dev DB) reject with
|
||||||
// `08P01 unsupported startup parameter in options: search_path`.
|
// `08P01 unsupported startup parameter in options: search_path`.
|
||||||
// The search_path is instead applied per-connection via a pool `connect`
|
// The search_path is instead applied per-connection via a pool `connect`
|
||||||
// handler in app.module.ts (see setPoolSearchPath).
|
// handler (app.module.ts `setPoolSearchPath`, migrate.ts `applySearchPath`).
|
||||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
|
||||||
migrations: [
|
|
||||||
iamMigrationsGlob,
|
|
||||||
freightMigrationsGlob,
|
|
||||||
],
|
|
||||||
migrationsTransactionMode: "each",
|
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging:
|
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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,179 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { AppDataSource } from "../data-source";
|
import { DataSource, DataSourceOptions } from "typeorm";
|
||||||
import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas";
|
import {
|
||||||
import { buildDataSourceOptions } from "../config/database.config";
|
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 ensurePostgresSchemas(buildDataSourceOptions());
|
||||||
|
|
||||||
await AppDataSource.initialize();
|
const iam = await initialize(buildIamMigrationDataSourceOptions());
|
||||||
try {
|
try {
|
||||||
const applied = await AppDataSource.runMigrations();
|
const iamMigrationNames = iam.migrations.map(
|
||||||
for (const migration of applied) {
|
(migration) => migration.name ?? migration.constructor.name,
|
||||||
console.log(`applied: ${migration.name}`);
|
);
|
||||||
}
|
await adoptLegacyHistory(iam, iamMigrationNames);
|
||||||
if (applied.length === 0) console.log("no pending migrations");
|
await runMigrations("iam", iam);
|
||||||
} finally {
|
} finally {
|
||||||
await AppDataSource.destroy();
|
await iam.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const freight = await initialize(buildFreightMigrationDataSourceOptions());
|
||||||
|
try {
|
||||||
|
await runMigrations("freight", freight);
|
||||||
|
} finally {
|
||||||
|
await freight.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
if (require.main === module) {
|
||||||
console.error(err);
|
runAllMigrations().catch((err) => {
|
||||||
process.exit(1);
|
console.error(err);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import { AppDataSource } from '../data-source';
|
import { runAllMigrations } from './migrate';
|
||||||
|
|
||||||
async function runMigrations() {
|
// Alias for `migrate.ts` — the real logic lives there. IAM and freight now run as
|
||||||
try {
|
// two separate migration histories, so this must not go back to
|
||||||
console.log('Initializing datasource...');
|
// `AppDataSource.runMigrations()`: the runtime DataSource carries no migrations.
|
||||||
await AppDataSource.initialize();
|
runAllMigrations()
|
||||||
console.log('Datasource initialized. Running migrations...');
|
.then(() => process.exit(0))
|
||||||
const migrations = await AppDataSource.runMigrations();
|
.catch((err) => {
|
||||||
console.log(`Applied ${migrations.length} migrations.`);
|
|
||||||
await AppDataSource.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration run failed:', err);
|
console.error('Migration run failed:', err);
|
||||||
try {
|
|
||||||
await AppDataSource.destroy();
|
|
||||||
} catch {}
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
runMigrations();
|
|
||||||
|
|||||||
Reference in New Issue
Block a user