Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/sql-emission-identity-one-source.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- 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. -->
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
135 changes: 135 additions & 0 deletions packages/drivers/driver-sql/src/dialect-emission-refusal.ts
Original file line number Diff line number Diff line change
@@ -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.`
);
}
11 changes: 11 additions & 0 deletions packages/drivers/driver-sql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading