diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md new file mode 100644 index 0000000000..d9f5558e19 --- /dev/null +++ b/.changeset/operator-facing-raw-exec-cause-text.md @@ -0,0 +1,90 @@ +--- +'@objectstack/types': minor +'@objectstack/metadata-protocol': patch +'@objectstack/metadata': patch +'@objectstack/cli': patch +'@objectstack/driver-sql': patch +--- + +fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + +Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer +lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a +COMPOSED message that discloses neither the statement nor the diagnostic, and carries +the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and +is unchanged here. + +What changed underneath it is what every consumer STORED. Each migration probe, backfill +and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded +`error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + +where they used to read + + no such column: foo + +For a live console that costs nothing — the driver prints the statement and the dialect +text to its warn sink one line earlier. For a record read later it costs everything: +whoever opens a customer install's backfill result a week on never had that line, and the +dialect's words were unrecoverable for them. + +`@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk +of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen +stored-record sites plus `os db clean`'s console line read through it: + +- `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; +- `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; +- `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); +- `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; +- `os db clean` — the `VACUUM failed` line. + +Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned +on its own message channel, its `cause` never walked, and a declared envelope that is not +the raw-path one — the typed read exits' terminal, which composes a different sentence — +is left exactly as it arrived. + +That message channel is deliberately NOT byte-identical to what the replaced expressions +computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as +`messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the +string itself when a string was thrown, and `String(error)` when neither yields text. Every +difference from the replaced expressions follows from that rule, so read the rule and not a +list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, +which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a +thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read +`undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no +record was written at all and the operation aborted; an object carrying a NON-EMPTY string +`message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` +(one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads +`''`, so this channel is neither always prose nor never empty. + +## The levels, and why they are not uniform + +`@objectstack/types` takes **`minor`**: it is the one package here that grows a published +surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the +export list. A purely additive widening takes at least `minor`. + +The other four take **`patch`**, because none of them widens anything: they are a bug fix in a +released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named +because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper +against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this +PR: no entry point reaches a test file, and `files` packs `dist` only. + +**Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: +what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name +and never a type. The change these sites were made for is the declared raw-path fault, where +the record gains the dialect's words in place of the driver's composed placeholder. Every +other throw now reaches these records through the rule above rather than through the +expression each site spelled out, so its text can move too — a consequence of the rule, not a +bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, +and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an +`Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is +`seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on +top of the rule, so those same three shapes record `'unknown error'` there rather than `''`; +that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and +whether it should go is tracked by #17167. The sentence being replaced is not a value any +consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these +records gets the dialect's words back where it had been getting a placeholder. diff --git a/packages/cli/src/commands/db/clean.ts b/packages/cli/src/commands/db/clean.ts index a5e0500a5e..0057fdecba 100644 --- a/packages/cli/src/commands/db/clean.ts +++ b/packages/cli/src/commands/db/clean.ts @@ -3,6 +3,7 @@ import { Command, Flags } from '@oclif/core'; import { statSync, existsSync } from 'node:fs'; import chalk from 'chalk'; +import { operatorFacingErrorText } from '@objectstack/types'; import { printError } from '../../utils/format.js'; import { resolveTelemetryDbPath } from '../../utils/telemetry-datasource.js'; @@ -112,7 +113,7 @@ export default class DbClean extends Command { ); } catch (error: any) { failed = true; - printError(`VACUUM failed for ${file}: ${error?.message ?? error}`); + printError(`VACUUM failed for ${file}: ${operatorFacingErrorText(error)}`); } } if (failed) this.exit(1); diff --git a/packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts b/packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts new file mode 100644 index 0000000000..5c843f0d32 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16657] The producer↔consumer pin for `operatorFacingErrorText`. + * + * `@objectstack/types` cannot import a driver — every driver depends on it — + * so the helper that reads the raw-path envelope carries its own copy of the + * sentence that identifies one. A copy is a phantom check the moment the + * producer rewords: every fixture that BUILDS the envelope by hand would keep + * passing, and the only symptom would be a customer's backfill record silently + * going back to saying nothing. + * + * This file is the leg that cannot go stale. It takes a REAL `SqlDriver` + * refusal — the composition `TursoDriver` remote mode reaches through + * `SqlDriver.rawStatementFault` as well — and asserts the helper reads the + * dialect's words out of it. If `rawStatementFaultError` is reworded, this + * reddens here, naming the helper, rather than in a customer's log a release + * later. + * + * ⛔ It asserts nothing about what the ENVELOPE discloses. That is #16019's + * disclosure clause and it is unchanged: the message still carries neither the + * statement nor the diagnostic, which the sibling + * `sql-driver-16019-raw-statement-fault-envelope.test.ts` owns and this file + * deliberately does not restate. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { operatorFacingErrorText } from '@objectstack/types'; +import { SqlDriver } from './index.js'; + +/** A column no table has — SQLite answers `no such column: foo`, distinctively. */ +const MISSING_COLUMN_SQL = 'select foo'; + +async function faultOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the driver to refuse this statement, but it resolved'); +} + +/** + * The driver's log sink is `protected`, so the only way to hold it is from a + * subclass — the shape the sibling #16019 suite uses. The dialect text is + * written HERE on any default deployment; a stored record's reader never sees + * this line, which is the whole card. + */ +class QuietSqlDriver extends SqlDriver { + constructor() { + super({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + this.logger = { warn: () => {} }; + } +} + +describe('[#16657] a real raw-exec refusal still yields the dialect text to an operator', () => { + let driver: SqlDriver; + + beforeEach(() => { + driver = new QuietSqlDriver(); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('the envelope says the composed sentence and the helper says `no such column: foo`', async () => { + const thrown = (await faultOf(() => driver.execute(MISSING_COLUMN_SQL))) as Error; + + // BEFORE — the message every consumer used to store, unchanged. + expect(thrown.message).toMatch(/refused to run a raw statement/); + expect(thrown.message).not.toMatch(/no such column/); + + // AFTER — read off the cause the driver already attached. + const operatorText = operatorFacingErrorText(thrown); + expect(operatorText).toContain('no such column: foo'); + expect(operatorText).not.toMatch(/refused to run a raw statement/); + }); + + it('an UNDECLARED throw from the same seam is returned on its own message channel', async () => { + // The control that proves the pin above reads the declaration and not the + // shape of any error the seam happens to produce. + const bare = new Error('connection terminated unexpectedly'); + + expect(operatorFacingErrorText(bare)).toBe('connection terminated unexpectedly'); + }); +}); diff --git a/packages/metadata-protocol/src/migrations/partial-index-probe.ts b/packages/metadata-protocol/src/migrations/partial-index-probe.ts index 2a657fdddb..76b53c7102 100644 --- a/packages/metadata-protocol/src/migrations/partial-index-probe.ts +++ b/packages/metadata-protocol/src/migrations/partial-index-probe.ts @@ -34,10 +34,20 @@ * when a tightening fails differs per table (ADR-0120 D4 requires naming the * key that is not enforced and the consequence of it not being enforced), and * one generic sentence would be true of neither table. This module hands back - * a classified status plus the driver's own text and stays out of the way. + * a classified status plus the OPERATOR-facing text and stays out of the way. + * + * ⚠️ That text is no longer simply "whatever the seam threw". Since #16019 the + * raw-SQL seam declares its own fault — `DATABASE_ERROR` / 500 with a COMPOSED + * message that discloses neither the statement nor the diagnostic — and carries + * the dialect error whole under a non-enumerable `cause`. Read bare, `detail` + * became *"the database refused to run a raw statement"* for every caller that + * stores it. `operatorFacingErrorText` (`@objectstack/types`, #16657) reads the + * dialect's own words back out of that chain, so a stored record still names + * `no such column: foo`. The envelope itself is left exactly as the driver + * declared it: this is a READ of the cause, never a widening of the disclosure. */ -import { isUniqueViolationError } from '@objectstack/types'; +import { isUniqueViolationError, operatorFacingErrorText } from '@objectstack/types'; import { driverCanRunSql, resolveDriverExec } from './driver-exec.js'; @@ -356,12 +366,15 @@ export async function probeThenReplaceIndex( try { await exec(buildSql(probeIndexName)); } catch (err: unknown) { - // `detail` is the OPERATOR-facing text and stays the driver's own prose. + // `detail` is the OPERATOR-facing text: the dialect's own prose, read + // out of the `cause` the raw seam attaches when it declares its fault + // (#16019/#16657 — see the module header). Callers STORE it, and a + // stored record is the only copy its reader ever gets. // The VERDICT is taken from the error object itself, so a conflict // reported on `code` / `errno` / `cause` with unhelpful prose is still // classified as one (#6699) — unwrapping first is exactly what the // migration onto the shared predicate exists to stop. - const detail = err instanceof Error ? err.message : String(err); + const detail = operatorFacingErrorText(err); await dropIndexQuietly(exec, probeIndexName); return { status: classifyIndexFailure(err), detail, failedAt: 'probe' }; } @@ -373,7 +386,7 @@ export async function probeThenReplaceIndex( try { await exec(buildSql(indexName)); } catch (err: unknown) { - const detail = err instanceof Error ? err.message : String(err); + const detail = operatorFacingErrorText(err); return { status: 'failed', detail, failedAt: 'replace' }; } return { status: 'created' }; diff --git a/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts b/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts new file mode 100644 index 0000000000..3d7730013e --- /dev/null +++ b/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16657] The operator records this package stores name the DIALECT, not the + * driver's composed refusal. + * + * ## The regression + * + * Since #16019 the raw-SQL seam every probe and backfill here runs through + * declares its own fault — `DATABASE_ERROR` / 500, a composed message that + * discloses neither the statement nor the diagnostic, and the dialect error + * whole under a non-enumerable `cause`. The driver prints the dialect text to + * its warn sink one line earlier, so a live console lost nothing. Every record + * this package STORES did: `detail` and `error` fields began carrying *"the + * database refused to run a raw statement"*, and the reader of a customer + * install's backfill record a week later has no console line to fall back on. + * + * ## The control, in both directions + * + * Every case below asserts the envelope's OWN message first — the composed + * sentence, which is exactly what these fields used to hold — and only then the + * record's. A fixture that could not produce the "before" half would make every + * "after" assertion unfalsifiable. + * + * The negative direction is pinned per site as well: a seam failure that is NOT + * a declared raw-statement fault is NOT unwrapped — its `cause` is never walked + * and the record reads the thrown value's own message channel, + * `messageChannelOf(error) || String(error)`, at every site here but the one + * noted below — because the alternative, a helper that unwraps whatever it is + * handed, is the message sniffing #16019 exists to remove. + * + * ⚠️ That formula is the WHOLE record at eight of this package's nine call + * sites (thirteen of the fourteen across the change), not at all of them. + * `seed-tenancy-backfill`'s ORGANIZATION probe still spells + * `operatorFacingErrorText(e) || 'unknown error'` — the one surviving fallback + * — so where the channel is EMPTY that record reads `'unknown error'` and + * never `''`. Measured at that probe: a thrown `''`, a thrown `[]`, and an + * `Error` whose `name` and `message` are both empty each record + * `'unknown error'`; the control `new Error('boom')` records `'boom'`. The + * fallback is load-bearing rather than leftover — this site reads + * `organizationProbeError === ''` as "the probe did not fail", and with the + * fallback deleted a thrown `''` routes the run down the benign + * `no-organization-yet` path instead of the ambiguous one (measured by + * ablation), which is the "unknown read as zero" confusion #9261 exists to + * prevent. Whether this record SHOULD be `''` like the other eight is a + * BEHAVIOUR question, deliberately not taken here; #17167 carries it. + * + * ⚠️ That channel is a RULE, not byte-identity with what each site used to + * compute. Every negative pin below throws a NON-EMPTY `new Error(…)`, the + * shape for which the rule and the replaced expression agree; they differ + * elsewhere — at the `error instanceof Error ? … : String(error)` sites + * (`runProbe`, the seam-failure fan-out, both `partial-index-probe` legs) + * `new Error('')` recorded `''` and now records `'Error'`, and `{message:'x'}` + * recorded `'[object Object]'` and now records `'x'`; at the five + * `(e as Error).message` sites in `seed-tenancy-backfill` a thrown `'x'` + * recorded `undefined` — `'unknown error'` at the one site that spelled + * `|| 'unknown error'` — and now records `'x'`, and a thrown `null` threw a + * `TypeError` out of the catch at all five where it now records `'null'`. + * + * ⚠️ The composed sentence here is the producer's, copied. `driver-sql`'s + * `sql-driver-16657-operator-facing-cause-text.test.ts` pins the copy against a + * REAL `SqlDriver.execute()` refusal, so a reworded envelope reddens there + * instead of turning these cases into tests of their own fixture. + */ + +import { describe, it, expect } from 'vitest'; + +import { collectRuntimeIndexPreflight } from './runtime-index-preflight.js'; +import { probeThenReplaceIndex, type IndexExec } from './partial-index-probe.js'; +import { + backfillSeedTenancy, + buildGlobalCounterProbeSql, + ORGANIZATION_TABLE, + SEQUENCES_TABLE, +} from './seed-tenancy-backfill.js'; + +/** `rawStatementFaultError`'s composed message, verbatim (`sql-driver.ts`). */ +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.'; + +/** knex 3.3.0 + better-sqlite3: ` - `. */ +const DIALECT_TEXT = 'select "foo" from "sys_metadata" - no such column: foo'; + +/** The envelope the raw terminal composes, cause carrier and all. */ +function rawStatementFault(dialect = DIALECT_TEXT): Error { + const err = new Error(COMPOSED) as Error & { code?: string; status?: number }; + err.code = 'DATABASE_ERROR'; + err.status = 500; + const cause = new Error(dialect) as Error & { code?: string }; + cause.code = 'SQLITE_ERROR'; + Object.defineProperty(err, 'cause', { + value: cause, + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + +/** The "before" half, asserted once so every case below can lean on it. */ +it('[the fixture] the envelope IS the composed sentence and hides the dialect', () => { + const thrown = rawStatementFault(); + expect(thrown.message).toBe(COMPOSED); + expect(thrown.message).not.toContain('no such column'); + expect((thrown as { cause?: Error }).cause?.message).toBe(DIALECT_TEXT); +}); + +function createLogger() { + const warn: Array<{ message: string; meta?: Record }> = []; + return { + warn, + logger: { + warn: (message: string, meta?: Record) => { + warn.push({ message, meta }); + }, + info: () => {}, + error: () => {}, + }, + }; +} + +describe('[#16657] runtime-index-preflight — the per-probe detail', () => { + it('a duplicate probe refused by the backend reports the dialect text', async () => { + const exec: IndexExec = async (sql: string) => { + if (sql.includes('HAVING')) throw rawStatementFault(); + return []; + }; + + const results = await collectRuntimeIndexPreflight(exec); + + expect(results.length).toBeGreaterThan(0); + for (const probe of results) { + expect(probe.status).toBe('unreadable'); + expect(probe.detail).toBe(DIALECT_TEXT); + expect(probe.detail).not.toContain('refused to run a raw statement'); + } + }); + + it('a dead seam reports the dialect text on every probe', async () => { + // The liveness statement itself is refused, so ONE failure is fanned out + // to every probe — the record shape an operator reads for a whole run. + const exec: IndexExec = async () => { + throw rawStatementFault('no such table: main.sys_metadata'); + }; + + const results = await collectRuntimeIndexPreflight(exec); + + expect(results.every((p) => p.detail === 'no such table: main.sys_metadata')).toBe(true); + }); + + it('an UNDECLARED seam failure reaches the detail on its own message channel', async () => { + const exec: IndexExec = async () => { + throw new Error('connection terminated unexpectedly'); + }; + + const results = await collectRuntimeIndexPreflight(exec); + + expect(results.every((p) => p.detail === 'connection terminated unexpectedly')).toBe(true); + }); +}); + +describe('[#16657] partial-index-probe — the detail both callers report', () => { + const options = { + indexName: 'idx_real', + probeIndexName: 'idx_probe', + buildSql: (name: string) => `CREATE UNIQUE INDEX ${name} ON t (a) WHERE b IS NULL`, + }; + + it('a refused PROBE build reports the dialect text', async () => { + const exec: IndexExec = async (sql: string) => { + if (sql.includes('CREATE')) throw rawStatementFault(); + return []; + }; + + const outcome = await probeThenReplaceIndex(exec, options); + + expect(outcome.failedAt).toBe('probe'); + expect(outcome.detail).toBe(DIALECT_TEXT); + }); + + it('a refused REPLACE build reports the dialect text', async () => { + const exec: IndexExec = async (sql: string) => { + if (sql.includes(`CREATE UNIQUE INDEX ${options.indexName}`)) throw rawStatementFault(); + return []; + }; + + const outcome = await probeThenReplaceIndex(exec, options); + + expect(outcome.failedAt).toBe('replace'); + expect(outcome.detail).toBe(DIALECT_TEXT); + }); + + it('the VERDICT is still taken from the error object, not the text', async () => { + // The dialect word the `unsupported` arm matches sits in the CAUSE, and + // `classifyIndexFailure` is cause-following — untouched by this change. + const exec: IndexExec = async (sql: string) => { + if (sql.includes('CREATE')) { + throw rawStatementFault('near "where": syntax error'); + } + return []; + }; + + const outcome = await probeThenReplaceIndex(exec, options); + + expect(outcome.status).toBe('unsupported'); + expect(outcome.detail).toBe('near "where": syntax error'); + }); + + it('an UNDECLARED build failure reports its own message channel, no cause walked', async () => { + const exec: IndexExec = async (sql: string) => { + if (sql.includes('CREATE')) throw new Error('disk I/O error'); + return []; + }; + + const outcome = await probeThenReplaceIndex(exec, options); + + expect(outcome.detail).toBe('disk I/O error'); + }); +}); + +describe('[#16657] seed-tenancy-backfill — the stored operator record', () => { + /** + * The statements the module compiles, dispatched the way + * `seed-tenancy-backfill.test.ts`'s own fixture dispatches them, with one + * injectable refusal so a single run can be pointed at one seam at a time. + */ + function seamExec(refuse: (sql: string) => boolean) { + return async (sql: string): Promise => { + if (refuse(sql)) throw rawStatementFault(); + if (sql.includes('WHERE 1 = 0')) return []; + if (sql.includes('LEFT JOIN')) { + return [ + { + object: 'crm_case', + field: 'case_number', + global_last_value: 38, + organization_last_value: 1, + }, + ]; + } + if (sql.includes(ORGANIZATION_TABLE)) return [{ id: 'org_a' }]; + if (sql.includes('rows_holding')) return []; + return []; + }; + } + + it('[absent] a refused split probe stores the dialect text as `detail`', async () => { + const result = await backfillSeedTenancy({ + exec: seamExec((sql) => sql.includes('LEFT JOIN')), + client: 'better-sqlite3', + }); + + expect(result.status).toBe('absent'); + expect(result.detail).toBe(DIALECT_TEXT); + expect(result.detail).not.toContain('refused to run a raw statement'); + }); + + it('[ambiguous] a refused organization probe names the dialect in the report', async () => { + const log = createLogger(); + const result = await backfillSeedTenancy( + { exec: seamExec((sql) => sql.includes(ORGANIZATION_TABLE)), client: 'better-sqlite3' }, + log.logger, + ); + + expect(result.status).toBe('skipped-ambiguous-organization'); + const line = log.warn.find((w) => w.message.includes('probe FAILED')); + expect(line?.message).toContain(DIALECT_TEXT); + expect(line?.meta?.organizationProbeError).toBe(DIALECT_TEXT); + }); + + it('[collision probe] the warn meta carries the dialect text', async () => { + const log = createLogger(); + await backfillSeedTenancy( + { exec: seamExec((sql) => sql.includes('rows_holding')), client: 'better-sqlite3' }, + log.logger, + ); + + const line = log.warn.find((w) => w.message.includes('already-minted duplicates')); + expect(line?.meta?.error).toBe(DIALECT_TEXT); + }); + + it('[stamp] the warn meta carries the dialect text', async () => { + const log = createLogger(); + await backfillSeedTenancy( + { + exec: seamExec( + (sql) => sql.startsWith('UPDATE') && !sql.includes(SEQUENCES_TABLE), + ), + client: 'better-sqlite3', + }, + log.logger, + ); + + const line = log.warn.find((w) => w.message.includes('could not stamp')); + expect(line?.meta?.error).toBe(DIALECT_TEXT); + }); + + it('[counter merge] the warn meta carries the dialect text', async () => { + const log = createLogger(); + await backfillSeedTenancy( + { + // The first statement `mergeSplitCounter` issues — matched by + // BUILDING it here, so a builder that changes shape breaks this + // fixture instead of silently never refusing anything. + exec: seamExec( + (sql) => + sql === buildGlobalCounterProbeSql(true, 'better-sqlite3') || + sql === buildGlobalCounterProbeSql(false, 'better-sqlite3'), + ), + client: 'better-sqlite3', + }, + log.logger, + ); + + const line = log.warn.find((w) => w.message.includes('could not merge the counter')); + expect(line?.meta?.error).toBe(DIALECT_TEXT); + }); + + it('an UNDECLARED refusal reads its own message channel at the sites without a fallback', async () => { + // "Without a fallback" is eight of this package's nine call sites. The + // exception is the ORGANIZATION probe (`|| 'unknown error'`), whose + // record for an EMPTY channel is `'unknown error'` rather than `''` — + // see the module docblock above, and #17167. This pin drives the + // duplicates warning, which carries no fallback, with a NON-EMPTY + // message, so it exercises the channel and not the fallback. + const log = createLogger(); + const bare = async (sql: string): Promise => { + if (sql.includes('rows_holding')) throw new Error('connection terminated unexpectedly'); + return seamExec(() => false)(sql); + }; + + await backfillSeedTenancy({ exec: bare, client: 'better-sqlite3' }, log.logger); + + const line = log.warn.find((w) => w.message.includes('already-minted duplicates')); + expect(line?.meta?.error).toBe('connection terminated unexpectedly'); + }); +}); diff --git a/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts b/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts index 23c46a7367..bad2a5a406 100644 --- a/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts +++ b/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts @@ -69,6 +69,7 @@ * first re-keying. */ +import { operatorFacingErrorText } from '@objectstack/types'; import { isResultSet, normalizeRows } from './seed-tenancy-backfill.js'; import type { IndexExec } from './partial-index-probe.js'; import { @@ -295,7 +296,7 @@ async function runProbe(exec: IndexExec, probe: RuntimeIndexProbe): Promise { @@ -334,7 +335,7 @@ export async function collectRuntimeIndexPreflight( try { if (!isResultSet(await exec(SEAM_LIVENESS_SQL))) seamFailure = SEAM_NO_ANSWER_DETAIL; } catch (error) { - seamFailure = error instanceof Error ? error.message : String(error); + seamFailure = operatorFacingErrorText(error); } if (seamFailure !== undefined) { return probes.map((probe) => ({ diff --git a/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts index f4d619b79a..a9818b94cd 100644 --- a/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts +++ b/packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts @@ -136,7 +136,7 @@ */ import { createHash } from 'node:crypto'; -import { resolveTenancyPosture } from '@objectstack/types'; +import { operatorFacingErrorText, resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; import type { IndexMigrationLogger } from './partial-index-probe.js'; @@ -1256,7 +1256,7 @@ export async function backfillSeedTenancy( organizationLastValue: toNumber(r.organization_last_value), })); } catch (e) { - return { status: 'absent', ...empty, detail: (e as Error).message }; + return { status: 'absent', ...empty, detail: operatorFacingErrorText(e) }; } if (splits.length === 0) return { status: 'no-split', ...empty }; @@ -1302,7 +1302,7 @@ export async function backfillSeedTenancy( .map((r) => (r.id == null ? '' : String(r.id))) .filter((id) => id.length > 0); } catch (e) { - organizationProbeError = (e as Error).message || 'unknown error'; + organizationProbeError = operatorFacingErrorText(e) || 'unknown error'; organizationIds = []; } // 4a. NO organization yet — benign, and NOT the ambiguous case (#12395). @@ -1385,7 +1385,7 @@ export async function backfillSeedTenancy( `[metadata-protocol] could not list already-minted duplicates for ${split.object}.${split.field} ` + `(#8686) — the backfill continues; verify manually with: ` + `${buildCollisionProbeSql(split.object, split.field, client)}`, - { error: (e as Error).message }, + { error: operatorFacingErrorText(e) }, ); } } @@ -1411,7 +1411,7 @@ export async function backfillSeedTenancy( `[metadata-protocol] seed tenancy backfill could not stamp ${object} (#8686) — its rows keep ` + `${ORGANIZATION_FIELD} = NULL and the counter merge below is SKIPPED for it, so the split ` + `survives and the next boot retries. Nothing was lost; nothing was repaired for this object.`, - { error: (e as Error).message }, + { error: operatorFacingErrorText(e) }, ); } } @@ -1444,7 +1444,7 @@ export async function backfillSeedTenancy( `[metadata-protocol] seed tenancy backfill could not merge the counter for ` + `${split.object}.${split.field} (#8686) — the '${GLOBAL_TENANT}' counter is left in ` + `place, so the high-water mark is intact and the next boot retries the repair`, - { error: (e as Error).message }, + { error: operatorFacingErrorText(e) }, ); } } diff --git a/packages/metadata/src/migrations/drop-projection-tables.ts b/packages/metadata/src/migrations/drop-projection-tables.ts index 4d51e7693c..2195b5fda2 100644 --- a/packages/metadata/src/migrations/drop-projection-tables.ts +++ b/packages/metadata/src/migrations/drop-projection-tables.ts @@ -18,6 +18,8 @@ */ import type { IDataDriver } from '@objectstack/spec/contracts'; +import { operatorFacingErrorText } from '@objectstack/types'; + import { driverExecRefusal, resolveDriverExec } from './driver-exec.js'; @@ -58,7 +60,7 @@ export async function dropProjectionTables(driver: IDataDriver): Promise - `. */ +const DIALECT_TEXT = 'alter table "sys_metadata" rename column - no such column: env_id'; + +function rawStatementFault(dialect = DIALECT_TEXT): Error { + const err = new Error(COMPOSED) as Error & { code?: string; status?: number }; + err.code = 'DATABASE_ERROR'; + err.status = 500; + const cause = new Error(dialect) as Error & { code?: string }; + cause.code = 'SQLITE_ERROR'; + Object.defineProperty(err, 'cause', { + value: cause, + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + +/** + * A driver whose `PRAGMA table_info` reports `columns` and whose every other + * statement is refused by `refusal()`. `execute` is the member `IDataDriver` + * declares, so that is the surface these doubles offer. + */ +function refusingDriver(columns: readonly string[], refusal: () => unknown) { + return { + async execute(sql: string) { + if (sql.startsWith('PRAGMA table_info')) return columns.map((name) => ({ name })); + throw refusal(); + }, + } as never; +} + +it('[the fixture] the envelope IS the composed sentence and hides the dialect', () => { + const thrown = rawStatementFault(); + expect(thrown.message).toBe(COMPOSED); + expect(thrown.message).not.toContain('no such column'); + expect((thrown as { cause?: Error }).cause?.message).toBe(DIALECT_TEXT); +}); + +describe('[#16657] migrateEnvIdToProjectId — the per-table error record', () => { + it('a refused RENAME records the dialect text', async () => { + const results = await migrateEnvIdToProjectId( + refusingDriver(['id', 'env_id'], () => rawStatementFault()), + ); + + const errors = results.filter((r) => r.status === 'error'); + expect(errors.length).toBeGreaterThan(0); + for (const row of errors) { + expect(row.error).toBe(DIALECT_TEXT); + expect(row.error).not.toContain('refused to run a raw statement'); + } + }); + + it('an UNDECLARED refusal is recorded on its own message channel', async () => { + const results = await migrateEnvIdToProjectId( + refusingDriver(['id', 'env_id'], () => new Error('database is locked')), + ); + + expect(results.filter((r) => r.status === 'error').every((r) => r.error === 'database is locked')).toBe(true); + }); +}); + +describe('[#16657] migrateProjectIdToEnvironmentId — the per-table error record', () => { + it('a refused RENAME records the dialect text', async () => { + const results = await migrateProjectIdToEnvironmentId( + refusingDriver(['id', 'project_id'], () => rawStatementFault()), + ); + + const errors = results.filter((r) => r.status === 'error'); + // The migration's own table list decides how many rows there are; the + // assertion is about every one it produced, not about a count. + expect(errors.length).toBeGreaterThan(0); + expect(errors.length).toBeLessThanOrEqual(AFFECTED_TABLES.length); + for (const row of errors) expect(row.error).toBe(DIALECT_TEXT); + }); + + it('an UNDECLARED refusal is recorded on its own message channel', async () => { + const results = await migrateProjectIdToEnvironmentId( + refusingDriver(['id', 'project_id'], () => new Error('database is locked')), + ); + + expect(results.filter((r) => r.status === 'error').every((r) => r.error === 'database is locked')).toBe(true); + }); +}); + +describe('[#16657] dropProjectionTables — the per-table error record', () => { + it('a refused DROP records the dialect text', async () => { + const results = await dropProjectionTables({ + async execute() { + throw rawStatementFault('drop table "sys_object" - table is locked'); + }, + } as never); + + expect(results.length).toBeGreaterThan(0); + for (const row of results) { + expect(row.status).toBe('error'); + expect(row.error).toBe('drop table "sys_object" - table is locked'); + } + }); + + it('an UNDECLARED refusal is recorded on its own message channel', async () => { + const results = await dropProjectionTables({ + async execute() { + throw new Error('database is locked'); + }, + } as never); + + expect(results.every((r) => r.error === 'database is locked')).toBe(true); + }); +}); + +describe('[#16657] migrateSysNotificationToEvent — the run-level error record', () => { + /** No `getObject`, so the receipt is `no-ledger` and the run is the subject. */ + const noLedgerEngine = { async find() { return []; } } as never; + + const legacyColumns = ['id', 'recipient_id', 'type', 'title', 'body']; + + it('a refused legacy SELECT records the dialect text', async () => { + const result = await migrateSysNotificationToEvent({ + driver: refusingDriver(legacyColumns, () => + rawStatementFault('select id, recipient_id from "sys_event" - no such column: topic'), + ), + data: noLedgerEngine, + }); + + expect(result.status).toBe('error'); + expect(result.error).toBe('select id, recipient_id from "sys_event" - no such column: topic'); + expect(result.error).not.toContain('refused to run a raw statement'); + }); + + it('an UNDECLARED refusal is recorded on its own message channel', async () => { + const result = await migrateSysNotificationToEvent({ + driver: refusingDriver(legacyColumns, () => new Error('connection reset')), + data: noLedgerEngine, + }); + + expect(result.status).toBe('error'); + expect(result.error).toBe('connection reset'); + }); +}); diff --git a/packages/types/src/driver-error-classification.operator-text.test.ts b/packages/types/src/driver-error-classification.operator-text.test.ts new file mode 100644 index 0000000000..7093ac0eb9 --- /dev/null +++ b/packages/types/src/driver-error-classification.operator-text.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16657] `operatorFacingErrorText` — the dialect's words for a record an + * operator reads later. + * + * ## The regression this closes, and why "one `cause` away" was not enough + * + * Since #16019 the raw-SQL seam declares its own fault: `DATABASE_ERROR` / 500, + * a COMPOSED message that discloses neither the statement nor the diagnostic, + * and the dialect error whole under a non-enumerable `cause`. On a LIVE console + * that costs nothing — the driver writes the statement and the dialect text to + * its warn sink one line earlier. In a STORED record it costs everything: + * whoever reads a backfill's `detail` a week later never had that line, so + * *"no such column: foo"* was replaced, irrecoverably for them, by *"the + * database refused to run a raw statement"*. + * + * ## What is asserted here, in both directions + * + * The envelope's OWN message is pinned as the composed sentence in the same + * test that pins the helper's answer as the dialect text — so "after" is never + * asserted without "before" being visible beside it. The three narrowings are + * pinned as hard as the unwrap itself, because each one is a way this helper + * could quietly become a message sniffer: + * + * - an UNDECLARED throw comes back as `messageChannelOf(e) || String(e)` — its + * own string `message`, the string itself for a thrown string, `String(e)` + * otherwise — with its `cause` never walked. A rule, not byte-identity with + * whatever the call site used to compute; + * - a DECLARED envelope that is not the raw-path one — the read-exit terminal + * `backendStatementFaultError`, the #8931 / PR #9273 half — is left exactly + * as it arrived, which is what keeps that decision out of this change; + * - the walk is bounded, so a cyclic or absurdly deep chain terminates. + * + * ⚠️ The composed sentence below is the PRODUCER's, copied. It is pinned + * against the real producer by `driver-sql`'s + * `sql-driver-16657-operator-facing-cause-text.test.ts`, which drives a real + * `SqlDriver.execute()` refusal through this helper — so a driver that rewords + * its envelope reddens there rather than silently turning every case in this + * file into a test of its own fixture. + */ + +import { describe, expect, it } from 'vitest'; + +import { operatorFacingErrorText } from './driver-error-classification.js'; + +/** `rawStatementFaultError`'s composed message, verbatim (`sql-driver.ts`). */ +const RAW_PATH_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.'; + +/** `backendStatementFaultError`'s composed message — the READ exit, not this one. */ +const READ_EXIT_COMPOSED = + "The database refused to run this query for object 'crm_case'. The driver could not " + + 'attribute the failure to any part of the request, so no verdict about the query is ' + + "claimed here. The backend's own diagnostic and the compiled statement were written " + + 'to the server log for an operator to read.'; + +/** knex 3.3.0 + better-sqlite3: ` - `. */ +const DIALECT_TEXT = 'select "foo" from "sys_metadata" - no such column: foo'; + +interface Declared extends Error { + code?: string; + status?: number; +} + +/** The envelope the raw terminal composes, cause carrier and all. */ +function rawStatementFault(cause: unknown, message = RAW_PATH_COMPOSED): Declared { + const err = new Error(message) as Declared; + err.code = 'DATABASE_ERROR'; + err.status = 500; + Object.defineProperty(err, 'cause', { + value: cause, + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + +/** The dialect error knex hands back, `code` and all. */ +function dialectError(text = DIALECT_TEXT): Declared { + const err = new Error(text) as Declared; + err.code = 'SQLITE_ERROR'; + return err; +} + +describe('[#16657] operatorFacingErrorText — the raw-path envelope', () => { + it("returns the dialect's own words, where a bare read returns the composed sentence", () => { + const thrown = rawStatementFault(dialectError()); + + // BEFORE — what every site stored until this change, and what the + // envelope still says on its own message channel. Asserted here so the + // "after" line below is a comparison rather than a claim. + expect(thrown.message).toBe(RAW_PATH_COMPOSED); + expect(thrown.message).not.toContain('no such column'); + + // AFTER — the record an operator reads names the column. + expect(operatorFacingErrorText(thrown)).toBe(DIALECT_TEXT); + expect(operatorFacingErrorText(thrown)).toContain('no such column: foo'); + }); + + it('walks PAST a nested wrapper that re-composed the same sentence', () => { + // A transport that re-wraps the envelope (the shape `rawStatementFault` + // guards against by passing an already-declared error through) must not + // strand the dialect text one level deeper than the walk looks. + const thrown = rawStatementFault(rawStatementFault(dialectError())); + + expect(operatorFacingErrorText(thrown)).toBe(DIALECT_TEXT); + }); + + it('skips a node that carries no message channel at all', () => { + const silent = rawStatementFault(dialectError()); + Object.defineProperty(silent, 'cause', { + value: rawStatementFault(dialectError(), ''), + enumerable: false, + writable: true, + configurable: true, + }); + // The intermediate node says nothing; the one below it does. + expect(operatorFacingErrorText(silent)).toBe(DIALECT_TEXT); + }); + + it('reads a cause that is a bare string, not an Error', () => { + expect(operatorFacingErrorText(rawStatementFault('no such table: sys_metadata'))).toBe( + 'no such table: sys_metadata', + ); + }); +}); + +describe('[#16657] operatorFacingErrorText — the fallback channel when no cause speaks', () => { + it('falls back to the envelope itself when nothing is attached', () => { + // The measured shape when a transport drops `cause`: there is nothing + // better to say, and saying `undefined` is worse than saying this. + const thrown = rawStatementFault(undefined); + + expect(operatorFacingErrorText(thrown)).toBe(RAW_PATH_COMPOSED); + expect(operatorFacingErrorText(thrown)).not.toBe(''); + }); + + it('reads a thrown non-Error on its own channel, where `(e as Error).message` read `undefined` or threw', () => { + // `(e as Error).message` — the expression this helper replaces at five + // sites — answered these five two different ways, neither of them a + // record worth storing. It evaluated to `undefined` for the string, the + // number and `{}`; for `null` and `undefined` it threw a `TypeError` + // out of the catch, so no record was written at all and the operation + // aborted. The helper reads a channel instead, so all five store text. + expect(operatorFacingErrorText('no such column: foo')).toBe('no such column: foo'); + expect(operatorFacingErrorText(42)).toBe('42'); + expect(operatorFacingErrorText(undefined)).toBe('undefined'); + expect(operatorFacingErrorText(null)).toBe('null'); + expect(operatorFacingErrorText({})).toBe('[object Object]'); + }); + + it('falls back to the `name` of a declared envelope whose own message is empty', () => { + const empty = rawStatementFault(undefined, ''); + expect(operatorFacingErrorText(empty)).toBe('Error'); + }); +}); + +describe('[#16657] operatorFacingErrorText — the narrowings, each pinned', () => { + it('leaves an UNDECLARED throw exactly as its message channel reads', () => { + const bare = new Error('no strategy can handle query') as Declared; + Object.defineProperty(bare, 'cause', { + value: new Error('a cause nobody declared'), + enumerable: false, + writable: true, + configurable: true, + }); + + // Not `DATABASE_ERROR`, so the chain is not consulted — reading it would + // be the sniffing #16019 removed. + expect(operatorFacingErrorText(bare)).toBe('no strategy can handle query'); + }); + + it('leaves a declared fault under some OTHER code alone', () => { + const other = new Error('permission denied') as Declared; + other.code = 'PERMISSION_DENIED'; + other.status = 403; + Object.defineProperty(other, 'cause', { + value: dialectError(), + enumerable: false, + writable: true, + configurable: true, + }); + + expect(operatorFacingErrorText(other)).toBe('permission denied'); + }); + + it('leaves the READ-exit envelope untouched — same code, different sentence', () => { + // `backendStatementFaultError` (#8931 / PR #9273) declares the identical + // `DATABASE_ERROR` / 500 and carries its dialect error the same way. + // Whether ITS prose should be unwrapped is a separate decision; this + // helper does not take it, and that is what the sentence match buys. + const readExit = rawStatementFault(dialectError(), READ_EXIT_COMPOSED); + + expect(readExit.code).toBe('DATABASE_ERROR'); + expect(operatorFacingErrorText(readExit)).toBe(READ_EXIT_COMPOSED); + expect(operatorFacingErrorText(readExit)).not.toContain('no such column'); + }); +}); + +describe('[#16657] operatorFacingErrorText — the depth bound actually bounds', () => { + it('terminates on a CYCLIC cause chain and answers the envelope', () => { + const cyclic = rawStatementFault(undefined); + Object.defineProperty(cyclic, 'cause', { + value: cyclic, + enumerable: false, + writable: true, + configurable: true, + }); + + // The assertion that matters is that this line is reached at all. + expect(operatorFacingErrorText(cyclic)).toBe(RAW_PATH_COMPOSED); + }); + + it('answers the envelope when the dialect text sits BELOW the bound', () => { + // MAX_CAUSE_DEPTH is 4 in this module; ten composed wrappers is past it. + let deep: Declared = rawStatementFault(dialectError()); + for (let i = 0; i < 10; i += 1) deep = rawStatementFault(deep); + + expect(operatorFacingErrorText(deep)).toBe(RAW_PATH_COMPOSED); + }); + + it('still reaches text that sits exactly AT the bound', () => { + // Four composed nodes above the dialect one — the deepest the walk sees. + let atBound: Declared = rawStatementFault(dialectError()); + for (let i = 0; i < 3; i += 1) atBound = rawStatementFault(atBound); + + expect(operatorFacingErrorText(atBound)).toBe(DIALECT_TEXT); + }); +}); diff --git a/packages/types/src/driver-error-classification.ts b/packages/types/src/driver-error-classification.ts index 206459b741..8f514b4f34 100644 --- a/packages/types/src/driver-error-classification.ts +++ b/packages/types/src/driver-error-classification.ts @@ -652,3 +652,148 @@ export function isSchemaAlreadyExistsError(error: unknown, depth = 0): boolean { export function isMissingTableError(error: unknown, readObject?: string, depth = 0): boolean { return matchesDriverError(error, MISSING_TABLE, depth, readObject); } + +// --------------------------------------------------------------------------- +// Operator-facing text for a DECLARED driver fault (#16657) +// --------------------------------------------------------------------------- + +/** + * [#16657] The ADR-0112 code a driver declares when the backend, not the + * caller, refused the work. Spelled as a literal for the same reason + * {@link declaresServerFault} spells `status`/`code` by hand: this package is + * the common dependency every consumer of the question already has, and reading + * one string field must not drag a schema module into it. + */ +const DECLARED_DATABASE_FAULT_CODE = 'DATABASE_ERROR'; + +/** + * [#16657] The fragment that identifies `SqlDriver`'s RAW-path envelope, and + * only it. + * + * The raw terminal (`rawStatementFaultError`, `driver-sql/src/sql-driver.ts`; + * `TursoDriver` remote mode reaches the same composition through + * `SqlDriver.rawStatementFault`) COMPOSES its message on purpose — there is no + * cut of a dialect's text that keeps its words and reliably drops a caller's + * inlined literals, so the envelope discloses nothing and carries the dialect + * error whole under a non-enumerable `cause`. That is the disclosure clause of + * the raw path and ⛔ is not reverted here: the fix for an operator record is + * to read the `cause` the driver already attached, never to widen what the + * envelope discloses. + * + * ⚠️ Matching the sentence — rather than the declaration alone — is what keeps + * the READ-exit envelope (`backendStatementFaultError`, the #8931 / PR #9273 + * half) untouched: it declares the very same code and status, composes a + * DIFFERENT sentence, and whether its prose should be unwrapped is a separate + * decision this helper deliberately does not take. An envelope that declares + * the code and composes a NON-EMPTY sentence this fragment does not match is + * returned exactly as it arrived — it speaks at depth 0, so the walk stops on + * it. ⚠️ Not so for a declared envelope whose own message is EMPTY: an empty + * node says nothing, so the walk steps past it and that envelope IS unwrapped + * (measured: + * `{code:'DATABASE_ERROR', message:'', cause:{message:'walked'}}` answers + * `'walked'`). ⛔ No claim is made about whether any producer composes an + * empty-message `DATABASE_ERROR`; that was not measured. + * + * The producer is pinned in the driver, where a real refusal can be raised: + * `packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts` + * fails if `sql-driver.ts` stops composing a sentence this recognises. + */ +const RAW_STATEMENT_FAULT_SENTENCE = /refused to run a raw statement/; + +/** + * The message channel of one node of a `cause` chain, as text. + * + * Empty means "this node says nothing" — a caller distinguishes that from a + * node that speaks, and never records it. The channel is the node's own string + * `message` for an object or function, the string itself for a string, and + * `String()` for any other primitive; anything else reads `''`. So a non-Error + * node reads whatever text it carries rather than the `undefined` that + * `(e as Error).message` produced at the FIVE sites spelled that way (for + * `null` and `undefined` that expression produced nothing at all — it threw a + * `TypeError` out of the catch) — of the fourteen this helper replaces; the + * other nine spell `instanceof Error ? … : String()` (five) or + * `?.message ?? …` (four) and already carried a fallback — and a node whose own + * text is empty, a thrown empty string among them, reads `''`. + */ +function messageChannelOf(node: unknown): string { + if (typeof node === 'string') return node; + if (node === null || node === undefined) return ''; + if (typeof node === 'object' || typeof node === 'function') { + const message = (node as { message?: unknown }).message; + return typeof message === 'string' ? message : ''; + } + return String(node); +} + +/** + * The text an OPERATOR should read for `error` — the dialect's own words when a + * driver composed over them, the error's own message otherwise (#16657). + * + * # The defect this closes + * + * Since #16019 the raw-SQL seam every migration probe, backfill and + * `os db clean` runs through no longer lets the dialect's error out: it + * declares `DATABASE_ERROR` / 500 with a composed sentence and keeps the + * dialect error under `cause`. Every consumer that embedded `error.message` + * into an operator-facing record therefore began storing *"the database refused + * to run a raw statement"* where it used to store *"no such column: foo"*. + * + * For a LIVE console that is cosmetic — the driver writes the statement and the + * dialect text to its warn sink one line earlier, so the operator has already + * read it. For a STORED record it is not: whoever reads a backfill's `detail` + * field a week later never had that console line, and for them the dialect's + * words are unrecoverable. This helper is for the second class. + * + * # What it does, and the two things that bound it + * + * It walks the `cause` chain to the first node that says something which is not + * the raw-path composed sentence, and returns that. Both narrowings matter: + * + * - **only a DECLARED fault is reinterpreted.** An undeclared throw — anything + * without `code: DATABASE_ERROR` — comes back as `messageChannelOf(error) || + * String(error)`: the value's own string `message`, the string itself when a + * string was thrown, and `String(error)` when neither yields text. Its `cause` + * is never walked. That channel is deliberately NOT byte-identical to what + * the call sites used to compute, and how it differs follows from that rule + * rather than from a list of shapes: an empty-message `Error` reads its + * `name`; a thrown non-`Error` reads its own text or `String(error)` where + * `(e as Error).message` read `undefined`, and where `null` / `undefined` + * threw out of the catch instead of recording anything; an object carrying a + * NON-EMPTY string `message` reads it where `String(err)` recorded + * `[object Object]` — one carrying an EMPTY `message` still reads + * `[object Object]`, because an empty channel is no channel. A + * thrown EMPTY string reads `''`, so this channel is neither always prose nor + * never empty. Reading a `cause` chain nobody declared would be sniffing, + * which is the mechanism #16019 removed; + * - **only the raw-path sentence is walked through.** See + * {@link RAW_STATEMENT_FAULT_SENTENCE}. + * + * The walk is bounded by the same {@link MAX_CAUSE_DEPTH} every predicate in + * this module uses, so a cyclic or absurdly deep chain terminates. Exhausting + * the bound — like finding no `cause` at all — falls back to the SAME surface + * channel an undeclared throw reads, `messageChannelOf(error) || String(error)`. + * ⛔ That fallback is not a promise of prose: it is `''` exactly when that + * channel is, which inside this branch means a declared envelope whose own + * `message` and `name` are both empty (measured: it answers `''`). The + * "neither always prose nor never empty" reading above holds here too — what + * the fallback rules out is `undefined`, never emptiness. + * + * @param error - the thrown value, of any shape. + * @returns text for an operator; never `undefined`, never empty for a thrown + * value that has any textual channel at all. + */ +export function operatorFacingErrorText(error: unknown): string { + const surface = messageChannelOf(error) || String(error); + if (typeof error !== 'object' || error === null) return surface; + const { code } = error as { code?: unknown }; + if (code !== DECLARED_DATABASE_FAULT_CODE) return surface; + + let node: unknown = error; + for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth += 1) { + const text = messageChannelOf(node); + if (text !== '' && !RAW_STATEMENT_FAULT_SENTENCE.test(text)) return text; + if (node === null || (typeof node !== 'object' && typeof node !== 'function')) break; + node = (node as { cause?: unknown }).cause; + } + return surface; +}