Skip to content

Commit dfebfc8

Browse files
os-warrenclaude
andauthored
sql-driver: one emission-identity source — loud DDL refusal for redshift/cockroachdb, pgnative joins the Postgres family (#12591)
* feat(driver-sql,spec): one emission-identity source, loud DDL refusal for redshift/cockroachdb, pgnative in the Postgres family 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * docs(spec): regenerate the api reference for SQL_DIALECT_EMISSION_UNSUPPORTED 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. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c61ad20 commit dfebfc8

9 files changed

Lines changed: 810 additions & 70 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
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)
7+
8+
**BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor`
9+
under the repo's launch-window convention for breaking changes — and a widening
10+
in the same edit, so read both directions.
11+
12+
Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative
13+
归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol
14+
without being the PostgreSQL this driver emits DDL for, and the driver had no
15+
opinion about any of them — it simply let knex compile whatever it compiles.
16+
Measured on `origin/main` before the change, one `CREATE TABLE` per client:
17+
18+
```
19+
pg / pgnative / cockroachdb "body" text primary key inline
20+
redshift "body" varchar(max) primary key in a separate ALTER TABLE
21+
```
22+
23+
So on Redshift the pre-ruling behaviour was not a failure — it was a table of a
24+
different shape, built quietly, with the deployment finding out when it wrote
25+
data into it.
26+
27+
**Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches
28+
schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`,
29+
`reconcileManagedSchema` — now gets an immediate
30+
`UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED`
31+
(newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`),
32+
HTTP status `501`, and a message naming the client, every client the driver
33+
DOES emit for, and the supported way to keep the database — manage its schema
34+
out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws
35+
before any statement is issued, so nothing is half-built. Connection, the
36+
connect bound and the #11389 calendar-day parser are untouched: the boundary is
37+
DDL only, drawn where behaviour was actually verified.
38+
39+
**Recognised (widening).** `pgnative` is now a member of the Postgres emission
40+
family — knex resolves it to the same `postgresql` dialect and the same query
41+
compiler as `pg`, differing only in which npm binding carries the bytes. It was
42+
previously in neither the emission set nor the wire table, so a `date` column
43+
got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact
44+
#11550 defect) and no calendar-day parser. It now behaves identically to `pg`
45+
and carries the #11389 pin.
46+
47+
**One source of truth.** The pair `cockroachdb, redshift` used to be
48+
hand-written into the connect-timeout table and again into the wire table. It
49+
is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend
50+
the emission sets through it — as does the refusal, which reads the same set.
51+
Adding a future pg-wire client is one edit, and the three answers cannot drift
52+
apart. `mariadb` is explicitly out of the ruling's scope and keeps its third
53+
state: neither recognised nor refused.
54+
55+
<!-- adr-0087: not-required (no-migration-prescription) A DDL-emission scope narrowing plus one added client spelling, both inside `driver-sql`. No spec schema, no authorable metadata key and no runtime interface is removed, renamed or re-shaped: the value that decides the outcome is a datasource's knex `client`, which lives in deployment configuration rather than in any stored `sys_metadata` document, so `objectstack migrate meta` has nothing to rewrite and there is no tombstone to project. The channel that reaches an affected deployment is the refusal itself — raised at the DDL gate, before any statement is issued, naming the supported clients and the `skipSchemaSync` posture — and choosing between "move this datasource to a supported database" and "manage its schema out-of-band" is a deployment decision no migration entry can make on an operator's behalf. `pgnative` is a widening and needs no upgrade action at all. -->

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **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) |
30+
| **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) |
3131
| **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) |
3232
| **message** | `string` || Readable error message |
3333
| **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);
304304
* `SHARE_REVOKE_FAILED`
305305
* `SHARING_NOT_ENABLED`
306306
* `SIGN_IN_REQUIRED`
307+
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
307308
* `SSO_REGISTER_FAILED`
308309
* `SSO_REGISTER_FORBIDDEN`
309310
* `STORED_TYPE_NOT_CANONICAL`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ const result = ErrorCode.parse(data);
408408
* `SHARE_REVOKE_FAILED`
409409
* `SHARING_NOT_ENABLED`
410410
* `SIGN_IN_REQUIRED`
411+
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
411412
* `SSO_REGISTER_FAILED`
412413
* `SSO_REGISTER_FORBIDDEN`
413414
* `STORED_TYPE_NOT_CANONICAL`
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The DDL-emission scope boundary, said out loud (#11991, landing the #11756
5+
* ruling).
6+
*
7+
* ## The ruling this implements
8+
*
9+
* Maintainer, 2026-08-25, verbatim 「同意」 on 「C,但 pgnative 归入 Postgres
10+
* 家族」 (#11756, comment 5404884704). Three databases speak the Postgres wire
11+
* protocol without being the Postgres this driver emits DDL for. The ruling
12+
* split them:
13+
*
14+
* - `pgnative` — the same knex dialect and the same query compiler as `pg`,
15+
* differing only in which npm binding carries the bytes. It JOINS the
16+
* Postgres family for emission.
17+
* - `redshift` / `cockroachdb` — wire recognition stays (connection and
18+
* result parsing, #11389, deliberate); emission identity is refused. A
19+
* configuration of theirs that reaches the DDL path is told so, by name,
20+
* at once.
21+
*
22+
* ## Why a refusal rather than "just emit Postgres and see"
23+
*
24+
* Because the alternative fails silently and late. Measured on the pinned knex
25+
* (#11991), one `CREATE TABLE` compiled by each client:
26+
*
27+
* ```
28+
* pg / pgnative "body" text primary key inline in the CREATE
29+
* redshift "body" varchar(max) primary key in a separate ALTER TABLE
30+
* ```
31+
*
32+
* Emitting Postgres DDL at a Redshift therefore does not throw — it builds a
33+
* table of a different shape, and the deployment finds out when it writes data
34+
* into it. That is the failure this refusal exists to convert into a sentence
35+
* an operator reads at boot, on the axis the ruling weighed most: an author
36+
* whose configuration is wrong should be told at the moment they get it wrong.
37+
*
38+
* ## Why the platform still connects
39+
*
40+
* The boundary is drawn where behaviour was actually verified — wire yes,
41+
* emission no — rather than at the package boundary. Connection, the connect
42+
* bound and the #11389 calendar-day parser all still apply, so a deployment
43+
* that manages its schema out-of-band (`skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`,
44+
* the documented posture after running migrations manually) keeps working on
45+
* these databases. That escape hatch is named in the message, because a refusal
46+
* that does not say what to do instead is only half of "loud".
47+
*
48+
* ## Reopening
49+
*
50+
* Recorded on #11756: no customer is known on either database, and evidence of
51+
* a real one reopens this toward recognition — starting with a MEASURED DDL
52+
* difference and the two databases judged separately (CockroachDB's Postgres
53+
* compatibility is visibly higher: knex already compiles it with the
54+
* `postgresql` dialect, where `redshift` has a dialect of its own).
55+
*/
56+
57+
/**
58+
* ADR-0112 D3 extension code, registered by `@objectstack/driver-sql` in
59+
* `ERROR_CODE_LEDGER`.
60+
*
61+
* Registered rather than parked as a driver-local string because this refusal
62+
* IS wire-reachable: publishing a drafted object calls `engine.syncObjectSchema`
63+
* → `SqlDriver.syncSchema` → the DDL gate, on a server that is already serving
64+
* HTTP. That is the test the ledger applies (the class `MONGODB_MULTI_TENANT_UNSUPPORTED`
65+
* was UNregistered for failing — a boot refusal the CLI rethrows pre-HTTP, which
66+
* no response envelope could ever carry). This one can be carried, so it is
67+
* registered and the door serves it under its own name instead of demoting it
68+
* to `declaredCode` behind a 500.
69+
*
70+
* No standard-catalog member covers the condition: `NOT_IMPLEMENTED` says "not
71+
* yet", and the whole content of the ruling is that this is a decided, stated
72+
* boundary rather than an unfinished one.
73+
*/
74+
export const DIALECT_EMISSION_UNSUPPORTED_CODE = 'SQL_DIALECT_EMISSION_UNSUPPORTED';
75+
76+
/**
77+
* 501 — the status `HttpStatusErrorCodeMap` already names for "this server does
78+
* not do that". Deliberately not 400 (the caller's request is well-formed and
79+
* would succeed unchanged on a supported database) and not 500 (nothing
80+
* faulted; the driver declined on purpose and said why).
81+
*/
82+
export const DIALECT_EMISSION_UNSUPPORTED_STATUS = 501;
83+
84+
/**
85+
* Thrown by `SqlDriver.assertDialectEmits` when a knex client this driver
86+
* recognises on the wire — and only those — reaches the DDL path.
87+
*
88+
* The structured fields are the reason this is a class and not a bare `Error`:
89+
* a host that wants to render its own message (Studio, the CLI's migrate
90+
* plan, an installer) reads `client` and `supportedClients` instead of parsing
91+
* the sentence back out of `message`.
92+
*/
93+
export class UnsupportedDialectEmissionError extends Error {
94+
readonly code = DIALECT_EMISSION_UNSUPPORTED_CODE;
95+
readonly status = DIALECT_EMISSION_UNSUPPORTED_STATUS;
96+
97+
constructor(
98+
/** The knex `client` spelling as configured. */
99+
readonly client: string,
100+
/** The DDL operation that was refused, e.g. `initObjects`. */
101+
readonly operation: string,
102+
/** Every client spelling this driver DOES emit DDL for, sorted. */
103+
readonly supportedClients: readonly string[],
104+
) {
105+
super(renderDialectEmissionRefusal(client, operation, supportedClients));
106+
this.name = 'UnsupportedDialectEmissionError';
107+
}
108+
}
109+
110+
/**
111+
* The refusal's prose, rendered from the driver's own tables.
112+
*
113+
* Exported so the pin suite asserts the SAME renderer the driver throws through
114+
* — a message pinned by copying its text into a test is a pin on the test.
115+
*
116+
* Three things it must carry, in this order, because that is the order an
117+
* operator needs them: what was refused and why, what IS supported, and what to
118+
* do to keep this database.
119+
*/
120+
export function renderDialectEmissionRefusal(
121+
client: string,
122+
operation: string,
123+
supportedClients: readonly string[],
124+
): string {
125+
return (
126+
`DDL operation '${operation}' was refused: knex client '${client}' speaks the PostgreSQL wire ` +
127+
`protocol, but ObjectStack does not emit schema DDL for it, so no table was created or altered. ` +
128+
`Emitting PostgreSQL DDL there would not fail loudly — it would build a table of the wrong shape, ` +
129+
`and the deployment would find out when it writes data into it. ` +
130+
`Supported clients for schema emission: ${supportedClients.join(', ')}. ` +
131+
`To keep using this database, manage its schema out-of-band and boot with ` +
132+
`\`skipSchemaSync\` / OS_SKIP_SCHEMA_SYNC=1 — connection, the connect bound and result parsing ` +
133+
`are unaffected by this refusal.`
134+
);
135+
}

packages/drivers/driver-sql/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ export { resolveSqliteAbsentFileTarget } from './sql-driver.js';
1414
// stops an embedder from re-deriving the seam (or, worse, putting the text back
1515
// on the wire by spreading the error, which the symbol key exists to prevent).
1616
export { withheldFilterDiagnosticOf } from './sql-driver.js';
17+
// [#11991] The #11756 emission-scope refusal. Exported because a host that
18+
// renders its own diagnostics (Studio, `os migrate plan`, an installer) needs
19+
// the structured `client` / `supportedClients` and the stable `code` — the
20+
// alternative is parsing the sentence back out of `message`, which is how a
21+
// refusal's wording becomes an accidental contract.
22+
export {
23+
DIALECT_EMISSION_UNSUPPORTED_CODE,
24+
DIALECT_EMISSION_UNSUPPORTED_STATUS,
25+
UnsupportedDialectEmissionError,
26+
renderDialectEmissionRefusal,
27+
} from './dialect-emission-refusal.js';
1728
export type {
1829
SqlDriverConfig,
1930
SqliteJournalMode,

0 commit comments

Comments
 (0)