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
+73 -3
View File
@@ -18,9 +18,10 @@ permissions:
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# client-e2e drives the real app in Chromium and WebKit, and client-bundle
# builds the client and checks each map engine keeps a chunk of its own.
# Neither feeds the scan.
# client-e2e drives the real app in Chromium and WebKit, client-bundle
# builds the client and checks each map engine keeps a chunk of its own,
# and postgres-probe runs the dialect helpers and every repository
# statement against a Postgres service. None of them feeds the scan.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
@@ -521,6 +522,74 @@ jobs:
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
# What of the server's SQL runs on Postgres today (server/scripts/pg-probe.ts).
# TREK runs on SQLite; the dialect layer in server/src/db/dialect/ is written
# so a second engine is a branch there and not a sweep through the
# repositories, and this is the job that holds it to that. Against an empty
# Postgres it derives a schema from the entities, runs every dialect helper
# on SQLite and Postgres (every case must pass), and calls every public
# repository method once, each statement in its own rolled-back transaction.
# The statements Postgres refuses are held per method to
# server/scripts/pg-probe-baseline.json, which may only shrink. The measured
# baseline and the full report are uploaded either way, so a change that
# fixes a statement can lower the baseline without a local Postgres.
postgres-probe:
name: Postgres Probe
runs-on: ubuntu-24.04
timeout-minutes: 10
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
if: needs.changes.outputs.code == 'true'
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: probe
POSTGRES_DB: trek_probe
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d trek_probe"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
# The probe reads the repositories' parameter types, some of which come
# from @trek/shared's built declarations.
- name: Build shared
run: npm run build --workspace=shared
- name: Run the Postgres probe
env:
TREK_PG_PROBE_URL: postgres://postgres:probe@127.0.0.1:5432/trek_probe
run: >-
cd server && npm run probe:pg --
--next-baseline="$RUNNER_TEMP/pg-probe-baseline.json"
--report="$RUNNER_TEMP/pg-probe-report.json"
- name: Upload the measured baseline and the report
if: always()
uses: actions/upload-artifact@v6
with:
name: pg-probe-baseline
path: |
${{ runner.temp }}/pg-probe-baseline.json
${{ runner.temp }}/pg-probe-report.json
if-no-files-found: ignore
retention-days: 14
server-coverage:
name: Server Coverage
runs-on: ubuntu-24.04
@@ -884,6 +953,7 @@ jobs:
- client-bundle
- client-e2e
- s3-contract
- postgres-probe
- server-coverage
- client-coverage
- shared-coverage
+2 -1
View File
@@ -44,6 +44,7 @@ cd client && npm run lint:skips # no .only; skipped tests may
cd client && npm run lint:i18n-keys # every key the client names exists in en (CI gate)
cd server && npm run lint:boundaries # no new import cycles or cross-domain reach-ins (CI gate)
cd server && npm run lint:dialect # SQLite-only SQL spellings may only shrink (CI gate; rules in server/CLAUDE.md)
cd server && npm run probe:pg # dialect helpers + every repository statement against Postgres (CI job; needs TREK_PG_PROBE_URL)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
```
@@ -118,7 +119,7 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:boundaries`, the client's `lint:format`, `lint:layers`, `lint:offline`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:boundaries`, the client's `lint:format`, `lint:layers`, `lint:offline`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and every repository statement against a Postgres service, held to a baseline that only shrinks; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
+223
View File
@@ -4981,6 +4981,28 @@
"reflect-metadata": "^0.1.0 || ^0.2.0"
}
},
"node_modules/@mikro-orm/postgresql": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@mikro-orm/postgresql/-/postgresql-7.2.1.tgz",
"integrity": "sha512-PQ1qe4Bj/hQSHYJjR02l06FfEDwniAvncduwZSCLSKuaCUddHc5AN2Utj3yo4xRIVRY8pOa7DFitEQPBozTMJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mikro-orm/sql": "7.2.1",
"kysely": "0.29.6",
"pg": "8.23.0",
"pg-cursor": "2.22.0",
"postgres-array": "3.0.4",
"postgres-date": "2.1.0",
"postgres-interval": "4.1.0"
},
"engines": {
"node": ">= 22.17.0"
},
"peerDependencies": {
"@mikro-orm/core": "7.2.1"
}
},
"node_modules/@mikro-orm/seeder": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@mikro-orm/seeder/-/seeder-7.2.1.tgz",
@@ -16833,6 +16855,146 @@
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-cursor": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.22.0.tgz",
"integrity": "sha512-knzXLKqarTjOvb3qDSW0JiGsazmxwEKXrqHfWRte7XUsOYccQRafn3BLnQobWwInkzFJSyOej8y8cQRh2z3kGw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": "^8"
}
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pg-types/node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/pg-types/node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pg-types/node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -17127,6 +17289,46 @@
"dev": true,
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
"integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
"integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-interval": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-4.1.0.tgz",
"integrity": "sha512-nJLACcOHi2iyRuMJeW6CuIA0Dc0i1YEpRr26XDyHAfxPjYUJOFxwZjmOc5oFFctTTh/OlRAMlnlXEJvr170fJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
@@ -19014,6 +19216,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -21725,6 +21937,16 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -21988,6 +22210,7 @@
"@aws-lite/s3-types": "^0.2.8",
"@eslint/js": "^10.0.1",
"@mikro-orm/entity-generator": "^7.2.1",
"@mikro-orm/postgresql": "^7.2.1",
"@nestjs/testing": "^11.2.5",
"@swc/core": "^1.16.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
+4 -1
View File
@@ -22,6 +22,7 @@ npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touchi
npm run lint:size # no file in src/ or scripts/ past 1000 lines, longer ones only shrink (CI gate)
npm run lint:boundaries # import cycles and cross-domain reach-ins may only shrink (CI gate)
npm run lint:dialect # SQLite-only SQL spellings under src/ may only shrink (CI gate)
npm run probe:pg # dialect helpers + every repository statement against Postgres (CI job postgres-probe; needs TREK_PG_PROBE_URL)
npm run contracts:dto-open # open shapes in the createZodDto schemas may only shrink (runs in the unit tests)
```
@@ -37,6 +38,8 @@ Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `np
**`lint:dialect`** (`scripts/dialect-lint.mjs`) reads every string and template literal under `src/` (comments do not count) and every `.insertId` read, and counts the SQLite-only spellings per file and rule: `INSERT OR IGNORE`/`REPLACE`, `datetime()`/`julianday()`/`unixepoch()` (also as the bare name handed to `eb.fn`), `strftime`, `json_extract` and its siblings, `GLOB`, `last_insert_rowid`, `AUTOINCREMENT`, a `PRAGMA` statement outside the `src/db/*.ts` lifecycle files and the migrations, and `||` concatenation in SQL. The counts are held to `scripts/dialect-baseline.json`; a new hit fails, and so does an entry above what its file holds now, so the change that removes one runs `npm run lint:dialect -- --update` (it never raises or adds an entry). The dialect layer `src/db/dialect/` is not scanned, and neither are the migrations up to `FROZEN_MIGRATIONS_THROUGH` in the script; a newer migration is held to the DML rules, while its DDL stays engine specific.
**`probe:pg`** (`scripts/pg-probe.ts`, CI job `postgres-probe`) measures what of the SQL runs on Postgres. Given an empty database (`TREK_PG_PROBE_URL`, or `--url=`; it refuses one holding tables it did not create) it derives a Postgres schema from the entities with MikroORM's schema generator (timestamps stay TEXT, SQLite-only defaults and `COLLATE NOCASE` get their Postgres spelling, no foreign keys), runs every dialect helper on an in-memory SQLite and on Postgres and checks both against the same expectation (`scripts/pg-probe/helper-cases.ts`; every case must pass), then calls every public method of every class in `src/db/repositories/*.ts` once. The arguments come from the parameter types, read with the TypeScript checker (`pg-probe/arg-samples.ts`), and the calls go through a MikroORM Postgres driver that runs each statement alone in a rolled-back transaction with a statement timeout and records whether Postgres accepted it (`pg-probe/recorder.ts`), so every statement meets empty tables and nothing persists. The distinct statements Postgres refuses are held per `Class.method` to `scripts/pg-probe-baseline.json`: a method may fail no more than its entry, and an entry above what it fails now fails too. The job uploads the measured baseline and a JSON report (`pg-probe-baseline` artifact), so the change that fixes a statement copies the lowered baseline from there or runs `npm run probe:pg -- --update` against a local Postgres (`docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=probe postgres:17`); `--update` never raises or adds an entry. While the baseline's `failing` is `null` it has never been measured: the run reports and passes, and the artifact holds the first measurement to commit. The unit tests under `tests/unit/scripts/pg-probe/` cover the probe's logic, including every helper case on SQLite.
## Architecture invariants
Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`controller` + `service` + `module`, registered in `src/nest/app.module.ts`); business logic lives in the injectable `.service.ts`, pure halves in `.helpers.ts`, SQL in a `.repository.ts` where one exists. There is no legacy service layer — ESLint errors on any `services/` import. **`weather/` is the reference module shape; `collections.controller.ts` is the reference for body validation and enumeration-safe access checks.**
@@ -72,7 +75,7 @@ SQLite is the engine today and Postgres is the planned second one. Code under `s
- **Strings and casts**: `concat`/`concatKysely` instead of `||`, `substring`/`substringKysely`, `lower`/`lowerTrim`, `collateNoCase`, `castInteger`/`castIntegerKysely`, and `startsWithIsoDate`/`startsWithIsoDateKysely` instead of `GLOB`.
- **JSON columns** are read whole and parsed in the service; no `json_extract` in SQL.
- **PRAGMA** belongs to the `src/db/` lifecycle files (`database.ts`, `connection.ts`, `durability.ts`, the migrations). `MaintenanceRepository` is the baselined SQLite maintenance adapter (`wal_checkpoint`, `VACUUM INTO`).
- **A missing helper** goes into `sql-functions.ts` (or `kysely-functions.ts` for a Kysely expression), dispatching on the live platform through `src/db/dialect/platform.ts`: a SQLite branch and a Postgres branch (one spelling under `isKnownPlatform` where both engines read it alike), `unsupported(platform)` for anything else. The Postgres branch keeps the SQLite storage model, so a clock helper renders the same `YYYY-MM-DD HH:MM:SS` UTC text. Pin the Postgres text in `tests/unit/db/dialect/sql-functions.postgres.test.ts`. `src/db/dialect/` is the only place allowed to spell an engine's own SQL.
- **A missing helper** goes into `sql-functions.ts` (or `kysely-functions.ts` for a Kysely expression), dispatching on the live platform through `src/db/dialect/platform.ts`: a SQLite branch and a Postgres branch (one spelling under `isKnownPlatform` where both engines read it alike), `unsupported(platform)` for anything else. The Postgres branch keeps the SQLite storage model, so a clock helper renders the same `YYYY-MM-DD HH:MM:SS` UTC text. Pin the Postgres text in `tests/unit/db/dialect/sql-functions.postgres.test.ts` and add a case to `scripts/pg-probe/helper-cases.ts` so the probe runs it on both engines. `src/db/dialect/` is the only place allowed to spell an engine's own SQL.
- **Migrations** that have shipped are frozen as they are. A new migration's data statements follow the rules above; its DDL is written for the engine it runs on.
- **Storage model**: the generated Kysely types describe what SQLite stores (0/1 integers for flags, TEXT timestamps, JSON in TEXT). Convert at the service boundary, as the contracts already require.
- **MySQL/MariaDB are out of scope.** The repositories rely on `INSERT ... ON CONFLICT` (Kysely's `onConflict`, often a target-less `doNothing()`) and on `RETURNING`, and MySQL has neither: it would need an insert-ignore/upsert wrapper in the dialect layer and a different way to read new ids before it could be considered.
+2
View File
@@ -25,6 +25,7 @@
"check:entities": "node --import tsx scripts/generate-entities.ts --check",
"gen:db-types": "node --import tsx scripts/generate-db-types.ts",
"check:db-types": "node --import tsx scripts/generate-db-types.ts --check",
"probe:pg": "node --import tsx scripts/pg-probe.ts",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
@@ -103,6 +104,7 @@
"@aws-lite/s3-types": "^0.2.8",
"@eslint/js": "^10.0.1",
"@mikro-orm/entity-generator": "^7.2.1",
"@mikro-orm/postgresql": "^7.2.1",
"@nestjs/testing": "^11.2.5",
"@swc/core": "^1.16.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
+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;
}
@@ -0,0 +1,149 @@
/**
* The Postgres probe has no fixtures: scripts/pg-probe/arg-samples.ts reads
* every repository with the TypeScript checker and builds one argument list
* per public method from the parameter types. These tests run it over a
* throwaway repository file with the server's own non-strict compiler
* settings, so each shape it meets in the real repositories has a pinned
* sample (or a pinned reason it has none).
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { planRepositories, readCompilerOptions, sampleString, type MethodPlan } from '../../../../scripts/pg-probe/arg-samples';
const FIXTURE = `
export interface NewThing {
name: string;
trip_id: number;
note?: string | null;
tags: string[];
kind: 'a' | 'b';
at: Date;
onSave?: () => void;
}
export class ThingsRepository {
private secret(): void {}
protected guarded(): void {}
static make(): void {}
async listByTrip(tripId: number, includeHidden?: boolean): Promise<void> {}
async create(data: NewThing): Promise<void> {}
async byDate(date: string, created_at: string, email: string): Promise<void> {}
async withCallback(fn: (x: number) => void): Promise<void> {}
async optionalCallback(id: number, fn?: () => void): Promise<void> {}
async rest(...ids: number[]): Promise<void> {}
async idOrString(id: number | string): Promise<void> {}
async tuple(pair: [number, string]): Promise<void> {}
async collections(ids: Set<number>, byId: Map<string, number>): Promise<void> {}
async nullable(x: string | null): Promise<void> {}
async generic<T extends number>(x: T): Promise<void> {}
async record(r: Record<string, number>): Promise<void> {}
async literals(n: bigint, flag: true): Promise<void> {}
async destructured({ id, label }: { id: number; label: string }): Promise<void> {}
}
export abstract class AbstractRepository {
async skipped(): Promise<void> {}
}
class NotExported {
async skipped(): Promise<void> {}
}
`;
let root: string;
let plans: MethodPlan[];
const planOf = (method: string): MethodPlan => {
const plan = plans.find((p) => p.method === method);
if (!plan) throw new Error(`no plan for ${method}`);
return plan;
};
const argsOf = (method: string): unknown[] => {
const plan = planOf(method);
if (!('args' in plan)) throw new Error(`${method} is unprobeable: ${plan.unprobeable}`);
return plan.args;
};
beforeAll(() => {
root = mkdtempSync(path.join(tmpdir(), 'trek-pg-probe-'));
mkdirSync(path.join(root, 'src'));
writeFileSync(path.join(root, 'src/Things.repository.ts'), FIXTURE);
// The server's own settings (non-strict: `string | null` is `string`), so
// the fixture is sampled the way the real repositories are.
const options = readCompilerOptions(path.join(__dirname, '../../../../tsconfig.json'));
plans = planRepositories([path.join(root, 'src/Things.repository.ts')], { ...options, rootDir: root, paths: undefined, types: [] }, root);
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
describe('pg-probe argument samples', () => {
it('PGPROBE-030: plans every public instance method of every exported concrete class, sorted', () => {
expect(plans.map((p) => `${p.className}.${p.method}`)).toEqual(
[
'byDate',
'collections',
'create',
'destructured',
'generic',
'idOrString',
'listByTrip',
'literals',
'nullable',
'optionalCallback',
'record',
'rest',
'tuple',
'withCallback',
].map((m) => `ThingsRepository.${m}`),
);
expect(plans.every((p) => p.file === 'src/Things.repository.ts')).toBe(true);
});
it('PGPROBE-031: numbers are 1, booleans false, a union prefers its number', () => {
expect(argsOf('listByTrip')).toEqual([1, false]);
expect(argsOf('idOrString')).toEqual([1]);
expect(argsOf('generic')).toEqual([1]);
expect(argsOf('nullable')).toEqual(['probe']);
expect(argsOf('literals')).toEqual([BigInt(1), true]);
});
it('PGPROBE-032: strings are shaped by their names', () => {
expect(argsOf('byDate')).toEqual(['2026-01-01', '2026-01-01 00:00:00', 'probe@example.com']);
expect(sampleString('start_date')).toBe('2026-01-01');
expect(sampleString('updatedAt')).toBe('2026-01-01 00:00:00');
expect(sampleString('reservation_time')).toBe('2026-01-01 00:00:00');
expect(sampleString('image_url')).toBe('https://example.com/probe');
expect(sampleString('color')).toBe('#000000');
expect(sampleString('tripId')).toBe('1');
expect(sampleString('place_ids')).toBe('1');
expect(sampleString('name')).toBe('probe');
});
it('PGPROBE-033: objects get every property it can sample, a callback property is left out', () => {
expect(argsOf('create')).toEqual([
{ name: 'probe', trip_id: 1, note: 'probe', tags: ['probe'], kind: 'a', at: new Date('2026-01-01T00:00:00Z') },
]);
expect(argsOf('destructured')).toEqual([{ id: 1, label: 'probe' }]);
expect(argsOf('record')).toEqual([{}]);
});
it('PGPROBE-034: arrays, tuples, sets, maps and rest parameters', () => {
expect(argsOf('rest')).toEqual([1]);
expect(argsOf('tuple')).toEqual([[1, 'probe']]);
expect(argsOf('collections')).toEqual([new Set([1]), new Map([['1', 1]])]);
});
it('PGPROBE-035: a required callback makes the method unprobeable, an optional one is left off', () => {
expect(planOf('withCallback')).toMatchObject({ unprobeable: 'fn: a function parameter' });
expect(argsOf('optionalCallback')).toEqual([1]);
});
it('PGPROBE-036: reads compiler options the way tsc -p does, and refuses a missing file', () => {
const options = readCompilerOptions(path.join(__dirname, '../../../../tsconfig.json'));
expect(options.experimentalDecorators).toBe(true);
expect(options.module).toBe(ts.ModuleKind.CommonJS);
expect(() => readCompilerOptions(path.join(root, 'missing.json'))).toThrow();
});
});
@@ -0,0 +1,67 @@
/**
* The Postgres probe's ratchet (scripts/pg-probe/baseline.ts). The probe
* itself only runs in CI against a Postgres service, so the rules that decide
* whether that job passes are pinned here: a method may not fail more
* statements than its entry, a stale entry fails until it is lowered, an
* unmeasured baseline only reports, and --update never raises or adds.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { compareWithBaseline, formatBaseline, lowerBaseline, parseBaseline } from '../../../../scripts/pg-probe/baseline';
const now = (entries: Record<string, number>): Map<string, number> => new Map(Object.entries(entries));
describe('pg-probe baseline', () => {
it('PGPROBE-001: parses a measured and an unmeasured baseline', () => {
expect(parseBaseline('{"failing": null}', 'b.json')).toEqual({ failing: null });
expect(parseBaseline('{"failing": {"TagsRepository.listByUser": 2}}', 'b.json')).toEqual({ failing: { 'TagsRepository.listByUser': 2 } });
});
it('PGPROBE-002: refuses a malformed baseline instead of reading it as empty', () => {
expect(() => parseBaseline('nope', 'b.json')).toThrow(/b.json is not valid JSON/);
expect(() => parseBaseline('{}', 'b.json')).toThrow(/"failing" key/);
expect(() => parseBaseline('[]', 'b.json')).toThrow(/"failing" key/);
expect(() => parseBaseline('{"failing": []}', 'b.json')).toThrow(/null or an object/);
expect(() => parseBaseline('{"failing": {"listByUser": 1}}', 'b.json')).toThrow(/not a Class.method key/);
expect(() => parseBaseline('{"failing": {"A.b": 0}}', 'b.json')).toThrow(/expected a positive integer/);
expect(() => parseBaseline('{"failing": {"A.b": 1.5}}', 'b.json')).toThrow(/expected a positive integer/);
});
it('PGPROBE-003: an unmeasured baseline only reports', () => {
expect(compareWithBaseline({ failing: null }, now({ 'A.b': 3 }))).toEqual({ unseeded: true, grown: [], stale: [] });
});
it('PGPROBE-004: a method failing more statements than its entry (or any without one) fails', () => {
const verdict = compareWithBaseline({ failing: { 'A.b': 1 } }, now({ 'A.b': 2, 'C.d': 1, 'E.f': 0 }));
expect(verdict.grown).toEqual([
{ method: 'A.b', allowed: 1, now: 2 },
{ method: 'C.d', allowed: 0, now: 1 },
]);
expect(verdict.stale).toEqual([]);
});
it('PGPROBE-005: an entry above what its method fails now is stale, also when the method is gone', () => {
const verdict = compareWithBaseline({ failing: { 'A.b': 2, 'Gone.away': 1 } }, now({ 'A.b': 1 }));
expect(verdict.grown).toEqual([]);
expect(verdict.stale).toEqual([
{ method: 'A.b', allowed: 2, now: 1 },
{ method: 'Gone.away', allowed: 1, now: 0 },
]);
});
it('PGPROBE-006: --update lowers and drops entries but never raises or adds one', () => {
const lowered = lowerBaseline({ failing: { 'A.b': 2, 'C.d': 1, 'Gone.away': 1 } }, now({ 'A.b': 1, 'C.d': 5, 'New.one': 3 }));
expect(lowered).toEqual({ failing: { 'A.b': 1, 'C.d': 1 } });
});
it('PGPROBE-007: the first --update seeds an unmeasured baseline with what fails, sorted', () => {
expect(lowerBaseline({ failing: null }, now({ 'Z.z': 1, 'A.a': 2, 'M.m': 0 }))).toEqual({ failing: { 'A.a': 2, 'Z.z': 1 } });
});
it('PGPROBE-008: the checked-in baseline parses and formats back to itself', () => {
const file = path.join(__dirname, '../../../../scripts/pg-probe-baseline.json');
const text = readFileSync(file, 'utf8');
expect(formatBaseline(parseBaseline(text, file))).toBe(text.replace(/\r\n/g, '\n'));
});
});
@@ -0,0 +1,89 @@
/**
* The dialect-helper cases the Postgres probe runs on both engines
* (scripts/pg-probe/helper-cases.ts). Postgres only exists in the CI job,
* but SQLite is right here: every case must already hold on SQLite, through
* the same engine adapter the probe uses, so a case that fails in CI points
* at the Postgres branch and not at the case. The value comparison itself is
* pinned too, since it is what turns a Postgres answer into pass or fail.
*/
import { describe, expect, it } from 'vitest';
import { sqliteHelperEngine } from '../../../../scripts/pg-probe/engines';
import { checkValue, HELPER_CASES, isoSeconds, runHelperCases, type HelperEngine } from '../../../../scripts/pg-probe/helper-cases';
const NOW = Date.parse('2026-10-08T12:00:00Z');
describe('pg-probe helper cases', () => {
it('PGPROBE-050: every case holds on SQLite', async () => {
const engine = sqliteHelperEngine();
try {
const results = await runHelperCases(engine);
expect(results.filter((result) => result.failure !== null)).toEqual([]);
expect(results).toHaveLength(HELPER_CASES.length);
expect(results.every((result) => result.engine === 'sqlite')).toBe(true);
} finally {
engine.close();
}
});
it('PGPROBE-051: case names are unique and every case selects through exactly one API', () => {
const names = HELPER_CASES.map((c) => c.name);
expect(new Set(names).size).toBe(names.length);
for (const helperCase of HELPER_CASES) expect(Number(Boolean(helperCase.raw)) + Number(Boolean(helperCase.kysely))).toBe(1);
});
it('PGPROBE-052: compares values the way both drivers hand them back', () => {
expect(checkValue({ equals: 7 }, '7', 'postgres', NOW)).toBeNull();
expect(checkValue({ equals: 7 }, BigInt(7), 'postgres', NOW)).toBeNull();
expect(checkValue({ equals: 3 }, '3.000000000000', 'postgres', NOW)).toBeNull();
expect(checkValue({ equals: 7 }, 8, 'sqlite', NOW)).toBe('expected 7, got 8');
expect(checkValue({ equals: true }, 1, 'sqlite', NOW)).toBeNull();
expect(checkValue({ equals: true }, true, 'postgres', NOW)).toBeNull();
expect(checkValue({ equals: false }, 0, 'sqlite', NOW)).toBeNull();
expect(checkValue({ equals: false }, true, 'postgres', NOW)).toBe('expected false, got true');
expect(checkValue({ equals: 'a' }, 'a', 'sqlite', NOW)).toBeNull();
expect(checkValue({ equals: 'a' }, 'b', 'sqlite', NOW)).toBe('expected "a", got "b"');
expect(checkValue({ equals: null }, null, 'sqlite', NOW)).toBeNull();
expect(checkValue({ equals: null }, 'x', 'sqlite', NOW)).toBe('expected null, got "x"');
});
it('PGPROBE-053: holds clock values to SQLite\'s text and to the moment of the query', () => {
expect(checkValue({ timestamp: 0 }, '2026-10-08 12:00:30', 'postgres', NOW)).toBeNull();
expect(checkValue({ timestamp: 90_000 }, '2026-10-08 12:01:30', 'postgres', NOW)).toBeNull();
expect(checkValue({ timestamp: 0 }, '2026-10-08 12:10:00', 'postgres', NOW)).toBe('2026-10-08 12:10:00 is 600s off');
expect(checkValue({ timestamp: 0 }, '2026-10-08T12:00:00Z', 'postgres', NOW)).toMatch(/expected timestamp text/);
expect(checkValue({ timestamp: 0 }, '2026-10-08 12:00:00.123456+00', 'postgres', NOW)).toMatch(/expected timestamp text/);
expect(checkValue({ date: -400 }, '2025-09-03', 'postgres', NOW)).toBeNull();
expect(checkValue({ date: 1 }, '2026-10-09', 'sqlite', NOW)).toBeNull();
expect(checkValue({ date: 1 }, '2026-10-12', 'sqlite', NOW)).toBe('2026-10-12 is not 1 day(s) from today');
expect(checkValue({ date: 0 }, 1, 'sqlite', NOW)).toMatch(/expected date text/);
});
it('PGPROBE-054: an expectation may differ per engine', () => {
const expectation = { perEngine: { sqlite: { equals: 'a' }, postgres: { equals: 'b' } } } as const;
expect(checkValue(expectation, 'a', 'sqlite', NOW)).toBeNull();
expect(checkValue(expectation, 'b', 'postgres', NOW)).toBeNull();
expect(checkValue(expectation, 'a', 'postgres', NOW)).toBe('expected "b", got "a"');
});
it('PGPROBE-055: a case that throws or names no select is a failure, not a crash', async () => {
const sqlite = sqliteHelperEngine();
const engine: HelperEngine = {
...sqlite,
selectRaw: () => Promise.reject(new Error('relation "x" does not exist\nmore')),
};
const results = await runHelperCases(engine, [
{ name: 'throws', row: {}, raw: () => ({ sql: '1', params: [] }), expect: { equals: 1 } },
{ name: 'empty', row: {}, expect: { equals: 1 } },
]);
sqlite.close();
expect(results).toEqual([
{ engine: 'sqlite', name: 'throws', failure: 'threw: relation "x" does not exist' },
{ engine: 'sqlite', name: 'empty', failure: 'threw: a case needs raw or kysely' },
]);
});
it('PGPROBE-056: renders epoch milliseconds as whole-second ISO text', () => {
expect(isoSeconds(1_758_459_909_123)).toBe(new Date(1_758_459_909_000).toISOString().replace('.000Z', 'Z'));
expect(isoSeconds(0)).toBe('1970-01-01T00:00:00Z');
});
});
@@ -0,0 +1,133 @@
/**
* The statement recorder under the Postgres probe (scripts/pg-probe/recorder.ts),
* driven through a real Kysely Postgres dialect over a fake `pg` pool that
* logs what it is sent. What the probe's numbers mean rests on these: each
* statement runs alone in a rolled-back transaction, a refused one is
* recorded with its SQLSTATE and answered as an empty table, the callers'
* own transaction calls do nothing, and statements land on the method that
* sent them.
*/
import { Kysely, PostgresDialect, type PostgresPool } from 'kysely';
import { describe, expect, it } from 'vitest';
import {
errorLine,
isTransactionControl,
ProbePostgresDialect,
sqlState,
StatementRecorder,
} from '../../../../scripts/pg-probe/recorder';
interface ProbeDB {
t: { v: number };
}
function fakeDatabase(): { db: Kysely<ProbeDB>; log: string[]; recorder: StatementRecorder; released: () => number } {
const log: string[] = [];
let released = 0;
const client = {
async query(sql: string) {
log.push(sql);
if (sql.includes('"boom"')) {
throw Object.assign(new Error('column "boom" does not exist\nLINE 1: select "boom"'), { code: '42703' });
}
return { command: 'SELECT', rowCount: 1, rows: [{ v: 1 }] };
},
release() {
released += 1;
},
};
const pool = { connect: async () => client, end: async () => undefined } as unknown as PostgresPool;
const recorder = new StatementRecorder();
const db = new Kysely<ProbeDB>({ dialect: new ProbePostgresDialect(new PostgresDialect({ pool }), recorder, 1234) });
return { db, log, recorder, released: () => released };
}
describe('pg-probe recorder', () => {
it('PGPROBE-020: outside a probed method a statement passes straight through', async () => {
const { db, log, recorder } = fakeDatabase();
await db.selectFrom('t').select('v').execute();
expect(log).toEqual(['select "v" from "t"']);
expect(recorder.entries()).toEqual([]);
});
it('PGPROBE-021: inside one it runs alone in a rolled-back transaction with a timeout and is recorded', async () => {
const { db, log, recorder, released } = fakeDatabase();
const rows = await recorder.run('A.b', () => db.selectFrom('t').select('v').execute());
expect(rows).toEqual([{ v: 1 }]);
expect(log).toEqual(['begin', 'set local statement_timeout = 1234', 'select "v" from "t"', 'rollback']);
expect(recorder.statementsOf('A.b')).toEqual([{ sql: 'select "v" from "t"', outcome: { ok: true } }]);
expect(released()).toBe(1);
});
it('PGPROBE-022: a refused statement is recorded with its SQLSTATE and answered as an empty table', async () => {
const { db, log, recorder } = fakeDatabase();
const rows = await recorder.run('A.b', async () => {
const first = await db.selectFrom('t').select('boom' as 'v').execute();
await db.selectFrom('t').select('v').execute();
return first;
});
expect(rows).toEqual([]);
expect(log).toEqual([
'begin',
'set local statement_timeout = 1234',
'select "boom" from "t"',
'rollback',
'begin',
'set local statement_timeout = 1234',
'select "v" from "t"',
'rollback',
]);
expect(recorder.statementsOf('A.b')).toEqual([
{ sql: 'select "boom" from "t"', outcome: { ok: false, code: '42703', message: 'column "boom" does not exist' } },
{ sql: 'select "v" from "t"', outcome: { ok: true } },
]);
});
it('PGPROBE-023: the caller\'s own transaction and savepoints never reach the database', async () => {
const { db, log, recorder } = fakeDatabase();
await recorder.run('A.b', () =>
db.transaction().execute(async (trx) => {
await trx.selectFrom('t').select('v').execute();
}),
);
expect(log).toEqual(['begin', 'set local statement_timeout = 1234', 'select "v" from "t"', 'rollback']);
log.length = 0;
await recorder.run('A.c', async () => {
const trx = await db.startTransaction().execute();
const savepoint = await trx.savepoint('sp1').execute();
await savepoint.rollbackToSavepoint('sp1').execute();
await trx.commit().execute();
});
expect(log).toEqual([]);
expect(recorder.statementsOf('A.c')).toEqual([]);
});
it('PGPROBE-024: concurrent methods each keep their own statements', async () => {
const { db, recorder } = fakeDatabase();
await Promise.all([
recorder.run('A.one', () => db.selectFrom('t').select('v').execute()),
recorder.run('B.two', async () => {
await db.selectFrom('t').select('v').execute();
await db.selectFrom('t').select('v').where('v', '=', 2).execute();
}),
]);
expect(recorder.statementsOf('A.one')).toHaveLength(1);
expect(recorder.statementsOf('B.two').map((s) => s.sql)).toEqual(['select "v" from "t"', 'select "v" from "t" where "v" = $1']);
expect(recorder.current()).toBeNull();
});
it('PGPROBE-025: classifies transaction control and reads driver errors', () => {
for (const sql of ['begin', 'COMMIT', 'rollback', 'start transaction isolation level serializable', 'savepoint "trx1"', 'release savepoint "trx1"', 'rollback to savepoint "trx1"']) {
expect(isTransactionControl(sql)).toBe(true);
}
for (const sql of ['select 1', 'insert into "begin_log" values (1)', 'update "t" set "v" = 1']) {
expect(isTransactionControl(sql)).toBe(false);
}
expect(sqlState(Object.assign(new Error('x'), { code: '42P01' }))).toBe('42P01');
expect(sqlState(Object.assign(new Error('x'), { code: 'ECONNREFUSED' }))).toBe('NOSQLSTATE');
expect(sqlState('plain')).toBe('NOSQLSTATE');
expect(errorLine(new Error('first line\nsecond line'))).toBe('first line');
expect(errorLine('just text')).toBe('just text');
});
});
@@ -0,0 +1,127 @@
/**
* What the Postgres probe reports and when it passes (scripts/pg-probe/report.ts
* and the entry point's argument and exit-code rules in scripts/pg-probe.ts).
* The CI job's verdict is exactly `exitCode()`, so it is pinned here rather
* than discovered in a red run.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { exitCode, parseArgs, repositoryFiles } from '../../../../scripts/pg-probe';
import type { BaselineVerdict } from '../../../../scripts/pg-probe/baseline';
import type { HelperResult } from '../../../../scripts/pg-probe/helper-cases';
import { formatConsole, formatMarkdown, oneLine, passed, summarize, type MethodResult, type ReportInput } from '../../../../scripts/pg-probe/report';
const ok = (sql: string) => ({ sql, outcome: { ok: true as const } });
const refused = (sql: string, code = '42883', message = 'function datetime(unknown) does not exist') => ({
sql,
outcome: { ok: false as const, code, message },
});
const RESULTS: MethodResult[] = [
{ key: 'A.clean', statements: [ok('select 1'), ok('select 1'), ok('select 2')] },
{ key: 'B.broken', statements: [refused('select datetime(1)'), refused('select datetime(1)'), ok('select 3'), refused('insert or ignore', '42601', 'syntax error')] },
{ key: 'C.silent', statements: [] },
{ key: 'D.hung', statements: [ok('select 4')], timedOut: true },
{ key: 'E.skipped', statements: [], unprobeable: 'fn: a function parameter' },
];
const CLEAN: BaselineVerdict = { unseeded: false, grown: [], stale: [] };
const helper = (failure: string | null): HelperResult => ({ engine: 'postgres', name: 'dateOf', failure });
function input(overrides: Partial<ReportInput> = {}): ReportInput {
return { summary: summarize(RESULTS), verdict: CLEAN, helpers: [helper(null)], schemaFailures: [], results: RESULTS, ...overrides };
}
describe('pg-probe report', () => {
it('PGPROBE-060: counts methods and distinct statements, and what Postgres refused per method and SQLSTATE', () => {
const summary = summarize(RESULTS);
expect(summary).toMatchObject({ methods: 5, called: 4, unprobeable: 1, withoutSql: 1, timedOut: 1, statements: 6 });
expect(summary.failingByMethod).toEqual(new Map([['A.clean', 0], ['B.broken', 2], ['C.silent', 0], ['D.hung', 0]]));
expect(summary.failuresByCode).toEqual(new Map([['42883', 1], ['42601', 1]]));
expect(summary.failedStatements.map((f) => `${f.method} ${f.code}`)).toEqual(['B.broken 42883', 'B.broken 42601']);
});
it('PGPROBE-061: passes only with every helper case green and the ratchet held', () => {
expect(passed(input())).toBe(true);
expect(passed(input({ helpers: [helper('expected 1, got 2')] }))).toBe(false);
expect(passed(input({ verdict: { ...CLEAN, grown: [{ method: 'B.broken', allowed: 1, now: 2 }] } }))).toBe(false);
expect(passed(input({ verdict: { ...CLEAN, stale: [{ method: 'B.broken', allowed: 3, now: 2 }] } }))).toBe(false);
expect(passed(input({ verdict: { unseeded: true, grown: [], stale: [] } }))).toBe(true);
});
it('PGPROBE-062: --update forgives a stale entry but never a grown one or a failing helper', () => {
const stale = input({ verdict: { ...CLEAN, stale: [{ method: 'B.broken', allowed: 3, now: 2 }] } });
expect(exitCode(stale, false)).toBe(1);
expect(exitCode(stale, true)).toBe(0);
expect(exitCode(input({ verdict: { ...CLEAN, grown: [{ method: 'B.broken', allowed: 1, now: 2 }] } }), true)).toBe(1);
expect(exitCode(input({ helpers: [helper('threw: boom')] }), true)).toBe(1);
expect(exitCode(input(), false)).toBe(0);
});
it('PGPROBE-063: the console log names every refused statement and every ratchet failure', () => {
const text = formatConsole(
input({
helpers: [helper('expected 1, got 2')],
verdict: { unseeded: false, grown: [{ method: 'B.broken', allowed: 1, now: 2 }], stale: [{ method: 'Z.gone', allowed: 1, now: 0 }] },
schemaFailures: [{ statement: 'create table "x" (a text collate "NOCASE")', message: 'collation "NOCASE" does not exist' }],
}),
);
expect(text).toContain('FAIL [postgres] dateOf: expected 1, got 2');
expect(text).toContain('collation "NOCASE" does not exist');
expect(text).toContain('Statements: 6 distinct, 2 refused by Postgres.');
expect(text).toContain('B.broken 42883 function datetime(unknown) does not exist');
expect(text).toContain('FAIL B.broken sends 2 statement(s) Postgres refuses, 1 allowed.');
expect(text).toContain('FAIL Z.gone is held at 1 failing statement(s) but fails 0 now; lower the baseline.');
expect(text.trim().endsWith('Postgres probe failed.')).toBe(true);
expect(formatConsole(input({ verdict: { unseeded: true, grown: [], stale: [] } }))).toContain('Baseline: not measured yet');
});
it('PGPROBE-064: the step summary is a Markdown table with escaped cells', () => {
const md = formatMarkdown(input({ helpers: [{ engine: 'postgres', name: 'a|b', failure: 'got `x`' }] }));
expect(md).toContain('## Postgres probe');
expect(md).toContain('Result: **failed**');
expect(md).toContain('| postgres | a\\|b | got \'x\' |');
expect(md).toContain('| 42883 | 1 |');
expect(md).toContain('| E.skipped | fn: a function parameter |');
expect(formatMarkdown(input({ verdict: { unseeded: true, grown: [], stale: [] } }))).toContain('has not been measured yet');
});
it('PGPROBE-065: flattens and cuts SQL for a log line', () => {
expect(oneLine('select\n 1,\n 2')).toBe('select 1, 2');
expect(oneLine('x'.repeat(10), 5)).toBe('xxxx…');
});
});
describe('pg-probe entry point', () => {
it('PGPROBE-070: takes the URL from the environment or --url and refuses to run without one', () => {
expect(parseArgs([], { TREK_PG_PROBE_URL: 'postgres://u:p@h:5432/d' })).toEqual({
url: 'postgres://u:p@h:5432/d',
update: false,
nextBaseline: null,
report: null,
summary: null,
});
const args = parseArgs(['--url=postgresql://h/d', '--update', '--next-baseline=n.json', '--report=r.json', '--summary=s.md'], {});
expect(args).toEqual({ url: 'postgresql://h/d', update: true, nextBaseline: 'n.json', report: 'r.json', summary: 's.md' });
expect(parseArgs([], { TREK_PG_PROBE_URL: 'postgres://h/d', GITHUB_STEP_SUMMARY: '/tmp/summary' }).summary).toBe('/tmp/summary');
expect(() => parseArgs([], {})).toThrow(/no Postgres URL/);
expect(() => parseArgs(['--url=mysql://h/d'], {})).toThrow(/no Postgres URL/);
expect(() => parseArgs(['--verbose'], { TREK_PG_PROBE_URL: 'postgres://h/d' })).toThrow(/unknown argument --verbose/);
expect(() => parseArgs(['--update=yes'], { TREK_PG_PROBE_URL: 'postgres://h/d' })).toThrow(/unknown argument/);
});
it('PGPROBE-071: probes every repository file and none of the shared helpers', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'trek-pg-probe-files-'));
try {
mkdirSync(path.join(dir, '_shared'));
for (const name of ['B.repository.ts', 'A.repository.ts', 'types.d.ts', 'notes.md', '_shared/base.ts']) {
writeFileSync(path.join(dir, name), '');
}
expect(repositoryFiles(dir).map((file) => path.basename(file))).toEqual(['A.repository.ts', 'B.repository.ts']);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,54 @@
/**
* How the Postgres probe turns the entities into a Postgres schema
* (scripts/pg-probe/schema.ts): SQLite-only defaults, TEXT timestamps, the
* NOCASE collation, and splitting the generated DDL so one refused statement
* does not take the rest with it.
*/
import { describe, expect, it } from 'vitest';
import { adjustColumns, NOCASE_COLLATION_SQL, PG_NOW_TEXT_DEFAULT, portableDefault, splitStatements, type ColumnMeta } from '../../../../scripts/pg-probe/schema';
class DbTimestampType {}
class TextType {}
describe('pg-probe schema', () => {
it('PGPROBE-040: maps the SQLite clock defaults and leaves every other default alone', () => {
expect(PG_NOW_TEXT_DEFAULT).toBe("to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 'YYYY-MM-DD HH24:MI:SS')");
expect(portableDefault('CURRENT_TIMESTAMP')).toBe(PG_NOW_TEXT_DEFAULT);
expect(portableDefault("(datetime('now'))")).toBe(PG_NOW_TEXT_DEFAULT);
expect(portableDefault("datetime( 'now' )")).toBe(PG_NOW_TEXT_DEFAULT);
expect(portableDefault("(strftime('%s','now'))")).toBe('CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) AS bigint)');
for (const kept of ['0', '1', 'NULL', "'none'", "'#10b981'", "'{}'"]) expect(portableDefault(kept)).toBe(kept);
});
it('PGPROBE-041: makes timestamp columns text, rewrites defaults and the NOCASE collation, and names what it changed', () => {
const columns: ColumnMeta[] = [
{ name: 'created_at', customType: new DbTimestampType(), columnTypes: ['timestamptz'], defaultRaw: 'CURRENT_TIMESTAMP' },
{ name: 'title', customType: new TextType(), columnTypes: ['text'], defaultRaw: "'x'" },
{ name: 'stamp', columnTypes: ['int'], defaultRaw: "(strftime('%s','now'))" },
{ name: 'region', columnTypes: ['text'], collation: 'NOCASE' },
{ name: 'id', columnTypes: ['int'] },
];
expect(adjustColumns(columns)).toEqual(['created_at', 'stamp', 'region']);
expect(columns[0]).toMatchObject({ columnTypes: ['text'], defaultRaw: PG_NOW_TEXT_DEFAULT });
expect(columns[1]).toMatchObject({ columnTypes: ['text'], defaultRaw: "'x'" });
expect(columns[2]).toMatchObject({ columnTypes: ['int'], defaultRaw: 'CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) AS bigint)' });
expect(columns[3]).toMatchObject({ collation: 'nocase' });
expect(NOCASE_COLLATION_SQL).toContain('CREATE COLLATION IF NOT EXISTS nocase');
});
it('PGPROBE-042: splits DDL on semicolons outside quotes and drops session settings', () => {
const ddl = [
"set names 'utf8';",
'create table "a" ("id" serial primary key, "note" text default \'a;b\');',
'create index "a_x" on "a" ("note") where "note" <> \';\';',
'',
'create table "b ;" ("id" int)',
].join('\n');
expect(splitStatements(ddl)).toEqual([
'create table "a" ("id" serial primary key, "note" text default \'a;b\')',
'create index "a_x" on "a" ("note") where "note" <> \';\'',
'create table "b ;" ("id" int)',
]);
expect(splitStatements(' ; ; ')).toEqual([]);
});
});
+1 -1
View File
@@ -4,5 +4,5 @@
"rootDir": ".",
"noEmit": true
},
"include": ["scripts/generate-entities.ts", "scripts/generate-db-types.ts", "scripts/migrate-encryption.ts"]
"include": ["scripts/generate-entities.ts", "scripts/generate-db-types.ts", "scripts/migrate-encryption.ts", "scripts/pg-probe.ts", "scripts/pg-probe/*.ts"]
}