ci: probe the server's SQL against a Postgres service

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.
This commit is contained in:
Maurice
2026-10-08 16:19:36 +02:00
parent 4fa2f3dc8b
commit b8144bb92a
21 changed files with 2469 additions and 6 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"failing": null
}
+207
View File
@@ -0,0 +1,207 @@
/**
* The Postgres probe: what of the server's SQL runs on Postgres today.
*
* TREK runs on SQLite, and the dialect layer (src/db/dialect/) is written so
* a second engine is a branch there rather than a sweep through the
* repositories. Nothing tested that claim until this probe. Given an empty
* Postgres database it
*
* 1. derives a Postgres schema from the entities (pg-probe/schema.ts);
* 2. runs every dialect helper on SQLite and on Postgres and checks both
* return what the helper promises (pg-probe/helper-cases.ts); every
* case must pass;
* 3. calls every public repository method once with sample arguments the
* TypeScript checker builds from its parameter types
* (pg-probe/arg-samples.ts), through a MikroORM Postgres driver that
* runs each statement in its own rolled-back transaction and records
* whether Postgres accepted it (pg-probe/recorder.ts);
* 4. holds the statements Postgres refuses, per method, to
* scripts/pg-probe-baseline.json, which may only shrink
* (pg-probe/baseline.ts).
*
* It needs a Postgres server, so CI runs it in the `postgres-probe` job
* against a service container. Locally:
*
* docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=probe postgres:17
* TREK_PG_PROBE_URL=postgres://postgres:probe@127.0.0.1:5432/postgres npm run probe:pg
*
* Options:
* --url=<postgres url> instead of TREK_PG_PROBE_URL
* --update lower scripts/pg-probe-baseline.json to this run (never raises or adds an entry;
* an unmeasured baseline gets its first measurement)
* --next-baseline=<path> write the baseline this run would leave (the CI artifact)
* --report=<path> write everything the run saw as JSON
* --summary=<path> append the Markdown summary (defaults to $GITHUB_STEP_SUMMARY)
*
* Exit codes: 0 passed, 1 a helper case or the ratchet failed, 2 the probe
* could not run (no database, a database that is not empty, a crash).
*/
import { appendFileSync, readdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { planRepositories, readCompilerOptions, type MethodPlan } from './pg-probe/arg-samples';
import { compareWithBaseline, formatBaseline, lowerBaseline, readBaseline } from './pg-probe/baseline';
import { connectProbeOrm, connectSetupOrm, postgresHelperEngine, prepareDatabase, probeRepositories, sqliteHelperEngine } from './pg-probe/engines';
import { runHelperCases, type HelperResult } from './pg-probe/helper-cases';
import { StatementRecorder } from './pg-probe/recorder';
import { formatConsole, formatMarkdown, passed, summarize, type MethodResult, type ReportInput } from './pg-probe/report';
const SERVER_ROOT = path.join(__dirname, '..');
export const BASELINE_PATH = path.join(SERVER_ROOT, 'scripts', 'pg-probe-baseline.json');
const REPOSITORIES_DIR = path.join(SERVER_ROOT, 'src', 'db', 'repositories');
/** A statement that runs longer than this on empty tables is stuck. */
const STATEMENT_TIMEOUT_MS = 5_000;
/** A method still running after this has hung (a pool wait, a loop over a result it never gets). */
const METHOD_TIMEOUT_MS = 20_000;
export interface ProbeArgs {
url: string;
update: boolean;
nextBaseline: string | null;
report: string | null;
summary: string | null;
}
export function parseArgs(argv: readonly string[], env: Readonly<Record<string, string | undefined>>): ProbeArgs {
const args: ProbeArgs = {
url: env.TREK_PG_PROBE_URL ?? '',
update: false,
nextBaseline: null,
report: null,
summary: env.GITHUB_STEP_SUMMARY || null,
};
for (const arg of argv) {
const [flag, value] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, ''];
if (flag === '--update' && value === '') args.update = true;
else if (flag === '--url' && value) args.url = value;
else if (flag === '--next-baseline' && value) args.nextBaseline = value;
else if (flag === '--report' && value) args.report = value;
else if (flag === '--summary' && value) args.summary = value;
else throw new Error(`pg-probe: unknown argument ${arg}`);
}
if (!/^postgres(ql)?:\/\//.test(args.url)) {
throw new Error('pg-probe: no Postgres URL. Set TREK_PG_PROBE_URL or pass --url=postgres://user:password@host:port/database.');
}
return args;
}
/** The repository files the probe calls into: every `src/db/repositories/*.ts`, the `_shared/` helpers excepted. */
export function repositoryFiles(dir: string): string[] {
return readdirSync(dir)
.filter((name) => name.endsWith('.ts') && !name.endsWith('.d.ts'))
.sort()
.map((name) => path.join(dir, name));
}
/**
* 0 when every helper case passed and no method fails more statements than
* its entry allows. An entry above what its method fails now fails the run
* too, unless this run is the `--update` that lowers it.
*/
export function exitCode(input: Pick<ReportInput, 'verdict' | 'helpers'>, update: boolean): number {
if (passed(input)) return 0;
const helpersOk = input.helpers.every((result) => result.failure === null);
return helpersOk && input.verdict.grown.length === 0 && update ? 0 : 1;
}
/** GitHub annotations when running in Actions, plain lines otherwise. */
function annotate(level: 'error' | 'warning' | 'notice', message: string): void {
console.log(process.env.GITHUB_ACTIONS === 'true' ? `::${level}::${message}` : `${level.toUpperCase()}: ${message}`);
}
function timed<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = Date.now();
console.log(`-- ${label}`);
return fn().finally(() => console.log(` ${label}: ${((Date.now() - start) / 1000).toFixed(1)}s`));
}
async function main(): Promise<number> {
const args = parseArgs(process.argv.slice(2), process.env);
const baseline = readBaseline(BASELINE_PATH);
const setup = await timed('connect', () => connectSetupOrm(args.url));
let helpers: HelperResult[] = [];
let schemaFailures: { statement: string; message: string }[] = [];
let results: MethodResult[] = [];
try {
const schema = await timed('derive the schema from the entities', () => prepareDatabase(setup));
schemaFailures = schema.failures;
console.log(` ${schema.statements} DDL statements, ${schema.adjusted} columns adjusted, ${schema.failures.length} refused`);
helpers = await timed('dialect helpers on SQLite and Postgres', async () => {
const sqlite = sqliteHelperEngine();
try {
return [...(await runHelperCases(sqlite)), ...(await runHelperCases(postgresHelperEngine(setup)))];
} finally {
sqlite.close();
}
});
const plans: MethodPlan[] = await timed('plan the repository calls', async () =>
planRepositories(repositoryFiles(REPOSITORIES_DIR), readCompilerOptions(path.join(SERVER_ROOT, 'tsconfig.json')), SERVER_ROOT),
);
console.log(` ${plans.length} public repository methods`);
const recorder = new StatementRecorder();
const probe = await connectProbeOrm(args.url, recorder, STATEMENT_TIMEOUT_MS);
try {
results = await timed('call every repository method', () => probeRepositories(probe, recorder, plans, SERVER_ROOT, METHOD_TIMEOUT_MS));
} finally {
await probe.close(true);
}
} finally {
await setup.close(true);
}
const summary = summarize(results);
const verdict = compareWithBaseline(baseline, summary.failingByMethod);
const input: ReportInput = { summary, verdict, helpers, schemaFailures, results };
console.log(formatConsole(input));
const next = lowerBaseline(baseline, summary.failingByMethod);
if (args.nextBaseline) writeFileSync(args.nextBaseline, formatBaseline(next));
if (args.update) {
writeFileSync(BASELINE_PATH, formatBaseline(next));
console.log(`Wrote ${path.relative(SERVER_ROOT, BASELINE_PATH)}.`);
}
if (args.report) {
const counts = { failingByMethod: Object.fromEntries(summary.failingByMethod), failuresByCode: Object.fromEntries(summary.failuresByCode) };
const report = { summary: { ...summary, ...counts }, verdict, helpers, schemaFailures, results };
writeFileSync(args.report, `${JSON.stringify(report, null, 2)}\n`);
}
if (args.summary) appendFileSync(args.summary, formatMarkdown(input));
for (const failure of helpers.filter((result) => result.failure !== null)) {
annotate('error', `pg-probe helper case [${failure.engine}] ${failure.name}: ${failure.failure}`);
}
for (const entry of verdict.grown) {
annotate('error', `pg-probe: ${entry.method} sends ${entry.now} statement(s) Postgres refuses, ${entry.allowed} allowed.`);
}
for (const entry of verdict.stale) {
annotate(
'error',
`pg-probe: ${entry.method} fails ${entry.now} statement(s), the baseline allows ${entry.allowed}. ` +
'Lower it (--update, or the pg-probe-baseline artifact).',
);
}
if (verdict.unseeded) annotate('notice', 'pg-probe: the baseline has not been measured yet; the pg-probe-baseline artifact holds the first one.');
for (const failure of schemaFailures) annotate('warning', `pg-probe schema: ${failure.message}`);
return exitCode(input, args.update);
}
if (require.main === module) {
// A method abandoned after its timeout may still reject later; that is
// already counted as a timeout and must not take the whole run down.
process.on('unhandledRejection', (reason: unknown) => {
annotate('warning', `pg-probe: a rejection nobody awaited: ${reason instanceof Error ? reason.message.split('\n')[0] : String(reason)}`);
});
main().then(
(code) => process.exit(code),
(error: unknown) => {
console.error(`pg-probe could not run: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
process.exit(2);
},
);
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Which repository methods the Postgres probe calls, and with what.
*
* The probe has no fixtures: it reads every repository class with the
* TypeScript checker and builds one sample argument list per public method
* from the declared parameter types (a number is 1, a string is "probe" or a
* date-shaped string when the name says so, an object gets every property
* it can sample, an array one element). The values only have to get the
* method to send its SQL; what the probe measures is whether Postgres
* accepts that SQL against an empty schema. A parameter the sampler cannot
* build (a callback, an EntityManager, an entity instance) makes the method
* unprobeable, which the report lists instead of guessing.
*/
import path from 'node:path';
import ts from 'typescript';
export type Sample = { ok: true; value: unknown } | { ok: false; reason: string };
export type MethodPlan =
| { className: string; file: string; method: string; args: unknown[] }
| { className: string; file: string; method: string; unprobeable: string };
const MAX_DEPTH = 4;
const SAMPLE_DATE = '2026-01-01';
const SAMPLE_TIMESTAMP = '2026-01-01 00:00:00';
/** A string sample shaped by the parameter or property name it is for. */
export function sampleString(name: string): string {
const lower = name.toLowerCase();
if (/(^|_)(date|day)$|date$/.test(lower) || /^(start|end|from|to)$/.test(lower)) return SAMPLE_DATE;
if (/(_at|time|timestamp)$/.test(lower) || /At$/.test(name)) return SAMPLE_TIMESTAMP;
if (lower.includes('email')) return 'probe@example.com';
if (/(url|uri|href)$/.test(lower)) return 'https://example.com/probe';
if (/colou?r$/.test(lower)) return '#000000';
if (/(^|_)ids?$|Ids?$/.test(name)) return '1';
return 'probe';
}
const OK = (value: unknown): Sample => ({ ok: true, value });
const NO = (reason: string): Sample => ({ ok: false, reason });
/** Why a sample failed (the server's non-strict config does not narrow `ok: false`). */
function reasonOf(sample: Sample): string {
return 'reason' in sample ? sample.reason : '';
}
function isNullish(type: ts.Type): boolean {
return (type.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0;
}
function declaredInNodeModules(symbol: ts.Symbol | undefined): boolean {
const declarations = symbol?.getDeclarations() ?? [];
return declarations.length > 0 && declarations.every((d) => d.getSourceFile().fileName.includes('/node_modules/'));
}
/** Builds a sample value for `type`; `name` is the parameter or property it is for. */
export function sampleFor(checker: ts.TypeChecker, type: ts.Type, name: string, depth = 0): Sample {
if (depth > MAX_DEPTH) return NO(`${name}: nested deeper than ${MAX_DEPTH}`);
const flags = type.flags;
if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return OK(sampleString(name));
if (flags & ts.TypeFlags.TypeParameter) {
const constraint = checker.getBaseConstraintOfType(type);
return constraint && constraint !== type ? sampleFor(checker, constraint, name, depth + 1) : OK(sampleString(name));
}
if (type.isUnion()) {
const members = type.types.filter((member) => !isNullish(member));
if (members.length === 0) return OK(null);
if (members.every((member) => member.flags & ts.TypeFlags.BooleanLiteral)) return OK(false);
const numeric = members.find((member) => member.flags & ts.TypeFlags.NumberLike);
return sampleFor(checker, numeric ?? members[0]!, name, depth + 1);
}
if (type.isStringLiteral()) return OK(type.value);
if (type.isNumberLiteral()) return OK(type.value);
if (flags & ts.TypeFlags.BooleanLiteral) return OK(checker.typeToString(type) === 'true');
if (flags & ts.TypeFlags.BooleanLike) return OK(false);
if (flags & ts.TypeFlags.BigIntLike) return OK(BigInt(1));
if (flags & ts.TypeFlags.NumberLike) return OK(1);
if (flags & (ts.TypeFlags.StringLike | ts.TypeFlags.TemplateLiteral)) return OK(sampleString(name));
if (isNullish(type)) return OK(null);
if (type.isIntersection()) return sampleObject(checker, type, name, depth);
if (flags & ts.TypeFlags.Object) return sampleObjectType(checker, type as ts.ObjectType, name, depth);
return NO(`${name}: no sample for ${checker.typeToString(type)}`);
}
function sampleObjectType(checker: ts.TypeChecker, type: ts.ObjectType, name: string, depth: number): Sample {
if (checker.isTupleType(type)) {
const values: unknown[] = [];
for (const [i, element] of checker.getTypeArguments(type as ts.TypeReference).entries()) {
const sample = sampleFor(checker, element, `${name}[${i}]`, depth + 1);
if (!sample.ok) return sample;
values.push(sample.value);
}
return OK(values);
}
if (checker.isArrayType(type)) {
const element = checker.getTypeArguments(type as ts.TypeReference)[0];
if (!element) return OK([]);
const sample = sampleFor(checker, element, singular(name), depth + 1);
return sample.ok ? OK([sample.value]) : sample;
}
const symbolName = type.getSymbol()?.getName();
if (symbolName === 'Date') return OK(new Date(`${SAMPLE_DATE}T00:00:00Z`));
if (symbolName === 'Buffer' || symbolName === 'Uint8Array') return OK(Buffer.from('probe'));
if (symbolName === 'Set' || symbolName === 'ReadonlySet' || symbolName === 'Map' || symbolName === 'ReadonlyMap') {
return sampleCollection(checker, type, symbolName, name, depth);
}
if (type.getCallSignatures().length > 0) return NO(`${name}: a function parameter`);
if (symbolName === 'Promise') return NO(`${name}: a promise parameter`);
const symbol = type.getSymbol();
if (symbol && symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface) && declaredInNodeModules(symbol)) {
return NO(`${name}: a ${symbolName} parameter`);
}
return sampleObject(checker, type, name, depth);
}
function sampleCollection(checker: ts.TypeChecker, type: ts.ObjectType, kind: string, name: string, depth: number): Sample {
const args = checker.getTypeArguments(type as ts.TypeReference);
const samples = args.map((arg, i) => sampleFor(checker, arg, i === 0 ? singular(name) : name, depth + 1));
const failed = samples.find((sample) => !sample.ok);
if (failed) return failed;
const values = samples.map((sample) => (sample.ok ? sample.value : undefined));
if (kind.endsWith('Set')) return OK(new Set([values[0]]));
return OK(new Map([[values[0], values[1]]]));
}
function sampleObject(checker: ts.TypeChecker, type: ts.Type, name: string, depth: number): Sample {
const value: Record<string, unknown> = {};
for (const property of checker.getPropertiesOfType(type)) {
const propertyType = checker.getTypeOfSymbol(property);
if (propertyType.getCallSignatures().length > 0) continue;
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0;
const sample = sampleFor(checker, propertyType, property.getName(), depth + 1);
if (sample.ok) value[property.getName()] = sample.value;
else if (!optional) return NO(`${name}.${reasonOf(sample)}`);
}
return OK(value);
}
function singular(name: string): string {
return name.endsWith('s') ? name.slice(0, -1) : name;
}
function isPublicMethod(member: ts.ClassElement): member is ts.MethodDeclaration {
if (!ts.isMethodDeclaration(member) || !member.body || !ts.isIdentifier(member.name)) return false;
const modifiers = ts.getCombinedModifierFlags(member);
return (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) === 0;
}
/** The sample argument list for one method, or why there is none. */
export function planMethod(checker: ts.TypeChecker, method: ts.MethodDeclaration): { args: unknown[] } | { unprobeable: string } {
const args: unknown[] = [];
for (const parameter of method.parameters) {
const name = ts.isIdentifier(parameter.name) ? parameter.name.text : 'options';
const optional = parameter.questionToken !== undefined || parameter.initializer !== undefined;
const sample = sampleFor(checker, checker.getTypeAtLocation(parameter), name);
if (!sample.ok) {
if (optional || parameter.dotDotDotToken) break;
return { unprobeable: reasonOf(sample) };
}
if (parameter.dotDotDotToken && Array.isArray(sample.value)) args.push(...(sample.value as unknown[]));
else args.push(sample.value);
}
return { args };
}
/** Every public method of every exported class declared in `files`, with its sample arguments. */
export function planRepositories(files: readonly string[], compilerOptions: ts.CompilerOptions, root: string): MethodPlan[] {
const program = ts.createProgram({ rootNames: [...files], options: { ...compilerOptions, noEmit: true } });
const checker = program.getTypeChecker();
const wanted = new Set(files.map((file) => path.resolve(file)));
const plans: MethodPlan[] = [];
for (const sourceFile of program.getSourceFiles()) {
if (!wanted.has(path.resolve(sourceFile.fileName))) continue;
const file = path.relative(root, sourceFile.fileName).split(path.sep).join('/');
for (const statement of sourceFile.statements) {
if (!ts.isClassDeclaration(statement) || !statement.name) continue;
if (!(ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export)) continue;
if (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Abstract) continue;
const className = statement.name.text;
for (const member of statement.members) {
if (!isPublicMethod(member)) continue;
const method = (member.name as ts.Identifier).text;
const plan = planMethod(checker, member);
plans.push('args' in plan ? { className, file, method, args: plan.args } : { className, file, method, unprobeable: plan.unprobeable });
}
}
}
return plans.sort((a, b) => `${a.className}.${a.method}`.localeCompare(`${b.className}.${b.method}`));
}
/** The compiler options of a tsconfig file, as `tsc -p` would read them. */
export function readCompilerOptions(tsconfigPath: string): ts.CompilerOptions {
const read = ts.readConfigFile(tsconfigPath, (file) => ts.sys.readFile(file));
if (read.error) throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n'));
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(tsconfigPath));
return parsed.options;
}
+103
View File
@@ -0,0 +1,103 @@
/**
* The Postgres probe's ratchet: how many statements of each repository method
* Postgres may still refuse.
*
* `scripts/pg-probe-baseline.json` maps `Class.method` to the number of
* distinct statements of that method that failed on Postgres when the entry
* was written. A method may fail no more statements than its entry (none
* without one), and an entry above what its method fails now fails the check
* too, so the change that fixes a statement lowers the baseline with it.
* `--update` lowers entries and drops the ones that reach zero or whose
* method is gone; it never raises or adds one. The only exception is the
* first run: a baseline whose `failing` is `null` has never been measured,
* so the check reports and passes, and `--update` (or the CI artifact)
* writes the first measurement.
*/
import { readFileSync } from 'node:fs';
export interface ProbeBaseline {
/** `Class.method` → failing statements allowed; `null` until the first measurement is written. */
failing: Record<string, number> | null;
}
export interface BaselineVerdict {
/** The baseline has never been measured: report only. */
unseeded: boolean;
/** Methods failing more statements than their entry allows. */
grown: { method: string; allowed: number; now: number }[];
/** Entries above what their method fails now (the method may be gone). */
stale: { method: string; allowed: number; now: number }[];
}
export function parseBaseline(text: string, source: string): ProbeBaseline {
let data: unknown;
try {
data = JSON.parse(text);
} catch (error) {
throw new Error(`${source} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
if (data === null || typeof data !== 'object' || Array.isArray(data) || !('failing' in data)) {
throw new Error(`${source} must be an object with a "failing" key`);
}
const failing = (data as { failing: unknown }).failing;
if (failing === null) return { failing: null };
if (typeof failing !== 'object' || Array.isArray(failing)) {
throw new Error(`${source}: "failing" must be null or an object of Class.method to counts`);
}
const entries: Record<string, number> = {};
for (const [method, count] of Object.entries(failing as Record<string, unknown>)) {
if (!/^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/.test(method)) {
throw new Error(`${source}: "${method}" is not a Class.method key`);
}
if (typeof count !== 'number' || !Number.isInteger(count) || count < 1) {
throw new Error(`${source}: ${method} holds ${JSON.stringify(count)}, expected a positive integer`);
}
entries[method] = count;
}
return { failing: entries };
}
export function readBaseline(path: string): ProbeBaseline {
return parseBaseline(readFileSync(path, 'utf8'), path);
}
/** Compares what failed now (`Class.method` → failing statements, zero counts allowed) with the baseline. */
export function compareWithBaseline(baseline: ProbeBaseline, failingNow: ReadonlyMap<string, number>): BaselineVerdict {
if (baseline.failing === null) return { unseeded: true, grown: [], stale: [] };
const allowedFor = baseline.failing;
const grown: BaselineVerdict['grown'] = [];
const stale: BaselineVerdict['stale'] = [];
for (const [method, now] of [...failingNow.entries()].sort(([a], [b]) => a.localeCompare(b))) {
const allowed = allowedFor[method] ?? 0;
if (now > allowed) grown.push({ method, allowed, now });
}
for (const method of Object.keys(allowedFor).sort()) {
const allowed = allowedFor[method]!;
const now = failingNow.get(method) ?? 0;
if (now < allowed) stale.push({ method, allowed, now });
}
return { unseeded: false, grown, stale };
}
/**
* The baseline `--update` writes: every entry lowered to what its method
* fails now, zeros dropped, nothing added. An unseeded baseline is seeded
* with everything that fails now.
*/
export function lowerBaseline(baseline: ProbeBaseline, failingNow: ReadonlyMap<string, number>): ProbeBaseline {
const next: Record<string, number> = {};
if (baseline.failing === null) {
for (const [method, now] of failingNow) if (now > 0) next[method] = now;
} else {
for (const [method, allowed] of Object.entries(baseline.failing)) {
const now = Math.min(allowed, failingNow.get(method) ?? 0);
if (now > 0) next[method] = now;
}
}
return { failing: Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))) };
}
export function formatBaseline(baseline: ProbeBaseline): string {
return `${JSON.stringify(baseline, null, 2)}\n`;
}
+250
View File
@@ -0,0 +1,250 @@
/**
* 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;
}
+281
View File
@@ -0,0 +1,281 @@
/**
* The dialect helpers, run on both engines.
*
* Each case puts one row into a scratch table, selects one helper's
* expression over it and checks the value. The same case runs on SQLite
* (an in-memory better-sqlite3 database) and on Postgres, so a Postgres
* branch is held to what its SQLite twin returns, not to a value written
* down once. The cases stay inside what both engines agree on: Postgres
* folds every letter in LOWER and NOCASE while SQLite folds only ASCII (the
* helpers' docstrings say so), and what Postgres does with a non-ASCII
* letter also depends on the database's locale, so the inputs are ASCII. An
* expectation may still name a value per engine where they are meant to
* differ.
*
* Unlike the repository statements, nothing here is baselined: every case
* must pass on both engines.
*/
import type { Platform } from '@mikro-orm/core';
import type { AliasableExpression, ExpressionBuilder } from 'kysely';
import {
absDifference,
caseWhenEquals,
caseWhenNotNull,
castInteger,
coalesce,
coalesceOverride,
coalesceOverrideWhileSame,
coalesceParam,
collateNoCase,
columnIncrementedBy,
columnRef,
concat,
countAll,
countAllRef,
currentTimestamp,
dateAdd,
dateOf,
dayDistance,
foundAgainState,
lower,
lowerParam,
lowerTrim,
lowerTrimParam,
maxOf,
minOf,
nowDateOffset,
nowMinusDays,
nowMinusHours,
nowPlusSeconds,
startsWithIsoDate,
substring,
trim,
} from '../../src/db/dialect/sql-functions';
import {
castIntegerKysely,
concatKysely,
currentTimestampKysely,
nowPlusSecondsKysely,
startsWithIsoDateKysely,
substringKysely,
unixEpochToIsoKysely,
} from '../../src/db/dialect/kysely-functions';
export type Engine = 'sqlite' | 'postgres';
export const VALUES_TABLE = 'trek_pg_probe_values';
export interface ValuesRow {
id: number;
a: string | null;
b: string | null;
n: number | null;
x: number | null;
}
export interface ValuesDB {
trek_pg_probe_values: ValuesRow;
}
export const VALUES_TABLE_DDL: Record<Engine, string> = {
sqlite: `CREATE TABLE ${VALUES_TABLE} (id INTEGER PRIMARY KEY, a TEXT, b TEXT, n INTEGER, x REAL)`,
postgres: `CREATE TABLE ${VALUES_TABLE} (id integer PRIMARY KEY, a text, b text, n bigint, x double precision)`,
};
/** A fragment rendered into `select <sql> [as v] from trek_pg_probe_values`. */
export interface RawSelect {
sql: string;
params: unknown[];
/** The fragment already names its column (`COUNT(*) as c`); read that one instead of `v`. */
column?: string;
}
export type KyselySelect = (platform: Platform, eb: ExpressionBuilder<ValuesDB, 'trek_pg_probe_values'>) => AliasableExpression<unknown>;
export type Expectation =
| { equals: string | number | boolean | null }
| { timestamp: number }
| { date: number }
| { perEngine: Record<Engine, Expectation> };
export interface HelperCase {
name: string;
row: Partial<Omit<ValuesRow, 'id'>>;
raw?: (platform: Platform) => RawSelect;
kysely?: KyselySelect;
expect: Expectation;
}
const DAY_MS = 86_400_000;
const CLOCK_TOLERANCE_MS = 120_000;
const rawOf = (fragment: { sql: string; params: readonly unknown[] }, column?: string): RawSelect => ({
sql: fragment.sql,
params: [...fragment.params],
column,
});
/** `(<key> = <value>)` for the helpers used as a filter key. */
const comparison = (key: { sql: string; params: readonly unknown[] }, value: { sql: string; params: readonly unknown[] }): RawSelect => ({
sql: `(${key.sql} = ${value.sql})`,
params: [...key.params, ...value.params],
});
const bound = (value: unknown): { sql: string; params: unknown[] } => ({ sql: '?', params: [value] });
/** Epoch milliseconds as the ISO text `unixEpochToIsoKysely` renders (whole seconds, `Z`). */
export function isoSeconds(ms: number): string {
return new Date(Math.floor(ms / 1000) * 1000).toISOString().replace('.000Z', 'Z');
}
const EPOCH_MS = 1_758_459_909_123;
export const HELPER_CASES: readonly HelperCase[] = [
{ name: 'dateOf', row: { a: '2026-09-21 13:05:09' }, raw: (p) => rawOf(dateOf(p, 'a')), expect: { equals: '2026-09-21' } },
{ name: 'dateAdd forward', row: { a: '2026-09-21 13:05:09' }, raw: (p) => rawOf(dateAdd(p, 'a', 10)), expect: { equals: '2026-10-01' } },
{ name: 'dateAdd back', row: { a: '2026-09-21 13:05:09' }, raw: (p) => rawOf(dateAdd(p, 'a', -10)), expect: { equals: '2026-09-11' } },
{ name: 'currentTimestamp', row: {}, raw: (p) => rawOf(currentTimestamp(p)), expect: { timestamp: 0 } },
{ name: 'nowMinusDays', row: {}, raw: (p) => rawOf(nowMinusDays(p, 30)), expect: { timestamp: -30 * DAY_MS } },
{ name: 'nowMinusHours', row: {}, raw: (p) => rawOf(nowMinusHours(p, 20)), expect: { timestamp: -20 * 3_600_000 } },
{ name: 'nowPlusSeconds', row: {}, raw: (p) => rawOf(nowPlusSeconds(p, 90)), expect: { timestamp: 90_000 } },
{ name: 'nowDateOffset back', row: {}, raw: (p) => rawOf(nowDateOffset(p, -400)), expect: { date: -400 } },
{ name: 'nowDateOffset forward', row: {}, raw: (p) => rawOf(nowDateOffset(p, 1)), expect: { date: 1 } },
{ name: 'columnRef', row: { n: 7 }, raw: (p) => rawOf(columnRef(p, 'n')), expect: { equals: 7 } },
{ name: 'columnIncrementedBy', row: { n: 7 }, raw: (p) => rawOf(columnIncrementedBy(p, 'n', -2)), expect: { equals: 5 } },
{ name: 'lower', row: { a: 'Probe@X.com' }, raw: (p) => rawOf(lower(p, 'a')), expect: { equals: 'probe@x.com' } },
{ name: 'lower = lowerParam', row: { a: 'Probe@X.com' }, raw: (p) => comparison(lower(p, 'a'), lowerParam(p, 'PROBE@x.COM')), expect: { equals: true } },
{ name: 'trim', row: { a: ' probe ' }, raw: (p) => rawOf(trim(p, 'a')), expect: { equals: 'probe' } },
{ name: 'lowerTrim', row: { a: ' AbC ' }, raw: (p) => rawOf(lowerTrim(p, 'a')), expect: { equals: 'abc' } },
{ name: 'lowerTrim = lowerTrimParam', row: { a: ' AbC ' }, raw: (p) => comparison(lowerTrim(p, 'a'), lowerTrimParam(p, ' aBc')), expect: { equals: true } },
{ name: 'coalesce', row: { a: null, b: 'fallback' }, raw: (p) => rawOf(coalesce(p, 'a', 'b')), expect: { equals: 'fallback' } },
{ name: 'coalesceParam keeps the column', row: { a: 'stored' }, raw: (p) => rawOf(coalesceParam(p, 'a', 'new')), expect: { equals: 'stored' } },
{ name: 'coalesceParam fills a null', row: { a: null }, raw: (p) => rawOf(coalesceParam(p, 'a', 'new')), expect: { equals: 'new' } },
{ name: 'coalesceOverride prefers the value', row: { a: 'stored' }, raw: (p) => rawOf(coalesceOverride(p, 'new', 'a')), expect: { equals: 'new' } },
{ name: 'coalesceOverride keeps on null', row: { a: 'stored' }, raw: (p) => rawOf(coalesceOverride(p, null, 'a')), expect: { equals: 'stored' } },
{ name: 'absDifference', row: { x: 4 }, raw: (p) => rawOf(absDifference(p, 'x', 6.5)), expect: { equals: 2.5 } },
{ name: 'countAll', row: {}, raw: (p) => rawOf(countAll(p, 'c'), 'c'), expect: { equals: 1 } },
{ name: 'countAllRef', row: {}, raw: (p) => rawOf(countAllRef(p)), expect: { equals: 1 } },
{ name: 'minOf', row: { n: 3 }, raw: (p) => rawOf(minOf(p, 'n', 'lo'), 'lo'), expect: { equals: 3 } },
{ name: 'maxOf', row: { n: 3 }, raw: (p) => rawOf(maxOf(p, 'n', 'hi'), 'hi'), expect: { equals: 3 } },
{ name: 'caseWhenEquals', row: { n: 7 }, raw: (p) => rawOf(caseWhenEquals(p, 'n', 7, 'owner', 'member')), expect: { equals: 'owner' } },
{ name: 'caseWhenEquals else', row: { n: 8 }, raw: (p) => rawOf(caseWhenEquals(p, 'n', 7, 'owner', 'member')), expect: { equals: 'member' } },
{ name: 'startsWithIsoDate yes', row: { a: '2026-09-21T10:00' }, raw: (p) => rawOf(startsWithIsoDate(p, 'a')), expect: { equals: true } },
{ name: 'startsWithIsoDate no', row: { a: 'abcd-ef-gh' }, raw: (p) => rawOf(startsWithIsoDate(p, 'a')), expect: { equals: false } },
{ name: 'substring with length', row: { a: '2026-09-21T10:00' }, raw: (p) => rawOf(substring(p, 'a', 1, 10)), expect: { equals: '2026-09-21' } },
{ name: 'substring to the end', row: { a: '2026-09-21T10:00' }, raw: (p) => rawOf(substring(p, 'a', 12)), expect: { equals: '10:00' } },
{
name: 'concat',
row: { a: '2026-09-21', b: '10:00' },
raw: (p) => rawOf(concat(p, { column: 'a' }, { value: 'T' }, { column: 'b' })),
expect: { equals: '2026-09-21T10:00' },
},
{ name: 'castInteger of 14.0', row: { a: '14.0' }, raw: (p) => rawOf(castInteger(p, 'a')), expect: { equals: 14 } },
{ name: 'castInteger of 14', row: { a: '14' }, raw: (p) => rawOf(castInteger(p, 'a')), expect: { equals: 14 } },
{ name: 'dayDistance', row: { a: '2026-09-21' }, raw: (p) => rawOf(dayDistance(p, 'a', '2026-09-24')), expect: { equals: 3 } },
{ name: 'collateNoCase', row: { a: 'Probe' }, raw: (p) => comparison(collateNoCase(p, 'a'), bound('pROBE')), expect: { equals: true } },
{
name: 'foundAgainState recovers',
row: { a: 'remote_missing', b: 'file-1' },
raw: (p) => rawOf(foundAgainState(p, 'a', 'b')),
expect: { equals: 'synced' },
},
{ name: 'foundAgainState passes through', row: { a: 'error', b: 'file-1' }, raw: (p) => rawOf(foundAgainState(p, 'a', 'b')), expect: { equals: 'error' } },
{
name: 'coalesceOverrideWhileSame keeps for the same key',
row: { a: 'https://a', n: 1 },
raw: (p) => rawOf(coalesceOverrideWhileSame(p, null, 'n', 'a', 'https://a')),
expect: { equals: 1 },
},
{
name: 'coalesceOverrideWhileSame resets for a new key',
row: { a: 'https://a', n: 1 },
raw: (p) => rawOf(coalesceOverrideWhileSame(p, null, 'n', 'a', 'https://b')),
expect: { equals: 0 },
},
{ name: 'caseWhenNotNull', row: { a: 'x', b: 'route' }, raw: (p) => rawOf(caseWhenNotNull(p, 'a', 'b')), expect: { equals: 'route' } },
{ name: 'caseWhenNotNull null', row: { a: null, b: 'route' }, raw: (p) => rawOf(caseWhenNotNull(p, 'a', 'b')), expect: { equals: null } },
{
name: 'startsWithIsoDateKysely',
row: { a: '2026-09-21 10:00:00' },
kysely: (p, eb) => eb.case().when(startsWithIsoDateKysely(p, eb, 'a')).then('yes').else('no').end(),
expect: { equals: 'yes' },
},
{ name: 'substringKysely', row: { a: '2026-09-21T10:00' }, kysely: (p, eb) => substringKysely(p, eb, 'a', 1, 10), expect: { equals: '2026-09-21' } },
{
name: 'concatKysely',
row: { a: '2026-09-21', b: 'xx10:00' },
kysely: (p, eb) => concatKysely(p, eb, { column: 'a' }, { value: 'T' }, { expression: substringKysely(p, eb, 'b', 3) }),
expect: { equals: '2026-09-21T10:00' },
},
{ name: 'castIntegerKysely', row: { a: '14.0' }, kysely: (p, eb) => castIntegerKysely(p, eb, 'a'), expect: { equals: 14 } },
{ name: 'unixEpochToIsoKysely', row: { n: EPOCH_MS }, kysely: (p, eb) => unixEpochToIsoKysely(p, eb, 'n'), expect: { equals: isoSeconds(EPOCH_MS) } },
{ name: 'nowPlusSecondsKysely', row: {}, kysely: (p, eb) => nowPlusSecondsKysely(p, eb, 45), expect: { timestamp: 45_000 } },
{ name: 'currentTimestampKysely', row: {}, kysely: (p) => currentTimestampKysely(p), expect: { timestamp: 0 } },
];
const TIMESTAMP_TEXT = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const DATE_TEXT = /^\d{4}-\d{2}-\d{2}$/;
/** Why `value` does not meet `expectation` on `engine`, or null when it does. `now` is epoch ms around the query. */
export function checkValue(expectation: Expectation, value: unknown, engine: Engine, now: number): string | null {
if ('perEngine' in expectation) return checkValue(expectation.perEngine[engine], value, engine, now);
if ('timestamp' in expectation) {
if (typeof value !== 'string' || !TIMESTAMP_TEXT.test(value)) return `expected timestamp text, got ${JSON.stringify(value)}`;
const off = Date.parse(`${value.replace(' ', 'T')}Z`) - (now + expectation.timestamp);
return Math.abs(off) <= CLOCK_TOLERANCE_MS ? null : `${value} is ${Math.round(off / 1000)}s off`;
}
if ('date' in expectation) {
if (typeof value !== 'string' || !DATE_TEXT.test(value)) return `expected date text, got ${JSON.stringify(value)}`;
const wanted = Date.parse(new Date(now + expectation.date * DAY_MS).toISOString().slice(0, 10));
return Math.abs(Date.parse(value) - wanted) <= DAY_MS ? null : `${value} is not ${expectation.date} day(s) from today`;
}
const expected = expectation.equals;
if (expected === null) return value === null ? null : `expected null, got ${JSON.stringify(value)}`;
if (typeof expected === 'boolean') {
const truthy = value === true || value === 1 || value === 't' || value === '1';
const falsy = value === false || value === 0 || value === 'f' || value === '0';
return (expected ? truthy : falsy) ? null : `expected ${expected}, got ${JSON.stringify(value)}`;
}
if (typeof expected === 'number') {
const n = typeof value === 'bigint' ? Number(value) : typeof value === 'string' || typeof value === 'number' ? Number(value) : Number.NaN;
return Math.abs(n - expected) < 1e-9 ? null : `expected ${expected}, got ${JSON.stringify(typeof value === 'bigint' ? String(value) : value)}`;
}
return value === expected ? null : `expected ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`;
}
/** How one engine runs a case: write the row, select the expression, hand back the value. */
export interface HelperEngine {
engine: Engine;
platform: Platform;
writeRow(row: ValuesRow): Promise<void>;
selectRaw(select: RawSelect): Promise<unknown>;
selectKysely(select: KyselySelect): Promise<unknown>;
}
export interface HelperResult {
engine: Engine;
name: string;
failure: string | null;
}
export async function runHelperCases(engine: HelperEngine, cases: readonly HelperCase[] = HELPER_CASES): Promise<HelperResult[]> {
const results: HelperResult[] = [];
for (const helperCase of cases) {
let failure: string | null;
try {
await engine.writeRow({ id: 1, a: null, b: null, n: null, x: null, ...helperCase.row });
const before = Date.now();
let value: unknown;
if (helperCase.raw) value = await engine.selectRaw(helperCase.raw(engine.platform));
else if (helperCase.kysely) value = await engine.selectKysely(helperCase.kysely);
else throw new Error('a case needs raw or kysely');
failure = checkValue(helperCase.expect, value, engine.engine, Math.round((before + Date.now()) / 2));
} catch (error) {
failure = `threw: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`;
}
results.push({ engine: engine.engine, name: helperCase.name, failure });
}
return results;
}
+217
View File
@@ -0,0 +1,217 @@
/**
* The statement recorder under the Postgres probe.
*
* The probe runs every repository method against an empty Postgres schema and
* wants two things from each SQL statement the method sends: whether
* Postgres accepts it, and that nothing it does survives. This module wraps a
* Kysely dialect so both hold for every statement, whichever API built it
* (the MikroORM QueryBuilder, `em.insert`, `this.kysely()`), because they all
* end in the same Kysely driver:
*
* - every statement runs alone inside `begin; set local statement_timeout;
* <statement>; rollback`, so it always meets empty tables and can never
* hang the run;
* - a statement Postgres refuses is recorded with its SQLSTATE and answered
* with an empty result, the way an empty table would answer, so the method
* goes on and its later statements are probed too;
* - the transaction calls MikroORM and Kysely make (begin, commit,
* savepoints) become no-ops, since each statement already rolls back.
*
* Statements are attributed to the method that sent them through
* AsyncLocalStorage, so a straggler from a timed-out method can never be
* counted against the next one.
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import {
CompiledQuery,
type DatabaseConnection,
type DatabaseIntrospector,
type DialectAdapter,
type Driver,
type Kysely,
PostgresDialect,
type QueryCompiler,
type QueryResult,
} from 'kysely';
export type StatementOutcome = { ok: true } | { ok: false; code: string; message: string };
export interface StatementRecord {
sql: string;
outcome: StatementOutcome;
}
/** Transaction control a probed method sends; each statement already runs in its own rolled-back transaction. */
const TRANSACTION_CONTROL = /^\s*(begin|commit|rollback|end|abort|start\s+transaction|savepoint|release(\s+savepoint)?)\b/i;
export function isTransactionControl(sql: string): boolean {
return TRANSACTION_CONTROL.test(sql);
}
/** The SQLSTATE of a driver error (`pg` puts it on `code`), or a marker when there is none. */
export function sqlState(error: unknown): string {
if (error !== null && typeof error === 'object' && 'code' in error) {
const code = (error as { code: unknown }).code;
if (typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code)) return code;
}
return 'NOSQLSTATE';
}
/** The first line of an error message, without the stack. */
export function errorLine(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.split('\n')[0]!.trim();
}
export class StatementRecorder {
private readonly scope = new AsyncLocalStorage<string>();
private readonly records = new Map<string, StatementRecord[]>();
/** Runs `fn` with every statement it sends attributed to `key`. */
async run<T>(key: string, fn: () => Promise<T>): Promise<T> {
if (!this.records.has(key)) this.records.set(key, []);
return this.scope.run(key, fn);
}
/** The method the current async context belongs to, or null outside one. */
current(): string | null {
return this.scope.getStore() ?? null;
}
record(key: string, record: StatementRecord): void {
const list = this.records.get(key);
if (list) list.push(record);
else this.records.set(key, [record]);
}
statementsOf(key: string): readonly StatementRecord[] {
return this.records.get(key) ?? [];
}
entries(): [string, readonly StatementRecord[]][] {
return [...this.records.entries()];
}
}
export class ProbeConnection implements DatabaseConnection {
constructor(
readonly inner: DatabaseConnection,
private readonly recorder: StatementRecorder,
private readonly statementTimeoutMs: number,
) {}
async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
const key = this.recorder.current();
if (key === null) return this.inner.executeQuery<R>(compiled);
if (isTransactionControl(compiled.sql)) return { rows: [] };
await this.inner.executeQuery(CompiledQuery.raw('begin'));
try {
await this.inner.executeQuery(CompiledQuery.raw(`set local statement_timeout = ${this.statementTimeoutMs}`));
const result = await this.inner.executeQuery<R>(compiled);
this.recorder.record(key, { sql: compiled.sql, outcome: { ok: true } });
return result;
} catch (error) {
this.recorder.record(key, { sql: compiled.sql, outcome: { ok: false, code: sqlState(error), message: errorLine(error) } });
return { rows: [] };
} finally {
await this.inner.executeQuery(CompiledQuery.raw('rollback'));
}
}
streamQuery<R>(compiled: CompiledQuery, chunkSize?: number): AsyncIterableIterator<QueryResult<R>> {
return this.inner.streamQuery<R>(compiled, chunkSize);
}
}
export class ProbeDriver implements Driver {
private readonly wrapped = new WeakMap<DatabaseConnection, ProbeConnection>();
constructor(
private readonly inner: Driver,
private readonly recorder: StatementRecorder,
private readonly statementTimeoutMs: number,
) {}
init(): Promise<void> {
return this.inner.init();
}
async acquireConnection(): Promise<DatabaseConnection> {
const connection = await this.inner.acquireConnection();
let probe = this.wrapped.get(connection);
if (!probe) {
probe = new ProbeConnection(connection, this.recorder, this.statementTimeoutMs);
this.wrapped.set(connection, probe);
}
return probe;
}
async beginTransaction(): Promise<void> {
// Every statement runs in a transaction of its own (ProbeConnection).
}
async commitTransaction(): Promise<void> {
// See beginTransaction.
}
async rollbackTransaction(): Promise<void> {
// See beginTransaction.
}
async savepoint(): Promise<void> {
// See beginTransaction.
}
async rollbackToSavepoint(): Promise<void> {
// See beginTransaction.
}
async releaseSavepoint(): Promise<void> {
// See beginTransaction.
}
releaseConnection(connection: DatabaseConnection): Promise<void> {
return this.inner.releaseConnection(connection instanceof ProbeConnection ? connection.inner : connection);
}
destroy(): Promise<void> {
return this.inner.destroy();
}
}
/**
* A Postgres dialect whose driver is `base`'s wrapped in a {@link ProbeDriver};
* compiler, adapter and introspector stay `base`'s own. A subclass rather
* than a plain `Dialect` object because MikroORM's `PostgreSqlConnection`
* declares `createKyselyDialect()` to return a `PostgresDialect`. The config
* handed to `super` is never read: every method that would read it is
* overridden to delegate to `base`.
*/
export class ProbePostgresDialect extends PostgresDialect {
constructor(
private readonly base: PostgresDialect,
private readonly recorder: StatementRecorder,
private readonly statementTimeoutMs: number,
) {
super({ pool: () => Promise.reject(new Error('ProbePostgresDialect: the base dialect owns the pool')) });
}
override createDriver(): Driver {
return new ProbeDriver(this.base.createDriver(), this.recorder, this.statementTimeoutMs);
}
override createQueryCompiler(): QueryCompiler {
return this.base.createQueryCompiler();
}
override createAdapter(): DialectAdapter {
return this.base.createAdapter();
}
override createIntrospector(db: Kysely<unknown>): DatabaseIntrospector {
return this.base.createIntrospector(db);
}
}
+182
View File
@@ -0,0 +1,182 @@
/**
* Turns what the Postgres probe saw into counts, a console log and the
* Markdown the CI job writes to its step summary.
*/
import type { BaselineVerdict } from './baseline';
import type { HelperResult } from './helper-cases';
import type { StatementRecord } from './recorder';
export interface MethodResult {
/** `Class.method`. */
key: string;
statements: readonly StatementRecord[];
/** Why the method was not called at all. */
unprobeable?: string;
/** The method threw (in JS, after or before its SQL); the statements it sent still count. */
threw?: string;
timedOut?: boolean;
}
export interface FailedStatement {
method: string;
code: string;
message: string;
sql: string;
}
export interface ProbeSummary {
methods: number;
called: number;
unprobeable: number;
/** Called, but sent no SQL (a pure helper, or it threw before reaching the database). */
withoutSql: number;
timedOut: number;
/** Distinct statements per method, summed. */
statements: number;
failedStatements: FailedStatement[];
/** `Class.method` → distinct statements Postgres refused; every called method appears, zeros included. */
failingByMethod: Map<string, number>;
/** SQLSTATE → distinct failing statements. */
failuresByCode: Map<string, number>;
}
export function summarize(results: readonly MethodResult[]): ProbeSummary {
const summary: ProbeSummary = {
methods: results.length,
called: 0,
unprobeable: 0,
withoutSql: 0,
timedOut: 0,
statements: 0,
failedStatements: [],
failingByMethod: new Map(),
failuresByCode: new Map(),
};
for (const result of results) {
if (result.unprobeable !== undefined) {
summary.unprobeable += 1;
continue;
}
summary.called += 1;
if (result.timedOut) summary.timedOut += 1;
if (result.statements.length === 0) summary.withoutSql += 1;
const seen = new Set<string>();
const failed = new Map<string, StatementRecord>();
for (const statement of result.statements) {
seen.add(statement.sql);
if (!statement.outcome.ok && !failed.has(statement.sql)) failed.set(statement.sql, statement);
}
summary.statements += seen.size;
summary.failingByMethod.set(result.key, failed.size);
for (const statement of failed.values()) {
const outcome = statement.outcome;
if (!('code' in outcome)) continue;
summary.failedStatements.push({ method: result.key, code: outcome.code, message: outcome.message, sql: statement.sql });
summary.failuresByCode.set(outcome.code, (summary.failuresByCode.get(outcome.code) ?? 0) + 1);
}
}
return summary;
}
/** SQL on one line, cut to `max` characters, for a log or a table cell. */
export function oneLine(sql: string, max = 160): string {
const flat = sql.replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function cell(text: string): string {
return text.replace(/\|/g, '\\|').replace(/`/g, "'");
}
export interface ReportInput {
summary: ProbeSummary;
verdict: BaselineVerdict;
helpers: readonly HelperResult[];
schemaFailures: readonly { statement: string; message: string }[];
results: readonly MethodResult[];
}
export function helperFailures(helpers: readonly HelperResult[]): HelperResult[] {
return helpers.filter((result) => result.failure !== null);
}
/** True when the run passes: every helper case on both engines, and the ratchet. */
export function passed(input: Pick<ReportInput, 'verdict' | 'helpers'>): boolean {
return helperFailures(input.helpers).length === 0 && input.verdict.grown.length === 0 && input.verdict.stale.length === 0;
}
export function formatConsole(input: ReportInput): string {
const { summary, verdict } = input;
const lines: string[] = [];
const failures = helperFailures(input.helpers);
lines.push(`Dialect helpers: ${input.helpers.length - failures.length}/${input.helpers.length} cases pass on SQLite and Postgres.`);
for (const failure of failures) lines.push(` FAIL [${failure.engine}] ${failure.name}: ${failure.failure}`);
if (input.schemaFailures.length > 0) {
lines.push(`Schema: ${input.schemaFailures.length} DDL statement(s) Postgres refused (their tables are missing below):`);
for (const failure of input.schemaFailures) lines.push(` ${failure.message} <- ${oneLine(failure.statement, 120)}`);
}
lines.push(
`Repositories: ${summary.methods} methods, ${summary.called} called, ${summary.unprobeable} unprobeable, ` +
`${summary.withoutSql} sent no SQL, ${summary.timedOut} timed out.`,
);
lines.push(`Statements: ${summary.statements} distinct, ${summary.failedStatements.length} refused by Postgres.`);
for (const [code, count] of [...summary.failuresByCode.entries()].sort((a, b) => b[1] - a[1])) {
lines.push(` ${code}: ${count}`);
}
for (const failed of summary.failedStatements) {
lines.push(` ${failed.method} ${failed.code} ${failed.message}`);
lines.push(` ${oneLine(failed.sql)}`);
}
if (verdict.unseeded) {
lines.push('Baseline: not measured yet (failing is null). This run passes and writes the first measurement as the next baseline.');
}
for (const entry of verdict.grown) {
lines.push(`FAIL ${entry.method} sends ${entry.now} statement(s) Postgres refuses, ${entry.allowed} allowed.`);
}
for (const entry of verdict.stale) {
lines.push(`FAIL ${entry.method} is held at ${entry.allowed} failing statement(s) but fails ${entry.now} now; lower the baseline.`);
}
lines.push(passed(input) ? 'Postgres probe passed.' : 'Postgres probe failed.');
return lines.join('\n');
}
export function formatMarkdown(input: ReportInput): string {
const { summary, verdict } = input;
const failures = helperFailures(input.helpers);
const out: string[] = ['## Postgres probe', ''];
out.push(passed(input) ? 'Result: **passed**' : 'Result: **failed**', '');
out.push('| | |', '|---|---|');
out.push(`| Dialect helper cases (SQLite and Postgres) | ${input.helpers.length - failures.length} of ${input.helpers.length} pass |`);
out.push(`| Schema statements refused | ${input.schemaFailures.length} |`);
out.push(`| Repository methods | ${summary.methods} (${summary.called} called, ${summary.unprobeable} unprobeable) |`);
out.push(`| Distinct statements | ${summary.statements} |`);
out.push(`| Refused by Postgres | ${summary.failedStatements.length} |`);
out.push('');
if (verdict.unseeded) out.push('The baseline has not been measured yet; the `pg-probe-baseline` artifact holds the first one.', '');
if (verdict.grown.length > 0 || verdict.stale.length > 0) {
out.push('### Ratchet', '', '| Method | Allowed | Now |', '|---|---|---|');
for (const entry of [...verdict.grown, ...verdict.stale]) out.push(`| ${cell(entry.method)} | ${entry.allowed} | ${entry.now} |`);
out.push('');
}
if (failures.length > 0) {
out.push('### Helper cases that failed', '', '| Engine | Case | Why |', '|---|---|---|');
for (const failure of failures) out.push(`| ${failure.engine} | ${cell(failure.name)} | ${cell(failure.failure ?? '')} |`);
out.push('');
}
if (summary.failuresByCode.size > 0) {
out.push('### Refused statements by SQLSTATE', '', '| SQLSTATE | Statements |', '|---|---|');
for (const [code, count] of [...summary.failuresByCode.entries()].sort((a, b) => b[1] - a[1])) out.push(`| ${code} | ${count} |`);
out.push('');
out.push('<details><summary>Every refused statement</summary>', '', '| Method | SQLSTATE | Message |', '|---|---|---|');
for (const failed of summary.failedStatements) out.push(`| ${cell(failed.method)} | ${failed.code} | ${cell(failed.message)} |`);
out.push('', '</details>', '');
}
const unprobeable = input.results.filter((result) => result.unprobeable !== undefined);
if (unprobeable.length > 0) {
out.push('<details><summary>Methods the probe could not call</summary>', '', '| Method | Why |', '|---|---|');
for (const result of unprobeable) out.push(`| ${cell(result.key)} | ${cell(result.unprobeable ?? '')} |`);
out.push('', '</details>', '');
}
return `${out.join('\n')}\n`;
}
+103
View File
@@ -0,0 +1,103 @@
/**
* The Postgres schema the probe runs against, derived from the entities.
*
* The entities are generated from the migrated SQLite schema and a parity
* test pins them to it (`npm run check:entities`), so they are the one
* description of the tables both engines can read. MikroORM's schema
* generator turns them into Postgres DDL after three adjustments that keep
* the probe about the SQL the repositories send, not about the storage
* decisions a real Postgres port still has to make:
*
* - timestamps stay TEXT, the storage model the services and the dialect
* helpers are written for (server/CLAUDE.md, "Storage model");
* - a SQLite-only default (`datetime('now')`, `strftime('%s','now')`) gets
* its Postgres equivalent, and a column declared `COLLATE NOCASE` uses
* the `nocase` collation `collateNoCase()` names on Postgres;
* - no foreign keys, so a statement is never refused only because the
* sample row it writes points at nothing (every statement meets empty
* tables).
*
* Unique and partial unique indexes stay, because `ON CONFLICT` needs them.
*/
import { pgTimestampText, PG_UTC_NOW } from '../../src/db/dialect/platform';
/** SQLite's timestamp text on Postgres, for a column default. */
export const PG_NOW_TEXT_DEFAULT = pgTimestampText(PG_UTC_NOW);
/** The case-insensitive collation `collateNoCase()` names on Postgres. */
export const NOCASE_COLLATION_SQL =
"CREATE COLLATION IF NOT EXISTS nocase (provider = icu, locale = 'und-u-ks-level2', deterministic = false)";
/** The Postgres spelling of a column default the entities carry in SQLite's. */
export function portableDefault(raw: string): string {
const text = raw.trim().replace(/^\((.*)\)$/s, '$1').trim();
if (/^current_timestamp$/i.test(text) || /^datetime\(\s*'now'\s*\)$/i.test(text)) return PG_NOW_TEXT_DEFAULT;
if (/^strftime\(\s*'%s'\s*,\s*'now'\s*\)$/i.test(text)) return 'CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) AS bigint)';
return raw;
}
/**
* Splits generated DDL into single statements on `;` outside quotes, so one
* statement Postgres refuses does not take the rest of the schema with it.
*/
export function splitStatements(sql: string): string[] {
const statements: string[] = [];
let current = '';
let quote: '"' | "'" | null = null;
for (const char of sql) {
if (quote) {
if (char === quote) quote = null;
current += char;
} else if (char === "'" || char === '"') {
quote = char;
current += char;
} else if (char === ';') {
if (current.trim()) statements.push(current.trim());
current = '';
} else {
current += char;
}
}
if (current.trim()) statements.push(current.trim());
return statements.filter((statement) => !/^set\s/i.test(statement));
}
/** The subset of MikroORM's property metadata the schema adjustments touch. */
export interface ColumnMeta {
name: string;
defaultRaw?: string;
collation?: string;
columnTypes?: string[];
customType?: { constructor: { name: string } };
}
/**
* Applies the adjustments above to one entity's properties in place:
* timestamp columns become `text`, SQLite-only defaults and the NOCASE
* collation their Postgres spelling. Returns the names of the properties
* it changed, for the log.
*/
export function adjustColumns(properties: readonly ColumnMeta[]): string[] {
const changed: string[] = [];
for (const property of properties) {
let touched = false;
if (property.customType?.constructor.name === 'DbTimestampType' && property.columnTypes) {
property.columnTypes = property.columnTypes.map(() => 'text');
touched = true;
}
if (typeof property.defaultRaw === 'string') {
const next = portableDefault(property.defaultRaw);
if (next !== property.defaultRaw) {
property.defaultRaw = next;
touched = true;
}
}
if (property.collation?.toUpperCase() === 'NOCASE') {
property.collation = 'nocase';
touched = true;
}
if (touched) changed.push(property.name);
}
return changed;
}