diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 52a19c68b4..7dd3ce9ec7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1559,6 +1559,30 @@ jobs: - name: Cross-package test inputs run: pnpm check:cross-package-test-inputs + # Live-server database isolation (#10382). CI provisions ONE Postgres and + # ONE MySQL for the whole temporal-conformance job and points every live + # leg at them, and every live suite in the repo issues a `drop` when it + # finishes — so two suites naming the same database do not contend, they + # destroy each other's fixture. When the shared name is the one the + # connection URL itself carries, the next suite cannot even complete its + # handshake: measured on a live MariaDB 10.11, `Error: Unknown database + # 'conformance'`, deterministically, without any concurrency needed. + # + # #9350 fixed this and enforced it — inside `packages/drivers/driver-sql` + # only. That scan was correctly green while two live suites in + # `packages/metadata-protocol` named their databases with hand-typed + # constants, distinct from each other only because two authors typed two + # different strings. A per-package scan cannot see the package that has + # not been written yet, and the next live suite will be in a third one. + # + # This gate is the repo-wide half and nothing has to opt in: it finds the + # live files itself (they are the ones reading OS_TEST_*_URL) and fails + # any whose database name reaches its DDL from a literal. It does NOT + # check distinctness — that needs the derivation actually run, and lives + # in the two packages' own isolation suites. Reads ~4 files; sub-second. + - name: Live-server database isolation + run: pnpm check:live-db-isolation + # LAYER C of the gate above (#10379). That gate verifies two of the three # layers a cross-package declaration has: it finds the escaping tests # itself, and `--verify` makes turbo.json hash every declared glob so the diff --git a/package.json b/package.json index a003b2dbca..033c8a85f5 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "check:shard-attestation": "node scripts/check-shard-attestation.mjs --self-test && node scripts/check-shard-attestation.mjs", "check:required-contexts": "node scripts/check-required-contexts.mjs --self-test && node scripts/check-required-contexts.mjs", "check:cross-package-test-inputs": "node scripts/check-cross-package-test-inputs.mjs --self-test && node scripts/check-cross-package-test-inputs.mjs", + "check:live-db-isolation": "node scripts/check-live-db-isolation.mjs --self-test && node scripts/check-live-db-isolation.mjs", "check:examples-live-imports": "node scripts/check-examples-live-imports.mjs --self-test && node scripts/check-examples-live-imports.mjs", "examples:live-imports": "node scripts/check-examples-live-imports.mjs --list", "check:test-source-alias": "node scripts/check-test-source-alias.mjs --self-test && node scripts/check-test-source-alias.mjs", diff --git a/packages/metadata-protocol/src/migrations/live-mysql-database.isolation.test.ts b/packages/metadata-protocol/src/migrations/live-mysql-database.isolation.test.ts new file mode 100644 index 0000000000..77b4bd333e --- /dev/null +++ b/packages/metadata-protocol/src/migrations/live-mysql-database.isolation.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10382 — this package's live-MySQL suites must not be able to share one + * database. + * + * ## Why this suite is structural, and what it is a control FOR + * + * A green live run is not evidence here, and that is a measurement rather than + * an assumption. Before this change both live files named their database with a + * hard-coded constant; pointing BOTH at one name that is not the connection + * URL's own database and running the suite on a live MariaDB 10.11.14 produced + * four GREEN runs, 10/10. The server's general query log shows why — the two + * files never overlap, with default settings and with `--maxWorkers=2 + * --fileParallelism` alike. See `live-mysql-database.testkit.ts` for the log + * excerpt and for the second form of the hazard, which is not dormant. + * + * So the live suite was green with the property present and green with the + * property deliberately broken. What this file asserts instead is the property + * that makes the collision impossible — **two files resolve two different + * databases, and each file's database derives from the file rather than from a + * shared constant** — which needs no server at all. + * + * ⚠️ Which kind of control this is, stated plainly: it is an ABLATION control, + * not a defect control. Its subject — the derivation — does not exist before + * the change, so no pre-fix red run can exist for it. It is falsified by + * removing the derivation, and was: replacing both calls with the literal + * `'conformance'` takes the first assertion below red with + * `expected 1 to be greater than or equal to 3`, because the two live files + * drop out of the population it measures over. Never by a pre-fix measurement. + * + * The one control that DOES red on the real pre-fix tree is + * `scripts/check-live-db-isolation.mjs` — a source scan, so it sees the + * constants that were actually there. + * + * ## Division of labour with the repo-wide gate + * + * This file asserts what only running code can answer: that the derivation is + * injective over the real files on disk and fits both dialects' identifier + * limits. `scripts/check-live-db-isolation.mjs` asserts what only a repo-wide + * pass can: that no live suite ANYWHERE in the tree — including packages that + * do not exist yet — names its database with a literal. Neither subsumes the + * other, and each says so in its own header. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + LIVE_DB_PREFIX, + currentLiveMysqlDatabase, + liveMysqlDatabaseNameFor, +} from './live-mysql-database.testkit.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** Repo-relative prefix of this directory — the key the derivation hashes. */ +const DIR_KEY = 'packages/metadata-protocol/src/migrations/'; + +/** The identifier ceiling that binds: Postgres 63 bytes, MySQL 64. */ +const IDENTIFIER_LIMIT = 63; + +/** Source with line and block comments removed, so prose is not a hit. */ +const codeOf = (file: string): string => + readFileSync(join(HERE, file), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^[ \t]*\/\/.*$/gm, ''); + +/** + * The REAL population, read off disk: every test file in this directory that + * resolves a per-file live database. + * + * Not a hand-written list, for the reason #9350 records — a property that holds + * for two invented strings and fails for the two files that actually collide is + * a measurement of a neighbouring object. A file that starts calling the + * resolver joins this list on its next run with nothing to remember. + */ +const LIVE_TEST_FILES = readdirSync(HERE) + .filter((f) => f.endsWith('.test.ts')) + .sort() + .filter((f) => codeOf(f).includes('currentLiveMysqlDatabase(')); + +describe('live-MySQL suites — per-file database isolation (#10382)', () => { + it('has a non-trivial file list to measure over', () => { + // Guards the vacuous pass: every assertion below iterates this list, so an + // empty list would report success having checked nothing. Three today — + // the two live suites and this file. + expect(LIVE_TEST_FILES.length).toBeGreaterThanOrEqual(3); + expect(LIVE_TEST_FILES).toContain('seed-tenancy-backfill.live-mysql.test.ts'); + expect(LIVE_TEST_FILES).toContain('sys-setting-identity-index.live-mysql.test.ts'); + }); + + it('gives every live file in this package a DISTINCT database', () => { + const byName = new Map(); + for (const file of LIVE_TEST_FILES) { + const name = liveMysqlDatabaseNameFor(`${DIR_KEY}${file}`); + byName.set(name, [...(byName.get(name) ?? []), file]); + } + const collisions = [...byName].filter(([, files]) => files.length > 1); + expect( + collisions, + `these files would share one database — and each drops it in afterAll: ${JSON.stringify(collisions)}`, + ).toEqual([]); + expect(byName.size).toBe(LIVE_TEST_FILES.length); + }); + + it('is deterministic — the same file always resolves the same database', () => { + for (const file of LIVE_TEST_FILES) { + const key = `${DIR_KEY}${file}`; + expect(liveMysqlDatabaseNameFor(key)).toBe(liveMysqlDatabaseNameFor(key)); + } + }); + + it('derives the name from the WHOLE path, so same-named files in two packages differ', () => { + // The cross-package half of the joint injectivity argument: CI points both + // this package's live leg and driver-sql's at ONE MySQL server, so two + // identically-named files in different packages must not meet there. + const a = `${DIR_KEY}seed-tenancy-backfill.live-mysql.test.ts`; + const b = 'packages/drivers/driver-sql/src/seed-tenancy-backfill.live-mysql.test.ts'; + expect(liveMysqlDatabaseNameFor(a)).not.toBe(liveMysqlDatabaseNameFor(b)); + }); + + it('stays distinct where the readable slug truncates to the same prefix', () => { + // The trap: the slug is capped at 34 characters so the whole identifier + // fits, and a cap is not injective. Two file names that agree over the + // first 34 characters are the realistic shape of that. + const a = `${DIR_KEY}a-very-long-live-mysql-database-name-one.test.ts`; + const b = `${DIR_KEY}a-very-long-live-mysql-database-name-two.test.ts`; + const [na, nb] = [liveMysqlDatabaseNameFor(a), liveMysqlDatabaseNameFor(b)]; + expect(na.slice(0, 40)).toBe(nb.slice(0, 40)); // the fixture is vacuous otherwise + expect(na).not.toBe(nb); + }); + + it('fits both dialects’ identifier limits, and is DDL-safe by construction', () => { + // The name is interpolated into `create database` / `use` / `drop database` + // unquoted-by-value, so its character set is a safety property, not style. + // Postgres truncates over 63 bytes SILENTLY, which would fold two long + // names back onto one database with nothing red anywhere. + for (const file of LIVE_TEST_FILES) { + const name = liveMysqlDatabaseNameFor(`${DIR_KEY}${file}`); + expect(name.length, `${name} exceeds the ${IDENTIFIER_LIMIT}-byte limit`) + .toBeLessThanOrEqual(IDENTIFIER_LIMIT); + expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + expect(name.startsWith(LIVE_DB_PREFIX)).toBe(true); + } + }); + + it('cannot carry a backtick out of a file name into the DDL', () => { + // The escape story for `create database \`${DB}\``, which does no escaping: + // it is the character set, so a hostile or merely non-ASCII file name must + // still slug down to `[a-z0-9_]`. Stated as a test because the resolver's + // own length/charset guard is unreachable by construction — 6-byte prefix + + // 34-byte cap + 1 + 12 hex can never exceed 63 — so that guard is + // defensive, and this is the property actually being relied on. + const hostile = liveMysqlDatabaseNameFor(`${DIR_KEY}a\`; drop database x; --.test.ts`); + expect(hostile).toMatch(/^[a-z][a-z0-9_]*$/); + + // A name that slugs to nothing still resolves — and still distinctly, + // because the hash is taken over the full path rather than the slug. + const one = liveMysqlDatabaseNameFor(`${DIR_KEY}äö.test.ts`); + const two = liveMysqlDatabaseNameFor(`${DIR_KEY}üï.test.ts`); + expect(one).toMatch(/^os_lv__[0-9a-f]{12}$/); + expect(two).toMatch(/^os_lv__[0-9a-f]{12}$/); + expect(one).not.toBe(two); + }); + + it('resolves the CURRENT file from vitest’s testPath, not from an argument', () => { + // `currentLiveMysqlDatabase()` takes nothing, so there is no parameter a + // copy-paste could carry over from the file it was copied from — the exact + // way the two constants this replaced stayed distinct only by luck. + expect(currentLiveMysqlDatabase()).toBe( + liveMysqlDatabaseNameFor(`${DIR_KEY}live-mysql-database.isolation.test.ts`), + ); + }); + + it('two different files do not resolve to one shared name', () => { + expect(liveMysqlDatabaseNameFor(`${DIR_KEY}seed-tenancy-backfill.live-mysql.test.ts`)).not.toBe( + liveMysqlDatabaseNameFor(`${DIR_KEY}sys-setting-identity-index.live-mysql.test.ts`), + ); + }); +}); diff --git a/packages/metadata-protocol/src/migrations/live-mysql-database.testkit.ts b/packages/metadata-protocol/src/migrations/live-mysql-database.testkit.ts new file mode 100644 index 0000000000..fc0c887389 --- /dev/null +++ b/packages/metadata-protocol/src/migrations/live-mysql-database.testkit.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10382 — the per-file live MySQL database for this package's live suites. + * + * ## What was actually wrong, which is not what the card said + * + * The card that asked for this states that the two live-MySQL suites here + * "land in the `conformance` database the CI job provisions — the same one, as + * each other". That was already false when it was filed: both files have + * created and `use`-d their own database since the day each landed, and the two + * names differ. What was true is the half that matters for the next file: + * those names were HARD-CODED CONSTANTS (`os_metadata_protocol_9381`, + * `os_metadata_protocol_9434`), so the property #9350 established for + * `packages/drivers/driver-sql` — *a live file's database derives from the + * file, not from a shared constant* — held here only by the authors having + * remembered to type two different strings. + * + * ## The hazard has TWO forms, and only one of them is dormant + * + * Both were measured on a live MariaDB 10.11.14 raised in an agent container + * (`sql_mode` set to MySQL 8's default set), by pointing both constants at one + * name and running the suite. + * + * **Form 1 — a shared name that is NOT the URL's database: dormant.** With both + * files on `os_shared_hazard_probe`, four runs, all GREEN 10/10. The server's + * general query log says why: the two files never overlap. File A's `drop + * database` completes before file B's first `Connect`, under the default + * settings and under an explicit `--maxWorkers=2 --fileParallelism` alike — + * + * 16:32:34 40 Connect … 40 Query CREATE DATABASE IF NOT EXISTS `…` + * 41 Connect … 41 Query USE `…` → DROP DATABASE IF EXISTS `…` + * 16:32:35 42 Connect … 42 Query CREATE DATABASE IF NOT EXISTS `…` + * 43 Connect … 43 Query USE `…` → DROP DATABASE IF EXISTS `…` + * + * — because each file's whole run is ~150 ms while a second fork takes ~1 s to + * boot, so with exactly two small files the destructive window never opens. + * + * **Form 2 — a shared name that IS the URL's database: a hard, deterministic + * red.** With both files on `conformance` (the name CI's `OS_TEST_MYSQL_URL` + * carries, and the one the card claimed they already shared), the run fails at + * `mysql.createConnection(MYSQL_URL)` in the second file's `beforeAll`: + * + * Error: Unknown database 'conformance' + * + * The first file's `afterAll` dropped the database the URL itself points at, so + * the next connection cannot complete its handshake. Sequential execution is no + * protection here — being sequential is exactly how it happens. + * + * The two forms together are the argument for deriving the name rather than + * hand-typing it. Form 1 says nothing observable distinguishes the broken + * configuration from the correct one, so the property cannot be left to a green + * run to defend; Form 2 says the blast radius when it does fire is the whole + * leg, not one flaky assertion. And every input that decides which form you get + * is incidental — the file count in the `live-mysql` filter, the runner's CPU + * count, vitest's `fileParallelism` default, and which string an author typed. + * + * ## What each control in this change actually proves + * + * - `live-mysql-database.isolation.test.ts` is an ABLATION control. Its + * subject — the derivation — does not exist before this change, so no + * pre-fix red run can exist for it. Measured: replacing both calls with a + * literal takes it red (`expected 1 to be greater than or equal to 3`). + * - Form 2 above is a DEFECT control for the hazard, deterministic and + * reproducible, but it is a control on a deliberately broken tree, not on + * the tree as it was: the pre-fix files carried two DISTINCT constants. + * - `scripts/check-live-db-isolation.mjs` is the only control that reds on the + * real pre-fix tree, because it reads source rather than behaviour, and the + * constants were there to read. + * + * ## Why this is a sibling of driver-sql's resolver rather than an import of it + * + * `packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts` holds the + * same derivation, and sharing one copy would be better if it were reachable. + * It is not, for three independent reasons: + * + * - it is not part of `@objectstack/driver-sql`'s public surface — the package + * exports `.` only, `index.ts` does not re-export the testkit, and its own + * header says *"Test-only: not exported from `index.ts`."* Reaching it means + * either publishing vitest-dependent test scaffolding to npm consumers or + * importing another package's `src/`, which is the thing + * `check:cross-package-test-inputs` exists to police; + * - `@objectstack/metadata-protocol` does not depend on `@objectstack/driver-sql` + * at all. Adding it — even as a devDependency — pulls knex, `SqlDriver` and + * that package's whole build into this one's test closure to obtain a pure + * string function, and inverts the layering (a protocol package would depend + * on one storage driver); + * - most of that testkit is not this function. `DialectCell` carries knex + * configs, `readServerZone` takes a `SqlDriver`, and `liveSchemaLedger()` + * hard-codes `packages/drivers/driver-sql/src/` as the directory it reads. + * None of it applies to two raw `mysql2` connections. + * + * The copy is deliberately BYTE-FOR-BYTE the same derivation, same + * `os_lv_` prefix, same 34-character slug cap, same 12 hex of sha256 over the + * same key (the WORKSPACE-RELATIVE path). That is what makes the two + * independent copies jointly injective on the one MySQL server CI provisions + * for both legs: two files can only collide by having the same repo-relative + * path, or by a sha256 collision in 96 bits. A different prefix or a different + * cap would have been safe too; sameness additionally means one `show + * databases` prefix identifies a leftover from any package. + * + * Drift between the two copies is what `scripts/check-live-db-isolation.mjs` + * watches, repo-wide: it fails any live suite anywhere in the tree whose + * database name reaches its DDL from a string literal instead of a call. + * + * ## `use`, not the connection URL — the opposite of driver-sql's choice + * + * driver-sql names the database in the CONNECTION and its testkit explains at + * length why `use` was wrong there: knex's `client.database()` keeps returning + * the URL's database and knex binds THAT into `columnInfo`, so DDL ran in one + * database while the column read answered from another. None of that mechanism + * is present here. These files hold a raw `mysql2` connection, issue their own + * SQL, and nothing reads `connection.config.database` — so the session is the + * only notion of "current database" there is. `use` is kept because it is what + * the files already do and what the card asked for; the reason it is SAFE here + * is the absence of knex, not a disagreement with #9350. + */ + +import { expect } from 'vitest'; +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +/** + * Prefix every per-file live database carries, so a leftover on a shared server + * is identifiable — the same one driver-sql's schemas use, on purpose. + */ +export const LIVE_DB_PREFIX = 'os_lv_'; + +/** The identifier ceiling that binds: Postgres 63 bytes, MySQL 64. */ +const IDENTIFIER_LIMIT = 63; + +/** + * The live database for one test file, named by its workspace-relative path. + * + * Pure and deterministic, so `live-mysql-database.isolation.test.ts` can assert + * distinctness over the real file list with no server at all. The shape is + * `os_lv__<12 hex of sha256(path)>`: + * + * - the SLUG is readable, so an operator looking at `show databases` can tell + * which file owns a leftover; + * - the HASH carries the uniqueness. The slug is truncated to keep the whole + * name inside the shorter of the two dialect limits, and a truncated slug is + * not injective; the hash is taken over the FULL path, so two files that + * truncate to the same slug still differ. + * + * `[a-z0-9_]` only, asserted rather than assumed: the name is interpolated into + * DDL, and a name that can only be those characters cannot carry a backtick out + * of a file name. + */ +export function liveMysqlDatabaseNameFor(testFileKey: string): string { + const key = testFileKey.replace(/\\/g, '/'); + const slug = basename(key) + .replace(/\.test\.tsx?$/, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 34); + const hash = createHash('sha256').update(key).digest('hex').slice(0, 12); + const name = `${LIVE_DB_PREFIX}${slug}_${hash}`; + if (!/^[a-z][a-z0-9_]*$/.test(name) || name.length > IDENTIFIER_LIMIT) { + throw new Error( + `live-mysql isolation: derived an unusable database name ${JSON.stringify(name)} from ` + + `${JSON.stringify(testFileKey)} — it must match /^[a-z][a-z0-9_]*$/ and fit the ` + + `${IDENTIFIER_LIMIT}-byte identifier limit.`, + ); + } + return name; +} + +/** Memoised so the walk below runs once per file, not once per connection. */ +const REPO_RELATIVE_CACHE = new Map(); + +/** + * An absolute test path reduced to something stable across machines: the path + * relative to the workspace root (the nearest ancestor holding + * `pnpm-workspace.yaml`). + * + * The absolute path would isolate just as well — it differs per file either way + * — but it also differs per checkout, so the same file would get a different + * database in a worktree than in CI and the isolation test could not pin a name. + */ +function repoRelativeTestPath(absolutePath: string): string { + const cached = REPO_RELATIVE_CACHE.get(absolutePath); + if (cached !== undefined) return cached; + let dir = dirname(absolutePath); + let relative = basename(absolutePath); + for (;;) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) { + relative = absolutePath.slice(dir.length + 1); + break; + } + const parent = dirname(dir); + if (parent === dir) break; // reached the filesystem root: fall back to the basename + dir = parent; + } + REPO_RELATIVE_CACHE.set(absolutePath, relative); + return relative; +} + +/** + * The live MySQL database the CURRENT test file owns. + * + * Takes no argument on purpose, and that is the whole design. A parameter is + * the one thing a consumer can get wrong: two files handed the same literal are + * back to sharing a database, and nothing about either call site would look + * wrong — which is precisely how this package ended up with two hand-typed + * constants. `testPath` is vitest's own per-file fact, available at module + * scope as well as inside a test (vitest 4.1), so the derivation cannot be + * pointed anywhere else. + * + * Absent `testPath` is a hard error rather than a fallback, because the only + * available fallback is a shared name — the defect. + */ +export function currentLiveMysqlDatabase(): string { + const testPath = expect.getState().testPath; + if (!testPath) { + throw new Error( + 'live-mysql isolation (#10382): vitest reported no testPath, so this live connection ' + + 'cannot be given a per-file database and would fall back to sharing one with every ' + + 'other live file in this package — including its `drop database` in afterAll. Call ' + + 'currentLiveMysqlDatabase() from a test file.', + ); + } + return liveMysqlDatabaseNameFor(repoRelativeTestPath(testPath)); +} diff --git a/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.live-mysql.test.ts b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.live-mysql.test.ts index 75bd638afc..6f7c0c3b95 100644 --- a/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.live-mysql.test.ts +++ b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.live-mysql.test.ts @@ -31,10 +31,18 @@ * missing URL into a failure so a dropped `env:` line cannot quietly return this * seam to no coverage at all. * - * Everything runs in its OWN database (`os_metadata_protocol_9381`), created on - * the spot, because two of the three tables this migration touches have fixed - * platform names (`_objectstack_sequences`, `sys_organization`) that other live - * suites also use. + * Everything runs in its OWN database, created on the spot, because two of the + * three tables this migration touches have fixed platform names + * (`_objectstack_sequences`, `sys_organization`) that other live suites also + * use. + * + * That database is DERIVED FROM THIS FILE's path (#10382) rather than named by + * a constant. It used to be the literal `os_metadata_protocol_9381`, which was + * distinct from the sibling suite's only because two authors happened to type + * two different strings — and `afterAll` below issues `drop database`, so a + * third live file copy-pasted from this one that kept the constant would drop + * the database a running sibling is mid-test in. `currentLiveMysqlDatabase()` + * takes no argument, so there is nothing for a copy-paste to carry over. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -52,10 +60,11 @@ import { SEQUENCES_TABLE, type SeedTenancySeam, } from './seed-tenancy-backfill.js'; +import { currentLiveMysqlDatabase } from './live-mysql-database.testkit.js'; const MYSQL_URL = process.env.OS_TEST_MYSQL_URL; const EXPECT_LIVE = process.env.OS_EXPECT_LIVE_DIALECT_MATRIX === '1'; -const DB = 'os_metadata_protocol_9381'; +const DB = currentLiveMysqlDatabase(); const OBJECT = 'os9381_case'; const FIELD = 'case_number'; diff --git a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.live-mysql.test.ts b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.live-mysql.test.ts index 3cf941ecba..72ee0dea58 100644 --- a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.live-mysql.test.ts +++ b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.live-mysql.test.ts @@ -61,9 +61,16 @@ * missing URL into a failure so a dropped `env:` line cannot quietly return this * seam to no coverage. * - * Everything runs in its OWN database (`os_metadata_protocol_9434`), created on - * the spot, because `sys_setting` is a fixed platform table name that other live - * suites also use. + * Everything runs in its OWN database, created on the spot, because + * `sys_setting` is a fixed platform table name that other live suites also use. + * + * That database is DERIVED FROM THIS FILE's path (#10382) rather than named by + * a constant. It used to be the literal `os_metadata_protocol_9434`, which was + * distinct from the sibling suite's only because two authors happened to type + * two different strings — and `afterAll` below issues `drop database`, so a + * third live file copy-pasted from this one that kept the constant would drop + * the database a running sibling is mid-test in. `currentLiveMysqlDatabase()` + * takes no argument, so there is nothing for a copy-paste to carry over. */ import { describe, expect, it, beforeAll, afterAll } from 'vitest'; @@ -78,10 +85,11 @@ import { SYS_SETTING_TABLE, } from './sys-setting-identity-index.js'; import type { IndexExec } from './partial-index-probe.js'; +import { currentLiveMysqlDatabase } from './live-mysql-database.testkit.js'; const MYSQL_URL = process.env.OS_TEST_MYSQL_URL; const EXPECT_LIVE = process.env.OS_EXPECT_LIVE_DIALECT_MATRIX === '1'; -const DB = 'os_metadata_protocol_9434'; +const DB = currentLiveMysqlDatabase(); /** * The sentence the `unsupported` arm ends with, immediately before the diff --git a/scripts/check-live-db-isolation.mjs b/scripts/check-live-db-isolation.mjs new file mode 100644 index 0000000000..5bf267d7a1 --- /dev/null +++ b/scripts/check-live-db-isolation.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-live-db-isolation (#10382) -- no live suite anywhere in the tree may +// name its database or schema with a constant. +// +// node scripts/check-live-db-isolation.mjs +// node scripts/check-live-db-isolation.mjs --self-test +// +// ## The property, and why it needs a REPO-WIDE watcher +// +// CI provisions ONE Postgres and ONE MySQL for the whole `temporal-conformance` +// job, and points every live leg at them through `OS_TEST_POSTGRES_URL` / +// `OS_TEST_MYSQL_URL`. Two suites that name the same database therefore meet on +// a real server, and every live suite in this repo issues `drop database` (or +// `drop schema`) when it is done. So a shared name is not contention, it is +// destruction: the loser's tables vanish mid-run, or -- when the shared name is +// the one the URL itself carries -- the loser cannot even complete a handshake. +// +// #9350 established the fix (derive the name from the test file) and enforced it +// in `packages/drivers/driver-sql/src/live-dialect-matrix.isolation.test.ts`. +// That scan reads its own package only. It was green, correctly, while two live +// suites in `packages/metadata-protocol` named their databases +// `os_metadata_protocol_9381` and `os_metadata_protocol_9434` -- distinct from +// each other purely because two authors typed two different strings. A +// per-package scan cannot see the package that has not been written yet, and the +// next live suite will be in a third package. Hence this one, which is not +// scoped to any package and needs none to opt in. +// +// ## What this gate proves, and what it deliberately leaves to the suites +// +// It proves a SOURCE property: the identifier that reaches a live `create +// database` / `drop database` / `use` / `create schema` / `drop schema` is not a +// literal, and is not an identifier initialised from a literal. That is exactly +// the state the two metadata-protocol files were in, so this gate reds on the +// pre-#10382 tree -- which is the only control in that change that does. +// +// It deliberately does NOT try to prove the names are DISTINCT. That needs the +// derivation actually run over the real file list, which is running code, and it +// lives where it can run: +// +// packages/drivers/driver-sql/src/live-dialect-matrix.isolation.test.ts +// packages/metadata-protocol/src/migrations/live-mysql-database.isolation.test.ts +// +// Neither half subsumes the other. A derived-but-colliding name passes here and +// fails there; a package with no isolation suite at all passes there (vacuously) +// and fails here. Both are required, and each header says so. +// +// ## Why a source scan rather than a runtime check +// +// Same reason #9350 gives for its own scan: a detector with no dependencies +// cannot itself fail to resolve in CI, and the thing being prevented is a file +// being WRITTEN, which is a source-time event. The price is that it sees only +// the spellings it knows -- so every spelling it recognises is pinned by +// `--self-test`, and a live file whose DDL it cannot parse at all is reported, +// never skipped. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const ROOTS = ['packages', 'apps', 'examples']; +const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo', 'coverage', '.next', 'build']); + +/** + * The env-var read that marks a file as talking to a PROVISIONED live server. + * + * Assembled rather than written out, for the reason #9350's scan records after + * flagging itself on its first run: a literal here would make this file's own + * source match, and adding an exclusion list is the exact shape of hole the scan + * exists to close. + */ +const LIVE_ENV_READ = new RegExp('process' + String.raw`\.env\.OS_TEST_(?:MYSQL|POSTGRES)_URL`); + +/** Backtick, spelled as a code point so it never appears literally in here. */ +const BT = '\\x60'; + +/** + * A live DDL statement head plus the start of its operand. `use` is included + * without a database/schema keyword because that is how MySQL spells it. + */ +const STATEMENT = new RegExp( + String.raw`\b(create\s+database|drop\s+database|create\s+schema|drop\s+schema|use)\b` + + String.raw`(?:\s+if\s+(?:not\s+)?exists)?\s+([^\n;]{0,160})`, + 'gi', +); + +/** The quoted identifier a live statement names, backtick or ANSI double quote. */ +const QUOTED_NAME = new RegExp( + String.raw`^\\?(?:` + BT + String.raw`([^` + BT + String.raw`\n]*?)\\?` + BT + + String.raw`|"([^"\n]*?)")`, +); + +/** Exactly one interpolation and nothing else -- `${DB}`. */ +const SOLE_INTERPOLATION = /^\$\{\s*([A-Za-z_$][\w$]*)\s*\}$/; + +/** + * Source with block and line comments BLANKED -- replaced space-for-space + * rather than deleted, so prose is never a hit and the line numbers this gate + * reports are still the line numbers in the real file. Deleting the comments + * was the first spelling and it reported `…live-mysql.test.ts:45` for a + * statement that lives on line 81; a gate that points at the wrong line is a + * gate the next author stops believing. + */ +export function codeOf(source) { + const blank = (m) => m.replace(/[^\n]/g, ' '); + return source.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/^[ \t]*\/\/.*$/gm, blank); +} + +/** Is this initialiser a constant string? */ +function isLiteralInit(init) { + const trimmed = init.trim(); + if (/^['"]/.test(trimmed)) return true; + // a template literal with no interpolation is just as constant + if (new RegExp('^' + BT).test(trimmed) && !trimmed.includes('${')) return true; + return false; +} + +/** The initialiser of `const = ...` in this source, or undefined. */ +export function initialiserOf(code, ident) { + const decl = new RegExp( + String.raw`\b(?:const|let|var)\s+` + ident + String.raw`\s*(?::[^=\n]+)?=\s*([^\n]+)`, + ).exec(code); + return decl ? decl[1] : undefined; +} + +/** + * Every violation in one file's source. Exported so `--self-test` drives the + * real function rather than a paraphrase of it. + */ +export function violationsIn(code) { + const found = []; + for (const match of code.matchAll(STATEMENT)) { + const verb = match[1].replace(/\s+/g, ' ').toLowerCase(); + const named = QUOTED_NAME.exec(match[2]); + // Not a quoted identifier: `use strict`, a `use` in a string, a statement + // built some other way. Nothing to judge, and nothing to report. + if (!named) continue; + const name = named[1] ?? named[2] ?? ''; + const line = code.slice(0, match.index).split('\n').length; + + if (!name.includes('${')) { + found.push({ line, verb, name, why: `names the literal "${name}"` }); + continue; + } + const sole = SOLE_INTERPOLATION.exec(name); + if (!sole) continue; // interpolated with extra structure -- derived enough + const init = initialiserOf(code, sole[1]); + if (init !== undefined && isLiteralInit(init)) { + found.push({ + line, + verb, + name, + why: `interpolates ${sole[1]}, which is the constant ${init.trim().replace(/;$/, '')}`, + }); + } + } + return found; +} + +function walk(dir, out) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.') continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + walk(full, out); + } else if (/\.m?ts$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + out.push(full); + } + } + return out; +} + +function main() { + const files = []; + for (const root of ROOTS) { + const abs = join(REPO_ROOT, root); + try { + if (statSync(abs).isDirectory()) walk(abs, files); + } catch { + /* a root that does not exist in this checkout is not an error */ + } + } + + const live = []; + for (const file of files.sort()) { + const code = codeOf(readFileSync(file, 'utf8')); + if (LIVE_ENV_READ.test(code)) live.push({ file: relative(REPO_ROOT, file), code }); + } + + // Vacuity guard. Every judgement below iterates this list, so a list that came + // back empty would report success having read nothing -- the failure mode this + // whole family of gates exists to prevent. + if (live.length < 2) { + console.error( + `check-live-db-isolation: found ${live.length} live-server file(s) in the tree. ` + + 'At least two are expected (driver-sql\'s matrix and metadata-protocol\'s migrations). ' + + 'Either the detector stopped matching, or the live suites moved -- both are defects ' + + 'in this gate, not a clean run.', + ); + process.exit(1); + } + + const offenders = []; + for (const { file, code } of live) { + for (const v of violationsIn(code)) offenders.push({ file, ...v }); + } + + console.log(`check-live-db-isolation: ${live.length} live-server file(s) scanned`); + for (const { file } of live) console.log(` - ${file}`); + + if (offenders.length > 0) { + console.error('\ncheck-live-db-isolation: FAIL'); + for (const o of offenders) { + console.error(` ${o.file}:${o.line} ${o.verb} ${o.why}`); + } + console.error( + '\nA live suite must derive its database/schema from its own test FILE, never from a\n' + + 'constant. CI points every live leg at one server, and each of these suites issues a\n' + + "drop when it finishes -- so two suites sharing a name destroy each other's fixture,\n" + + 'and a name equal to the one in the connection URL breaks the next handshake outright.\n' + + 'Use a no-argument resolver seeded from vitest\'s own testPath:\n' + + ' packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts currentLiveSchema()\n' + + ' packages/metadata-protocol/src/migrations/live-mysql-database.testkit.ts\n' + + ' currentLiveMysqlDatabase()\n' + + 'A resolver takes no argument on purpose: there is then no parameter a copy-paste can\n' + + 'carry over from the file it was copied from.', + ); + process.exit(1); + } + + console.log('check-live-db-isolation: PASS -- every live suite derives its database'); +} + +function selfTest() { + const cases = []; + const bt = String.fromCharCode(96); + const check = (label, ok) => cases.push({ label, ok }); + + // 1. the literal-in-the-DDL form + const literalDdl = `await c.query(${bt}CREATE DATABASE IF NOT EXISTS \\${bt}conformance\\${bt}${bt});`; + check('flags a database named by a literal', violationsIn(literalDdl).length === 1); + + // 2. the literal-behind-an-identifier form -- the pre-#10382 tree, exactly + const literalConst = + `const DB = 'os_metadata_protocol_9381';\n` + + `await c.query(${bt}CREATE DATABASE IF NOT EXISTS \\${bt}\${DB}\\${bt}${bt});\n` + + `await c.query(${bt}USE \\${bt}\${DB}\\${bt}${bt});`; + check('flags an identifier initialised from a literal', violationsIn(literalConst).length === 2); + check( + 'names the offending constant in the message', + violationsIn(literalConst)[0].why.includes('os_metadata_protocol_9381'), + ); + + // 3. the fixed form -- derived from a call + const derived = + `const DB = currentLiveMysqlDatabase();\n` + + `await c.query(${bt}CREATE DATABASE IF NOT EXISTS \\${bt}\${DB}\\${bt}${bt});\n` + + `await c.query(${bt}DROP DATABASE IF EXISTS \\${bt}\${DB}\\${bt}${bt});`; + check('passes a database derived from a call', violationsIn(derived).length === 0); + + // 4. the Postgres spelling, both directions + const pgLiteral = `await db.raw(${bt}create schema if not exists "public"${bt});`; + const pgDerived = + `const schema = currentLiveSchema();\n` + + `await db.raw(${bt}create schema if not exists "\${schema}"${bt});`; + check('flags a schema named by a literal', violationsIn(pgLiteral).length === 1); + check('passes a schema derived from a call', violationsIn(pgDerived).length === 0); + + // 5. a loop variable is derived too -- driver-sql's globalSetup shape, which + // has no declaration for the gate to find and must not be flagged for it + const loopVar = + `for (const { schema } of liveSchemaLedger()) {\n` + + ` await db.raw(${bt}create database if not exists \\${bt}\${schema}\\${bt}${bt});\n` + + `}`; + check('passes an identifier with no local literal declaration', violationsIn(loopVar).length === 0); + + // 6. the detector must be able to see nothing, without that meaning "clean" + check('reports no violation in source with no live DDL', violationsIn('const x = 1;').length === 0); + + // 7. comments are not source -- the reason codeOf exists + const inComment = `// CREATE DATABASE IF NOT EXISTS \\${bt}conformance\\${bt}\nconst x = 1;`; + check('ignores DDL that appears only in a comment', violationsIn(codeOf(inComment)).length === 0); + + // 8. the live-file needle must MATCH a real read and not the cell form -- + // without this, a needle that silently stopped matching would report a + // clean scan of an empty population forever + check( + 'the live-file needle matches a direct env read', + LIVE_ENV_READ.test('const U = process' + '.env.OS_TEST_MYSQL_URL;'), + ); + check( + 'the live-file needle does not match the cell form', + !LIVE_ENV_READ.test('const U = MYSQL_CELL.url;'), + ); + + // 9. `use strict` and friends are not live DDL + check('does not flag a non-identifier use', violationsIn(`'use strict';`).length === 0); + + const failed = cases.filter((c) => !c.ok); + for (const c of cases) console.log(`${c.ok ? 'ok ' : 'FAIL'} ${c.label}`); + if (failed.length > 0) { + console.error(`\ncheck-live-db-isolation --self-test: ${failed.length}/${cases.length} FAILED`); + process.exit(1); + } + console.log(`\ncheck-live-db-isolation --self-test: PASS (${cases.length} cases)`); +} + +// This file exports its detector so `--self-test` drives the real functions +// rather than a paraphrase of them, which means it can be imported FOR those +// exports — and an unguarded top-level dispatch would then run the whole scan, +// and its `process.exit`, inside the importer. `check:entry-guard` enforces this +// (and caught exactly that here on the first run). +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) selfTest(); + else main(); +}