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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/driver-raw-statement-declared-fault.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@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`** 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 `<statement> - 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 — three doors, each because a declared fault is relayed where an undeclared one was re-labelled.**

- `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/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 #16657 (read `cause` there).
Original file line number Diff line number Diff line change
@@ -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
* `<statement> - <diagnostic>`. 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<unknown>): Promise<WireBearingError> {
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: `<formatted statement> - <engine diagnostic>`, 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading