From 004bf4f271699eb6d2e66bca9729453901b7cbc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 04:16:11 +0000 Subject: [PATCH 01/10] fix(types,metadata,cli): read the dialect text out of `cause` for operator records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #16019 the raw-SQL seam declares its own fault with a composed message and keeps the dialect error under a non-enumerable `cause`, so every consumer that embedded `error.message` into an operator-facing record began storing "the database refused to run a raw statement" instead of "no such column: foo". Add `operatorFacingErrorText` to `@objectstack/types` — a depth-bounded walk of the `cause` chain, shaped like `matchesDriverError` — and apply it at the eleven stored-record sites plus the one console site. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- packages/cli/src/commands/db/clean.ts | 3 +- .../src/migrations/partial-index-probe.ts | 23 +++- .../src/migrations/runtime-index-preflight.ts | 5 +- .../src/migrations/seed-tenancy-backfill.ts | 12 +- .../src/migrations/drop-projection-tables.ts | 4 +- .../migrate-env-id-to-project-id.ts | 4 +- .../migrate-project-id-to-environment-id.ts | 4 +- .../migrate-sys-notification-to-event.ts | 4 +- .../types/src/driver-error-classification.ts | 114 ++++++++++++++++++ 9 files changed, 155 insertions(+), 18 deletions(-) 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/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/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 Date: Wed, 9 Sep 2026 04:31:03 +0000 Subject: [PATCH 02/10] test: pin the operator-facing text in both directions at every site Adds the helper's own unit cases (the three narrowings, the depth bound), the site-level records in metadata-protocol and metadata, and the producer pin in driver-sql that drives a real SqlDriver.execute() refusal through the helper so a reworded envelope reddens there rather than in a customer's log. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- ...r-16657-operator-facing-cause-text.test.ts | 81 +++++ .../raw-exec-operator-detail-16657.test.ts | 304 ++++++++++++++++++ .../raw-exec-operator-detail-16657.test.ts | 182 +++++++++++ ...error-classification.operator-text.test.ts | 227 +++++++++++++ 4 files changed, 794 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts create mode 100644 packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts create mode 100644 packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts create mode 100644 packages/types/src/driver-error-classification.operator-text.test.ts 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..d2ff426e52 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts @@ -0,0 +1,81 @@ +// 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'); +} + +describe('[#16657] a real raw-exec refusal still yields the dialect text to an operator', () => { + let driver: SqlDriver; + + beforeEach(() => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + // The dialect text is written here on any default deployment. A stored + // record's reader never sees this line — which is the whole card. + driver.logger = { warn: () => {} }; + }); + + 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 unchanged', 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/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..4eff5bd4da --- /dev/null +++ b/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts @@ -0,0 +1,304 @@ +// 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 must reach the record exactly as it did + * before, because the alternative — a helper that unwraps whatever it is handed + * — is the message sniffing #16019 exists to remove. + * + * ⚠️ 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 exactly as before', 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, unchanged', 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 keeps its own message at every one of these sites', async () => { + 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/src/migrations/raw-exec-operator-detail-16657.test.ts b/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts new file mode 100644 index 0000000000..e2faa0982a --- /dev/null +++ b/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16657] The per-table `error` these migrations return names the DIALECT. + * + * Every migration in this directory reports per table rather than throwing, and + * the caller stores or prints that report. Since #16019 the raw-SQL seam + * declares its own fault with a COMPOSED message and keeps the dialect error + * under a non-enumerable `cause`, so `err?.message` — the expression each of + * these `catch` blocks used — began recording *"the database refused to run a + * raw statement"* for a rename that actually failed on `no such column: foo`. + * + * A migration result is a stored operator record by construction: whoever reads + * it later never saw the driver's warn line, so for them the dialect's words + * were unrecoverable. + * + * Each case pins the "before" half beside the "after" one — the envelope's own + * message is the composed sentence, and the record's is not — plus the negative + * direction: an UNDECLARED throw reaches the record exactly as it did before. + * + * ⚠️ The composed sentence 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. + */ + +import { describe, expect, it } from 'vitest'; + +import { migrateEnvIdToProjectId } from './migrate-env-id-to-project-id.js'; +import { + AFFECTED_TABLES, + migrateProjectIdToEnvironmentId, +} from './migrate-project-id-to-environment-id.js'; +import { migrateSysNotificationToEvent } from './migrate-sys-notification-to-event.js'; +import { dropProjectionTables } from './drop-projection-tables.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 = '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 exactly as before', 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 exactly as before', 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 exactly as before', 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 exactly as before', 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..2534d4cbe9 --- /dev/null +++ b/packages/types/src/driver-error-classification.operator-text.test.ts @@ -0,0 +1,227 @@ +// 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 is returned byte-for-byte on its own message channel; + * - 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 — a record is never empty or undefined', () => { + 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('answers prose for a thrown value that is not an Error at all', () => { + // `(e as Error).message` — the expression this helper replaces at five + // sites — evaluates to `undefined` for every one of these. + 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('answers prose for 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); + }); +}); From f05bd5054bf3aaaed61492fbd6f3349b9172af76 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 04:33:02 +0000 Subject: [PATCH 03/10] chore: changeset for the operator-facing cause text Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../operator-facing-raw-exec-cause-text.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .changeset/operator-facing-raw-exec-cause-text.md 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..5f2564a2a4 --- /dev/null +++ b/.changeset/operator-facing-raw-exec-cause-text.md @@ -0,0 +1,47 @@ +--- +'@objectstack/types': patch +'@objectstack/metadata-protocol': patch +'@objectstack/metadata': patch +'@objectstack/cli': 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 eleven +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 +byte-for-byte on its own message channel, 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. From f15d28c9a9f1133ee0c42f136e575788a36fe186 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 05:39:20 +0000 Subject: [PATCH 04/10] =?UTF-8?q?test(driver-sql):=20hold=20the=20log=20si?= =?UTF-8?q?nk=20from=20a=20subclass=20=E2=80=94=20`logger`=20is=20protecte?= =?UTF-8?q?d?= 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 --- ...r-16657-operator-facing-cause-text.test.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) 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 index d2ff426e52..edc3da74f1 100644 --- 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 @@ -40,18 +40,28 @@ async function faultOf(run: () => Promise): Promise { throw new Error('expected the driver to refuse this statement, but it resolved'); } -describe('[#16657] a real raw-exec refusal still yields the dialect text to an operator', () => { - let driver: SqlDriver; - - beforeEach(() => { - driver = new SqlDriver({ +/** + * 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, }); - // The dialect text is written here on any default deployment. A stored - // record's reader never sees this line — which is the whole card. - driver.logger = { warn: () => {} }; + 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 () => { From de0bd50469a6c5f20102f67e0901c43fe316567c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 06:14:07 +0000 Subject: [PATCH 05/10] chore(changeset): grade the widened package `minor` and name driver-sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-changeset-no-major.mjs`'s LEVEL AXIS refuses a clause-② `yes` PR that grades NO package whose `packages/**/src/**` it moves at `minor` or above. `@objectstack/types` is the package that actually grew — `operatorFacingErrorText` is a new export — so it takes the `minor`; the rest stay `patch`, which is what a bug fix in a released package takes. `@objectstack/driver-sql` joins the entry list because this diff moves its `src/**` (one added test file); its published `dist/` is byte-unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../operator-facing-raw-exec-cause-text.md | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md index 5f2564a2a4..78a71d7697 100644 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ b/.changeset/operator-facing-raw-exec-cause-text.md @@ -1,8 +1,9 @@ --- -'@objectstack/types': patch +'@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 @@ -45,3 +46,24 @@ Two narrowings are part of the contract, not incidental: an UNDECLARED throw is byte-for-byte on its own message channel, 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. + +## 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. +The only value that changes is the TEXT inside an operator-facing `detail` / `error` field, and +only where the thrown error declares `DATABASE_ERROR` *and* its message is the raw path's +composed sentence — the case where that text was the wrong text. Every other throw reaches +these records byte-for-byte as before, the field names and types are unchanged, and 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. From cdcd6d3f5e30583edba3595c7d098a2e12992d01 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 10:27:26 +0000 Subject: [PATCH 06/10] docs(types): correct the site count, the byte-for-byte claim and the pin pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three text-only repairs from the clause-② contract review. No behaviour changes; no package moves that were not already moving. 1. The changeset said "the eleven stored-record sites plus `os db clean`". Re-measured on this tree: `operatorFacingErrorText(` occurs 15 times in non-test source, one of which is the declaration, so 14 call sites — 13 stored-record sites plus the `os db clean` console line. The changeset's own bullet list already summed to 13. Corrected to "thirteen". This file is release-notes input, which is why the number matters. 2. "byte-for-byte for undeclared throws" was false, and it shipped: the docblock it appears in belongs to the exported `operatorFacingErrorText` and reaches `packages/types/dist/index.d.ts`. Two shapes are not byte-identical to what the replaced expressions computed — a thrown non-`Error` now yields prose where `(e as Error).message` yielded `undefined`, and an error with an EMPTY message reads `Error` / `TypeError` through `|| String(error)` where those expressions yielded `''`, or `unknown error` at the one site that ors in a default. Both the docblock and the changeset's two copies of the claim now say what the code does. 3. The `RAW_STATEMENT_FAULT_SENTENCE` docblock cited `driver-error-classification.raw-statement-pin.test.ts`, which does not exist. It now names the real producer pin, `packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts`. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude --- .../operator-facing-raw-exec-cause-text.md | 23 ++++++++++++------- .../types/src/driver-error-classification.ts | 14 +++++++---- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md index 78a71d7697..3465b947e5 100644 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ b/.changeset/operator-facing-raw-exec-cause-text.md @@ -30,7 +30,7 @@ whoever opens a customer install's backfill result a week on never had that line 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 eleven +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; @@ -43,9 +43,15 @@ stored-record sites plus `os db clean`'s console line read through it: - `os db clean` — the `VACUUM failed` line. Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned -byte-for-byte on its own message channel, 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. +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. Two shapes read differently, and both read better: a thrown non-`Error` now +yields prose where `(e as Error).message` yielded `undefined`, and an error whose message +is EMPTY reads `Error` / `TypeError` — the `|| String(error)` last resort — where those +expressions yielded `''`, or `unknown error` at the one site that ors in a default. ## The levels, and why they are not uniform @@ -63,7 +69,8 @@ PR: no entry point reaches a test file, and `files` packs `dist` only. The only value that changes is the TEXT inside an operator-facing `detail` / `error` field, and only where the thrown error declares `DATABASE_ERROR` *and* its message is the raw path's composed sentence — the case where that text was the wrong text. Every other throw reaches -these records byte-for-byte as before, the field names and types are unchanged, and 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. +these records on its own message channel — as before, save for the two shapes named above, +where the text gets better rather than different in kind. The field names and types are +unchanged, and 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/types/src/driver-error-classification.ts b/packages/types/src/driver-error-classification.ts index 1a22cae1c3..a97671973d 100644 --- a/packages/types/src/driver-error-classification.ts +++ b/packages/types/src/driver-error-classification.ts @@ -687,7 +687,8 @@ const DECLARED_DATABASE_FAULT_CODE = 'DATABASE_ERROR'; * decision this helper deliberately does not take. An envelope that declares * the code but does not carry this sentence is returned exactly as it arrived. * - * The producer is pinned: `driver-error-classification.raw-statement-pin.test.ts` + * 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/; @@ -735,9 +736,14 @@ function messageChannelOf(node: unknown): string { * 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` — is returned on its own message channel, - * byte for byte what the call site used to compute. Reading a `cause` chain - * nobody declared would be sniffing, which is the mechanism #16019 removed; + * without `code: DATABASE_ERROR` — is returned on its own message channel and + * its `cause` is never walked. That channel is deliberately NOT byte-identical + * to what the call site used to compute: a thrown non-`Error` yields prose + * where `(e as Error).message` yielded `undefined`, and an error whose message + * is EMPTY reads `Error` / `TypeError` through the `|| String(error)` last + * resort where those expressions yielded `''` — or `unknown error`, at the one + * site that ors in a default. 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}. * From fff6e30629d79e939175e1ae743bd141e40efa9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 11:38:56 +0000 Subject: [PATCH 07/10] docs(types): state the undeclared-channel RULE instead of enumerating its cases The previous tidy round replaced a false absolute ("byte-for-byte for undeclared throws") with a different false absolute: "Two shapes read differently, and both read better", and "save for the two shapes named above, where the text gets better rather than different in kind". A contract-tier re-verification measured that false in four corners against the built bundle. Re-measured here as a 10x5 matrix (9 shapes plus a custom-named Error, against the helper and the four replaced expression families) run against packages/types/dist/index.mjs; it reproduces the re-verification's table exactly. The wording now states the rule and marks its examples as illustrations: an undeclared throw comes back as `messageChannelOf(error) || String(error)` -- the value's own string `message`, the string itself for a thrown string, `String(error)` otherwise -- with its `cause` never walked. Consequences, not a closed list: an empty-message Error reads its `name` (a named subclass reads the subclass name, not only 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 and the operation aborted; an object carrying a string `message` reads it where the instanceof-else-String expression recorded [object Object]; a thrown EMPTY string reads '', which is what makes both "never empty" and "yields prose" false. Four carriers of the claim, not the two the re-verification located. A content grep over the PR's 14 files found two more: - messageChannelOf's own docblock still said "a thrown non-Error still yields prose rather than `undefined`" -- the same false sentence, in the same file, uncorrected by the previous round; - driver-error-classification.operator-text.test.ts's file docblock still carried the ORIGINAL claim verbatim, "an UNDECLARED throw is returned byte-for-byte on its own message channel". The previous round corrected two of that sentence's three copies. No behaviour change: every changed line in both .ts files is a JSDoc ` *` line, and each file's source with comment blocks stripped hashes identical to HEAD. The `@returns` line's "never empty for a thrown value that has any textual channel at all" was measured and left: it is conditional, and the only value returning '' is a thrown empty string, whose channel is empty. No claim is made about which shapes in-repo seams actually throw; that was not measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- .../operator-facing-raw-exec-cause-text.md | 34 ++++++++++++------- ...error-classification.operator-text.test.ts | 5 ++- .../types/src/driver-error-classification.ts | 30 ++++++++++------ 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md index 3465b947e5..a4dbbf4276 100644 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ b/.changeset/operator-facing-raw-exec-cause-text.md @@ -48,10 +48,17 @@ the raw-path one — the typed read exits' terminal, which composes a different is left exactly as it arrived. That message channel is deliberately NOT byte-identical to what the replaced expressions -computed. Two shapes read differently, and both read better: a thrown non-`Error` now -yields prose where `(e as Error).message` yielded `undefined`, and an error whose message -is EMPTY reads `Error` / `TypeError` — the `|| String(error)` last resort — where those -expressions yielded `''`, or `unknown error` at the one site that ors in a default. +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 string `message` +reads it where `err instanceof Error ? … : String(err)` recorded `[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 @@ -65,12 +72,13 @@ because this change moves its `src/**` — by one ADDED file, the `.test.ts` tha 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. -The only value that changes is the TEXT inside an operator-facing `detail` / `error` field, and -only where the thrown error declares `DATABASE_ERROR` *and* its message is the raw path's -composed sentence — the case where that text was the wrong text. Every other throw reaches -these records on its own message channel — as before, save for the two shapes named above, -where the text gets better rather than different in kind. The field names and types are -unchanged, and 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. +**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, and one shape (a thrown empty string) still records `''`. 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/types/src/driver-error-classification.operator-text.test.ts b/packages/types/src/driver-error-classification.operator-text.test.ts index 2534d4cbe9..3a9e1b69a4 100644 --- a/packages/types/src/driver-error-classification.operator-text.test.ts +++ b/packages/types/src/driver-error-classification.operator-text.test.ts @@ -23,7 +23,10 @@ * pinned as hard as the unwrap itself, because each one is a way this helper * could quietly become a message sniffer: * - * - an UNDECLARED throw is returned byte-for-byte on its own message channel; + * - 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; diff --git a/packages/types/src/driver-error-classification.ts b/packages/types/src/driver-error-classification.ts index a97671973d..b76ed87d0c 100644 --- a/packages/types/src/driver-error-classification.ts +++ b/packages/types/src/driver-error-classification.ts @@ -697,9 +697,12 @@ 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. `String()` is the last resort so that - * a thrown non-Error still yields prose rather than `undefined`, which is the - * shape `(e as Error).message` produced at every site this helper replaces. + * 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 every site this helper replaces — 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; @@ -736,14 +739,19 @@ function messageChannelOf(node: unknown): string { * 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` — is returned on its own message channel and - * its `cause` is never walked. That channel is deliberately NOT byte-identical - * to what the call site used to compute: a thrown non-`Error` yields prose - * where `(e as Error).message` yielded `undefined`, and an error whose message - * is EMPTY reads `Error` / `TypeError` through the `|| String(error)` last - * resort where those expressions yielded `''` — or `unknown error`, at the one - * site that ors in a default. Reading a `cause` chain nobody declared would be - * sniffing, which is the mechanism #16019 removed; + * 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 + * string `message` reads it where `String(err)` recorded `[object Object]`. 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}. * From a0f5b4612159e6d26794eb64394829d8e94399da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:04:44 +0000 Subject: [PATCH 08/10] =?UTF-8?q?docs(types,metadata,metadata-protocol,dri?= =?UTF-8?q?ver-sql):=20census=20by=20rule=20=E2=80=94=20every=20undeclared?= =?UTF-8?q?-throw=20claim=20measured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth prose round on #16657. The census is by CLAIM, not by spelling: every sentence in the PR's 14-file set that asserts what happens to an UNDECLARED throw was enumerated and judged against the built bundle. Blocking: - driver-error-classification.ts: "so a record always carries a sentence rather than `undefined` or an empty string" SHIPPED and was false. The fallback now states only what it does — the same surface channel an undeclared throw reads, which is '' exactly when that channel is. The suite carrying the same absolute ("a record is never empty or undefined") is renamed to what it pins. - Both raw-exec-operator-detail-16657.test.ts docblocks said an undeclared throw reaches the record "exactly as it did before". They now state what the pins verify — not unwrapped, `cause` never walked, read on the value's own message channel — and name the measured differences from the replaced expressions. Non-blocking, same commit: the object-message illustration is scoped to a NON-EMPTY `message` (3); the changeset's "one shape" count is corrected to the three measured (4); "at every site this helper replaces" is scoped to the five `(e as Error).message` sites of fourteen (5); the recognizer's "returned exactly as it arrived" is scoped to a non-empty sentence, with the empty-message case stated as measured (6); the eight identity-worded pin titles now name the message channel instead of byte-identity (7). Comments, JSDoc and test titles only: 82 changed lines across five .ts files, 0 not a comment or a title; comment+title-stripped sources hash identical to HEAD in all five; 0 skips, `it()` counts unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- .../operator-facing-raw-exec-cause-text.md | 11 ++++--- ...r-16657-operator-facing-cause-text.test.ts | 2 +- .../raw-exec-operator-detail-16657.test.ts | 26 +++++++++++++---- .../raw-exec-operator-detail-16657.test.ts | 21 ++++++++++---- ...error-classification.operator-text.test.ts | 4 +-- .../types/src/driver-error-classification.ts | 29 ++++++++++++++----- 6 files changed, 68 insertions(+), 25 deletions(-) diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md index a4dbbf4276..b5926a804b 100644 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ b/.changeset/operator-facing-raw-exec-cause-text.md @@ -56,9 +56,10 @@ list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads 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 string `message` -reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]`. A thrown -EMPTY string reads `''`, so this channel is neither always prose nor never empty. +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 @@ -78,7 +79,9 @@ and never a type. The change these sites were made for is the declared raw-path 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, and one shape (a thrown empty string) still records `''`. The +bounded list of exceptions, and some shapes still record `''` — a thrown empty string, a +thrown empty array, and an `Error` whose `name` and `message` are both empty are the ones +measured. 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/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 index edc3da74f1..5c843f0d32 100644 --- 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 @@ -81,7 +81,7 @@ describe('[#16657] a real raw-exec refusal still yields the dialect text to an o expect(operatorText).not.toMatch(/refused to run a raw statement/); }); - it('an UNDECLARED throw from the same seam is returned unchanged', async () => { + 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'); 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 index 4eff5bd4da..6b6a3c739b 100644 --- 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 @@ -23,9 +23,23 @@ * "after" assertion unfalsifiable. * * The negative direction is pinned per site as well: a seam failure that is NOT - * a declared raw-statement fault must reach the record exactly as it did - * before, because the alternative — a helper that unwraps whatever it is handed - * — is the message sniffing #16019 exists to remove. + * 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)` — because the alternative, a + * helper that unwraps whatever it is handed, is the message sniffing #16019 + * exists to remove. + * + * ⚠️ 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 @@ -121,7 +135,7 @@ describe('[#16657] runtime-index-preflight — the per-probe detail', () => { expect(results.every((p) => p.detail === 'no such table: main.sys_metadata')).toBe(true); }); - it('an UNDECLARED seam failure reaches the detail exactly as before', async () => { + it('an UNDECLARED seam failure reaches the detail on its own message channel', async () => { const exec: IndexExec = async () => { throw new Error('connection terminated unexpectedly'); }; @@ -179,7 +193,7 @@ describe('[#16657] partial-index-probe — the detail both callers report', () = expect(outcome.detail).toBe('near "where": syntax error'); }); - it('an UNDECLARED build failure reports its own message, unchanged', async () => { + 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 []; @@ -289,7 +303,7 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => expect(line?.meta?.error).toBe(DIALECT_TEXT); }); - it('an UNDECLARED refusal keeps its own message at every one of these sites', async () => { + it('an UNDECLARED refusal reads its own message channel at every one of these sites', async () => { const log = createLogger(); const bare = async (sql: string): Promise => { if (sql.includes('rows_holding')) throw new Error('connection terminated unexpectedly'); diff --git a/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts b/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts index e2faa0982a..dbedaef3d2 100644 --- a/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts +++ b/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts @@ -16,7 +16,18 @@ * * Each case pins the "before" half beside the "after" one — the envelope's own * message is the composed sentence, and the record's is not — plus the negative - * direction: an UNDECLARED throw reaches the record exactly as it did before. + * direction: an UNDECLARED throw is NOT unwrapped. Its `cause` is never walked + * and the record reads the thrown value's own message channel, + * `messageChannelOf(error) || String(error)`. + * + * ⚠️ That channel is a RULE, not byte-identity with what these `catch` blocks + * used to compute, and the negative pins below do not claim otherwise: each + * throws a NON-EMPTY `new Error(…)`, the shape for which the rule and the old + * expression agree. They differ elsewhere — at the three `err?.message ?? + * String(err)` sites `new Error('')` recorded `''` and now records `'Error'`, + * and `{message:42}` recorded the number where it now records + * `'[object Object]'`; at the `error instanceof Error ? … : String(error)` site + * `{message:'x'}` recorded `'[object Object]'` and now records `'x'`. * * ⚠️ The composed sentence is the producer's, copied; `driver-sql`'s * `sql-driver-16657-operator-facing-cause-text.test.ts` pins the copy against a @@ -93,7 +104,7 @@ describe('[#16657] migrateEnvIdToProjectId — the per-table error record', () = } }); - it('an UNDECLARED refusal is recorded exactly as before', async () => { + 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')), ); @@ -116,7 +127,7 @@ describe('[#16657] migrateProjectIdToEnvironmentId — the per-table error recor for (const row of errors) expect(row.error).toBe(DIALECT_TEXT); }); - it('an UNDECLARED refusal is recorded exactly as before', async () => { + 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')), ); @@ -140,7 +151,7 @@ describe('[#16657] dropProjectionTables — the per-table error record', () => { } }); - it('an UNDECLARED refusal is recorded exactly as before', async () => { + it('an UNDECLARED refusal is recorded on its own message channel', async () => { const results = await dropProjectionTables({ async execute() { throw new Error('database is locked'); @@ -170,7 +181,7 @@ describe('[#16657] migrateSysNotificationToEvent — the run-level error record' expect(result.error).not.toContain('refused to run a raw statement'); }); - it('an UNDECLARED refusal is recorded exactly as before', async () => { + 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, diff --git a/packages/types/src/driver-error-classification.operator-text.test.ts b/packages/types/src/driver-error-classification.operator-text.test.ts index 3a9e1b69a4..1eac38a3a2 100644 --- a/packages/types/src/driver-error-classification.operator-text.test.ts +++ b/packages/types/src/driver-error-classification.operator-text.test.ts @@ -130,7 +130,7 @@ describe('[#16657] operatorFacingErrorText — the raw-path envelope', () => { }); }); -describe('[#16657] operatorFacingErrorText — a record is never empty or undefined', () => { +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. @@ -140,7 +140,7 @@ describe('[#16657] operatorFacingErrorText — a record is never empty or undefi expect(operatorFacingErrorText(thrown)).not.toBe(''); }); - it('answers prose for a thrown value that is not an Error at all', () => { + it('reads a thrown non-Error on its own channel, where `(e as Error).message` read `undefined`', () => { // `(e as Error).message` — the expression this helper replaces at five // sites — evaluates to `undefined` for every one of these. expect(operatorFacingErrorText('no such column: foo')).toBe('no such column: foo'); diff --git a/packages/types/src/driver-error-classification.ts b/packages/types/src/driver-error-classification.ts index b76ed87d0c..f212917603 100644 --- a/packages/types/src/driver-error-classification.ts +++ b/packages/types/src/driver-error-classification.ts @@ -685,7 +685,13 @@ const DECLARED_DATABASE_FAULT_CODE = 'DATABASE_ERROR'; * 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 but does not carry this sentence is returned exactly as it arrived. + * the code and composes a DIFFERENT, NON-EMPTY sentence 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` @@ -701,8 +707,11 @@ const RAW_STATEMENT_FAULT_SENTENCE = /refused to run a raw statement/; * `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 every site this helper replaces — and a - * node whose own text is empty, a thrown empty string among them, reads `''`. + * `(e as Error).message` produced at the FIVE sites spelled that way — 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; @@ -748,7 +757,9 @@ function messageChannelOf(node: unknown): string { * `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 - * string `message` reads it where `String(err)` recorded `[object Object]`. 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; @@ -757,9 +768,13 @@ function messageChannelOf(node: unknown): string { * * 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 surface - * message, so a record always carries a sentence rather than `undefined` or an - * empty string. + * 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 From 82ee689cdb2aafb8e4a478ef0afe28b0ed88eb4d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:15:45 +0000 Subject: [PATCH 09/10] docs(types): the empty-message fallback pin names the `name` it reads, not "prose" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The by-rule census caught one more carrier of the "always prose" half of the claim, in a title no spelling-census would have matched: "answers prose for a declared envelope whose own message is empty". Measured false in general — a declared envelope whose `message` AND `name` are both empty answers '' — and it contradicted the fallback sentence corrected in the previous commit. True of its own pin, which reads `name` = 'Error'; the title now says that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- .../types/src/driver-error-classification.operator-text.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/driver-error-classification.operator-text.test.ts b/packages/types/src/driver-error-classification.operator-text.test.ts index 1eac38a3a2..24a9cb434f 100644 --- a/packages/types/src/driver-error-classification.operator-text.test.ts +++ b/packages/types/src/driver-error-classification.operator-text.test.ts @@ -150,7 +150,7 @@ describe('[#16657] operatorFacingErrorText — the fallback channel when no caus expect(operatorFacingErrorText({})).toBe('[object Object]'); }); - it('answers prose for a declared envelope whose own message is empty', () => { + it('falls back to the `name` of a declared envelope whose own message is empty', () => { const empty = rawStatementFault(undefined, ''); expect(operatorFacingErrorText(empty)).toBe('Error'); }); From cd674714251dfca9b6db8d46b0f21b8891fbe7cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:56:30 +0000 Subject: [PATCH 10/10] docs(types,metadata-protocol,changeset): state the split, and name the one surviving fallback The census by rule found two claims carried in more than one place: a false universal about what `(e as Error).message` did to an undeclared throw, and a record formula that one of the fourteen call sites does not follow. Over the five values pinned at the operator-text fallback case, that expression did not answer one way. It read `undefined` for the string, the number and `{}`, and it threw a `TypeError` for `null` and `undefined`. The pin's title and the comment under it now state that split, which the shipped `operatorFacingErrorText` docblock and both site docblocks already stated. `seed-tenancy-backfill`'s organization probe keeps `operatorFacingErrorText(e) || 'unknown error'`, so for an EMPTY channel it records `'unknown error'`, not `''`. The metadata-protocol docblock, the pin title that claimed every site, and the changeset -- which ships as release notes -- now name that fallback and scope the formula to the other thirteen sites. The fallback is load-bearing rather than leftover: the site reads an empty value as "the probe did not fail", and with it removed an empty channel routes the run down the benign no-organization-yet path. Whether it should go is a behaviour question, tracked by #17167. Two more sentences of the same class: the recognizer docblock now reads "a NON-EMPTY sentence this fragment does not match" rather than "a DIFFERENT, NON-EMPTY sentence", and `messageChannelOf`'s docblock no longer attributes `undefined` to an expression that threw. No behaviour change. 58 changed .ts lines, 54 comment and 4 title, 0 other; comment-stripped and title-blanked hashes identical on all 13 .ts files; and packages/types/dist/index.mjs, dist/index.js and dist/index.d.ts are each byte-identical across this round. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- .../operator-facing-raw-exec-cause-text.md | 15 ++++++---- .../raw-exec-operator-detail-16657.test.ts | 30 ++++++++++++++++--- ...error-classification.operator-text.test.ts | 8 +++-- .../types/src/driver-error-classification.ts | 20 +++++++------ 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md index b5926a804b..d9f5558e19 100644 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ b/.changeset/operator-facing-raw-exec-cause-text.md @@ -79,9 +79,12 @@ and never a type. The change these sites were made for is the declared raw-path 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, and some shapes still record `''` — a thrown empty string, a -thrown empty array, and an `Error` whose `name` and `message` are both empty are the ones -measured. 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. +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/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts b/packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts index 6b6a3c739b..3d7730013e 100644 --- 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 @@ -25,9 +25,25 @@ * 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)` — because the alternative, a - * helper that unwraps whatever it is handed, is the message sniffing #16019 - * exists to remove. + * `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 @@ -303,7 +319,13 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => expect(line?.meta?.error).toBe(DIALECT_TEXT); }); - it('an UNDECLARED refusal reads its own message channel at every one of these sites', async () => { + 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'); diff --git a/packages/types/src/driver-error-classification.operator-text.test.ts b/packages/types/src/driver-error-classification.operator-text.test.ts index 24a9cb434f..7093ac0eb9 100644 --- a/packages/types/src/driver-error-classification.operator-text.test.ts +++ b/packages/types/src/driver-error-classification.operator-text.test.ts @@ -140,9 +140,13 @@ describe('[#16657] operatorFacingErrorText — the fallback channel when no caus expect(operatorFacingErrorText(thrown)).not.toBe(''); }); - it('reads a thrown non-Error on its own channel, where `(e as Error).message` read `undefined`', () => { + 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 — evaluates to `undefined` for every one of these. + // 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'); diff --git a/packages/types/src/driver-error-classification.ts b/packages/types/src/driver-error-classification.ts index f212917603..8f514b4f34 100644 --- a/packages/types/src/driver-error-classification.ts +++ b/packages/types/src/driver-error-classification.ts @@ -685,10 +685,11 @@ const DECLARED_DATABASE_FAULT_CODE = 'DATABASE_ERROR'; * 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 DIFFERENT, NON-EMPTY sentence 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: + * 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. @@ -707,11 +708,12 @@ const RAW_STATEMENT_FAULT_SENTENCE = /refused to run a raw statement/; * `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 — 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 `''`. + * `(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;