diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 15ffe5efb..6c9dfbd71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a5d458c51..744ad18c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 20c056762..4dc6b1358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 67ead37ea..7300ef553 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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//` (`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. diff --git a/server/package.json b/server/package.json index d6e712ca8..446de7392 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/scripts/pg-probe-baseline.json b/server/scripts/pg-probe-baseline.json new file mode 100644 index 000000000..35785d4ee --- /dev/null +++ b/server/scripts/pg-probe-baseline.json @@ -0,0 +1,3 @@ +{ + "failing": null +} diff --git a/server/scripts/pg-probe.ts b/server/scripts/pg-probe.ts new file mode 100644 index 000000000..f522d171e --- /dev/null +++ b/server/scripts/pg-probe.ts @@ -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= 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= write the baseline this run would leave (the CI artifact) + * --report= write everything the run saw as JSON + * --summary= 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>): 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, 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(label: string, fn: () => Promise): Promise { + const start = Date.now(); + console.log(`-- ${label}`); + return fn().finally(() => console.log(` ${label}: ${((Date.now() - start) / 1000).toFixed(1)}s`)); +} + +async function main(): Promise { + 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); + }, + ); +} diff --git a/server/scripts/pg-probe/arg-samples.ts b/server/scripts/pg-probe/arg-samples.ts new file mode 100644 index 000000000..fc92f818d --- /dev/null +++ b/server/scripts/pg-probe/arg-samples.ts @@ -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 = {}; + 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; +} diff --git a/server/scripts/pg-probe/baseline.ts b/server/scripts/pg-probe/baseline.ts new file mode 100644 index 000000000..78cefd029 --- /dev/null +++ b/server/scripts/pg-probe/baseline.ts @@ -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 | 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 = {}; + for (const [method, count] of Object.entries(failing as Record)) { + 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): 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): ProbeBaseline { + const next: Record = {}; + 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`; +} diff --git a/server/scripts/pg-probe/engines.ts b/server/scripts/pg-probe/engines.ts new file mode 100644 index 000000000..c7a30b939 --- /dev/null +++ b/server/scripts/pg-probe/engines.ts @@ -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 { + 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[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 { + 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 { + 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 => { + 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(); + 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[]>(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({ + 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 | 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 | 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 { + const byEntity = new Map(); + 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(); + 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)[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(promise: Promise, 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 { + 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; + 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; +} diff --git a/server/scripts/pg-probe/helper-cases.ts b/server/scripts/pg-probe/helper-cases.ts new file mode 100644 index 000000000..bf9e0342d --- /dev/null +++ b/server/scripts/pg-probe/helper-cases.ts @@ -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 = { + 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 [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) => AliasableExpression; + +export type Expectation = + | { equals: string | number | boolean | null } + | { timestamp: number } + | { date: number } + | { perEngine: Record }; + +export interface HelperCase { + name: string; + row: Partial>; + 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, +}); + +/** `( = )` 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; + selectRaw(select: RawSelect): Promise; + selectKysely(select: KyselySelect): Promise; +} + +export interface HelperResult { + engine: Engine; + name: string; + failure: string | null; +} + +export async function runHelperCases(engine: HelperEngine, cases: readonly HelperCase[] = HELPER_CASES): Promise { + 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; +} diff --git a/server/scripts/pg-probe/recorder.ts b/server/scripts/pg-probe/recorder.ts new file mode 100644 index 000000000..cb58da7c0 --- /dev/null +++ b/server/scripts/pg-probe/recorder.ts @@ -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; + * ; 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(); + private readonly records = new Map(); + + /** Runs `fn` with every statement it sends attributed to `key`. */ + async run(key: string, fn: () => Promise): Promise { + 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(compiled: CompiledQuery): Promise> { + const key = this.recorder.current(); + if (key === null) return this.inner.executeQuery(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(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(compiled: CompiledQuery, chunkSize?: number): AsyncIterableIterator> { + return this.inner.streamQuery(compiled, chunkSize); + } +} + +export class ProbeDriver implements Driver { + private readonly wrapped = new WeakMap(); + + constructor( + private readonly inner: Driver, + private readonly recorder: StatementRecorder, + private readonly statementTimeoutMs: number, + ) {} + + init(): Promise { + return this.inner.init(); + } + + async acquireConnection(): Promise { + 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 { + // Every statement runs in a transaction of its own (ProbeConnection). + } + + async commitTransaction(): Promise { + // See beginTransaction. + } + + async rollbackTransaction(): Promise { + // See beginTransaction. + } + + async savepoint(): Promise { + // See beginTransaction. + } + + async rollbackToSavepoint(): Promise { + // See beginTransaction. + } + + async releaseSavepoint(): Promise { + // See beginTransaction. + } + + releaseConnection(connection: DatabaseConnection): Promise { + return this.inner.releaseConnection(connection instanceof ProbeConnection ? connection.inner : connection); + } + + destroy(): Promise { + 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): DatabaseIntrospector { + return this.base.createIntrospector(db); + } +} diff --git a/server/scripts/pg-probe/report.ts b/server/scripts/pg-probe/report.ts new file mode 100644 index 000000000..5f4054cc6 --- /dev/null +++ b/server/scripts/pg-probe/report.ts @@ -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; + /** SQLSTATE → distinct failing statements. */ + failuresByCode: Map; +} + +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(); + const failed = new Map(); + 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): 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('
Every refused statement', '', '| Method | SQLSTATE | Message |', '|---|---|---|'); + for (const failed of summary.failedStatements) out.push(`| ${cell(failed.method)} | ${failed.code} | ${cell(failed.message)} |`); + out.push('', '
', ''); + } + const unprobeable = input.results.filter((result) => result.unprobeable !== undefined); + if (unprobeable.length > 0) { + out.push('
Methods the probe could not call', '', '| Method | Why |', '|---|---|'); + for (const result of unprobeable) out.push(`| ${cell(result.key)} | ${cell(result.unprobeable ?? '')} |`); + out.push('', '
', ''); + } + return `${out.join('\n')}\n`; +} diff --git a/server/scripts/pg-probe/schema.ts b/server/scripts/pg-probe/schema.ts new file mode 100644 index 000000000..1f092a3ee --- /dev/null +++ b/server/scripts/pg-probe/schema.ts @@ -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; +} diff --git a/server/tests/unit/scripts/pg-probe/arg-samples.test.ts b/server/tests/unit/scripts/pg-probe/arg-samples.test.ts new file mode 100644 index 000000000..6278868ac --- /dev/null +++ b/server/tests/unit/scripts/pg-probe/arg-samples.test.ts @@ -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 {} + async create(data: NewThing): Promise {} + async byDate(date: string, created_at: string, email: string): Promise {} + async withCallback(fn: (x: number) => void): Promise {} + async optionalCallback(id: number, fn?: () => void): Promise {} + async rest(...ids: number[]): Promise {} + async idOrString(id: number | string): Promise {} + async tuple(pair: [number, string]): Promise {} + async collections(ids: Set, byId: Map): Promise {} + async nullable(x: string | null): Promise {} + async generic(x: T): Promise {} + async record(r: Record): Promise {} + async literals(n: bigint, flag: true): Promise {} + async destructured({ id, label }: { id: number; label: string }): Promise {} +} +export abstract class AbstractRepository { + async skipped(): Promise {} +} +class NotExported { + async skipped(): Promise {} +} +`; + +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(); + }); +}); diff --git a/server/tests/unit/scripts/pg-probe/baseline.test.ts b/server/tests/unit/scripts/pg-probe/baseline.test.ts new file mode 100644 index 000000000..167a4f389 --- /dev/null +++ b/server/tests/unit/scripts/pg-probe/baseline.test.ts @@ -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): Map => 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')); + }); +}); diff --git a/server/tests/unit/scripts/pg-probe/helper-cases.test.ts b/server/tests/unit/scripts/pg-probe/helper-cases.test.ts new file mode 100644 index 000000000..ff641924d --- /dev/null +++ b/server/tests/unit/scripts/pg-probe/helper-cases.test.ts @@ -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'); + }); +}); diff --git a/server/tests/unit/scripts/pg-probe/recorder.test.ts b/server/tests/unit/scripts/pg-probe/recorder.test.ts new file mode 100644 index 000000000..86bebfe05 --- /dev/null +++ b/server/tests/unit/scripts/pg-probe/recorder.test.ts @@ -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; 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({ 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'); + }); +}); diff --git a/server/tests/unit/scripts/pg-probe/report.test.ts b/server/tests/unit/scripts/pg-probe/report.test.ts new file mode 100644 index 000000000..875088c84 --- /dev/null +++ b/server/tests/unit/scripts/pg-probe/report.test.ts @@ -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 { + 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 }); + } + }); +}); diff --git a/server/tests/unit/scripts/pg-probe/schema.test.ts b/server/tests/unit/scripts/pg-probe/schema.test.ts new file mode 100644 index 000000000..179dd5754 --- /dev/null +++ b/server/tests/unit/scripts/pg-probe/schema.test.ts @@ -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([]); + }); +}); diff --git a/server/tsconfig.scripts.json b/server/tsconfig.scripts.json index ee5fb10b2..c9278df77 100644 --- a/server/tsconfig.scripts.json +++ b/server/tsconfig.scripts.json @@ -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"] }