mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
b8144bb92a
Nothing in CI ran a statement against anything but SQLite, so the claim that the dialect layer keeps the repositories portable was never measured and new SQLite-only SQL could land unseen. The new postgres-probe job (server/scripts/pg-probe.ts, npm run probe:pg) does that against a postgres:17 service container: - it derives a Postgres schema from the entities with MikroORM's schema generator, keeping TEXT timestamps, mapping the SQLite-only defaults and the NOCASE collation, and leaving foreign keys out; - it runs every dialect helper on an in-memory SQLite and on Postgres against the same expectation, and every case has to pass; - it calls every public repository method once with arguments the TypeScript checker builds from the parameter types, through a Postgres driver that runs each statement alone in a rolled-back transaction with a statement timeout and records whether Postgres accepted it. The statements Postgres refuses are held per method to scripts/pg-probe-baseline.json, which may only shrink. The baseline starts unmeasured (failing: null): the job reports, passes and uploads the first measurement as the pg-probe-baseline artifact, together with a JSON report, so the commit that seeds it and every later commit that lowers it needs no local Postgres. The probe refuses a database holding tables it did not create. @mikro-orm/postgresql joins the server's devDependencies at the 7.2.1 the other MikroORM packages resolve to; the image's production install leaves it out. The job needs the preflight gates like every test job, runs only for the code area, stops after ten minutes and is part of ci-ok.
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
/**
|
|
* The parts of the Postgres probe that talk to a database: the two MikroORM
|
|
* instances (a stock one that builds the schema and runs the helper cases,
|
|
* and one whose driver records every repository statement), the scratch
|
|
* SQLite database the helper cases are compared on, and the loop that calls
|
|
* every repository method.
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
|
|
import { RequestContext, type Configuration, type EntityManager, type EntityProperty } from '@mikro-orm/core';
|
|
import { MikroORM, PostgreSqlConnection, PostgreSqlDriver, type Options } from '@mikro-orm/postgresql';
|
|
import { SqlitePlatform } from '@mikro-orm/sql';
|
|
import Database from 'better-sqlite3';
|
|
import { DummyDriver, Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, type PostgresDialect } from 'kysely';
|
|
|
|
import { ALL_ENTITIES } from '../../src/db/entities';
|
|
import type { MethodPlan } from './arg-samples';
|
|
import { VALUES_TABLE, VALUES_TABLE_DDL, type HelperEngine, type KyselySelect, type RawSelect, type ValuesDB, type ValuesRow } from './helper-cases';
|
|
import { ProbePostgresDialect, type StatementRecorder } from './recorder';
|
|
import type { MethodResult } from './report';
|
|
import { adjustColumns, NOCASE_COLLATION_SQL, splitStatements, type ColumnMeta } from './schema';
|
|
|
|
/** Marks a database the probe built, so a rerun may drop what it finds there and nothing else. */
|
|
const MARKER_TABLE = 'trek_pg_probe_marker';
|
|
|
|
function baseOptions(url: string): Options {
|
|
return {
|
|
clientUrl: url,
|
|
entities: [...ALL_ENTITIES],
|
|
discovery: { warnWhenNoEntities: false },
|
|
debug: false,
|
|
allowGlobalContext: true,
|
|
pool: { min: 0, max: 4 },
|
|
schemaGenerator: { createForeignKeyConstraints: false },
|
|
};
|
|
}
|
|
|
|
/** The stock ORM: builds the schema and runs the helper cases. */
|
|
export function connectSetupOrm(url: string): Promise<MikroORM> {
|
|
return MikroORM.init(baseOptions(url));
|
|
}
|
|
|
|
/** A Postgres driver whose Kysely dialect records every statement (see recorder.ts). */
|
|
export function createProbeDriverClass(recorder: StatementRecorder, statementTimeoutMs: number): typeof PostgreSqlDriver {
|
|
class ProbeConnection extends PostgreSqlConnection {
|
|
override createKyselyDialect(overrides: Parameters<PostgreSqlConnection['createKyselyDialect']>[0]): PostgresDialect {
|
|
return new ProbePostgresDialect(super.createKyselyDialect(overrides), recorder, statementTimeoutMs);
|
|
}
|
|
}
|
|
return class ProbePostgreSqlDriver extends PostgreSqlDriver {
|
|
constructor(config: Configuration) {
|
|
super(config);
|
|
// Same seam as src/db/orm-driver.ts: the base constructor hardcodes the
|
|
// connection class, so the instance is swapped before anything connects.
|
|
(this as unknown as { connection: PostgreSqlConnection }).connection = new ProbeConnection(config);
|
|
}
|
|
};
|
|
}
|
|
|
|
export function connectProbeOrm(url: string, recorder: StatementRecorder, statementTimeoutMs: number): Promise<MikroORM> {
|
|
return MikroORM.init({ ...baseOptions(url), driver: createProbeDriverClass(recorder, statementTimeoutMs) });
|
|
}
|
|
|
|
/**
|
|
* Refuses a database that holds tables the probe did not create, then drops
|
|
* and recreates the `public` schema. The probe needs a throwaway database
|
|
* (CI's service container); pointing it at anything else must not cost data.
|
|
*/
|
|
async function resetDatabase(orm: MikroORM): Promise<void> {
|
|
const connection = orm.em.getConnection();
|
|
const tables = await connection.execute<{ table_name: string }[]>(
|
|
"select table_name from information_schema.tables where table_schema = 'public'",
|
|
);
|
|
const names = tables.map((row) => row.table_name);
|
|
if (names.length > 0 && !names.includes(MARKER_TABLE)) {
|
|
throw new Error(`refusing to run: the database holds ${names.length} table(s) the probe did not create. Point it at an empty database.`);
|
|
}
|
|
if (names.length > 0) {
|
|
await connection.execute('drop schema public cascade');
|
|
await connection.execute('create schema public');
|
|
}
|
|
await connection.execute(`create table ${MARKER_TABLE} (created_at text)`);
|
|
}
|
|
|
|
export interface SchemaFailure {
|
|
statement: string;
|
|
message: string;
|
|
}
|
|
|
|
/** Resets the database and builds the probe schema; returns the DDL statements Postgres refused. */
|
|
export async function prepareDatabase(orm: MikroORM): Promise<{ failures: SchemaFailure[]; adjusted: number; statements: number }> {
|
|
await resetDatabase(orm);
|
|
const connection = orm.em.getConnection();
|
|
const failures: SchemaFailure[] = [];
|
|
const run = async (statement: string): Promise<void> => {
|
|
try {
|
|
await connection.execute(statement);
|
|
} catch (error) {
|
|
failures.push({ statement, message: error instanceof Error ? error.message.split('\n')[0]! : String(error) });
|
|
}
|
|
};
|
|
await run(NOCASE_COLLATION_SQL);
|
|
let adjusted = 0;
|
|
for (const meta of orm.getMetadata().getAll().values()) {
|
|
adjusted += adjustColumns(meta.props as EntityProperty[] as ColumnMeta[]).length;
|
|
}
|
|
const statements = splitStatements(await orm.schema.getCreateSchemaSQL({ wrap: false }));
|
|
for (const statement of statements) await run(statement);
|
|
await run(VALUES_TABLE_DDL.postgres);
|
|
return { failures, adjusted, statements: statements.length };
|
|
}
|
|
|
|
export function postgresHelperEngine(orm: MikroORM): HelperEngine {
|
|
const connection = orm.em.getConnection();
|
|
const kysely = orm.em.getKysely<ValuesDB>();
|
|
return {
|
|
engine: 'postgres',
|
|
platform: orm.em.getPlatform(),
|
|
async writeRow(row: ValuesRow) {
|
|
await connection.execute(`delete from ${VALUES_TABLE}`);
|
|
await kysely.insertInto(VALUES_TABLE).values(row).execute();
|
|
},
|
|
async selectRaw(select: RawSelect) {
|
|
const sql = `select ${select.sql}${select.column ? '' : ' as v'} from ${VALUES_TABLE}`;
|
|
const rows = await connection.execute<Record<string, unknown>[]>(sql, select.params);
|
|
return rows[0]?.[select.column ?? 'v'];
|
|
},
|
|
async selectKysely(select: KyselySelect) {
|
|
const platform = orm.em.getPlatform();
|
|
const row = await kysely.selectFrom(VALUES_TABLE).select((eb) => select(platform, eb).as('v')).executeTakeFirst();
|
|
return row?.v;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The same cases on an in-memory SQLite database, through the SQLite branch of every helper. */
|
|
export function sqliteHelperEngine(): HelperEngine & { close(): void } {
|
|
const db = new Database(':memory:');
|
|
db.exec(VALUES_TABLE_DDL.sqlite);
|
|
const platform = new SqlitePlatform();
|
|
const compiler = new Kysely<ValuesDB>({
|
|
dialect: {
|
|
createAdapter: () => new SqliteAdapter(),
|
|
createDriver: () => new DummyDriver(),
|
|
createIntrospector: (kysely) => new SqliteIntrospector(kysely),
|
|
createQueryCompiler: () => new SqliteQueryCompiler(),
|
|
},
|
|
});
|
|
const bind = (value: unknown): unknown => (typeof value === 'boolean' ? Number(value) : value);
|
|
return {
|
|
engine: 'sqlite',
|
|
platform,
|
|
async writeRow(row: ValuesRow) {
|
|
db.prepare(`delete from ${VALUES_TABLE}`).run();
|
|
db.prepare(`insert into ${VALUES_TABLE} (id, a, b, n, x) values (?, ?, ?, ?, ?)`).run(row.id, row.a, row.b, row.n, row.x);
|
|
},
|
|
async selectRaw(select: RawSelect) {
|
|
const sql = `select ${select.sql}${select.column ? '' : ' as v'} from ${VALUES_TABLE}`;
|
|
const row = db.prepare(sql).get(...select.params.map(bind)) as Record<string, unknown> | undefined;
|
|
return row?.[select.column ?? 'v'];
|
|
},
|
|
async selectKysely(select: KyselySelect) {
|
|
const compiled = compiler.selectFrom(VALUES_TABLE).select((eb) => select(platform, eb).as('v')).compile();
|
|
const row = db.prepare(compiled.sql).get(...compiled.parameters.map(bind)) as Record<string, unknown> | undefined;
|
|
return row?.v;
|
|
},
|
|
close: () => db.close(),
|
|
};
|
|
}
|
|
|
|
type RepositoryFactory = (em: EntityManager) => object;
|
|
|
|
/** How to reach each repository class: through its entity, or `new X(em)` for the two that have none. */
|
|
function repositoryFactories(orm: MikroORM, plans: readonly MethodPlan[], serverRoot: string): Map<string, RepositoryFactory | string> {
|
|
const byEntity = new Map<string, RepositoryFactory>();
|
|
for (const meta of orm.getMetadata().getAll().values()) {
|
|
const repository = meta.repository?.();
|
|
if (repository) byEntity.set(repository.name, (em) => em.getRepository(meta.class));
|
|
}
|
|
const factories = new Map<string, RepositoryFactory | string>();
|
|
for (const plan of plans) {
|
|
if (factories.has(plan.className)) continue;
|
|
const viaEntity = byEntity.get(plan.className);
|
|
if (viaEntity) {
|
|
factories.set(plan.className, viaEntity);
|
|
continue;
|
|
}
|
|
const exported = (require(path.join(serverRoot, plan.file)) as Record<string, unknown>)[plan.className];
|
|
if (typeof exported === 'function' && exported.length === 1) {
|
|
const RepositoryClass = exported as new (em: EntityManager) => object;
|
|
factories.set(plan.className, (em) => new RepositoryClass(em));
|
|
} else {
|
|
factories.set(plan.className, 'no entity maps it and it does not take an EntityManager');
|
|
}
|
|
}
|
|
return factories;
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<{ value: T } | 'timeout'> {
|
|
let timer: NodeJS.Timeout | undefined;
|
|
const timeout = new Promise<'timeout'>((resolve) => {
|
|
timer = setTimeout(() => resolve('timeout'), ms);
|
|
});
|
|
return Promise.race([promise.then((value) => ({ value })), timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
/** Calls every planned method once, inside a request context, and collects what each one sent. */
|
|
export async function probeRepositories(
|
|
orm: MikroORM,
|
|
recorder: StatementRecorder,
|
|
plans: readonly MethodPlan[],
|
|
serverRoot: string,
|
|
methodTimeoutMs: number,
|
|
): Promise<MethodResult[]> {
|
|
const factories = repositoryFactories(orm, plans, serverRoot);
|
|
const results: MethodResult[] = [];
|
|
for (const plan of plans) {
|
|
const key = `${plan.className}.${plan.method}`;
|
|
const factory = factories.get(plan.className);
|
|
if ('unprobeable' in plan) {
|
|
results.push({ key, statements: [], unprobeable: plan.unprobeable });
|
|
continue;
|
|
}
|
|
if (typeof factory !== 'function') {
|
|
results.push({ key, statements: [], unprobeable: factory ?? 'unknown class' });
|
|
continue;
|
|
}
|
|
const result: MethodResult = { key, statements: [] };
|
|
try {
|
|
const outcome = await recorder.run(key, () =>
|
|
withTimeout(
|
|
RequestContext.create(orm.em, async () => {
|
|
const repository = factory(RequestContext.getEntityManager() ?? orm.em.fork()) as Record<string, unknown>;
|
|
const method = repository[plan.method];
|
|
if (typeof method !== 'function') throw new Error(`${key} is not a function at runtime`);
|
|
return (await (method as (...args: unknown[]) => unknown).apply(repository, plan.args)) as unknown;
|
|
}),
|
|
methodTimeoutMs,
|
|
),
|
|
);
|
|
if (outcome === 'timeout') result.timedOut = true;
|
|
} catch (error) {
|
|
result.threw = error instanceof Error ? error.message.split('\n')[0]! : String(error);
|
|
}
|
|
result.statements = [...recorder.statementsOf(key)];
|
|
results.push(result);
|
|
}
|
|
return results;
|
|
}
|