|
| 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 | +} |
0 commit comments