diff --git a/.changeset/dialect-getter-knex-canonical-spellings.md b/.changeset/dialect-getter-knex-canonical-spellings.md new file mode 100644 index 0000000000..538d06f905 --- /dev/null +++ b/.changeset/dialect-getter-knex-canonical-spellings.md @@ -0,0 +1,5 @@ +--- +"@objectstack/driver-sql": patch +--- + +`SqlDriver` now recognises knex's own **canonical** client spellings, so `client: 'postgres'` and `client: 'sqlite'` no longer silently lose every dialect-specific behaviour (#11550). `SqlDriverConfig` is `Knex.Config & {…}`, so every client name knex accepts was already declared valid, while `isPostgres` / `isSqlite` enforced two literals each — and `postgres` is the canonical name of the dialect whose registered aliases are `pg` and `postgresql`, `sqlite` likewise for `sqlite3`. Nothing failed on the unrecognised spellings; the driver just emitted the wrong SQL. The sharpest case: `nowColumnDefault` fell through to a bare `CURRENT_TIMESTAMP` default on a `DATE` column, which resolves the calendar day in the **server's** timezone — the exact defect ("a UTC-12 server records YESTERDAY") that method's Postgres branch exists to remove. The three getters, the connect-timeout table, the pg wire-protocol set and the MySQL UTC session pin now derive from one identity source per dialect family instead of four hand-written lists that had already drifted apart. What an already-recognised spelling (`pg`, `postgresql`, `sqlite3`, `better-sqlite3`, `mysql`, `mysql2`) resolves to is unchanged, and `redshift` / `cockroachdb` keep pg **wire** recognition without gaining SQL-**emission** identity — that remains an open support-scope decision (#11756). diff --git a/packages/drivers/driver-sql/src/sql-driver-11550-dialect-client-spellings.test.ts b/packages/drivers/driver-sql/src/sql-driver-11550-dialect-client-spellings.test.ts new file mode 100644 index 0000000000..36218534ef --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11550-dialect-client-spellings.test.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11550 — `client: 'postgres'` is knex's own CANONICAL name for the Postgres + * dialect, and `SqlDriver` did not recognise it. + * + * ## The defect this suite pins + * + * `SqlDriverConfig` is `Knex.Config & {…}`, so the DECLARED surface is every + * client name knex accepts. The three identity getters enforced two literals + * each, and `postgres` was in neither pair — so `isPostgres` answered **false** + * on a config knex considers valid, and every Postgres branch in the driver + * silently stopped applying. + * + * The branch worth pinning is {@link SqlDriver.nowColumnDefault}, not the getter: + * a `getter === true` assertion still passes for a refactor that breaks what the + * getter feeds. With `client: 'postgres'` the method fell through to + * `knex.fn.now()` — a bare `CURRENT_TIMESTAMP` default on a `DATE` column, which + * resolves the calendar day in the SERVER's timezone. That is *the exact defect* + * the Postgres branch was added to remove ("measured: a UTC-12 server records + * YESTERDAY", per the method's own docblock). Nothing threw; the column was just + * created wrong. + * + * ## Why the sweep reads knex's tables instead of copying them + * + * "Which spellings mean one dialect" is knex's fact, not ours. The sweep below + * loads `CLIENT_ALIASES` / `SUPPORTED_CLIENTS` out of the INSTALLED knex and + * asserts an alias-CLOSURE property: if the driver recognises any spelling of a + * dialect, it must recognise every spelling knex resolves to the same canon. A + * hand-copied list would keep passing after a knex upgrade changed the answer. + * + * ## What this suite deliberately does NOT decide + * + * Whether `redshift` and `cockroachdb` — separate knex dialects that speak the + * pg WIRE protocol — should be treated as Postgres for SQL EMISSION is a + * support-scope question, open as **#11756**. The cases under "emission is not + * wire" pin the current, deliberate asymmetry so that a convergence refactor + * cannot answer #11756 as a side effect. When #11756 is decided, those cases are + * the ones that must be consciously rewritten — that is their job. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { SqlDriver } from './sql-driver.js'; + +// ── knex's own vocabulary, read from the pinned install ────────────────────── + +const req = createRequire(import.meta.url); +/** + * `knex/lib/constants.js` is not a declared subpath export, so it is loaded by + * absolute path off the resolved package entry. Reading the REAL table is the + * whole point: it is what makes the closure assertion below fail loudly on a + * knex upgrade that changes which spellings alias which dialect. + */ +const KNEX_CONSTANTS: { + CLIENT_ALIASES: Record; + SUPPORTED_CLIENTS: readonly string[]; +} = req(path.join(path.dirname(req.resolve('knex')), 'lib', 'constants.js')); + +const { CLIENT_ALIASES, SUPPORTED_CLIENTS } = KNEX_CONSTANTS; + +/** knex's canonical dialect name for one client spelling. */ +const canon = (spelling: string): string => CLIENT_ALIASES[spelling] ?? spelling; + +// ── Probes ─────────────────────────────────────────────────────────────────── + +/** Exposes the protected dialect surface without opening a connection. */ +class ProbeDriver extends SqlDriver { + get flags(): { sqlite: boolean; postgres: boolean; mysql: boolean } { + return { sqlite: this.isSqlite, postgres: this.isPostgres, mysql: this.isMysql }; + } + get dialect(): string { + return this.dialectName; + } + nowDefaultSql(type: string): string { + return this.nowColumnDefault(type).toString(); + } + /** + * Re-spell the DECLARED client after construction. + * + * Needed because this workspace installs `pg`, `mysql2` and `better-sqlite3` + * only — knex resolves a dialect's npm driver eagerly in its constructor, so + * `client: 'sqlite3'`, `'sqlite'`, `'mysql'` and `'mariadb'` cannot be + * instantiated here at all (asserted below, so this stand-in stays justified + * rather than merely convenient). The getters read `this.config.client` on + * every call, so this exercises exactly the decision a host with those + * packages installed would get. Every case that CAN construct the real thing + * — `postgres` above all — does so instead. + */ + respell(client: string): this { + (this.config as { client?: unknown }).client = client; + return this; + } +} + +const opened: SqlDriver[] = []; +function make(client: string): ProbeDriver { + const d = new ProbeDriver( + client === 'better-sqlite3' + ? ({ client, connection: { filename: ':memory:' }, useNullAsDefault: true } as any) + : ({ client, connection: { host: '127.0.0.1', port: 1, user: 'u', database: 'd' } } as any), + ); + opened.push(d); + return d; +} +/** A driver whose knex is a real (unconnected) SQLite one, re-spelled for the probe. */ +const respelled = (client: string): ProbeDriver => make('better-sqlite3').respell(client); + +afterEach(async () => { + await Promise.all(opened.splice(0).map((d) => d.disconnect().catch(() => {}))); +}); + +describe("#11550 — knex's alias table is the vocabulary, not a pair of literals", () => { + it('the premise: knex accepts `postgres`/`sqlite` and aliases the rest onto them', () => { + // If any of this stops holding, the fix below is aimed at the wrong target + // and the whole suite must be re-read rather than quietly kept green. + expect(SUPPORTED_CLIENTS).toContain('postgres'); + expect(SUPPORTED_CLIENTS).toContain('sqlite'); + expect(CLIENT_ALIASES).toMatchObject({ pg: 'postgres', postgresql: 'postgres', sqlite: 'sqlite3' }); + }); + + it('knex resolves pg/postgres/postgresql to ONE dialect and ONE driver', () => { + // knex's own answer to "are these the same database", asserted from a live + // client rather than from its docs. + for (const spelling of ['pg', 'postgres', 'postgresql']) { + const client = (make(spelling) as any).knex.client; + expect(client.dialect, spelling).toBe('postgresql'); + expect(client.driverName, spelling).toBe('pg'); + } + }); + + it('the stand-in is necessary: this workspace cannot instantiate sqlite3/mysql/mariadb', () => { + // Justifies `respell()`. `sqlite3`, `mysql` and `mariadb` are real knex + // clients whose npm drivers this repo does not install, so knex throws in + // its own constructor before any driver code runs. + for (const spelling of ['sqlite3', 'sqlite', 'mysql', 'mariadb']) { + expect(() => make(spelling), spelling).toThrow(/Cannot find module|Knex: run/); + } + }); +}); + +describe('#11550 — the branch the defect was measured in', () => { + // The card's own reproduction, on a driver constructed exactly as a host + // would: `client: 'postgres'`, no re-spelling anywhere in this block. + it("`client: 'postgres'` selects nowColumnDefault's Postgres DATE branch", () => { + const sql = make('postgres').nowDefaultSql('date'); + expect(sql).toContain("timezone('utc', now())::date"); + // The fall-through this card is about: a bare CURRENT_TIMESTAMP default on + // a DATE column resolves the calendar day in the SERVER's timezone. + expect(sql.toUpperCase()).not.toContain('CURRENT_TIMESTAMP'); + }); + + it("`client: 'postgres'` selects nowColumnDefault's Postgres TIME branch", () => { + const sql = make('postgres').nowDefaultSql('time'); + expect(sql).toContain("timezone('utc', now())::time(3)"); + expect(sql.toUpperCase()).not.toContain('CURRENT_TIMESTAMP'); + }); + + it('an alias and the canon emit byte-identical DDL defaults', () => { + // The point of the fix stated as an equality: three spellings, one dialect, + // one emitted default. Covers `datetime` too, where both correctly keep + // knex.fn.now() — so this case also proves the sweep is not just asserting + // "everything is a Postgres expression". + for (const type of ['date', 'time', 'datetime']) { + const viaCanon = make('postgres').nowDefaultSql(type); + expect(make('pg').nowDefaultSql(type), `pg/${type}`).toBe(viaCanon); + expect(make('postgresql').nowDefaultSql(type), `postgresql/${type}`).toBe(viaCanon); + } + expect(make('postgres').nowDefaultSql('datetime').toUpperCase()).toContain('CURRENT_TIMESTAMP'); + }); + + it("`client: 'sqlite'` selects the SQLite strftime branch", () => { + const d = respelled('sqlite'); + expect(d.flags).toMatchObject({ sqlite: true, postgres: false, mysql: false }); + expect(d.dialect).toBe('sqlite'); + expect(d.nowDefaultSql('date')).toContain("strftime('%Y-%m-%d'"); + }); + + it("`client: 'postgres'` reaches the drift differ as 'postgres', not 'unknown'", () => { + // `dialectName` feeds schema-drift; 'unknown' there disables every + // dialect-aware comparison, which is the same silent degradation one layer up. + expect(make('postgres').dialect).toBe('postgres'); + }); +}); + +describe('#11550 — identity across every spelling knex supports', () => { + /** What the driver answers for each of knex's supported client spellings. */ + const recognised = (family: 'sqlite' | 'postgres' | 'mysql'): string[] => + SUPPORTED_CLIENTS.filter((c) => respelled(c).flags[family]).sort(); + + it('recognition is closed under knex aliasing', () => { + // The invariant the two-literal getters violated: recognising `pg` while + // rejecting `postgres` means recognising an alias of a dialect whose own + // canonical name is unrecognised. + for (const family of ['sqlite', 'postgres', 'mysql'] as const) { + const set = new Set(recognised(family)); + for (const spelling of set) { + for (const other of SUPPORTED_CLIENTS) { + if (canon(other) !== canon(spelling)) continue; + expect(set, `${family}: ${spelling} recognised but ${other} is not`).toContain(other); + } + } + } + }); + + it('resolves the exact expected spelling table', () => { + // Names every answer, so a widening cannot slip in unannounced — and so the + // spellings that already worked are pinned as UNCHANGED. Sibling cards this + // round select behaviour through these getters with `pg` / `mysql2`. + expect(recognised('postgres')).toEqual(['pg', 'postgres', 'postgresql']); + expect(recognised('sqlite')).toEqual(['better-sqlite3', 'sqlite', 'sqlite3']); + expect(recognised('mysql')).toEqual(['mysql', 'mysql2']); + }); + + it('no spelling is claimed by two families, and unknown clients stay unknown', () => { + for (const spelling of SUPPORTED_CLIENTS) { + const f = respelled(spelling).flags; + expect(Number(f.sqlite) + Number(f.postgres) + Number(f.mysql), spelling).toBeLessThanOrEqual(1); + } + // A bring-your-own Client CONSTRUCTOR names no spelling in any table. + const byConstructor = make('better-sqlite3'); + (byConstructor as any).config.client = class {}; + expect(byConstructor.flags).toEqual({ sqlite: false, postgres: false, mysql: false }); + expect(byConstructor.dialect).toBe('unknown'); + }); +}); + +describe('#11550 — emission identity is not wire identity (#11756 stays open)', () => { + const emit = (name: string): ReadonlySet => (SqlDriver as any)[name]; + + it('redshift and cockroachdb parse pg wire but do NOT emit Postgres DDL', () => { + // ⚠️ These two cases are the deliberate, currently-undecided asymmetry. + // Rewriting them is how #11756 gets answered — not by a refactor quietly + // merging the tables. + for (const spelling of ['redshift', 'cockroachdb']) { + const d = respelled(spelling); + expect(d.flags, spelling).toEqual({ sqlite: false, postgres: false, mysql: false }); + expect(d.dialect, spelling).toBe('unknown'); + // The consequence, spelled out: they keep the CURRENT_TIMESTAMP default. + expect(d.nowDefaultSql('date').toUpperCase(), spelling).toContain('CURRENT_TIMESTAMP'); + // …while still getting the #11389 calendar-day wire hook. + expect(emit('POSTGRES_WIRE_CLIENTS'), spelling).toContain(spelling); + } + }); + + it('the wire set EXTENDS the emission set, never the reverse', () => { + const wire = emit('POSTGRES_WIRE_CLIENTS'); + for (const spelling of emit('POSTGRES_EMIT_CLIENTS')) expect(wire).toContain(spelling); + expect([...wire].sort()).toEqual( + ['cockroachdb', 'pg', 'postgres', 'postgresql', 'redshift'], + ); + }); + + it('mariadb is a separate knex dialect and is not MySQL for emission', () => { + expect(SUPPORTED_CLIENTS).toContain('mariadb'); + expect(respelled('mariadb').flags.mysql).toBe(false); + }); +}); + +describe('#11550 — the client-keyed tables now derive from one source', () => { + const table = (): Record => + (SqlDriver as any).DIALECT_CONNECT_TIMEOUT; + + it('the connect-timeout table covers every emission spelling of pg and mysql', () => { + // The drift the card measured, expressed as an invariant: a spelling the + // getters accept can no longer be one the timeout table has never heard of. + for (const spelling of (SqlDriver as any).POSTGRES_EMIT_CLIENTS as Set) { + expect(table()[spelling], spelling).toEqual({ + key: 'connectionTimeoutMillis', urlKey: 'connectionString', + }); + } + for (const spelling of (SqlDriver as any).MYSQL_EMIT_CLIENTS as Set) { + expect(table()[spelling], spelling).toEqual({ key: 'connectTimeout', urlKey: 'uri' }); + } + }); + + it('deriving the table changed no membership', () => { + // The refactor half must be a no-op. `redshift`'s ABSENCE is load-bearing — + // `withConnectBound`'s comment cites it as the reason its early return may + // not skip the session pins. + expect(Object.keys(table()).sort()).toEqual( + ['cockroachdb', 'mysql', 'mysql2', 'pg', 'postgres', 'postgresql'], + ); + }); + + it('the UTC session pin still fires for MySQL only', () => { + // `withUtcSession` used to carry its own `mysql`/`mysql2` pair; it now reads + // the shared set. Behaviour must be unchanged in both directions. + // + // Measured while writing this: BOTH families end up with a `pool.afterCreate` + // — `postgres` gets the #11389 calendar-day WIRE hook — so the presence of + // that function proves nothing about which pin was applied. The hooks are + // therefore driven with a recording connection, which is the only thing that + // tells them apart. + const bound = (client: string): any => + (SqlDriver as any).withConnectBound({ client, connection: { host: 'h' } }); + /** Run a config's afterCreate against a stand-in that records what it is asked. */ + const drive = (cfg: any) => { + const queries: string[] = []; + const parsers: number[] = []; + const connection = { + query: (sql: string, cb: (e?: unknown) => void) => { queries.push(sql); cb(); }, + getTypeParser: () => (t: string) => t, + setTypeParser: (oid: number) => { parsers.push(oid); }, + }; + cfg.pool.afterCreate(connection, () => {}); + return { queries, parsers }; + }; + + // MySQL: the session pin, on both layers. + expect(bound('mysql2').connection.timezone).toBe('Z'); + expect(drive(bound('mysql2')).queries).toEqual(["SET time_zone = '+00:00'"]); + + // Postgres, canonically spelled: no MySQL pin on either layer… + expect(bound('postgres').connection.timezone).toBeUndefined(); + expect(drive(bound('postgres')).queries).toEqual([]); + // …the wire hook it is entitled to… + expect(drive(bound('postgres')).parsers).toContain(1082); + // …and the pg connect bound, which the derived table must still supply. + expect(bound('postgres').connection.connectionTimeoutMillis).toBe(10_000); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 383ea31dd6..18b1ad2476 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -4192,22 +4192,109 @@ export class SqlDriver implements IDataDriver { else this.logger.warn(msg, meta); } - /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */ + /** + * The knex `client` spellings that mean "emit THIS dialect's SQL", one set + * per dialect family. The single source the three identity getters below and + * every client-keyed table further down read — {@link DIALECT_CONNECT_TIMEOUT}, + * {@link POSTGRES_WIRE_CLIENTS} and {@link withUtcSession} used to carry a + * hand-written copy each, and the copies had already drifted apart (#11550). + * + * ## Why a set and not a pair of literals + * + * {@link SqlDriverConfig} is `Knex.Config & {…}`, so the DECLARED surface is + * every client name knex accepts, while the enforced one used to be two + * literals per family. knex's own table (`knex/lib/constants.js`, knex 3.3.0 + * — the version this repo pins) registers aliases: + * + * ```js + * CLIENT_ALIASES = { pg: 'postgres', postgresql: 'postgres', sqlite: 'sqlite3' } + * ``` + * + * So `postgres` is knex's CANONICAL name for the dialect of which `pg` and + * `postgresql` are merely the registered aliases, and `sqlite` is an alias of + * the canonical `sqlite3`. Measured on the pinned knex: `pg`, `postgres` and + * `postgresql` each construct a client with `dialect === 'postgresql'` and + * `driverName === 'pg'` — one dialect, three spellings. + * + * Matching two literals therefore made `client: 'postgres'` — knex's PRIMARY + * name for the dialect — answer `isPostgres === false`, silently dropping + * every Postgres branch in this file on a config knex considers valid. + * Nothing failed. The sharpest case is {@link nowColumnDefault}: the + * fall-through emits a bare `CURRENT_TIMESTAMP` default on a `DATE` column, + * i.e. the calendar day in the SERVER's timezone — the exact defect + * ("measured: a UTC-12 server records YESTERDAY") that method's Postgres + * branch exists to remove. + * + * ## What is deliberately NOT in here + * + * `redshift` and `cockroachdb` speak the pg WIRE protocol and are in + * {@link POSTGRES_WIRE_CLIENTS} for that reason — but they are separate knex + * dialects (`redshift` compiles its own DDL; `cockroachdb` ships its own + * driver), and whether this driver should claim to emit correct DDL for them + * is a SUPPORT-SCOPE question rather than a spelling one. `mariadb` is the + * same shape against the MySQL family. They stay out until that is decided + * — see #11756. Wire recognition and the connect-timeout table EXTEND these + * sets; nothing derives these sets from a union of the others, because that + * would answer #11756 as a side effect of a refactor. + * + * `better-sqlite3` is a member rather than an extension because it is not a + * scope question: this driver's own default SQLite client is `better-sqlite3` + * and every SQLite branch in this file was written against it. + */ + private static readonly POSTGRES_EMIT_CLIENTS: ReadonlySet = new Set([ + 'postgres', 'pg', 'postgresql', + ]); + /** SQLite spellings that mean "emit SQLite SQL". See {@link POSTGRES_EMIT_CLIENTS}. */ + private static readonly SQLITE_EMIT_CLIENTS: ReadonlySet = new Set([ + 'sqlite3', 'sqlite', 'better-sqlite3', + ]); + /** MySQL spellings that mean "emit MySQL SQL". See {@link POSTGRES_EMIT_CLIENTS}. */ + private static readonly MYSQL_EMIT_CLIENTS: ReadonlySet = new Set([ + 'mysql', 'mysql2', + ]); + + /** + * The configured client as a STRING, or `''`. + * + * `Knex.Config['client']` also admits a Client CONSTRUCTOR (knex's documented + * bring-your-own-dialect hatch). Such a value names no spelling in any table + * here, and `''` is how it says so — the same answer the literal comparisons + * this replaces gave, without stringifying a class to get there. + */ + private static clientSpelling(config: unknown): string { + const c = (config as { client?: unknown } | null | undefined)?.client; + return typeof c === 'string' ? c : ''; + } + + /** + * Whether the underlying database is a SQLite variant. + * + * Every knex spelling of the family: `sqlite3` (knex's canon), its registered + * alias `sqlite`, and `better-sqlite3`. See {@link SQLITE_EMIT_CLIENTS}. + */ protected get isSqlite(): boolean { - const c = (this.config as any).client; - return c === 'sqlite3' || c === 'better-sqlite3'; + return SqlDriver.SQLITE_EMIT_CLIENTS.has(SqlDriver.clientSpelling(this.config)); } - /** Whether the underlying database is PostgreSQL. */ + /** + * Whether the underlying database is PostgreSQL. + * + * Every knex spelling of the family: `postgres` (knex's canon) and its + * registered aliases `pg` / `postgresql`. `redshift` and `cockroachdb` are + * deliberately NOT here — see {@link POSTGRES_EMIT_CLIENTS} and #11756. + */ protected get isPostgres(): boolean { - const c = (this.config as any).client; - return c === 'pg' || c === 'postgresql'; + return SqlDriver.POSTGRES_EMIT_CLIENTS.has(SqlDriver.clientSpelling(this.config)); } - /** Whether the underlying database is MySQL. */ + /** + * Whether the underlying database is MySQL. + * + * Both knex spellings of the family; `mariadb` is a separate knex dialect and + * is deliberately NOT here. See {@link POSTGRES_EMIT_CLIENTS} and #11756. + */ protected get isMysql(): boolean { - const c = (this.config as any).client; - return c === 'mysql' || c === 'mysql2'; + return SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(this.config)); } /** @@ -4473,14 +4560,24 @@ export class SqlDriver implements IDataDriver { * SQLite (`better-sqlite3` / `sqlite3`) is deliberately absent — it opens a * file, so there is no handshake to time out and nothing to inject. */ - private static readonly DIALECT_CONNECT_TIMEOUT: Record = { - pg: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' }, - postgres: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' }, - postgresql: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' }, - cockroachdb: { key: 'connectionTimeoutMillis', urlKey: 'connectionString' }, - mysql: { key: 'connectTimeout', urlKey: 'uri' }, - mysql2: { key: 'connectTimeout', urlKey: 'uri' }, - }; + private static readonly DIALECT_CONNECT_TIMEOUT: Record = + Object.fromEntries<{ key: string; urlKey: string }>([ + // Every spelling that means Postgres SQL, EXTENDED by `cockroachdb`: + // a separate knex dialect, but one that reaches the server through the + // same `pg` driver and therefore takes the same knob. Deriving the pg + // arm from {@link POSTGRES_EMIT_CLIENTS} is what keeps this table from + // drifting away from the getters again (#11550) — widening recognition + // now widens this with it. `redshift` still carries no entry; see the + // note in {@link withConnectBound} for why that absence is load-bearing. + ...[...SqlDriver.POSTGRES_EMIT_CLIENTS, 'cockroachdb'].map( + (c): [string, { key: string; urlKey: string }] => + [c, { key: 'connectionTimeoutMillis', urlKey: 'connectionString' }], + ), + ...[...SqlDriver.MYSQL_EMIT_CLIENTS].map( + (c): [string, { key: string; urlKey: string }] => + [c, { key: 'connectTimeout', urlKey: 'uri' }], + ), + ]); private static withConnectBound(knexConfig: Record): Record { const pool = knexConfig.pool as Record | undefined; @@ -4545,8 +4642,9 @@ export class SqlDriver implements IDataDriver { * knex extension point the host may already be using. */ private static withUtcSession(knexConfig: Record): Record { - const client = String(knexConfig.client ?? ''); - if (client !== 'mysql' && client !== 'mysql2') return knexConfig; + // The MySQL identity question again, asked from a static: the same set + // `isMysql` reads, not a fourth hand-written copy of it (#11550). + if (!SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(knexConfig))) return knexConfig; const out: Record = { ...knexConfig }; const conn = out.connection; @@ -4575,14 +4673,20 @@ export class SqlDriver implements IDataDriver { * knex client names that route to the `pg` npm driver, and therefore reach * {@link withPostgresCalendarDayAsText}. Wider than {@link isPostgres} on * purpose: that getter answers "which dialect's SQL do I emit", while this - * answers "which npm package parses the wire format" — `postgres` (knex's - * own alias), `cockroachdb` and `redshift` all speak the pg wire protocol - * with the same type OIDs. The hook is additionally guarded on the - * connection really exposing `setTypeParser`, so a client name that turns - * out not to be a `pg.Client` degrades to a no-op instead of throwing. + * answers "which npm package parses the wire format": `cockroachdb` and + * `redshift` speak the pg wire protocol with the same type OIDs without + * being the same DIALECT. The hook is additionally guarded on the connection + * really exposing `setTypeParser`, so a client name that turns out not to be + * a `pg.Client` degrades to a no-op instead of throwing. + * + * Built by EXTENDING {@link POSTGRES_EMIT_CLIENTS}, never by unioning tables + * into it: the reverse direction would grant `cockroachdb` and `redshift` + * SQL-emission identity as a silent side effect of a refactor, which is the + * open decision #11756 and not this file's to make (#11550). */ private static readonly POSTGRES_WIRE_CLIENTS: ReadonlySet = new Set([ - 'pg', 'postgres', 'postgresql', 'cockroachdb', 'redshift', + ...SqlDriver.POSTGRES_EMIT_CLIENTS, + 'cockroachdb', 'redshift', ]); /** Postgres OID of `date` — a bare calendar day, no time and no zone. */