From e2b65034a6e236b62b38fe2fdae8e634e2ffeb34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:47:22 +0000 Subject: [PATCH 1/6] wip(#16019): declare raw-statement faults on SqlDriver.execute and the Turso remote transport Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- packages/drivers/driver-sql/src/sql-driver.ts | 104 +++++++++++++++++- .../drivers/driver-turso/src/turso-driver.ts | 14 ++- packages/types/src/error-leak.ts | 23 +++- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 91337b2b75..c31eb97dc6 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -895,6 +895,69 @@ function backendStatementFaultError(object: string, cause: unknown, targetedTabl return err; } +/** + * [#16019] The raw-execution twin of {@link backendStatementFaultError}: the + * envelope {@link SqlDriver.execute} raises when the backend refuses a + * statement it was handed verbatim. `no such function: translate` on a SQLite + * datasource whose analytics compiler emitted a function the dialect lacks + * (#16028) is the measured case; every other dialect refusal on this path is + * the same class. + * + * # Why this exists beside the read-exit terminal + * + * Every typed read exit (`find`, `count`, `aggregate`) already terminates in + * {@link SqlDriver.backendStatementFault}, so a dialect refusal there leaves + * the driver DECLARED — `code: DATABASE_ERROR`, `status: 500` — and the HTTP + * doors withhold its prose by that declaration (`declaresServerFault`). The + * raw path had no terminal at all: `execute()` awaited `knex.raw()` bare, so + * the dialect's own error object left the driver with no `status`, a `code` + * from the backend's vocabulary (`SQLITE_ERROR`) and knex's ` - + * ` message. Undeclared, it fell to `looksLikeInternalErrorLeak` + * at the doors — a phrasing heuristic that recognises `no such column:` and + * not `no such function:` — so whether a caller saw the engine's text was a + * property of which limb the message happened to match, and one transport + * over, of whether the message carried a statement prefix at all. + * + * Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring + * list is not grown; the driver declares its own fault and the doors classify + * on the declaration. This is that declaration for the raw path — the idiom + * this file already spells at its other terminals, not a second mechanism. + * + * # What the envelope carries, and what it does not + * + * The message is COMPOSED, for the reason `backendStatementFaultError` gives: + * there is no cut of a dialect's text that keeps its words and reliably drops + * a caller's inlined value. No statement, no diagnostic and no function name + * reaches the message. The dialect error travels whole under `cause`, + * NON-ENUMERABLE — readable by cause-following predicates + * (`isMissingTableError` still classifies a missing table on this path + * through it), invisible to `JSON.stringify` and `{ ...err }` — and the driver + * writes it to the server log before composing. ⛔ No `DRIVER_TARGETED_TABLE` + * is declared here: a raw statement may reference any number of tables, and + * naming one would make a missing JOINED table read as the caller's own — the + * misclassification #13438 exists to prevent. + * + * @param cause - the dialect error, kept whole for the log and for + * cause-following predicates. + */ +function rawStatementFaultError(cause: unknown): Error { + const err = new Error( + 'The database refused to run a raw statement. The driver could not attribute the failure ' + + 'to any part of the request, so no verdict about the statement is claimed here. The ' + + "backend's own diagnostic and the statement were written to the server log for an " + + 'operator to read.', + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.DATABASE_ERROR; + err.status = 500; + Object.defineProperty(err, 'cause', { + value: cause, + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + /** * [#9354] How long a widening ALTER waits for a metadata lock, in seconds. * @@ -8212,6 +8275,39 @@ export class SqlDriver implements IDataDriver { // Raw Execution // =================================== + /** + * [#16019] The terminal of the raw path — compose + * {@link rawStatementFaultError} for a backend refusal nothing declared, + * writing the statement and the dialect's own message to the SERVER LOG on + * the way. + * + * The same "is it already ours" gate {@link SqlDriver.backendStatementFault} + * applies, asked over the DECLARED status and ⛔ never over an error class: a + * transport that already answers with an ADR-0112 envelope must not be + * buried under a generic one. + * + * Returns the error rather than throwing it — the shape every sibling + * terminal in this file uses — so each call site spells its own `throw`. + * `protected` so a subclass that routes `execute()` around this class's knex + * (`TursoDriver` in remote mode) declares through the SAME composition. + */ + protected rawStatementFault(command: string, error: unknown): Error { + const declared = (error as { status?: unknown } | null | undefined)?.status; + if (typeof declared === 'number') return error as Error; + + const detail = (error as { message?: unknown } | null | undefined)?.message; + const code = (error as { code?: unknown } | null | undefined)?.code; + this.logger.warn( + '[sql-driver] DATABASE_ERROR — the backend refused a raw statement' + + (typeof code === 'string' && code.length > 0 ? ` (${code})` : '') + + '. The statement and the dialect message below are kept server-side: the message ' + + 'carries the compiled statement, and on the dialects that inline them the bound ' + + `literals too (#7929, #8931). statement: ${command}; dialect: ` + + `${typeof detail === 'string' ? detail : String(error)}`, + ); + return rawStatementFaultError(error); + } + /** * Run a raw SQL string or knex builder through the underlying knex * connection. @@ -8238,7 +8334,13 @@ export class SqlDriver implements IDataDriver { ? this.knex.raw(command, params || []).transacting(options.transaction as Knex.Transaction) : this.knex.raw(command, params || []); - const result = await builder; + let result: unknown; + try { + result = await builder; + } catch (error) { + // [#16019] The raw path's terminal — see {@link SqlDriver.rawStatementFault}. + throw this.rawStatementFault(command, error); + } // Only after the statement actually succeeded — an index we failed to // create is not one we own (#4884). if (INDEX_DDL_PREFIX.test(command)) this.noteRuntimeIndexDdl(command); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 84c634d156..9424e890f3 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -1405,7 +1405,19 @@ export class TursoDriver extends SqlDriver { // =================================== override async execute(command: any, params?: any[], options?: DriverOptions): Promise { - if (this.isRemote) return this.remoteTransport!.execute(command, params); + if (this.isRemote) { + // [#16019] The remote transport hands the libsql client's error back + // whole — `SQLITE_ERROR: no such function: translate`: no statement, no + // `status`, the bare shape the HTTP doors' phrasing heuristic never + // covered. Declared through the base class's raw-path terminal so both + // transports leave this driver with ONE envelope (`DATABASE_ERROR`/500, + // the dialect error under a non-enumerable `cause`). + try { + return await this.remoteTransport!.execute(command, params); + } catch (error) { + throw this.rawStatementFault(typeof command === 'string' ? command : String(command), error); + } + } return super.execute(command, params, options); } diff --git a/packages/types/src/error-leak.ts b/packages/types/src/error-leak.ts index 4e2d8a49ab..dd1bb4040a 100644 --- a/packages/types/src/error-leak.ts +++ b/packages/types/src/error-leak.ts @@ -17,8 +17,10 @@ * "Do not ship driver internals to clients" is a property of the HTTP * boundary, not of one router, so the predicate lives here — the package both * `@objectstack/rest` and `@objectstack/runtime` already depend on — and each - * boundary applies it in its own envelope. One heuristic, one place to widen - * when a new dialect's phrasing shows up. + * boundary applies it in its own envelope. One heuristic, one place — and + * since #16019 a FROZEN one: a phrasing it does not recognise is closed by the + * producer declaring its fault, never by a new row here (the ruling is + * recorded on {@link DIALECT_LEAK_PHRASINGS}). * * Deliberately a *heuristic over the message*, not a driver taxonomy: these * errors arrive as plain `Error`s from a half-dozen dialects with no shared @@ -138,6 +140,23 @@ export const INTERNAL_ERROR_MESSAGE = 'Internal server error'; * a guessed pattern here is the over-match direction, which suppresses * diagnostics an operator needs. Measure one, then add it. * + * ⛔ **[#16019] This list no longer grows — it is the LAST-RESORT FALLBACK for + * an error that arrives with no declaration.** Maintainer ruling 2026-09-06 + * (decision batch #57, option 3): a driver phrasing this list does not + * recognise is closed by the DRIVER declaring its own fault — `code` plus + * `status >= 500`, the shape {@link declaresServerFault} reads — never by a + * row added here. The standing example is SQLite's `no such function:`, the + * sibling of the `no such (?:table|column):` limb above and deliberately NOT + * added beside it: `SqlDriver.execute` (`driver-sql`, the raw-SQL path the + * analytics compilers run on) now raises a declared `DATABASE_ERROR`/500 with + * the dialect error under a non-enumerable `cause`, so every future dialect + * message on that path is covered without a change here. `error-leak.test.ts` + * pins the phrase as UNCOVERED by this list and withheld by the declaration; + * the doors pin that a declaration wins over the heuristic. A producer outside + * a driver that still throws a bare `Error` carrying dialect text is this + * list's residual, by design — the heuristic's `false` on it means uncovered, + * as the paragraph above already says, and the remedy is a declaration. + * * ⚠️ Related but NOT reusable: `relation-sub-object.ts` owns the same Postgres * sentence for two other questions (which column? / is this a sub-object?), and * its note warns that its two widths must never be collapsed. Neither answers From acc99e7e5b033af1764286ab76ca1e8c8bd90592 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:52:02 +0000 Subject: [PATCH 2/6] wip(#16019): pins at the driver exits, the types predicate and the analytics door; changeset Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../driver-raw-statement-declared-fault.md | 12 + ...16019-raw-statement-fault-envelope.test.ts | 182 +++++++++++ ...emote-raw-statement-fault-envelope.test.ts | 134 ++++++++ ...lytics-16019-driver-declared-fault.test.ts | 286 ++++++++++++++++++ packages/types/src/error-leak.test.ts | 52 ++++ 5 files changed, 666 insertions(+) create mode 100644 .changeset/driver-raw-statement-declared-fault.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-16019-raw-statement-fault-envelope.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-driver-16019-remote-raw-statement-fault-envelope.test.ts create mode 100644 packages/rest/src/analytics-16019-driver-declared-fault.test.ts diff --git a/.changeset/driver-raw-statement-declared-fault.md b/.changeset/driver-raw-statement-declared-fault.md new file mode 100644 index 0000000000..c9a719ea2f --- /dev/null +++ b/.changeset/driver-raw-statement-declared-fault.md @@ -0,0 +1,12 @@ +--- +"@objectstack/driver-sql": patch +"@objectstack/driver-turso": patch +--- + +`SqlDriver.execute()` — the raw-SQL path the analytics compilers run on — now declares a backend refusal the way the typed read exits (`find` / `count` / `aggregate`) have since #8931: `code: DATABASE_ERROR`, `status: 500`, a composed message that carries none of the dialect's words, and the dialect error whole under a non-enumerable `cause`. `TursoDriver` in remote mode — the one transport that hands the engine's text back with no statement in front of it — declares through the same terminal, so both transports leave the driver with one envelope. **Graded `patch`:** no exported type, signature or option changes; this puts an envelope the driver already emits onto an existing refusal, on the one exit that still let the dialect's raw error object out undeclared. + +**The defect this closes (#16019, folding in the envelope half of #16028).** `no such function: translate` — what SQLite answers when a compiler emits a function the dialect lacks — left `execute()` as knex's own error: `code: 'SQLITE_ERROR'`, no `status`, message ` - no such function: translate`. Undeclared, it fell to the HTTP doors' phrasing heuristic (`looksLikeInternalErrorLeak`), which recognises `no such column:` and not `no such function:`, so whether the caller saw the engine's text depended on which limb the message happened to match: through knex it was withheld by accident (the statement prefix starts with `select`), through the Turso remote transport it was withheld by a different accident (`SQLITE_ERROR:` in front), and a bare `Error('no such function: translate')` reached the body verbatim. Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring list is not grown; the driver declares its own fault and the doors classify on the declaration. The heuristic stays as the last-resort fallback for an error that arrives with no declaration. + +**What moves on the wire.** A driver fault on the raw path now reaches `POST /api/v1/analytics/dataset/query` as `500 {"code":"DATABASE_ERROR","error":"Internal server error"}` — the declared-fault relay, the same answer the `/data` door and `/analytics/query` already give a declared 5xx — where it was `500 {"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` when the heuristic happened to fire and the raw engine text when it did not. The status is unchanged; the code is now the producer's, exactly as the read exits' faults already answer. + +**What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log. diff --git a/packages/drivers/driver-sql/src/sql-driver-16019-raw-statement-fault-envelope.test.ts b/packages/drivers/driver-sql/src/sql-driver-16019-raw-statement-fault-envelope.test.ts new file mode 100644 index 0000000000..6d47c49666 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-16019-raw-statement-fault-envelope.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16019] `SqlDriver.execute()` — the raw-SQL path every analytics compiler + * runs on — declares its own fault. + * + * ## The gap, measured on this tree + * + * The typed read exits terminate in `backendStatementFault`, so a dialect + * refusal on `find` / `count` / `aggregate` leaves the driver as a declared + * `DATABASE_ERROR`/500. The raw path had no terminal: `execute()` awaited + * `knex.raw()` bare, and knex's executor hands back the dialect's own error + * object — `code: 'SQLITE_ERROR'`, no `status`, and the message + * ` - `. Measured on knex 3.3.0 + better-sqlite3: + * + * ``` + * select translate('ABC', 'ABC', 'abc') as x - no such function: translate + * ``` + * + * The prefix is unconditional (`compileSqlOnError: false` only changes how the + * statement is formatted; it never drops it), so through knex the HTTP doors' + * phrasing heuristic withheld this text by ACCIDENT — `startsWith('select ')` + * — while `no such function:` itself matched nothing, and a transport that + * hands the engine's text back without a statement (`driver-turso` remote + * mode, pinned in its own package) reached the same door bare. Neither was a + * declaration. + * + * ## Maintainer ruling 2026-09-06 (decision batch #57, option 3) + * + * The substring list in `looksLikeInternalErrorLeak` is not grown. The driver + * declares its fault and the doors classify on the declaration. These pins + * are that declaration at the layer that produces it, in every direction that + * matters: the composed envelope carries `code` + `status` and none of the + * dialect's words; the dialect error survives whole under a NON-ENUMERABLE + * `cause`, so cause-following classification (`isMissingTableError`) keeps + * working; and an error that already declares a status passes through, never + * double-wrapped. + * + * ## Reverse verification, direction predicted BEFORE running + * + * Restore the bare `await builder` in `execute()` (delete its `try`/`catch`) + * and the envelope cases go RED on `code` / `status` (`SQLITE_ERROR` and + * `undefined` in their place) and on the message assertions (the statement and + * `no such function` are then IN the message). The positive control and the + * pass-through gate stay GREEN — that leg never hands the gate a knex error. + * Recorded in the PR, both legs. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from './index.js'; +import { declaresServerFault, isMissingTableError, looksLikeInternalErrorLeak } from '@objectstack/types'; + +/** The shape `declaresServerFault` and both HTTP doors read off a thrown driver error. */ +interface WireBearingError extends Error { + code?: string; + status?: number; + cause?: unknown; +} + +/** The statement SQLite refuses — `translate()` is the #16028 fault verbatim. */ +const TRANSLATE_SQL = "select translate('ABC', 'ABC', 'abc') as x"; + +async function faultOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the driver to refuse this statement, but it resolved'); +} + +/** + * A driver whose log sink is captured, so the server-side copy of the dialect + * text can be asserted, and whose protected terminal is exposed for the + * pass-through pin. + */ +class LoggedSqlDriver extends SqlDriver { + readonly warned: string[] = []; + + constructor() { + super({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + this.logger = { warn: (msg: string) => { this.warned.push(msg); } }; + } + + terminal(command: string, error: unknown): Error { + return this.rawStatementFault(command, error); + } +} + +describe('[#16019] SqlDriver.execute() declares a backend refusal as DATABASE_ERROR/500', () => { + let driver: LoggedSqlDriver; + + beforeEach(() => { + driver = new LoggedSqlDriver(); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it("the statement SQLite refuses leaves execute() as a declared fault carrying none of the dialect's words", async () => { + const err = await faultOf(() => driver.execute(TRANSLATE_SQL)); + + expect(err.code).toBe('DATABASE_ERROR'); + expect(err.status).toBe(500); + expect(declaresServerFault(err)).toBe(true); + expect(err.message).toMatch(/refused to run a raw statement/); + // Not the statement, not the diagnostic, not the function name we emitted. + expect(err.message).not.toMatch(/translate/i); + expect(err.message).not.toMatch(/no such function/i); + expect(err.message).not.toMatch(/select/i); + }); + + it('the dialect error travels whole under a NON-ENUMERABLE cause — the knex shape, statement prefixed', async () => { + const err = await faultOf(() => driver.execute(TRANSLATE_SQL)); + const cause = err.cause as WireBearingError; + + expect(cause).toBeInstanceOf(Error); + // knex 3.3.0's executor: ` - `, unconditionally. + expect(cause.message).toBe(`${TRANSLATE_SQL} - no such function: translate`); + expect(cause.code).toBe('SQLITE_ERROR'); + // Readable by code, invisible to serialisation — the same carrier discipline + // `backendStatementFaultError` applies one terminal over. + expect(Object.getOwnPropertyDescriptor(err, 'cause')?.enumerable).toBe(false); + expect(Object.keys(err)).not.toContain('cause'); + expect(JSON.stringify(err)).not.toMatch(/translate/); + }); + + it('the declaration, not the phrasing heuristic, is what withholds it', async () => { + const err = await faultOf(() => driver.execute(TRANSLATE_SQL)); + + // The heuristic never covered the engine's phrase, and the composed message + // gives it nothing to recognise either: the doors withhold on the declaration. + expect(looksLikeInternalErrorLeak('no such function: translate')).toBe(false); + expect(looksLikeInternalErrorLeak(err.message)).toBe(false); + expect(declaresServerFault(err)).toBe(true); + // Control: the sibling limb the heuristic DOES cover, so the `false` above is + // a reading about the phrase and not about a broken probe. + expect(looksLikeInternalErrorLeak('no such column: bogus_dim')).toBe(true); + }); + + it('writes the statement and the dialect message to the server log — after this change, the only copy', async () => { + await faultOf(() => driver.execute(TRANSLATE_SQL)); + + const line = driver.warned.find((m) => m.includes('DATABASE_ERROR')); + expect(line).toBeDefined(); + expect(line).toContain('(SQLITE_ERROR)'); + expect(line).toContain(TRANSLATE_SQL); + expect(line).toContain('no such function: translate'); + }); + + it('a missing table on the raw path stays classifiable through `cause` (isMissingTableError)', async () => { + const err = await faultOf(() => driver.execute('select 1 from nope_16019')); + + expect(err.code).toBe('DATABASE_ERROR'); + expect(err.status).toBe(500); + expect(err.message).not.toMatch(/nope_16019/); + expect(isMissingTableError(err, 'nope_16019')).toBe(true); + // Control: the same envelope is not a missing-table verdict about some + // OTHER relation — no `DRIVER_TARGETED_TABLE` is declared on this path, so + // the comparison is the caller's name against the phrase, as before. + expect(isMissingTableError(err, 'other_16019')).toBe(false); + }); + + it('an error that already declares a status passes through untouched — never double-wrapped', () => { + const declared = Object.assign(new Error('the Query Protocol has no such function'), { + code: 'INVALID_QUERY', + status: 400, + }); + + expect(driver.terminal('select 1', declared)).toBe(declared); + expect(driver.warned).toHaveLength(0); + }); + + it('POSITIVE CONTROL: a statement the engine runs still resolves with its rows, and logs nothing', async () => { + const rows: unknown = await driver.execute('select 1 as x'); + const first = Array.isArray(rows) ? rows[0] : (rows as { rows?: unknown[] })?.rows?.[0]; + + expect(first).toEqual({ x: 1 }); + expect(driver.warned).toHaveLength(0); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver-16019-remote-raw-statement-fault-envelope.test.ts b/packages/drivers/driver-turso/src/turso-driver-16019-remote-raw-statement-fault-envelope.test.ts new file mode 100644 index 0000000000..055d1b1222 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-16019-remote-raw-statement-fault-envelope.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16019] `TursoDriver.execute()` in REMOTE mode declares a backend refusal + * through the base class's raw-path terminal — the BARE shape, closed. + * + * ## Why this transport is the bare shape's in-repo producer + * + * Local and replica mode run `super.execute()`, i.e. knex, whose executor + * prefixes the statement to every dialect error (` - `) + * — the "knex shape" `driver-sql`'s own pin covers. Remote mode does not go + * through knex: `RemoteTransport.execute` hands the `@libsql/client` error back + * whole. Measured in this container against `@libsql/client` on + * `file::memory:`: + * + * ``` + * LibsqlError code: 'SQLITE_ERROR' status: undefined + * message: "SQLITE_ERROR: no such function: translate" + * ``` + * + * No statement, no `status` — so at the HTTP doors it was undeclared, and its + * text reached the caller or not depending on ONE substring (`sqlite_`) the + * phrasing heuristic happens to know. The card's own measurement was the + * shape one step barer still, `Error('no such function: translate')`, which + * that heuristic does not know at all. The stub below (better-sqlite3 wearing + * the libsql interface) raises exactly that bare text, so this file drives the + * card's measured shape through a real driver exit, not a hand-made throw. + * + * ## What is pinned + * + * The same envelope `SqlDriver.execute()` raises — `DATABASE_ERROR`/500, a + * composed message with none of the engine's words, the transport's error + * under a non-enumerable `cause`, the server log holding the only copy of the + * dialect text — so both transports of this driver leave it with ONE shape. + * `declaresServerFault` is not imported here: `@objectstack/types` is not a + * dependency of this package, and the predicate reads exactly the two fields + * asserted below (`status >= 500` and a non-empty `code`). + * + * ## Reverse verification, direction predicted BEFORE running + * + * Restore `if (this.isRemote) return this.remoteTransport!.execute(…)` and the + * envelope cases go RED on `code` (`SQLITE_ERROR` in its place) and `status` + * (`undefined`), and the message assertions go red because the bare text IS + * the message. The positive control stays GREEN. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { TursoDriver } from './turso-driver.js'; +import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; + cause?: unknown; +} + +const TRANSLATE_SQL = "select translate('ABC', 'ABC', 'abc') as x"; + +async function faultOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the driver to refuse this statement, but it resolved'); +} + +class LoggedTursoDriver extends TursoDriver { + readonly warned: string[] = []; + + constructor(stub: LibsqlSqliteStub) { + super({ url: 'libsql://issue-16019.turso.io', client: stub as never }); + this.logger = { warn: (msg: string) => { this.warned.push(msg); } }; + } +} + +describe('[#16019] TursoDriver remote — execute() declares a backend refusal as DATABASE_ERROR/500', () => { + let stub: LibsqlSqliteStub; + let driver: LoggedTursoDriver; + + beforeAll(async () => { + stub = makeLibsqlSqliteStub(); + driver = new LoggedTursoDriver(stub); + await driver.connect(); + expect(driver.transportMode).toBe('remote'); + }); + + afterAll(async () => { + await driver.disconnect(); + stub.close(); + }); + + it("the bare engine text the card was filed on leaves execute() as a declared fault carrying none of the engine's words", async () => { + const err = await faultOf(() => driver.execute(TRANSLATE_SQL)); + + expect(err.code).toBe('DATABASE_ERROR'); + expect(err.status).toBe(500); + expect(err.message).toMatch(/refused to run a raw statement/); + expect(err.message).not.toMatch(/translate/i); + expect(err.message).not.toMatch(/no such function/i); + expect(err.message).not.toMatch(/select/i); + }); + + it('the transport error travels whole under a NON-ENUMERABLE cause — no statement prefix, the bare shape', async () => { + const err = await faultOf(() => driver.execute(TRANSLATE_SQL)); + const cause = err.cause as WireBearingError; + + expect(cause).toBeInstanceOf(Error); + // better-sqlite3's own diagnostic, exactly as the card measured it: no + // statement in front of it, nothing the doors' `startsWith('select ')` limb + // could have caught by accident. + expect(cause.message).toBe('no such function: translate'); + expect(Object.getOwnPropertyDescriptor(err, 'cause')?.enumerable).toBe(false); + expect(JSON.stringify(err)).not.toMatch(/translate/); + }); + + it('writes the statement and the engine text to the server log — the only copy', async () => { + driver.warned.length = 0; + await faultOf(() => driver.execute(TRANSLATE_SQL)); + + const line = driver.warned.find((m) => m.includes('DATABASE_ERROR')); + expect(line).toBeDefined(); + expect(line).toContain(TRANSLATE_SQL); + expect(line).toContain('no such function: translate'); + }); + + it('POSITIVE CONTROL: a statement the engine runs still resolves with its rows through the remote transport', async () => { + driver.warned.length = 0; + const rows = await driver.execute('select 1 as x'); + + expect(rows).toEqual([{ x: 1 }]); + expect(driver.warned).toHaveLength(0); + }); +}); diff --git a/packages/rest/src/analytics-16019-driver-declared-fault.test.ts b/packages/rest/src/analytics-16019-driver-declared-fault.test.ts new file mode 100644 index 0000000000..69d2a12d34 --- /dev/null +++ b/packages/rest/src/analytics-16019-driver-declared-fault.test.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16019] `POST /analytics/dataset/query` — a driver fault on the raw-SQL path + * reaches the caller by DECLARATION, and the declaration wins over the + * phrasing heuristic. + * + * ## The two shapes, and why this file drives the real driver + * + * The card was filed on a hand-made `Error('no such function: translate')` and + * re-scoped when the Clause-② review of PR #16020 measured the shape production + * actually raises — knex's ` - no such function: translate` — and + * found it withheld already, by accident: `looksLikeInternalErrorLeak` fires on + * the `select ` prefix, never on the phrase. On `origin/main`: + * + * | shape reaching the door | body | + * |-----------------------------------------------|-------------------------------------------------------| + * | bare `Error('no such function: translate')` | `500 ANALYTICS_QUERY_FAILED`, raw engine text | + * | knex-shaped — what `SqlDriver.execute` raised | `500 ANALYTICS_QUERY_FAILED`, `Internal server error` | + * + * Both were UNDECLARED. Under the 2026-09-06 ruling (decision batch #57, + * option 3) `SqlDriver.execute()` declares the fault — `DATABASE_ERROR`/500, + * the dialect error under a non-enumerable `cause` — and this door's ③a arm + * relays a declared 5xx with the producer's code and `INTERNAL_ERROR_MESSAGE`. + * The first block drives that END TO END: a real `AnalyticsService` on the + * native-SQL strategy, a real better-sqlite3 `SqlDriver` behind the exact + * bridge `service-analytics`'s plugin wires (`engine.execute` → `driver.execute`), + * and a dataset dimension whose expression calls `translate()` — the function + * SQLite lacks, the #16028 fault verbatim — so the refusal is the ENGINE's, + * not a fixture's, and the compiled statement is the real one. + * + * The second block pins the ordering the ruling's execution notes name. A + * DECLARED fault is withheld even when its text is one the heuristic does not + * know (declared wins); an UNDECLARED knex-shaped fault still falls to the + * heuristic (the fallback stays); an UNDECLARED bare fault is the heuristic's + * residual — pinned as the coverage boundary, the way `error-leak.test.ts` + * pins MSSQL and Oracle, so the boundary keeps a live subject. ⛔ Not a leak to + * close with a row: the remedy for a producer that throws dialect text bare is + * to declare, which is the whole ruling. + * + * Note this file exercises the BUILT `@objectstack/service-analytics` and + * `@objectstack/driver-sql` (both resolve through their `exports` to `dist/`): + * mutating either source without rebuilding proves nothing here. + * + * ## Reverse verification, direction predicted BEFORE running + * + * Restore the bare `await builder` in `SqlDriver.execute()` and REBUILD + * driver-sql: the first block's `DATABASE_ERROR` assertions go RED + * (`ANALYTICS_QUERY_FAILED` returns) while its `INTERNAL_ERROR_MESSAGE` + * assertion stays GREEN — the accident the re-scope measured, the heuristic's + * `select ` limb — and the driver-log assertion goes RED (the driver no longer + * logs; the route's `logError` becomes the only copy). The second block stays + * GREEN throughout: it hands the door shapes that never touch the driver. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '@objectstack/service-analytics'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { INTERNAL_ERROR_MESSAGE, declaresServerFault, looksLikeInternalErrorLeak } from '@objectstack/types'; +import { RestServer } from './rest-server'; + +// ── harness (the shape `analytics-dataset-dimension-gate.test.ts` uses) ────── + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} +function mockProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + }; +} +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.end = vi.fn(() => res); + return res; +} + +function buildRoute(analyticsProvider?: any) { + const rest = new RestServer( + mockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + analyticsProvider, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + return rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query'))!; +} + +/** A service double whose `queryDataset` throws exactly the given error. */ +function throwingAnalytics(error: unknown) { + return { queryDataset: vi.fn().mockRejectedValue(error) }; +} + +async function post(route: any, body: unknown) { + const res = mockRes(); + await route.handler({ method: 'POST', params: {}, headers: {}, body } as any, res); + return res; +} + +const ACCOUNT_FIELDS = ['id', 'name', 'industry']; + +/** + * One plain dimension for the control, and one whose expression calls the + * function SQLite lacks. The compiler passes an expression through verbatim + * (`qualifyAndRegisterJoin` leaves anything that is not a bare identifier or + * a dotted path alone) and the source-field gate judges bare identifiers only, + * so this is the real statement the strategy emits, refused by the real engine. + */ +const dataset = { + name: 'account_metrics', + label: 'Account metrics', + object: 'crm_account', + dimensions: [ + { name: 'industry', field: 'industry', type: 'string' }, + { name: 'folded_name', field: "translate(name, 'ABC', 'abc')", type: 'string' }, + ], + measures: [{ name: 'account_count', aggregate: 'count' }], +}; + +async function realDriver(): Promise { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { + name: 'crm_account', + fields: { + id: { type: 'text', name: 'id' }, + name: { type: 'text', name: 'name' }, + industry: { type: 'text', name: 'industry' }, + }, + } as any, + ]); + await driver.create('crm_account', { id: '1', name: 'Acme', industry: 'tech' }); + return driver; +} + +/** + * A REAL `AnalyticsService` on the native-SQL path, behind the bridge + * `service-analytics/src/plugin.ts` wires when no `executeRawSql` is supplied: + * `$n` placeholders → `?`, then `engine.execute` → `driver.execute`. The engine + * layer adds driver SELECTION only, so the driver is called here directly. + */ +function realAnalytics(driver: SqlDriver): AnalyticsService { + const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; + return new AnalyticsService({ + logger: silent, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_object: string, sql: string, params: unknown[]) => + (await driver.execute(sql.replace(/\$(\d+)/g, '?'), params as any[])) as Record[], + isRegisteredObject: (n: string) => n === 'crm_account', + getObjectFieldNames: (n: string) => (n === 'crm_account' ? ACCOUNT_FIELDS : undefined), + }); +} + +let errored: string[] = []; +let warned: string[] = []; +let consoleError: ReturnType; +let consoleWarn: ReturnType; +beforeEach(() => { + errored = []; + warned = []; + const collect = (sink: string[]) => (...args: unknown[]) => { + sink.push(args.map((a) => (a instanceof Error ? a.message : String(a))).join(' ')); + }; + consoleError = vi.spyOn(console, 'error').mockImplementation(collect(errored)); + consoleWarn = vi.spyOn(console, 'warn').mockImplementation(collect(warned)); +}); +afterEach(() => { + consoleError.mockRestore(); + consoleWarn.mockRestore(); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#16019] a driver fault on the raw-SQL path reaches the caller by declaration — real compiler, real engine', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = await realDriver(); + }); + afterEach(async () => { + await driver.disconnect(); + }); + + it('the statement SQLite refuses answers 500 DATABASE_ERROR with the prose withheld — the ③a relay, not ③b', async () => { + const route = buildRoute(async () => realAnalytics(driver)); + const res = await post(route, { dataset, selection: { measures: ['account_count'], dimensions: ['folded_name'] } }); + + expect(res.statusCode).toBe(500); + // The producer's code, relayed — where an undeclared fault answered the + // route's generic one. + expect(res.body.code).toBe('DATABASE_ERROR'); + expect(res.body.code).not.toBe('ANALYTICS_QUERY_FAILED'); + expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE); + + const body = JSON.stringify(res.body); + expect(body).not.toMatch(/translate/i); + expect(body).not.toMatch(/no such function/i); + expect(body).not.toMatch(/SELECT|GROUP BY|crm_account/i); + }); + + it('the operator keeps the whole diagnostic: the driver logged the statement and the engine text, the route logged the envelope', async () => { + const route = buildRoute(async () => realAnalytics(driver)); + await post(route, { dataset, selection: { measures: ['account_count'], dimensions: ['folded_name'] } }); + + // The driver's warn line is now the only copy of the dialect text. + const driverLine = warned.find((m) => m.includes('[sql-driver] DATABASE_ERROR')); + expect(driverLine).toBeDefined(); + expect(driverLine).toContain('no such function: translate'); + expect(driverLine).toMatch(/translate\(name, 'ABC', 'abc'\)/i); + // The route still logs the fault it relayed — the composed envelope, which + // names no statement (`logError` prints `error.message`). + const routeLine = errored.find((m) => m.includes('[REST] Analytics dataset query error')); + expect(routeLine).toBeDefined(); + expect(routeLine).toContain('refused to run a raw statement'); + expect(routeLine).not.toContain('no such function'); + }); + + it('POSITIVE CONTROL: the same wiring with the plain dimension → 200 with rows', async () => { + const route = buildRoute(async () => realAnalytics(driver)); + const res = await post(route, { dataset, selection: { measures: ['account_count'], dimensions: ['industry'] } }); + + expect(res.statusCode).toBe(200); + expect(res.body.rows).toEqual([{ industry: 'tech', account_count: 1 }]); + expect(warned.filter((m) => m.includes('[sql-driver] DATABASE_ERROR'))).toHaveLength(0); + }); +}); + +describe('[#16019] at the door: a declaration wins over the heuristic, and the heuristic stays as the fallback', () => { + const selection = { measures: ['account_count'], dimensions: ['industry'] }; + const BARE = 'no such function: translate'; + const KNEX = + `SELECT translate(name, 'ABC', 'abc') AS "folded_name", COUNT(*) AS "account_count" ` + + `FROM "crm_account" GROUP BY translate(name, 'ABC', 'abc') - ${BARE}`; + + it("DECLARED, with a phrase the heuristic does not know → withheld, with the producer's code (the declared path wins)", async () => { + // The control that makes this a test of the declaration and not of the list. + expect(looksLikeInternalErrorLeak(BARE)).toBe(false); + const declared = Object.assign(new Error(BARE), { code: 'DATABASE_ERROR', status: 500 }); + expect(declaresServerFault(declared)).toBe(true); + + const res = await post(buildRoute(async () => throwingAnalytics(declared)), { dataset, selection }); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('DATABASE_ERROR'); + expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(res.body)).not.toContain('translate'); + }); + + it('UNDECLARED, the knex shape → still withheld, by the fallback (the heuristic stays)', async () => { + expect(looksLikeInternalErrorLeak(KNEX)).toBe(true); + + const res = await post(buildRoute(async () => throwingAnalytics(new Error(KNEX))), { dataset, selection }); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('ANALYTICS_QUERY_FAILED'); + expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(res.body)).not.toContain('translate'); + }); + + it("UNDECLARED, the bare shape → the fallback's coverage boundary, pinned as a live subject", async () => { + // ⛔ Asserting the residual, not endorsing it — see the file header. A + // producer that reaches this door with dialect text and no declaration is + // outside every driver in this repo (an embedder's own `executeRawSql`); + // the ruling's answer to it is a declaration, never a row in the list. + expect(looksLikeInternalErrorLeak(BARE)).toBe(false); + + const res = await post(buildRoute(async () => throwingAnalytics(new Error(BARE))), { dataset, selection }); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('ANALYTICS_QUERY_FAILED'); + expect(res.body.error).toBe(BARE); + }); +}); diff --git a/packages/types/src/error-leak.test.ts b/packages/types/src/error-leak.test.ts index 72db245cd6..97a11c9eed 100644 --- a/packages/types/src/error-leak.test.ts +++ b/packages/types/src/error-leak.test.ts @@ -401,3 +401,55 @@ describe('declaresServerFault', () => { expect(declaresServerFault({ statusCode: 503, message: 'Data service not available' })).toBe(false); }); }); + +/** + * [#16019] The list is FROZEN — a phrasing it does not cover is closed by the + * producer declaring its fault, never by a new row (maintainer ruling + * 2026-09-06, decision batch #57, option 3). + * + * The standing example is SQLite's `no such function:`, the sibling of the + * covered `no such (?:table|column):` limb and the same error family arriving + * by the same route. The card measured `false` on it and asked whether the + * list should learn it; the ruling went the other way — #5367 retired message + * sniffing on the analytics door on purpose — and `SqlDriver.execute()` now + * declares the fault instead (`driver-sql`'s own pin drives the real engine). + * + * ⛔ The first case asserts `false`, and as the block above says, a `false` here + * is NOT a verdict that the text is safe: it is the list being silent on a + * phrase it never learned. ⛔ Do not "fix" it by teaching the list — that is the + * option the ruling declined. The second case is the reason the `false` costs + * nothing on the path that matters: the DECLARED shape is withheld with the + * phrase still uncovered, so the declaration wins over the heuristic. + */ +describe('looksLikeInternalErrorLeak — frozen as a fallback; a new phrasing is closed by declaration (#16019)', () => { + it("is silent on SQLite's `no such function:` — the sibling of the covered `no such (table|column):` limb, deliberately NOT added", () => { + expect(looksLikeInternalErrorLeak('no such function: translate')).toBe(false); + // Controls, both directions. The covered sibling fires; and the knex shape + // of the SAME refusal fires on its statement prefix alone, which is why the + // production path through knex was withheld by accident before #16019. + expect(looksLikeInternalErrorLeak('no such column: bogus_dim')).toBe(true); + expect(looksLikeInternalErrorLeak("select translate('ABC', 'ABC', 'abc') as x - no such function: translate")).toBe(true); + }); + + it('the declared path wins: the shape SqlDriver.execute() raises is withheld with the phrase still uncovered', () => { + // The composed envelope: nothing for the heuristic to recognise, and it needs nothing. + const composed = { + status: 500, + code: 'DATABASE_ERROR', + message: 'The database refused to run a raw statement. The driver could not attribute the failure to any part of the request.', + }; + expect(looksLikeInternalErrorLeak(composed.message)).toBe(false); + expect(declaresServerFault(composed)).toBe(true); + + // And the bare engine text itself, once a producer declares it: still + // uncovered by the list, still withheld — by the declaration. + const bare = { status: 500, code: 'DATABASE_ERROR', message: 'no such function: translate' }; + expect(looksLikeInternalErrorLeak(bare.message)).toBe(false); + expect(declaresServerFault(bare)).toBe(true); + + // The residual the ruling leaves by design: the same text with NO + // declaration is neither covered nor withheld. A producer outside a + // driver that throws it bare is answered by declaring, not by a row. + expect(declaresServerFault(new Error('no such function: translate'))).toBe(false); + }); +}); From 709d51df1f9793859160d14c123b8666b8762703 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 15:11:33 +0000 Subject: [PATCH 3/6] fix(driver-sql): keep tracker ids out of the raw-statement fault's log prose check:doc-authoring's cross-package prose-id leg counts issue ids inside string prose; the new warn line carried two. The docblock keeps its provenance. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- packages/drivers/driver-sql/src/sql-driver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index c31eb97dc6..64b54958e1 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -8302,7 +8302,7 @@ export class SqlDriver implements IDataDriver { (typeof code === 'string' && code.length > 0 ? ` (${code})` : '') + '. The statement and the dialect message below are kept server-side: the message ' + 'carries the compiled statement, and on the dialects that inline them the bound ' + - `literals too (#7929, #8931). statement: ${command}; dialect: ` + + `literals too. statement: ${command}; dialect: ` + `${typeof detail === 'string' ? detail : String(error)}`, ); return rawStatementFaultError(error); From a5acd49733857debd13e111a4ee02fbf33a5bcea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 15:58:50 +0000 Subject: [PATCH 4/6] =?UTF-8?q?wip(#16019):=20review=20round=20=E2=80=94?= =?UTF-8?q?=20pin=20the=20package-door=20code=20flip,=20the=20door=20order?= =?UTF-8?q?ing,=20annotate=20the=20degrade=20docblocks,=20name=20three=20d?= =?UTF-8?q?oors=20in=20the=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../driver-raw-statement-declared-fault.md | 10 +- ...lytics-16019-driver-declared-fault.test.ts | 26 +++ ...oor-16019-raw-statement-fault-code.test.ts | 206 ++++++++++++++++++ .../src/analytics-service.ts | 21 ++ .../src/delete-driver-fault.test.ts | 55 +++++ .../src/publish-driver-fault.test.ts | 55 +++++ 6 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts diff --git a/.changeset/driver-raw-statement-declared-fault.md b/.changeset/driver-raw-statement-declared-fault.md index c9a719ea2f..dec1fbe987 100644 --- a/.changeset/driver-raw-statement-declared-fault.md +++ b/.changeset/driver-raw-statement-declared-fault.md @@ -3,10 +3,14 @@ "@objectstack/driver-turso": patch --- -`SqlDriver.execute()` — the raw-SQL path the analytics compilers run on — now declares a backend refusal the way the typed read exits (`find` / `count` / `aggregate`) have since #8931: `code: DATABASE_ERROR`, `status: 500`, a composed message that carries none of the dialect's words, and the dialect error whole under a non-enumerable `cause`. `TursoDriver` in remote mode — the one transport that hands the engine's text back with no statement in front of it — declares through the same terminal, so both transports leave the driver with one envelope. **Graded `patch`:** no exported type, signature or option changes; this puts an envelope the driver already emits onto an existing refusal, on the one exit that still let the dialect's raw error object out undeclared. +`SqlDriver.execute()` — the raw-SQL path the analytics compilers run on — now declares a backend refusal the way the typed read exits (`find` / `count` / `aggregate`) have since #8931: `code: DATABASE_ERROR`, `status: 500`, a composed message that carries none of the dialect's words, and the dialect error whole under a non-enumerable `cause`. `TursoDriver` in remote mode — the one transport that hands the engine's text back with no statement in front of it — declares through the same terminal, so both transports leave the driver with one envelope. **Graded `patch`** on AGENTS.md's changeset rule ("A bug fix in a released package takes a `patch` changeset"; breaking is what removes or renames something an author can write — a spec key, an export, a config field — and nothing here does: `execute()` stays `Promise` of `any`, and `code` / `status` were untyped before) and on the precedent of the identical change on the typed read exits, #8931 via PR #9273, which shipped `@objectstack/driver-sql: patch`. **The defect this closes (#16019, folding in the envelope half of #16028).** `no such function: translate` — what SQLite answers when a compiler emits a function the dialect lacks — left `execute()` as knex's own error: `code: 'SQLITE_ERROR'`, no `status`, message ` - no such function: translate`. Undeclared, it fell to the HTTP doors' phrasing heuristic (`looksLikeInternalErrorLeak`), which recognises `no such column:` and not `no such function:`, so whether the caller saw the engine's text depended on which limb the message happened to match: through knex it was withheld by accident (the statement prefix starts with `select`), through the Turso remote transport it was withheld by a different accident (`SQLITE_ERROR:` in front), and a bare `Error('no such function: translate')` reached the body verbatim. Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring list is not grown; the driver declares its own fault and the doors classify on the declaration. The heuristic stays as the last-resort fallback for an error that arrives with no declaration. -**What moves on the wire.** A driver fault on the raw path now reaches `POST /api/v1/analytics/dataset/query` as `500 {"code":"DATABASE_ERROR","error":"Internal server error"}` — the declared-fault relay, the same answer the `/data` door and `/analytics/query` already give a declared 5xx — where it was `500 {"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` when the heuristic happened to fire and the raw engine text when it did not. The status is unchanged; the code is now the producer's, exactly as the read exits' faults already answer. +**What moves on the wire — three doors, each because a declared fault is relayed where an undeclared one was re-labelled.** -**What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log. +- `POST /api/v1/analytics/dataset/query`: a driver fault on the raw path answers `500 {"code":"DATABASE_ERROR","error":"Internal server error"}` — the declared-fault relay, the same answer the `/data` door and `/analytics/query` already give a declared 5xx — where it was `500 {"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` when the phrasing heuristic happened to fire and the raw engine text when it did not. Status unchanged; the code is now the producer's, exactly as the typed read exits' faults have answered at this door since PR #9273. +- The same door, a dataset over a backing table that is NOT present, on the native-SQL strategy (the strategy every deployment whose data engine exposes `execute()` runs): `500 DATABASE_ERROR` where it was `200 {"rows":[],"fields":[],"totals":[]}` plus a `warn`. `queryDataset`'s missing-source degrade sits behind its declared-envelope re-throw (#5717 defence B: a declared envelope is re-thrown untouched, whatever it says), so a driver-raised missing table no longer reaches it — the answer the ObjectQL-aggregate strategy has given since #9273, now on both strategies. The degrade still applies to an undeclared producer (an embedder's own `executeRawSql`, the framework's not-registered signals). +- `POST /api/v1/packages` and `DELETE /api/v1/packages/:id`: a raw-exec driver fault under `sys_packages` answers `500 {"code":"DATABASE_ERROR"}` with the composed sentence as its message — `PackageService.publish` / `delete` re-throw a throw that declares an HTTP answer (`declaresHttpAnswer`, whose docblock already says a declared 5xx is re-thrown too) and the door's `sendThrownError` relays it — where it was `500 PACKAGE_PUBLISH_FAILED` / `500 PACKAGE_DELETE_FAILED` from the swallowing branch. Same status band, no dialect text on the wire either way; the ledgered `code` on those two doors moves. + +**What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log; the in-repo sites of that class are tracked as one follow-up card (read `cause` there). diff --git a/packages/rest/src/analytics-16019-driver-declared-fault.test.ts b/packages/rest/src/analytics-16019-driver-declared-fault.test.ts index 69d2a12d34..4f64bf14bb 100644 --- a/packages/rest/src/analytics-16019-driver-declared-fault.test.ts +++ b/packages/rest/src/analytics-16019-driver-declared-fault.test.ts @@ -51,6 +51,12 @@ * `select ` limb — and the driver-log assertion goes RED (the driver no longer * logs; the route's `logError` becomes the only copy). The second block stays * GREEN throughout: it hands the door shapes that never touch the driver. + * + * The ORDERING pin in block 2 has its own leg: gate the door's ③a relay + * behind `looksLikeInternalErrorLeak` being false (i.e. consult the heuristic + * first) and only that case goes RED (`ANALYTICS_QUERY_FAILED` in place of the + * producer's code); the neighbouring "phrase the heuristic does not know" + * case stays GREEN, which is precisely why it could not stand in for this one. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -270,6 +276,26 @@ describe('[#16019] at the door: a declaration wins over the heuristic, and the h expect(JSON.stringify(res.body)).not.toContain('translate'); }); + it("DECLARED, with a phrase the heuristic DOES know → the producer's code, not the fallback's (the ORDERING pin)", async () => { + // The one shape that discriminates the order of the two arms: the message + // trips `looksLikeInternalErrorLeak` AND the error declares. Declared-first + // (③a before ③b, the door as written) answers the producer's code; + // heuristic-first would answer `ANALYTICS_QUERY_FAILED` with the same + // withheld text and this case alone would go red. The case above cannot + // tell the two orders apart, because its message trips nothing. + expect(looksLikeInternalErrorLeak(KNEX)).toBe(true); + const declared = Object.assign(new Error(KNEX), { code: 'DATABASE_ERROR', status: 500 }); + expect(declaresServerFault(declared)).toBe(true); + + const res = await post(buildRoute(async () => throwingAnalytics(declared)), { dataset, selection }); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('DATABASE_ERROR'); + expect(res.body.code).not.toBe('ANALYTICS_QUERY_FAILED'); + expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE); + expect(JSON.stringify(res.body)).not.toContain('translate'); + }); + it("UNDECLARED, the bare shape → the fallback's coverage boundary, pinned as a live subject", async () => { // ⛔ Asserting the residual, not endorsing it — see the file header. A // producer that reaches this door with dialect text and no declaration is diff --git a/packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts b/packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts new file mode 100644 index 0000000000..b4a143aa79 --- /dev/null +++ b/packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16019] `POST /api/v1/packages/publish` and `DELETE /api/v1/packages/:id` + * — the wire `code` a raw-exec driver fault answers moved, and this file pins + * the flip at the door. + * + * ## The flip + * + * `PackageService.publish` / `delete` (`service-package/src/index.ts`) wrap + * `objectql.execute(...)` in a catch whose branch ② re-throws any error that + * `declaresHttpAnswer` — a numeric `status` or `statusCode` — and whose branch + * ③ swallows everything else as a driver fault, returning `{ success: false }` + * for the door's `sendError` to answer `500 PACKAGE_PUBLISH_FAILED` / + * `500 PACKAGE_DELETE_FAILED`. + * + * Before #16019 a raw-exec driver fault carried no `status` (knex's error + * object: `code: 'SQLITE_ERROR'`, message `STATEMENT - DIAGNOSTIC`) → branch + * ③. Since #16019 `SqlDriver.execute()` declares it — `code: DATABASE_ERROR`, + * `status: 500`, a composed message, the dialect error under a non-enumerable + * `cause` — → branch ② re-throws it → this door's catch-all `sendThrownError` + * → `500 DATABASE_ERROR`, the composed sentence as the message (it trips no + * phrasing heuristic, so it is not replaced by `INTERNAL_ERROR_MESSAGE`; it + * carries no dialect word to withhold). Same status band, no disclosure + * either way; the ledgered `code` on two published doors moves. + * + * The catch's own half — that the declared fault propagates UNCHANGED and the + * undeclared ancestor still takes branch ③ — is pinned where the catch lives, + * in `service-package`'s `publish-driver-fault.test.ts` / + * `delete-driver-fault.test.ts` (`[#16019]` blocks, identity-asserted). This + * file takes the re-thrown object from there and pins what the DOOR answers, + * with a `PackageService` double that throws it — the shape every + * `packageService.publish throws` case in `package-door-5xx-message-sanitization.test.ts` + * uses — so `@objectstack/service-package` is not imported into this package's + * test layer (it is not in `rest`'s unaliased-import ledger). + * + * ⛔ Not a re-judgement of either catch: `declaresHttpAnswer`'s docblock + * already says a declared 5xx is re-thrown too. The contract review of PR + * #16650 required the consequence to be NAMED and PINNED, nothing else. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; +import type { RouteHandler } from '@objectstack/spec/contracts'; +import { INTERNAL_ERROR_MESSAGE, looksLikeInternalErrorLeak } from '@objectstack/types'; +import { registerPackageRoutes } from './package-routes.js'; + +const PKGS = '/api/v1/packages'; +const MANIFEST = { id: 'com.acme.crm', version: '1.0.0' }; + +/** A caller holding every capability these routes gate on. */ +const CLEARS_THE_GATE = async () => ({ + userId: 'u_pkg', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], +}); + +interface Captured { + status: number; + body: any; +} + +function mount(svc: Record) { + const routes = new Map(); + const server = { + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, + put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); }, + delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); }, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + } as any; + registerPackageRoutes(server, () => svc as any, '/api/v1', { + resolveExecutionContext: CLEARS_THE_GATE, + } as any); + return routes; +} + +async function drive( + routes: Map, + method: string, + path: string, + req: Record = {}, +): Promise { + const handler = routes.get(`${method}:${path}`); + if (!handler) throw new Error(`no handler for ${method} ${path}`); + const captured: Captured = { status: 0, body: undefined }; + const res: any = { + json(data: any) { captured.body = data; }, + send() {}, + status(code: number) { captured.status = code; return res; }, + header() { return res; }, + }; + await handler( + { params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as any, + res, + ); + return captured; +} + +/** The wire contract, imported rather than restated. */ +function expectDeclaredEnvelope(captured: Captured): any { + expect(BaseResponseSchema.safeParse(captured.body).success).toBe(true); + expect(envelopeViolations(captured.body)).toEqual([]); + expect(captured.body?.success).toBe(false); + const parsed = ApiErrorSchema.safeParse(captured.body?.error); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + return captured.body.error; +} + +const DIALECT_LINE = 'insert into `sys_packages` (`id`, …) values (…) - no such table: sys_packages'; +const COMPOSED = + 'The database refused to run a raw statement. The driver could not attribute the failure ' + + 'to any part of the request, so no verdict about the statement is claimed here. The ' + + "backend's own diagnostic and the statement were written to the server log for an " + + 'operator to read.'; + +/** What `SqlDriver.execute()` raises since #16019, and what the service's branch ② re-throws. */ +function rawStatementFault(): Error { + const err = Object.assign(new Error(COMPOSED), { code: 'DATABASE_ERROR', status: 500 }); + Object.defineProperty(err, 'cause', { + value: Object.assign(new Error(DIALECT_LINE), { code: 'SQLITE_ERROR' }), + enumerable: false, writable: true, configurable: true, + }); + return err; +} + +async function publishWith(svc: Record): Promise { + return drive(mount(svc), 'POST', `${PKGS}/publish`, { + body: { manifest: MANIFEST, metadata: { author: 'acme' } }, + }); +} + +async function deleteWith(svc: Record): Promise { + return drive(mount(svc), 'DELETE', `${PKGS}/:id`, { params: { id: 'com.acme.crm' } }); +} + +describe('[#16019] a raw-exec driver fault under sys_packages answers the producer\'s code on both package doors', () => { + // The control that makes the assertions below about the DECLARATION and not + // about the heuristic: the composed sentence trips nothing. + it('the composed sentence is not a phrase the door\'s withhold heuristic knows', () => { + expect(looksLikeInternalErrorLeak(COMPOSED)).toBe(false); + expect(looksLikeInternalErrorLeak(DIALECT_LINE)).toBe(true); + }); + + it('POST /packages/publish — AFTER #16019: the re-thrown declared fault → 500 DATABASE_ERROR, composed sentence, no dialect word', async () => { + const publish = vi.fn(async () => { throw rawStatementFault(); }); + const captured = await publishWith({ publish }); + + expect(publish).toHaveBeenCalledTimes(1); + expect(captured.status).toBe(500); + const error = expectDeclaredEnvelope(captured); + expect(error.code).toBe('DATABASE_ERROR'); + expect(error.code).not.toBe('PACKAGE_PUBLISH_FAILED'); + expect(error.message).toBe(COMPOSED); + expect(JSON.stringify(captured.body)).not.toMatch(/sys_packages|no such table|insert into/i); + }); + + it('POST /packages/publish — BEFORE #16019: the swallowed driver fault → 500 PACKAGE_PUBLISH_FAILED (the control, still what an undeclared fault answers)', async () => { + // Branch ③'s return shape, verbatim from the service. + const publish = vi.fn(async () => ({ success: false, driverFault: { message: 'The package was not persisted.' } })); + const captured = await publishWith({ publish }); + + expect(captured.status).toBe(500); + const error = expectDeclaredEnvelope(captured); + expect(error.code).toBe('PACKAGE_PUBLISH_FAILED'); + }); + + it('DELETE /packages/:id — AFTER #16019: the re-thrown declared fault → 500 DATABASE_ERROR, composed sentence, no dialect word', async () => { + const del = vi.fn(async () => { throw rawStatementFault(); }); + const captured = await deleteWith({ delete: del }); + + expect(del).toHaveBeenCalledTimes(1); + expect(captured.status).toBe(500); + const error = expectDeclaredEnvelope(captured); + expect(error.code).toBe('DATABASE_ERROR'); + expect(error.code).not.toBe('PACKAGE_DELETE_FAILED'); + expect(error.message).toBe(COMPOSED); + expect(JSON.stringify(captured.body)).not.toMatch(/sys_packages|no such table|insert into/i); + }); + + it('DELETE /packages/:id — BEFORE #16019: the swallowed driver fault → 500 PACKAGE_DELETE_FAILED (the control)', async () => { + const del = vi.fn(async () => ({ success: false })); + const captured = await deleteWith({ delete: del }); + + expect(captured.status).toBe(500); + const error = expectDeclaredEnvelope(captured); + expect(error.code).toBe('PACKAGE_DELETE_FAILED'); + }); + + it('the withhold is untouched: a DECLARED fault whose message DOES carry dialect text is still replaced at this door', async () => { + // Beside the flip, the invariant #8086 pinned: `sendThrownError` withholds + // a leaky 5xx message whatever the code — so a producer that declared but + // let dialect text into its message would still not disclose it here. + const leaky = Object.assign(new Error(DIALECT_LINE), { code: 'DATABASE_ERROR', status: 500 }); + const publish = vi.fn(async () => { throw leaky; }); + const captured = await publishWith({ publish }); + + expect(captured.status).toBe(500); + const error = expectDeclaredEnvelope(captured); + expect(error.code).toBe('DATABASE_ERROR'); + expect(error.message).toBe(INTERNAL_ERROR_MESSAGE); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index e7a40f97d0..edcfe7a983 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -183,6 +183,16 @@ function isMissingColumnOfRelation(message: string): boolean { * (table/object/relation) — not column/syntax errors, which stay hard failures * so real query bugs still surface. * + * ⚠️ [#16019] Reached only by a BARE error. A fault an in-repo driver raises + * DECLARES itself — the typed read exits since #8931 (PR #9273), the raw-SQL + * path (`SqlDriver.execute`, which the native-SQL strategy runs on) since + * #16019 — and `queryDataset`'s catch re-throws a declared envelope before + * this question is asked (#5717 defence B). So a missing backing table raised + * by `driver-sql` / `driver-turso` does NOT degrade on either strategy: it + * answers `500 DATABASE_ERROR` at the door. What still degrades is an + * undeclared producer — an embedder's own `executeRawSql`, a bare `Error` + * from a driver outside this repo, the framework's not-registered signals. + * * ⚠️ It is a heuristic over driver PHRASING, so it is the SECOND question the * degradation path asks, never the first: {@link hasDeclaredErrorEnvelope} runs * ahead of it (#5717), and only an error whose producer declared nothing is @@ -1167,6 +1177,17 @@ export class AnalyticsService implements IAnalyticsService { // crash the widget with a 500. Datasets were the one read surface that // hard-failed on a missing source. // + // [#16019] That leniency now holds for a BARE error only. A fault an + // in-repo driver raises declares `code` + `status` — typed reads since + // #8931, the raw-SQL path the native-SQL strategy runs on since #16019 — + // and the `hasDeclaredErrorEnvelope` re-throw below (#5717 defence B) + // hands it to the door untouched, where it answers `500 DATABASE_ERROR` + // on both strategies. The degrade is not re-judged here; the ruling that + // drivers declare (and that a declared envelope is never re-read by its + // wording) decides it. Producers that still reach the degrade: an + // embedder's own `executeRawSql`, an out-of-repo driver throwing bare, the + // framework's not-registered signals. + // // #5033 — that leniency is scoped to the dataset's OWN source. Once the raw-SQL // bridge routes by object (`plugin.ts`), a dataset that JOINS across datasources // fails on the base object's datasource with the JOINED table missing. Reporting diff --git a/packages/services/service-package/src/delete-driver-fault.test.ts b/packages/services/service-package/src/delete-driver-fault.test.ts index e2d498de7b..e4e00542a4 100644 --- a/packages/services/service-package/src/delete-driver-fault.test.ts +++ b/packages/services/service-package/src/delete-driver-fault.test.ts @@ -302,3 +302,58 @@ describe('[#8275] a throw that DECLARES an envelope is re-thrown, not swallowed' }); } }); + +/** + * [#16019] The shape `SqlDriver.execute()` raises since #16019 — a DECLARED + * `DATABASE_ERROR` / 500 with a composed message and the dialect error under + * a non-enumerable `cause` — reaches this catch as a declared throw, so branch + * ② re-throws it and the door's catch-all (`sendThrownError`) answers + * `500 DATABASE_ERROR`. Before #16019 the same fault arrived UNDECLARED + * (`code: 'SQLITE_ERROR'`, no `status`, knex's `STATEMENT - DIAGNOSTIC` + * message) and took branch ③: `500 PACKAGE_DELETE_FAILED`. The status band and the + * withhold are unchanged; the wire `code` on this door moves, and this pair + * pins both halves of the flip so it is a disclosed consequence rather than + * an accident the `{code}`-only fakes above cannot see. + * + * ⛔ Not a re-judgement of `declaresHttpAnswer`: its docblock already says a + * declared 5xx is re-thrown too. The reviewer of PR #16650 required the flip + * to be pinned, not the predicate to be changed. + */ +describe('[#16019] a raw-statement fault that DECLARES its status is re-thrown, where its undeclared ancestor was swallowed', () => { + const DIALECT_LINE = 'select 1 from sys_packages - no such table: sys_packages'; + + /** What `SqlDriver.execute()` composes for that dialect error since #16019. */ + function rawStatementFault(): Error { + const err = Object.assign( + new Error( + 'The database refused to run a raw statement. The driver could not attribute the failure ' + + 'to any part of the request, so no verdict about the statement is claimed here.', + ), + { code: 'DATABASE_ERROR', status: 500 }, + ); + Object.defineProperty(err, 'cause', { + value: Object.assign(new Error(DIALECT_LINE), { code: 'SQLITE_ERROR' }), + enumerable: false, writable: true, configurable: true, + }); + return err; + } + + it('AFTER #16019 — the declared driver fault propagates UNCHANGED (branch ②): the door answers its own code', async () => { + const fault = rawStatementFault(); + const { svc, errorLogs } = await bootThrowing(fault); + + await expect(svc.delete('com.acme.crm')).rejects.toBe(fault); + expect(errorLogs.some((l) => l.msg === 'Failed to delete package')).toBe(true); + // What the door's `resolveThrownHttpError` will read off it. + expect((fault as { status?: unknown }).status).toBe(500); + expect((fault as { code?: unknown }).code).toBe('DATABASE_ERROR'); + }); + + it('BEFORE #16019 — the same dialect error, undeclared, is the driver fault of section 1 (branch ③) — the control', async () => { + const undeclared = Object.assign(new Error(DIALECT_LINE), { code: 'SQLITE_ERROR' }); + const { svc } = await bootThrowing(undeclared); + + const result = await svc.delete('com.acme.crm'); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/services/service-package/src/publish-driver-fault.test.ts b/packages/services/service-package/src/publish-driver-fault.test.ts index a7d620fa58..d9eb9e24fd 100644 --- a/packages/services/service-package/src/publish-driver-fault.test.ts +++ b/packages/services/service-package/src/publish-driver-fault.test.ts @@ -340,3 +340,58 @@ describe('[#8131] the caller-facing sentence interpolates nothing', () => { expect(PACKAGE_PUBLISH_DRIVER_FAULT_MESSAGE).toContain('no package data was written'); }); }); + +/** + * [#16019] The shape `SqlDriver.execute()` raises since #16019 — a DECLARED + * `DATABASE_ERROR` / 500 with a composed message and the dialect error under + * a non-enumerable `cause` — reaches this catch as a declared throw, so branch + * ② re-throws it and the door's catch-all (`sendThrownError`) answers + * `500 DATABASE_ERROR`. Before #16019 the same fault arrived UNDECLARED + * (`code: 'SQLITE_ERROR'`, no `status`, knex's `STATEMENT - DIAGNOSTIC` + * message) and took branch ③: `500 PACKAGE_PUBLISH_FAILED`. The status band and the + * withhold are unchanged; the wire `code` on this door moves, and this pair + * pins both halves of the flip so it is a disclosed consequence rather than + * an accident the `{code}`-only fakes above cannot see. + * + * ⛔ Not a re-judgement of `declaresHttpAnswer`: its docblock already says a + * declared 5xx is re-thrown too. The reviewer of PR #16650 required the flip + * to be pinned, not the predicate to be changed. + */ +describe('[#16019] a raw-statement fault that DECLARES its status is re-thrown, where its undeclared ancestor was swallowed', () => { + const DIALECT_LINE = 'select 1 from sys_packages - no such table: sys_packages'; + + /** What `SqlDriver.execute()` composes for that dialect error since #16019. */ + function rawStatementFault(): Error { + const err = Object.assign( + new Error( + 'The database refused to run a raw statement. The driver could not attribute the failure ' + + 'to any part of the request, so no verdict about the statement is claimed here.', + ), + { code: 'DATABASE_ERROR', status: 500 }, + ); + Object.defineProperty(err, 'cause', { + value: Object.assign(new Error(DIALECT_LINE), { code: 'SQLITE_ERROR' }), + enumerable: false, writable: true, configurable: true, + }); + return err; + } + + it('AFTER #16019 — the declared driver fault propagates UNCHANGED (branch ②): the door answers its own code', async () => { + const fault = rawStatementFault(); + const { svc, errorLogs } = await bootThrowing(fault); + + await expect(svc.publish({ manifest: MANIFEST, metadata: METADATA })).rejects.toBe(fault); + expect(errorLogs.some((l) => l.msg === 'Failed to publish package')).toBe(true); + // What the door's `resolveThrownHttpError` will read off it. + expect((fault as { status?: unknown }).status).toBe(500); + expect((fault as { code?: unknown }).code).toBe('DATABASE_ERROR'); + }); + + it('BEFORE #16019 — the same dialect error, undeclared, is the driver fault of section 1 (branch ③) — the control', async () => { + const undeclared = Object.assign(new Error(DIALECT_LINE), { code: 'SQLITE_ERROR' }); + const { svc } = await bootThrowing(undeclared); + + const result = await svc.publish({ manifest: MANIFEST, metadata: METADATA }); + expect(result.success).toBe(false); + }); +}); From 3fdd44c69c9ae444bfc7958f2b6ac6c7ade616c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:39:51 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(#16019):=20delta=20review=20=E2=80=94?= =?UTF-8?q?=20the=20publish=20door=20is=20/packages/publish,=20the=20degra?= =?UTF-8?q?de=20notes=20exclude=20turso=20remote,=20the=20card=20is=20cite?= =?UTF-8?q?d=20by=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text only: the changeset named the dispatcher's install route as the moving door and cited the follow-up card anonymously; the two degrade docblocks claimed the declared answer for driver-turso's remote transport, whose typed exits are undeclared and pre-date this card; the catch comment below them still said a declared 5xx is served through the ANALYTICS_QUERY_FAILED path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../driver-raw-statement-declared-fault.md | 4 ++-- .../src/analytics-service.ts | 21 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.changeset/driver-raw-statement-declared-fault.md b/.changeset/driver-raw-statement-declared-fault.md index dec1fbe987..114c945748 100644 --- a/.changeset/driver-raw-statement-declared-fault.md +++ b/.changeset/driver-raw-statement-declared-fault.md @@ -11,6 +11,6 @@ - `POST /api/v1/analytics/dataset/query`: a driver fault on the raw path answers `500 {"code":"DATABASE_ERROR","error":"Internal server error"}` — the declared-fault relay, the same answer the `/data` door and `/analytics/query` already give a declared 5xx — where it was `500 {"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` when the phrasing heuristic happened to fire and the raw engine text when it did not. Status unchanged; the code is now the producer's, exactly as the typed read exits' faults have answered at this door since PR #9273. - The same door, a dataset over a backing table that is NOT present, on the native-SQL strategy (the strategy every deployment whose data engine exposes `execute()` runs): `500 DATABASE_ERROR` where it was `200 {"rows":[],"fields":[],"totals":[]}` plus a `warn`. `queryDataset`'s missing-source degrade sits behind its declared-envelope re-throw (#5717 defence B: a declared envelope is re-thrown untouched, whatever it says), so a driver-raised missing table no longer reaches it — the answer the ObjectQL-aggregate strategy has given since #9273, now on both strategies. The degrade still applies to an undeclared producer (an embedder's own `executeRawSql`, the framework's not-registered signals). -- `POST /api/v1/packages` and `DELETE /api/v1/packages/:id`: a raw-exec driver fault under `sys_packages` answers `500 {"code":"DATABASE_ERROR"}` with the composed sentence as its message — `PackageService.publish` / `delete` re-throw a throw that declares an HTTP answer (`declaresHttpAnswer`, whose docblock already says a declared 5xx is re-thrown too) and the door's `sendThrownError` relays it — where it was `500 PACKAGE_PUBLISH_FAILED` / `500 PACKAGE_DELETE_FAILED` from the swallowing branch. Same status band, no dialect text on the wire either way; the ledgered `code` on those two doors moves. +- `POST /api/v1/packages/publish` and `DELETE /api/v1/packages/:id`: a raw-exec driver fault under `sys_packages` answers `500 {"code":"DATABASE_ERROR"}` with the composed sentence as its message — `PackageService.publish` / `delete` re-throw a throw that declares an HTTP answer (`declaresHttpAnswer`, whose docblock already says a declared 5xx is re-thrown too) and the door's `sendThrownError` relays it — where it was `500 PACKAGE_PUBLISH_FAILED` / `500 PACKAGE_DELETE_FAILED` from the swallowing branch. Same status band, no dialect text on the wire either way; the ledgered `code` on those two doors moves. -**What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log; the in-repo sites of that class are tracked as one follow-up card (read `cause` there). +**What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log; the in-repo sites of that class are tracked as #16657 (read `cause` there). diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index edcfe7a983..a51e7f0fb4 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -188,10 +188,15 @@ function isMissingColumnOfRelation(message: string): boolean { * path (`SqlDriver.execute`, which the native-SQL strategy runs on) since * #16019 — and `queryDataset`'s catch re-throws a declared envelope before * this question is asked (#5717 defence B). So a missing backing table raised - * by `driver-sql` / `driver-turso` does NOT degrade on either strategy: it - * answers `500 DATABASE_ERROR` at the door. What still degrades is an - * undeclared producer — an embedder's own `executeRawSql`, a bare `Error` - * from a driver outside this repo, the framework's not-registered signals. + * by `driver-sql`, or by `driver-turso` embedded, does NOT degrade on either + * strategy: it answers `500 DATABASE_ERROR` at the door. `driver-turso`'s + * REMOTE transport is the exception and pre-dates this card: its typed exits + * are undeclared, and `RemoteTransport.aggregate` swallows a missing table + * into `[]` itself, so on the ObjectQL-aggregate strategy that deployment + * answers `200` with no rows by the transport's own swallow. What still + * degrades HERE is an undeclared producer — an embedder's own + * `executeRawSql`, a bare `Error` from a driver outside this repo, the + * framework's not-registered signals. * * ⚠️ It is a heuristic over driver PHRASING, so it is the SECOND question the * degradation path asks, never the first: {@link hasDeclaredErrorEnvelope} runs @@ -1182,7 +1187,10 @@ export class AnalyticsService implements IAnalyticsService { // #8931, the raw-SQL path the native-SQL strategy runs on since #16019 — // and the `hasDeclaredErrorEnvelope` re-throw below (#5717 defence B) // hands it to the door untouched, where it answers `500 DATABASE_ERROR` - // on both strategies. The degrade is not re-judged here; the ruling that + // on both strategies — for `driver-sql`, and for `driver-turso` embedded; + // the remote transport's typed exits are undeclared and pre-date this card + // (`RemoteTransport.aggregate` swallows a missing table into `[]` on its + // own). The degrade is not re-judged here; the ruling that // drivers declare (and that a declared envelope is never re-read by its // wording) decides it. Producers that still reach the degrade: an // embedder's own `executeRawSql`, an out-of-repo driver throwing bare, the @@ -1208,7 +1216,8 @@ export class AnalyticsService implements IAnalyticsService { result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context); } catch (err) { // The producer answered the classification question — the route's - // envelope reader serves it (4xx as itself, declared 5xx through the + // envelope reader serves it (4xx as itself; a declared 5xx relayed with + // the producer's own code by the door's ③a arm, #11718 — no longer the // `ANALYTICS_QUERY_FAILED` path). Nothing here may re-judge it by wording. if (hasDeclaredErrorEnvelope(err)) throw err; if (isMissingSourceError(err)) { From 61b85c009b27083e808ba77dc326343198e22c1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 20:15:16 +0000 Subject: [PATCH 6/6] test(driver-sql): the prefix-unique measurement reads MySQL's rejection off the declared envelope's cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-MySQL pin in sql-driver-keyed-text-mysql.test.ts asserted toThrow(/Duplicate entry/i) on execute()'s rejection — the dialect text off error.message, which the raw-path envelope now composes. It was the one red in the Temporal Conformance job on every head of this branch (step 12, ~100 s in), reproduced locally against MySQL 8.0.46 + PG 16.13 with the job's zone settings. The measurement is unchanged: the second value is rejected as a duplicate (isUniqueViolationError follows cause; the cause's own line still reads Duplicate entry) and one row remains. 167 files / 3599 tests green against both live servers after the change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../src/sql-driver-keyed-text-mysql.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-keyed-text-mysql.test.ts b/packages/drivers/driver-sql/src/sql-driver-keyed-text-mysql.test.ts index bd13110b48..d00cc3d52d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-keyed-text-mysql.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-keyed-text-mysql.test.ts @@ -41,6 +41,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { SqlDriver } from '../src/index.js'; +import { isUniqueViolationError } from '@objectstack/types'; import { MYSQL_CELL, dialectCell, declareDialectCell } from './live-dialect-matrix.testkit.js'; /** @@ -314,12 +315,23 @@ declareDialectCell(MYSQL_CELL, 'keyed text columns (#11374)', (cell) => { first, ] as any); - await expect( - driver.execute(`insert into os11374_prefix (id, token) values (?, ?)`, [ - 'r2', - second, - ] as any), - ).rejects.toThrow(/Duplicate entry/i); + // [#16019] `execute()` declares its fault: `DATABASE_ERROR` / 500 with a + // composed message, the dialect error whole under a non-enumerable + // `cause`. The measurement is unchanged — MySQL rejects the second value + // as a duplicate — and it is read where the dialect's text now lives: + // the cause-following `isUniqueViolationError`, and the cause's own + // `Duplicate entry` line. Reading `error.message` for it was what this + // pin did before, and it is the one raw-exec consumer in this package + // that did; the declared envelope is what every door reads. + const rejection: unknown = await driver + .execute(`insert into os11374_prefix (id, token) values (?, ?)`, ['r2', second] as any) + .then(() => undefined, (e: unknown) => e); + expect(rejection).toBeInstanceOf(Error); + expect((rejection as { code?: unknown }).code).toBe('DATABASE_ERROR'); + expect((rejection as { status?: unknown }).status).toBe(500); + expect(isUniqueViolationError(rejection)).toBe(true); + const dialect = (rejection as { cause?: unknown }).cause as { message?: unknown } | undefined; + expect(String(dialect?.message)).toMatch(/Duplicate entry/i); // One row, from two distinct tokens: the second was lost to a constraint // the object never declared.