From d215aaa9032c1a4049576a693e073d6a28fea217 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 26 Aug 2026 13:12:03 +0000 Subject: [PATCH 1/2] feat(driver-sql,spec): one emission-identity source, loud DDL refusal for redshift/cockroachdb, pgnative in the Postgres family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the maintainer ruling of 2026-08-25 (option C, plus pgnative joining the Postgres family). The pair `cockroachdb, redshift` is declared once as POSTGRES_WIRE_ONLY_CLIENTS; the connect-timeout table, the wire table and the new DDL refusal all extend the emission sets through it. `pgnative` becomes a member of POSTGRES_EMIT_CLIENTS and reaches the other two tables by derivation. A redshift/cockroachdb datasource reaching schema DDL now gets UnsupportedDialectEmissionError — SQL_DIALECT_EMISSION_UNSUPPORTED / 501 — before any statement is issued, with the supported clients and the skipSchemaSync posture named in the message. mariadb stays out of scope: neither recognised nor refused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .../sql-emission-identity-one-source.md | 55 ++++ .../src/dialect-emission-refusal.ts | 135 ++++++++ packages/drivers/driver-sql/src/index.ts | 11 + ...ver-11550-dialect-client-spellings.test.ts | 147 ++++++--- ...er-11991-emission-identity-refusal.test.ts | 296 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 215 +++++++++++-- .../spec/src/api/error-code-ledger.zod.ts | 17 + 7 files changed, 807 insertions(+), 69 deletions(-) create mode 100644 .changeset/sql-emission-identity-one-source.md create mode 100644 packages/drivers/driver-sql/src/dialect-emission-refusal.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-11991-emission-identity-refusal.test.ts diff --git a/.changeset/sql-emission-identity-one-source.md b/.changeset/sql-emission-identity-one-source.md new file mode 100644 index 0000000000..55e93ea10a --- /dev/null +++ b/.changeset/sql-emission-identity-one-source.md @@ -0,0 +1,55 @@ +--- +'@objectstack/driver-sql': minor +'@objectstack/spec': minor +--- + +feat(driver-sql,spec): one emission-identity source — `redshift`/`cockroachdb` DDL is refused by name, `pgnative` joins the Postgres family (#11991, landing the #11756 ruling) + +**BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor` +under the repo's launch-window convention for breaking changes — and a widening +in the same edit, so read both directions. + +Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative +归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol +without being the PostgreSQL this driver emits DDL for, and the driver had no +opinion about any of them — it simply let knex compile whatever it compiles. +Measured on `origin/main` before the change, one `CREATE TABLE` per client: + +``` +pg / pgnative / cockroachdb "body" text primary key inline +redshift "body" varchar(max) primary key in a separate ALTER TABLE +``` + +So on Redshift the pre-ruling behaviour was not a failure — it was a table of a +different shape, built quietly, with the deployment finding out when it wrote +data into it. + +**Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches +schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`, +`reconcileManagedSchema` — now gets an immediate +`UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED` +(newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`), +HTTP status `501`, and a message naming the client, every client the driver +DOES emit for, and the supported way to keep the database — manage its schema +out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws +before any statement is issued, so nothing is half-built. Connection, the +connect bound and the #11389 calendar-day parser are untouched: the boundary is +DDL only, drawn where behaviour was actually verified. + +**Recognised (widening).** `pgnative` is now a member of the Postgres emission +family — knex resolves it to the same `postgresql` dialect and the same query +compiler as `pg`, differing only in which npm binding carries the bytes. It was +previously in neither the emission set nor the wire table, so a `date` column +got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact +#11550 defect) and no calendar-day parser. It now behaves identically to `pg` +and carries the #11389 pin. + +**One source of truth.** The pair `cockroachdb, redshift` used to be +hand-written into the connect-timeout table and again into the wire table. It +is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend +the emission sets through it — as does the refusal, which reads the same set. +Adding a future pg-wire client is one edit, and the three answers cannot drift +apart. `mariadb` is explicitly out of the ruling's scope and keeps its third +state: neither recognised nor refused. + + diff --git a/packages/drivers/driver-sql/src/dialect-emission-refusal.ts b/packages/drivers/driver-sql/src/dialect-emission-refusal.ts new file mode 100644 index 0000000000..673ec4276a --- /dev/null +++ b/packages/drivers/driver-sql/src/dialect-emission-refusal.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The DDL-emission scope boundary, said out loud (#11991, landing the #11756 + * ruling). + * + * ## The ruling this implements + * + * Maintainer, 2026-08-25, verbatim 「同意」 on 「C,但 pgnative 归入 Postgres + * 家族」 (#11756, comment 5404884704). Three databases speak the Postgres wire + * protocol without being the Postgres this driver emits DDL for. The ruling + * split them: + * + * - `pgnative` — the same knex dialect and the same query compiler as `pg`, + * differing only in which npm binding carries the bytes. It JOINS the + * Postgres family for emission. + * - `redshift` / `cockroachdb` — wire recognition stays (connection and + * result parsing, #11389, deliberate); emission identity is refused. A + * configuration of theirs that reaches the DDL path is told so, by name, + * at once. + * + * ## Why a refusal rather than "just emit Postgres and see" + * + * Because the alternative fails silently and late. Measured on the pinned knex + * (#11991), one `CREATE TABLE` compiled by each client: + * + * ``` + * pg / pgnative "body" text primary key inline in the CREATE + * redshift "body" varchar(max) primary key in a separate ALTER TABLE + * ``` + * + * Emitting Postgres DDL at a Redshift therefore does not throw — it builds a + * table of a different shape, and the deployment finds out when it writes data + * into it. That is the failure this refusal exists to convert into a sentence + * an operator reads at boot, on the axis the ruling weighed most: an author + * whose configuration is wrong should be told at the moment they get it wrong. + * + * ## Why the platform still connects + * + * The boundary is drawn where behaviour was actually verified — wire yes, + * emission no — rather than at the package boundary. Connection, the connect + * bound and the #11389 calendar-day parser all still apply, so a deployment + * that manages its schema out-of-band (`skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`, + * the documented posture after running migrations manually) keeps working on + * these databases. That escape hatch is named in the message, because a refusal + * that does not say what to do instead is only half of "loud". + * + * ## Reopening + * + * Recorded on #11756: no customer is known on either database, and evidence of + * a real one reopens this toward recognition — starting with a MEASURED DDL + * difference and the two databases judged separately (CockroachDB's Postgres + * compatibility is visibly higher: knex already compiles it with the + * `postgresql` dialect, where `redshift` has a dialect of its own). + */ + +/** + * ADR-0112 D3 extension code, registered by `@objectstack/driver-sql` in + * `ERROR_CODE_LEDGER`. + * + * Registered rather than parked as a driver-local string because this refusal + * IS wire-reachable: publishing a drafted object calls `engine.syncObjectSchema` + * → `SqlDriver.syncSchema` → the DDL gate, on a server that is already serving + * HTTP. That is the test the ledger applies (the class `MONGODB_MULTI_TENANT_UNSUPPORTED` + * was UNregistered for failing — a boot refusal the CLI rethrows pre-HTTP, which + * no response envelope could ever carry). This one can be carried, so it is + * registered and the door serves it under its own name instead of demoting it + * to `declaredCode` behind a 500. + * + * No standard-catalog member covers the condition: `NOT_IMPLEMENTED` says "not + * yet", and the whole content of the ruling is that this is a decided, stated + * boundary rather than an unfinished one. + */ +export const DIALECT_EMISSION_UNSUPPORTED_CODE = 'SQL_DIALECT_EMISSION_UNSUPPORTED'; + +/** + * 501 — the status `HttpStatusErrorCodeMap` already names for "this server does + * not do that". Deliberately not 400 (the caller's request is well-formed and + * would succeed unchanged on a supported database) and not 500 (nothing + * faulted; the driver declined on purpose and said why). + */ +export const DIALECT_EMISSION_UNSUPPORTED_STATUS = 501; + +/** + * Thrown by `SqlDriver.assertDialectEmits` when a knex client this driver + * recognises on the wire — and only those — reaches the DDL path. + * + * The structured fields are the reason this is a class and not a bare `Error`: + * a host that wants to render its own message (Studio, the CLI's migrate + * plan, an installer) reads `client` and `supportedClients` instead of parsing + * the sentence back out of `message`. + */ +export class UnsupportedDialectEmissionError extends Error { + readonly code = DIALECT_EMISSION_UNSUPPORTED_CODE; + readonly status = DIALECT_EMISSION_UNSUPPORTED_STATUS; + + constructor( + /** The knex `client` spelling as configured. */ + readonly client: string, + /** The DDL operation that was refused, e.g. `initObjects`. */ + readonly operation: string, + /** Every client spelling this driver DOES emit DDL for, sorted. */ + readonly supportedClients: readonly string[], + ) { + super(renderDialectEmissionRefusal(client, operation, supportedClients)); + this.name = 'UnsupportedDialectEmissionError'; + } +} + +/** + * The refusal's prose, rendered from the driver's own tables. + * + * Exported so the pin suite asserts the SAME renderer the driver throws through + * — a message pinned by copying its text into a test is a pin on the test. + * + * Three things it must carry, in this order, because that is the order an + * operator needs them: what was refused and why, what IS supported, and what to + * do to keep this database. + */ +export function renderDialectEmissionRefusal( + client: string, + operation: string, + supportedClients: readonly string[], +): string { + return ( + `DDL operation '${operation}' was refused: knex client '${client}' speaks the PostgreSQL wire ` + + `protocol, but ObjectStack does not emit schema DDL for it, so no table was created or altered. ` + + `Emitting PostgreSQL DDL there would not fail loudly — it would build a table of the wrong shape, ` + + `and the deployment would find out when it writes data into it. ` + + `Supported clients for schema emission: ${supportedClients.join(', ')}. ` + + `To keep using this database, manage its schema out-of-band and boot with ` + + `\`skipSchemaSync\` / OS_SKIP_SCHEMA_SYNC=1 — connection, the connect bound and result parsing ` + + `are unaffected by this refusal.` + ); +} diff --git a/packages/drivers/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts index ce67dfa0ba..447f524c69 100644 --- a/packages/drivers/driver-sql/src/index.ts +++ b/packages/drivers/driver-sql/src/index.ts @@ -14,6 +14,17 @@ export { resolveSqliteAbsentFileTarget } from './sql-driver.js'; // stops an embedder from re-deriving the seam (or, worse, putting the text back // on the wire by spreading the error, which the symbol key exists to prevent). export { withheldFilterDiagnosticOf } from './sql-driver.js'; +// [#11991] The #11756 emission-scope refusal. Exported because a host that +// renders its own diagnostics (Studio, `os migrate plan`, an installer) needs +// the structured `client` / `supportedClients` and the stable `code` — the +// alternative is parsing the sentence back out of `message`, which is how a +// refusal's wording becomes an accidental contract. +export { + DIALECT_EMISSION_UNSUPPORTED_CODE, + DIALECT_EMISSION_UNSUPPORTED_STATUS, + UnsupportedDialectEmissionError, + renderDialectEmissionRefusal, +} from './dialect-emission-refusal.js'; export type { SqlDriverConfig, SqliteJournalMode, 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 index f80acfff4c..d2fd9d64bc 100644 --- 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 @@ -29,20 +29,38 @@ * 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 + * ## The question this suite used to hold open, and its answer * * 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. + * pg WIRE protocol — should be treated as Postgres for SQL EMISSION was a + * support-scope question, held open as **#11756**. The cases under "emission is + * not wire" pinned the deliberate asymmetry so that a convergence refactor + * could not answer it as a side effect, and said in as many words that they + * were the cases to rewrite once it was decided. + * + * It was decided — maintainer, 2026-08-25, verbatim 「同意」 on 「C,但 + * pgnative 归入 Postgres 家族」 (#11756, comment 5404884704) — and **this file's + * rewrite (#11991) is that decision landing**, done as its own act rather than + * inherited from a refactor. What changed here, and only here: + * + * - `redshift` / `cockroachdb` keep wire recognition and are now REFUSED BY + * NAME at the DDL gate. The cases below assert the refusal's code, status + * and message rather than the silent fall-through they used to assert. + * - `pgnative` joins the Postgres family for emission and enters the wire + * table, so three expected-membership lists gained a name. + * - `mariadb` is untouched: the ruling put it out of scope, and its case + * below still pins it as unrecognised — AND, since #11991, as unrefused. + * + * The refusal's own behaviour is pinned next door, in + * `sql-driver-11991-emission-identity-refusal.test.ts`; what stays here is the + * spelling-table half this suite has always owned. */ import { describe, it, expect, afterEach } from 'vitest'; import { createRequire } from 'node:module'; import path from 'node:path'; import { SqlDriver } from './sql-driver.js'; +import { UnsupportedDialectEmissionError } from './dialect-emission-refusal.js'; // ── knex's own vocabulary, read from the pinned install ────────────────────── @@ -76,6 +94,10 @@ class ProbeDriver extends SqlDriver { nowDefaultSql(type: string): string { return this.nowColumnDefault(type).toString(); } + /** [#11991] Drive the DDL gate without a database — `initObjects`' first act. */ + gate(operation: string): void { + this.assertSchemaMutable(operation); + } /** * Re-spell the DECLARED client after construction. * @@ -137,6 +159,11 @@ describe("#11550 — knex's alias table is the vocabulary, not a pair of literal for (const spelling of ['sqlite3', 'sqlite', 'mysql', 'mariadb']) { expect(() => make(spelling), spelling).toThrow(/Cannot find module|Knex: run/); } + // [#11991] `pgnative` is NOT in that list, measured: knex constructs its + // client here and only resolves the native binding on connect. So the + // spelling the #11756 ruling admits is exercised through a REAL client + // wherever this suite compares it against `pg`, not through the stand-in. + expect(() => make('pgnative')).not.toThrow(); }); }); @@ -208,7 +235,13 @@ describe('#11550 — identity across every spelling knex supports', () => { // 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']); + // + // [#11991] `pgnative` is the ONE announced widening, and it is the #11756 + // ruling: knex compiles it with the same `postgresql` dialect as `pg` + // (asserted from a live client below), so it was the easy end of that + // decision lumped in with the hard end. `redshift` / `cockroachdb` did NOT + // join — they are refused instead, which is the other half of the ruling. + expect(recognised('postgres')).toEqual(['pg', 'pgnative', 'postgres', 'postgresql']); expect(recognised('sqlite')).toEqual(['better-sqlite3', 'sqlite', 'sqlite3']); expect(recognised('mysql')).toEqual(['mysql', 'mysql2']); }); @@ -226,43 +259,82 @@ describe('#11550 — identity across every spelling knex supports', () => { }); }); -describe('#11550 — emission identity is not wire identity (#11756 stays open)', () => { +describe('#11550 — emission identity is not wire identity (#11756 ruled, #11991 landed)', () => { 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. + it('redshift and cockroachdb parse pg wire and are REFUSED for DDL', () => { + // ⚠️ THE REWRITE. This case previously pinned the undecided asymmetry: + // no emission identity, no refusal either — the DDL path simply proceeded + // and knex built whatever its own compiler produced. #11756 was ruled on + // 2026-08-25 (option C, 「同意」) and this is the case changing to say so. + // It is edited deliberately, with the ruling named, precisely because the + // block it lives in existed to stop a refactor changing it silently. for (const spelling of ['redshift', 'cockroachdb']) { const d = respelled(spelling); + // Unchanged by the ruling: no emission identity, in either direction. 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. + // CHANGED: the fall-through is gone. What used to be "keeps the + // CURRENT_TIMESTAMP default and builds the table anyway" is now a named + // refusal — nothing is emitted at all. + expect(() => d.gate('initObjects'), spelling).toThrow(UnsupportedDialectEmissionError); + // Unchanged: the #11389 calendar-day wire hook still applies… expect(emit('POSTGRES_WIRE_CLIENTS'), spelling).toContain(spelling); - // …and, since #11784, the connect-timeout bound too. Both are properties - // of the npm driver doing the connecting (`pg`), which is why neither - // waits on #11756: knex's Client_Redshift/Client_CockroachDB extend - // Client_PG for the WIRE while overriding the query compiler for - // EMISSION. Timeout row present, emission identity still false — the - // independence #11784 asserted, pinned rather than argued. + // …and so does the #11784 connect-timeout bound. Both are properties of + // the npm driver doing the connecting (`pg`), which is why the ruling + // could keep them while refusing emission: knex's + // Client_Redshift/Client_CockroachDB extend Client_PG for the WIRE while + // overriding the query compiler for EMISSION. Wire yes, emission no — + // now stated by the driver instead of merely being true of it. expect((SqlDriver as any).DIALECT_CONNECT_TIMEOUT[spelling], spelling) .toEqual({ key: 'connectionTimeoutMillis', urlKey: 'connectionString' }); } }); + it('pgnative IS the Postgres family, and knex agrees from a live client', () => { + // The other half of the ruling, and the reason it was separable from the + // hard end: knex resolves `pgnative` to the SAME dialect and compiler as + // `pg` — only the npm binding differs. Asserted off a constructed client, + // not from knex's docs, so a knex upgrade that changed the answer fails + // here rather than silently widening what this driver claims. + const client = (make('pgnative') as any).knex.client; + expect(client.dialect).toBe('postgresql'); + expect(client.driverName).toBe('pgnative'); + + const d = respelled('pgnative'); + expect(d.flags).toEqual({ sqlite: false, postgres: true, mysql: false }); + expect(d.dialect).toBe('postgres'); + // The defect it inherited by being lumped in with Redshift: a bare + // CURRENT_TIMESTAMP default on a DATE column — the server's calendar day. + // Fixed by membership, not by a branch. + expect(d.nowDefaultSql('date')).toContain("timezone('utc', now())::date"); + expect(d.nowDefaultSql('date').toUpperCase()).not.toContain('CURRENT_TIMESTAMP'); + // …and it is NOT refused, which is the whole difference from the two above. + expect(() => d.gate('initObjects')).not.toThrow(); + }); + 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); + // [#11991] `pgnative` entered BOTH sets in one edit — it was absent from + // both before the ruling, so it got neither emission nor the #11389 + // calendar-day parser. The wire membership is derived from the emission + // one, so this list gaining a name proves the derivation, not a second + // hand-written table. expect([...wire].sort()).toEqual( - ['cockroachdb', 'pg', 'postgres', 'postgresql', 'redshift'], + ['cockroachdb', 'pg', 'pgnative', 'postgres', 'postgresql', 'redshift'], ); }); - it('mariadb is a separate knex dialect and is not MySQL for emission', () => { + it('mariadb is out of the ruling: not MySQL for emission, and not refused', () => { + // #11756 put `mariadb` explicitly out of scope, so it keeps the third + // state: unrecognised AND unrefused. The `not.toThrow()` is the load- + // bearing half — a refusal keyed on "the getters do not recognise this" + // instead of on the named wire-only set would sweep mariadb in and widen a + // decided ruling by one dialect, silently. expect(SUPPORTED_CLIENTS).toContain('mariadb'); expect(respelled('mariadb').flags.mysql).toBe(false); + expect(() => respelled('mariadb').gate('initObjects')).not.toThrow(); }); }); @@ -283,22 +355,27 @@ describe('#11550 — the client-keyed tables now derive from one source', () => } }); - it('membership is the derivation plus its literal extensions, and nothing else', () => { + it('membership is the derivation plus its ONE declared extension, and nothing else', () => { // #11550's refactor half had to be a no-op, and this pinned that. #11784 - // then added `redshift` — a real membership change, deliberate, and the only - // one since. The comment that stood here cited redshift's ABSENCE as - // load-bearing documentation for `withConnectBound`'s early-return note; - // that example was retired together with the row, and the note now carries - // its reasoning directly instead of leaning on this table. + // then added `redshift` — a real membership change, deliberate. #11991 added + // `pgnative`, and added it WITHOUT touching this table: it is a member of + // POSTGRES_EMIT_CLIENTS, and this table derives from that set. A row + // appearing here for free is the derivation working. // - // ⚠️ `cockroachdb` and `redshift` must stay LITERAL extensions of - // POSTGRES_EMIT_CLIENTS here. This list now coincides with - // POSTGRES_WIRE_CLIENTS, and spelling it as that set instead would grant - // both of them SQL-emission identity as a silent refactor side effect — - // which is #11756's open decision, not this table's. + // ⚠️ `cockroachdb` and `redshift` are no longer two hand-written literals + // here and two more in POSTGRES_WIRE_CLIENTS: #11991 named the extension + // once (POSTGRES_WIRE_ONLY_CLIENTS) and both tables extend THAT. What must + // still never happen is the reverse derivation — spelling this list as + // POSTGRES_WIRE_CLIENTS, or the emission set as a union of these tables, + // which would grant both databases SQL-emission identity as a refactor side + // effect and re-answer a ruling that has already been made. expect(Object.keys(table()).sort()).toEqual( - ['cockroachdb', 'mysql', 'mysql2', 'pg', 'postgres', 'postgresql', 'redshift'], + ['cockroachdb', 'mysql', 'mysql2', 'pg', 'pgnative', 'postgres', 'postgresql', 'redshift'], ); + // The extension set is the only literal, and it holds exactly the two + // databases #11756 ruled on — never `mariadb`, which stays out of scope. + expect([...((SqlDriver as any).POSTGRES_WIRE_ONLY_CLIENTS as ReadonlySet)].sort()) + .toEqual(['cockroachdb', 'redshift']); }); it('the UTC session pin still fires for MySQL only', () => { diff --git a/packages/drivers/driver-sql/src/sql-driver-11991-emission-identity-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-11991-emission-identity-refusal.test.ts new file mode 100644 index 0000000000..a8797ae7ea --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11991-emission-identity-refusal.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11991 — the #11756 ruling, pinned at the behaviour it decided. + * + * Maintainer, 2026-08-25, verbatim 「同意」 on 「C,但 pgnative 归入 Postgres + * 家族」 (#11756, comment 5404884704). Three things follow, and this suite pins + * each against the acceptance criterion the card wrote them as: + * + * 1. a `redshift` / `cockroachdb` configuration that reaches DDL emission + * receives ONE named, actionable refusal — code AND message; + * 2. `pgnative` behaves identically to `pg` for emission, and carries the + * #11389 calendar-day wire parser pin; + * 3. the emission-identity answer has exactly ONE source of truth. + * + * ## What was measured first, and why it is quoted here + * + * The refusal replaces a path that did NOT fail. Measured on `origin/main` + * (b307bfd2ae) before the change, one `CREATE TABLE` compiled per client: + * + * ``` + * pg create table "t" ("id" varchar(255), "body" text, + * constraint "t_pkey" primary key ("id")) + * pgnative … byte-identical to pg … + * cockroachdb … byte-identical to pg … + * redshift create table "t" ("id" varchar(255) not null, "body" varchar(max)); + * alter table "t" add constraint "t_pkey" primary key ("id") + * ``` + * + * So the pre-ruling behaviour on Redshift was not an error — it was a table of + * a different shape, built quietly, with the deployment finding out when it + * wrote data. That is the whole argument for a loud refusal, and it is a + * measurement rather than a claim about Redshift's documentation. + * + * ⚠️ `cockroachdb` compiles the SAME bytes as `pg` today. It is refused anyway, + * and that is the ruling, not an oversight: byte-equality on ONE probe table is + * not a measured DDL boundary, and #11756 explicitly declined to claim one + * without measuring. Its reopening condition (a real customer, then a measured + * boundary, the two databases judged separately) is recorded on that card. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { SqlDriver } from './sql-driver.js'; +import { + DIALECT_EMISSION_UNSUPPORTED_CODE, + DIALECT_EMISSION_UNSUPPORTED_STATUS, + UnsupportedDialectEmissionError, + renderDialectEmissionRefusal, +} from './dialect-emission-refusal.js'; + +/** The two databases the ruling refused for emission, and nothing else. */ +const REFUSED = ['redshift', 'cockroachdb'] as const; + +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 (see the #11550 suite). */ + respell(client: string): this { + (this.config as { client?: unknown }).client = client; + return this; + } + tableExists(name: string): Promise { + return (this as any).knex.schema.hasTable(name); + } +} + +const opened: SqlDriver[] = []; +/** A driver whose knex is a real, connectable in-memory SQLite one. */ +function make(client = 'better-sqlite3'): ProbeDriver { + const d = new ProbeDriver( + { client, connection: { filename: ':memory:' }, useNullAsDefault: true } as any, + ); + opened.push(d); + return d; +} +const respelled = (client: string): ProbeDriver => make().respell(client); + +afterEach(async () => { + await Promise.all(opened.splice(0).map((d) => d.disconnect().catch(() => {}))); +}); + +describe('#11991 — redshift/cockroachdb are refused at DDL emission, by name', () => { + it('initObjects refuses with ONE coded, actionable error', async () => { + for (const client of REFUSED) { + const d = respelled(client); + // The real entry point a boot uses, not the gate helper: this is the + // path a deployment actually reaches. + const err = await d + .initObjects([{ name: 'thing', fields: { title: { type: 'text' } } }]) + .then(() => null, (e: unknown) => e); + + expect(err, client).toBeInstanceOf(UnsupportedDialectEmissionError); + const e = err as UnsupportedDialectEmissionError; + // ADR-0112: the machine-readable half. `code` AND `status`, because a + // code with no status leaves every HTTP exit answering 500 with the code + // dropped at the boundary (#7739) — the failure mode that motivated the + // federation family declaring its own statuses. + expect(e.code, client).toBe('SQL_DIALECT_EMISSION_UNSUPPORTED'); + expect(e.code, client).toBe(DIALECT_EMISSION_UNSUPPORTED_CODE); + expect(e.status, client).toBe(501); + expect(e.status, client).toBe(DIALECT_EMISSION_UNSUPPORTED_STATUS); + // The structured half a host renders from instead of parsing prose. + expect(e.client, client).toBe(client); + expect(e.operation, client).toBe('initObjects'); + } + }); + + it('the message names the client, the supported set and the way out', async () => { + const d = respelled('redshift'); + const err = (await d.initObjects([{ name: 'thing' }]).then( + () => null, + (e: unknown) => e, + )) as UnsupportedDialectEmissionError; + + // Pinned against the SAME renderer the driver throws through — a message + // asserted by pasting its text into a test pins the test, not the product. + expect(err.message).toBe( + renderDialectEmissionRefusal('redshift', 'initObjects', err.supportedClients), + ); + + // …and the renderer's three obligations, asserted on the rendered string so + // a future edit that drops one is red here rather than merely different. + expect(err.message).toContain("knex client 'redshift'"); + expect(err.message).toContain('Supported clients for schema emission'); + expect(err.message).toContain('skipSchemaSync'); + expect(err.message).toContain('OS_SKIP_SCHEMA_SYNC=1'); + + // The supported list is DERIVED from the emission sets, never typed out: + // every spelling the driver emits for appears, and the two refused ones do + // not. This is what stops the guidance drifting from the behaviour — it is + // how `pgnative` entered the sentence with no edit to the sentence. + const emitted = [ + ...((SqlDriver as any).SQLITE_EMIT_CLIENTS as ReadonlySet), + ...((SqlDriver as any).POSTGRES_EMIT_CLIENTS as ReadonlySet), + ...((SqlDriver as any).MYSQL_EMIT_CLIENTS as ReadonlySet), + ].sort(); + expect([...err.supportedClients]).toEqual(emitted); + expect(err.supportedClients).toContain('pgnative'); + for (const refused of REFUSED) expect(err.supportedClients).not.toContain(refused); + }); + + it('refuses BEFORE any DDL runs — nothing is half-built', async () => { + // The ruling's phrase is "never a silently mis-built table". A refusal that + // fired after the first CREATE would leave exactly that, so the ordering is + // part of the contract and not an implementation detail. + const d = respelled('cockroachdb'); + await expect(d.initObjects([{ name: 'thing', fields: { title: { type: 'text' } } }])) + .rejects.toThrow(UnsupportedDialectEmissionError); + // The underlying knex here is a real in-memory SQLite, so this is a genuine + // round-trip against the database the driver would have written to. + d.respell('better-sqlite3'); + expect(await d.tableExists('thing')).toBe(false); + }); + + it('the DDL-free half still works — the escape hatch the message names is real', async () => { + // `skipSchemaSync` boots call `registerObjectMetadata` and stop (it is the + // DDL-free sibling, no gate, no round-trip). If the refusal reached that + // too, the guidance would be a dead end and these deployments would have no + // supported posture at all — which is not what option C decided. + const d = respelled('redshift'); + expect(() => d.registerObjectMetadata([{ name: 'thing', fields: { title: { type: 'text' } } }])) + .not.toThrow(); + }); + + it('reads and writes are untouched: the boundary is DDL only', async () => { + // Wire recognition stays (#11389, deliberate, restated by the ruling). The + // refusal must not have leaked into the query path — a driver that refused + // everything would be option B with extra steps. + const d = respelled('redshift'); + expect(d.flags).toEqual({ sqlite: false, postgres: false, mysql: false }); + expect((SqlDriver as any).POSTGRES_WIRE_CLIENTS).toContain('redshift'); + expect((SqlDriver as any).DIALECT_CONNECT_TIMEOUT['redshift']) + .toEqual({ key: 'connectionTimeoutMillis', urlKey: 'connectionString' }); + }); +}); + +describe('#11991 — pgnative is the Postgres family, for emission and for the wire', () => { + it('emits byte-identical column defaults to `pg`, across every type', () => { + // "Behaves identically to pg for emission" as an equality rather than a + // description. `datetime` is included on purpose: both keep knex.fn.now() + // there, so this also proves the assertion is not just "everything is a + // Postgres expression". + for (const type of ['date', 'time', 'datetime']) { + expect(respelled('pgnative').nowDefaultSql(type), type) + .toBe(respelled('pg').nowDefaultSql(type)); + } + expect(respelled('pgnative').nowDefaultSql('datetime').toUpperCase()) + .toContain('CURRENT_TIMESTAMP'); + // The defect membership fixed: a DATE default that resolves the calendar + // day in the SERVER's timezone (#11550's measured case, inherited by + // pgnative for want of a decision). + expect(respelled('pgnative').nowDefaultSql('date')) + .toContain("timezone('utc', now())::date"); + }); + + it('answers the same identity and reaches the drift differ as postgres', () => { + const d = respelled('pgnative'); + expect(d.flags).toEqual({ sqlite: false, postgres: true, mysql: false }); + // 'unknown' at the differ disables every dialect-aware comparison — the + // same silent degradation one layer up, which is why it is pinned here too. + expect(d.dialect).toBe('postgres'); + }); + + it('carries the #11389 calendar-day parser pin and the connect bound', () => { + // The membership it was missing from BOTH tables before the ruling. Driven + // through a recording connection, because `postgres` and `mysql` both end + // up with a `pool.afterCreate` — its presence proves nothing about which + // pin was installed. + const bound = (client: string): any => + (SqlDriver as any).withConnectBound({ client, connection: { host: 'h' } }); + const drive = (cfg: any) => { + const queries: string[] = []; + const parsers: number[] = []; + cfg.pool.afterCreate( + { + query: (sql: string, cb: (e?: unknown) => void) => { queries.push(sql); cb(); }, + getTypeParser: () => (t: string) => t, + setTypeParser: (oid: number) => { parsers.push(oid); }, + }, + () => {}, + ); + return { queries, parsers }; + }; + + // 1082 = Postgres OID of `date`. Without this the driver materialises a + // local-midnight JS Date and an east-of-UTC process reads YESTERDAY. + expect(drive(bound('pgnative')).parsers).toContain(1082); + expect(drive(bound('pgnative')).parsers).toEqual(drive(bound('pg')).parsers); + // No MySQL session pin leaked in with it. + expect(drive(bound('pgnative')).queries).toEqual([]); + // And the connect bound the derived table must now supply. + expect(bound('pgnative').connection.connectionTimeoutMillis).toBe(10_000); + }); +}); + +describe('#11991 — exactly one source of truth for emission identity', () => { + const set = (name: string): ReadonlySet => (SqlDriver as any)[name]; + + it('every client-keyed table is the emission set plus ONE declared extension', () => { + const emit = set('POSTGRES_EMIT_CLIENTS'); + const wireOnly = set('POSTGRES_WIRE_ONLY_CLIENTS'); + const wire = set('POSTGRES_WIRE_CLIENTS'); + const timeout = (SqlDriver as any).DIALECT_CONNECT_TIMEOUT as Record; + + // The wire table IS the union, exactly — not a copy that happens to agree. + expect([...wire].sort()).toEqual([...emit, ...wireOnly].sort()); + // The connect-timeout table's pg arm is the same union, and its mysql arm + // is the MySQL emission set — no third hand-written list anywhere. + const pgArm = Object.keys(timeout).filter( + (c) => (timeout[c] as any).key === 'connectionTimeoutMillis', + ); + expect(pgArm.sort()).toEqual([...emit, ...wireOnly].sort()); + const mysqlArm = Object.keys(timeout).filter( + (c) => (timeout[c] as any).key === 'connectTimeout', + ); + expect(mysqlArm.sort()).toEqual([...set('MYSQL_EMIT_CLIENTS')].sort()); + }); + + it('emission and the refusal are complements, never overlapping', async () => { + // The invariant that makes "one source" checkable rather than merely + // stated: a spelling cannot be both emitted for and refused, and the + // refusal reads the same set the tables extend by. + const emit = set('POSTGRES_EMIT_CLIENTS'); + for (const client of set('POSTGRES_WIRE_ONLY_CLIENTS')) { + expect(emit.has(client), client).toBe(false); + expect(respelled(client).flags.postgres, client).toBe(false); + await expect(respelled(client).initObjects([]), client).rejects.toBeInstanceOf( + UnsupportedDialectEmissionError, + ); + } + for (const client of emit) { + expect(set('POSTGRES_WIRE_ONLY_CLIENTS').has(client), client).toBe(false); + } + }); + + it('refusal is keyed on the named set, not on "unrecognised"', () => { + // A driver that refused every spelling its getters do not know would sweep + // in `mariadb` (out of the ruling's scope) and knex's bring-your-own Client + // CONSTRUCTOR hatch. Both must still pass the gate untouched. + const d = respelled('mariadb'); + expect(d.flags).toEqual({ sqlite: false, postgres: false, mysql: false }); + expect(() => (d as any).assertSchemaMutable('initObjects')).not.toThrow(); + + const byConstructor = make(); + (byConstructor as any).config.client = class {}; + expect(byConstructor.dialect).toBe('unknown'); + expect(() => (byConstructor as any).assertSchemaMutable('initObjects')).not.toThrow(); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index d1bcf0e42f..895550f512 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -64,6 +64,7 @@ import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; import { StandardErrorCode } from '@objectstack/spec/api'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; +import { UnsupportedDialectEmissionError } from './dialect-emission-refusal.js'; import { isUniqueViolationError, isUnbackedConflictTargetError, @@ -4354,24 +4355,54 @@ export class SqlDriver implements IDataDriver { * ("measured: a UTC-12 server records YESTERDAY") that method's Postgres * branch exists to remove. * - * ## What is deliberately NOT in here + * ## What is deliberately NOT in here — settled by the #11756 ruling * * `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 + * {@link POSTGRES_WIRE_ONLY_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. + * was a SUPPORT-SCOPE question rather than a spelling one. It is no longer + * open: the maintainer ruled on 2026-08-25 (#11756, verbatim 「同意」 on + * 「C,但 pgnative 归入 Postgres 家族」) that they keep wire recognition and + * get NO emission identity — and that a configuration of theirs reaching the + * DDL path is REFUSED BY NAME ({@link assertDialectEmits}) rather than served + * a table this driver cannot vouch for. + * + * What "cannot vouch for" means, measured on the pinned knex rather than + * argued (#11991, one CREATE TABLE, three clients): + * + * ``` + * pg create table "t" ("id" varchar(255), …, "body" text, + * constraint "t_pkey" primary key ("id")) + * pgnative … byte-identical to pg … + * redshift create table "t" ("id" varchar(255) not null, …, + * "body" varchar(max)); + * alter table "t" add constraint "t_pkey" primary key ("id") + * ``` + * + * `mariadb` is the same shape against the MySQL family and is EXPLICITLY out + * of that ruling's scope: it stays unrecognised, and — deliberately — it is + * not refused either. {@link POSTGRES_WIRE_ONLY_CLIENTS} is the refusal's + * only input, so nothing here quietly widens the ruling by one dialect. + * + * Wire recognition and the connect-timeout table EXTEND these sets through + * that one declared extension; nothing derives these sets from a union of the + * others, because that would re-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', + // `pgnative` is a MEMBER, not an extension (#11756 ruling): knex resolves it + // to `dialect === 'postgresql'` — the same query compiler as `pg`, differing + // only in which npm binding carries the bytes (`driverName: 'pgnative'`). + // Measured on the pinned knex while landing #11991. Before that ruling it + // was the easy end of the question lumped in with the hard end, and it paid + // for it: `isPostgres === false` meant a `date` column got a bare + // CURRENT_TIMESTAMP default — the server-timezone calendar day, the exact + // #11550 defect — on a config knex compiles as Postgres. + 'postgres', 'pg', 'postgresql', 'pgnative', ]); /** SQLite spellings that mean "emit SQLite SQL". See {@link POSTGRES_EMIT_CLIENTS}. */ private static readonly SQLITE_EMIT_CLIENTS: ReadonlySet = new Set([ @@ -4382,6 +4413,61 @@ export class SqlDriver implements IDataDriver { 'mysql', 'mysql2', ]); + /** + * The ONE declared extension over the emission sets above: knex clients this + * driver recognises on the pg WIRE and deliberately does not emit DDL for. + * + * #11756 ruling, maintainer, 2026-08-25, verbatim 「同意」 on 「C,但 + * pgnative 归入 Postgres 家族」 — "connection and parsing yes, building your + * tables no", stated in the open instead of left to be discovered. + * + * ## Why it exists as a NAMED set rather than two literal pairs + * + * Before #11991 the pair `cockroachdb, redshift` was hand-written into + * {@link DIALECT_CONNECT_TIMEOUT} and again into + * {@link POSTGRES_WIRE_CLIENTS}, and a third copy would have been needed for + * the refusal — three lists that must agree, kept in step by nobody. They + * answer one question ("recognised on the wire, not for emission") and now + * have one answer: both tables extend the emission sets through THIS set, and + * {@link assertDialectEmits} refuses exactly its members. Adding a future + * pg-wire client to this line therefore gives it the connect bound, the + * #11389 calendar-day parser AND the named DDL refusal in one edit — the + * three cannot drift apart again. + * + * ## The direction is load-bearing + * + * ⛔ Never derive the emission sets FROM this one. Emission is the source and + * this is the extension; the reverse would grant `redshift` and `cockroachdb` + * SQL-emission identity as a silent side effect of a refactor — the outcome + * (option A) the ruling explicitly declined, on a platform that has never + * measured how far their DDL diverges. + * + * ⛔ `mariadb` is NOT here. It is the same shape one family over and the + * ruling put it explicitly out of scope: unrecognised for emission, and not + * refused either. A dialect the platform never claimed and never refused is a + * third state, and it is the deliberate one. + */ + private static readonly POSTGRES_WIRE_ONLY_CLIENTS: ReadonlySet = new Set([ + 'cockroachdb', 'redshift', + ]); + + /** + * Every client spelling this driver will emit DDL for, sorted — the guidance + * half of {@link assertDialectEmits}'s refusal. + * + * Read off the emission sets rather than written out, so the sentence an + * operator is shown can never name a client the driver has stopped + * supporting, or omit one it has just gained (`pgnative` arrived exactly that + * way). + */ + protected static emissionClients(): string[] { + return [ + ...SqlDriver.SQLITE_EMIT_CLIENTS, + ...SqlDriver.POSTGRES_EMIT_CLIENTS, + ...SqlDriver.MYSQL_EMIT_CLIENTS, + ].sort(); + } + /** * The configured client as a STRING, or `''`. * @@ -4408,9 +4494,12 @@ export class SqlDriver implements IDataDriver { /** * 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. + * Every knex spelling of the family: `postgres` (knex's canon), its registered + * aliases `pg` / `postgresql`, and `pgnative` — a separate knex client name + * that resolves to the same `postgresql` dialect and compiler (#11756 ruling). + * `redshift` and `cockroachdb` are deliberately NOT here: they are refused by + * name at the DDL gate instead. See {@link POSTGRES_EMIT_CLIENTS}, + * {@link POSTGRES_WIRE_ONLY_CLIENTS} and {@link assertDialectEmits}. */ protected get isPostgres(): boolean { return SqlDriver.POSTGRES_EMIT_CLIENTS.has(SqlDriver.clientSpelling(this.config)); @@ -4420,7 +4509,9 @@ export class SqlDriver implements IDataDriver { * 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. + * is deliberately NOT here. The #11756 ruling put `mariadb` explicitly OUT of + * its scope, so it is neither recognised nor refused — see + * {@link POSTGRES_WIRE_ONLY_CLIENTS} for why that third state is deliberate. */ protected get isMysql(): boolean { return SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(this.config)); @@ -4691,14 +4782,16 @@ export class SqlDriver implements IDataDriver { */ private static readonly DIALECT_CONNECT_TIMEOUT: Record = Object.fromEntries<{ key: string; urlKey: string }>([ - // Every spelling that means Postgres SQL, EXTENDED by `cockroachdb` and - // `redshift`: separate knex dialects, but ones that reach the server - // through the same `pg` driver and therefore take the same knob. Knex's - // `Client_Redshift` literally `extends Client_PG`, so the settings object - // it hands to `pg.Client` honours `connectionTimeoutMillis` exactly as - // `pg` does. 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. + // Every spelling that means Postgres SQL, EXTENDED by + // {@link POSTGRES_WIRE_ONLY_CLIENTS}: separate knex dialects, but ones + // that reach the server through the same `pg` driver and therefore take + // the same knob. Knex's `Client_Redshift` literally `extends Client_PG`, + // so the settings object it hands to `pg.Client` honours + // `connectionTimeoutMillis` exactly as `pg` does. 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, which is how `pgnative` got its row when the + // #11756 ruling made it a Postgres client (#11991), with no edit here. // // `redshift`'s row arrived with #11784. It had the knob and would have // obeyed it, but carried no entry, so its connection attempt fell through @@ -4707,14 +4800,16 @@ export class SqlDriver implements IDataDriver { // and nothing was logged — the bound was simply 50% looser, for one // client name. // - // ⚠️ This arm and {@link POSTGRES_WIRE_CLIENTS} now happen to hold the - // same five names. That is a fact about today's membership, NOT an - // invariant, and must not be refactored into one: the two answer - // different questions (how do I spell the connect timeout vs. which npm - // package parses the wire), and unioning them would hand `redshift` and - // `cockroachdb` SQL-emission identity as a silent side effect — the open - // decision #11756, and #11550's subject, not this table's to make. - ...[...SqlDriver.POSTGRES_EMIT_CLIENTS, 'cockroachdb', 'redshift'].map( + // ⚠️ This arm and {@link POSTGRES_WIRE_CLIENTS} are spelled from the same + // two sets and therefore hold the same six names. That remains a fact + // about today's membership, NOT an invariant, and the two must not be + // collapsed into one another: they answer different questions (how do I + // spell the connect timeout vs. which npm package parses the wire), and + // one may gain a client the other should not. What #11991 removed is the + // hand-written `'cockroachdb', 'redshift'` pair that used to sit here and + // again in the wire set — the extension is declared ONCE now, so the two + // tables extend the same answer rather than two copies of it. + ...[...SqlDriver.POSTGRES_EMIT_CLIENTS, ...SqlDriver.POSTGRES_WIRE_ONLY_CLIENTS].map( (c): [string, { key: string; urlKey: string }] => [c, { key: 'connectionTimeoutMillis', urlKey: 'connectionString' }], ), @@ -4838,14 +4933,20 @@ export class SqlDriver implements IDataDriver { * 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). + * Built by EXTENDING {@link POSTGRES_EMIT_CLIENTS} with the one declared + * {@link POSTGRES_WIRE_ONLY_CLIENTS} set, 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 — the outcome #11756's + * ruling declined, and not this file's to re-make (#11550, #11991). + * + * `pgnative` is here because it is in the EMISSION set, not by a second + * decision: before #11991 it was in neither, so it got no calendar-day parser + * — the one table membership the #11756 ruling had to add by hand, and it is + * this derivation that added it. */ private static readonly POSTGRES_WIRE_CLIENTS: ReadonlySet = new Set([ ...SqlDriver.POSTGRES_EMIT_CLIENTS, - 'cockroachdb', 'redshift', + ...SqlDriver.POSTGRES_WIRE_ONLY_CLIENTS, ]); /** Postgres OID of `date` — a bare calendar day, no time and no zone. */ @@ -5012,6 +5113,12 @@ export class SqlDriver implements IDataDriver { * schema-mutating DDL is only performed on a `managed` datasource. * Federated datasources (`external` / `validate-only`) are guests in a * database ObjectStack does not own and must never run DDL against. + * + * [#11991] Since the #11756 ruling this choke-point asks a SECOND question + * after that one — may this driver emit DDL for the configured DIALECT at all + * ({@link assertDialectEmits}). Two questions, one gate, in this order: the + * datasource question is unchanged and still answered first, so the new + * refusal fires only where DDL would otherwise have PROCEEDED. */ protected assertSchemaMutable(operation: string): void { if (this.schemaMode !== 'managed') { @@ -5020,6 +5127,46 @@ export class SqlDriver implements IDataDriver { `ObjectStack never mutates the schema of an external database.`, ); } + this.assertDialectEmits(operation); + } + + /** + * Emission-scope gate (#11991, landing the #11756 ruling of 2026-08-25): + * refuse — by name, with guidance — a client this driver recognises on the pg + * WIRE and deliberately does not emit DDL for. + * + * ## Why it sits inside the DDL gate rather than at each DDL verb + * + * {@link assertSchemaMutable} is already the one place every schema mutation + * passes through (`initObjects` / `syncSchema`, `dropTable`, `rotateShards`, + * `reconcileManagedSchema`), so putting the question here answers it once for + * all of them. The alternative — refuse CREATE/ALTER but allow DROP, on the + * grounds that `DROP TABLE` is portable — requires deciding WHICH DDL is safe + * on Redshift, and how far Redshift's DDL diverges is precisely the thing + * #11756 recorded as never measured. A gate that guesses at that would be + * making the ruling's option A one verb at a time. + * + * ## What it deliberately does NOT refuse + * + * Only {@link POSTGRES_WIRE_ONLY_CLIENTS} — never "anything the getters do + * not recognise". `mariadb` is out of the ruling's scope and stays + * unrecognised-and-unrefused; so does a bring-your-own Client constructor + * (knex's documented hatch, which names no spelling in any table here). A + * refusal keyed on `dialectName === 'unknown'` would have swept both in and + * widened a decided ruling by side effect — the exact move the ruling's own + * implementation note forbids. + * + * ## Reads, never writes + * + * Nothing has been issued to the database when this throws: it runs before + * `ensureDatabaseExists` and before the first `hasTable` probe. The refusal + * is therefore total for schema work and inert for everything else — reads, + * writes and connection handling on these databases are untouched. + */ + protected assertDialectEmits(operation: string): void { + const client = SqlDriver.clientSpelling(this.config); + if (!SqlDriver.POSTGRES_WIRE_ONLY_CLIENTS.has(client)) return; + throw new UnsupportedDialectEmissionError(client, operation, SqlDriver.emissionClients()); } // =================================== diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 8fb636710e..78e59a17c0 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -643,6 +643,23 @@ export const ERROR_CODE_LEDGER = { 'SUGGESTION_NOT_FOUND', 'SUGGESTION_STATE', // suggestion exists but is not in a confirmable/dismissable state ], + '@objectstack/driver-sql': [ + // [#11991] The #11756 ruling's refusal (maintainer, 2026-08-25, verbatim + // 「同意」 on 「C,但 pgnative 归入 Postgres 家族」): a knex client this + // driver recognises on the PostgreSQL WIRE — `redshift`, `cockroachdb` — + // reached the DDL path, where emitting Postgres DDL would have built a + // table of the wrong shape rather than failing. 501, the status + // `HttpStatusErrorCodeMap` already names for "this server does not do + // that": the request is well-formed and nothing faulted. + // + // Registered — not left driver-local like `MULTI_TENANT_UNSUPPORTED_CODE` + // — because it IS wire-reachable: publishing a drafted object calls + // `engine.syncObjectSchema` → `SqlDriver.syncSchema` → the DDL gate, on a + // server already serving HTTP. That is the exact test #8035 applied when + // it UNregistered `MONGODB_MULTI_TENANT_UNSUPPORTED` for failing it. + // Producer: `packages/drivers/driver-sql/src/dialect-emission-refusal.ts`. + 'SQL_DIALECT_EMISSION_UNSUPPORTED', + ], '@objectstack/spec': [ 'CONNECTOR_UPSTREAM_UNAVAILABLE', 'EXTERNAL_SCHEMA_MISMATCH', From 78586928e22ad42c435c09b62169b4f95719c4e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 16:49:34 +0000 Subject: [PATCH 2/2] docs(spec): regenerate the api reference for SQL_DIALECT_EMISSION_UNSUPPORTED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `Type Check · source gates` was red on `check:docs`, which named both files and the exact remedy. The new ErrorCode union member added by this PR's ERROR_CODE_LEDGER registration flows into two GENERATED reference pages. Regenerated with the repo's own tooling (gen:schema then gen:docs); the diff is the two files CI named and nothing else, and `check:docs` now reports '229 generated files in sync with packages/spec'. These pages are generated and carry a do-not-hand-edit banner; nothing here was written by hand. --- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 6d762febcc..f83eca4614 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +288 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +289 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | @@ -304,6 +304,7 @@ const result = ApiErrorSchema.parse(data); * `SHARE_REVOKE_FAILED` * `SHARING_NOT_ENABLED` * `SIGN_IN_REQUIRED` +* `SQL_DIALECT_EMISSION_UNSUPPORTED` * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` * `STORED_TYPE_NOT_CANONICAL` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 648d290f6d..84a5f0bc80 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -408,6 +408,7 @@ const result = ErrorCode.parse(data); * `SHARE_REVOKE_FAILED` * `SHARING_NOT_ENABLED` * `SIGN_IN_REQUIRED` +* `SQL_DIALECT_EMISSION_UNSUPPORTED` * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` * `STORED_TYPE_NOT_CANONICAL`