Skip to content

Commit 46cfa5b

Browse files
Elon Muskclaude
andauthored
test(driver-sql): isolate the live-dialect matrix per test file (#10381)
* test(driver-sql): isolate the live-dialect matrix per test file (#9350) Every live-matrix file resolved its connection from one env var per dialect, so all 14 of them shared one conformance database — one `public` schema on Postgres, one `conformance` database on MySQL — including the driver's internal `_objectstack_sequences` counter table, which each file's autonumber path lazily creates and writes. `cell.config()` now derives a per-FILE schema (Postgres) / database (MySQL) from vitest's own `testPath`, created by a `pool.afterCreate` hook so every pooled connection lands in it. No assertion changed; no test is skipped, quarantined, retried, or given a larger budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * fix(driver-sql): name the isolated database in the connection, and let PG introspection follow the session (#9350) The first attempt moved the session with `use` (MySQL) while leaving the connection pointed at `conformance`. knex binds `client.database()` — the CONNECTION's database — into `columnInfo`, so DDL ran in the per-file database while the column read answered from `conformance`: the driver saw an empty column set for a fully populated table and emitted `alter table ... add <column>`, which the server rejected with *Duplicate column name*. Measured on a live MariaDB 10.11: 18 red, matching CI's three failing files exactly. The database is now named in the connection URL, so the two halves cannot disagree, and a vitest globalSetup creates the schemas before any pool opens. Postgres' index read pinned `n.nspname = 'public'`, which returned [] for a table that measurably had a primary key and a declared unique index once the suites moved off `public` — and [] reads as "no indexes", which assertConflictTargetHonoured turns into a refusal. It now resolves the table with to_regclass, the same way every other statement in the session resolves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * chore(changeset): the PG introspection fix is user-visible (#9350) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent bc400af commit 46cfa5b

10 files changed

Lines changed: 726 additions & 42 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
**Bug fix:** on Postgres, index and schema introspection now resolve tables the way the session does, instead of assuming the `public` schema (#9350).
6+
7+
`introspectIndexes` pinned `n.nspname = 'public'` and `introspectSchema` pinned `table_schema = 'public'`. For a driver whose connection carries a `searchPath` pointing anywhere else, both returned **empty** — not an error, an empty result. Measured on a live Postgres 16: for a table carrying a primary key *and* a declared unique index, `introspectIndexes` returned `[]` and `introspectSchema` listed no tables at all.
8+
9+
Empty does not read as "I could not see" downstream; it reads as "there are no indexes". `assertConflictTargetHonoured` turns that into a refusal, so an `upsert` against a perfectly well-indexed table would be rejected with *no PRIMARY KEY or UNIQUE index backs them* — and index-drift detection would propose creating indexes that already exist.
10+
11+
- `introspectIndexes` now resolves the table with `to_regclass(?)` and reads `pg_index` by OID. That is the same resolution every other statement in the session performs — first match along `search_path` — and it removes an ambiguity a schema list would introduce, since two schemas on the path can hold the same table name and only one of them is the one a query reaches.
12+
- `introspectSchema` now lists `table_schema = ANY (current_schemas(false))`.
13+
14+
**No change for a default deployment.** With the default `search_path`, `current_schemas(false)` is exactly `{public}` and `to_regclass` resolves into `public`, so both queries return what they returned before. The behaviour only differs where the old queries returned nothing.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #9350 — create the per-file schemas before any test opens a connection.
5+
*
6+
* ## Why this runs here and not in a hook
7+
*
8+
* The isolation names each file's database in the CONNECTION (see
9+
* `mysqlUrlForSchema`), which is what keeps knex's `client.database()` and the
10+
* session the same value. The cost of that choice is an ordering constraint:
11+
* connecting to a MySQL database that does not exist fails at the handshake, so
12+
* the databases have to exist before the first pool opens.
13+
*
14+
* A `beforeAll` cannot do it. `cell.config()` is called from inside `beforeEach`
15+
* in most of the eleven consumers, which is too late to register a hook, and the
16+
* testkit module is cached PER WORKER rather than per file — so a hook
17+
* registered at its module scope would attach to whichever file that worker
18+
* collected first and to no other. `globalSetup` runs once, in the main process,
19+
* before any worker starts, and can await. That is exactly the shape of the
20+
* constraint.
21+
*
22+
* ## Deliberately total, and deliberately cheap
23+
*
24+
* It creates a schema for EVERY test file in the package rather than for the
25+
* live ones only. Deciding which files are live would mean parsing them, and a
26+
* wrong answer is a handshake failure in a required check. A schema costs one
27+
* dictionary row on both dialects (`create database` on MySQL is not a template
28+
* copy the way Postgres' `createdb` is), and the teardown removes them.
29+
*
30+
* Without either URL this does nothing at all: a developer running without
31+
* servers sees no connection attempt, exactly as before.
32+
*/
33+
34+
import knex from 'knex';
35+
import { liveSchemaLedger } from './live-dialect-matrix.testkit.js';
36+
37+
const PG_URL = process.env.OS_TEST_POSTGRES_URL;
38+
const MYSQL_URL = process.env.OS_TEST_MYSQL_URL;
39+
40+
/**
41+
* Statements are built from the ledger's names, never from anything a caller
42+
* supplies, and `liveSchemaNameFor` refuses to emit a name outside
43+
* `/^[a-z][a-z0-9_]*$/` — so the interpolation below cannot carry a quote.
44+
*/
45+
async function withServer<T>(
46+
client: 'pg' | 'mysql2',
47+
connection: string,
48+
run: (db: ReturnType<typeof knex>) => Promise<T>,
49+
): Promise<T> {
50+
const db = knex({ client, connection, pool: { min: 0, max: 1 } });
51+
try {
52+
return await run(db);
53+
} finally {
54+
await db.destroy();
55+
}
56+
}
57+
58+
export async function setup(): Promise<void> {
59+
const ledger = liveSchemaLedger();
60+
if (PG_URL) {
61+
await withServer('pg', PG_URL, async (db) => {
62+
for (const { schema } of ledger) {
63+
await db.raw(`create schema if not exists "${schema}"`);
64+
}
65+
});
66+
}
67+
if (MYSQL_URL) {
68+
await withServer('mysql2', MYSQL_URL, async (db) => {
69+
for (const { schema } of ledger) {
70+
await db.raw(`create database if not exists \`${schema}\``);
71+
}
72+
});
73+
}
74+
}
75+
76+
/**
77+
* Drop what the setup created.
78+
*
79+
* Best-effort by design: a failed drop must not turn a green run red — the
80+
* schemas are re-created idempotently next time, and CI's servers are thrown
81+
* away with the job. It exists for the developer running against a long-lived
82+
* local server, who would otherwise accumulate one schema per test file.
83+
*/
84+
export async function teardown(): Promise<void> {
85+
const ledger = liveSchemaLedger();
86+
if (PG_URL) {
87+
await withServer('pg', PG_URL, async (db) => {
88+
for (const { schema } of ledger) {
89+
await db.raw(`drop schema if exists "${schema}" cascade`).catch(() => {});
90+
}
91+
}).catch(() => {});
92+
}
93+
if (MYSQL_URL) {
94+
await withServer('mysql2', MYSQL_URL, async (db) => {
95+
for (const { schema } of ledger) {
96+
await db.raw(`drop database if exists \`${schema}\``).catch(() => {});
97+
}
98+
}).catch(() => {});
99+
}
100+
}

0 commit comments

Comments
 (0)