From a7c5af31c55ea816693f88bf0a1b128e42dd9258 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:23:08 +0000 Subject: [PATCH 1/4] fix(types): operatorFacingErrorText answers through the one driver-fault cut WIP: the helper's single exit passes its answer through redactStatementFromMessage; text reached below the raw-path sentence is cut with statementSent, everything else asks the shared leak predicate. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- ...error-classification.operator-text.test.ts | 172 ++++++++++++++++-- .../types/src/driver-error-classification.ts | 89 +++++++-- 2 files changed, 237 insertions(+), 24 deletions(-) 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 1d01d7e1223..595ac3bc9b7 100644 --- a/packages/types/src/driver-error-classification.operator-text.test.ts +++ b/packages/types/src/driver-error-classification.operator-text.test.ts @@ -9,8 +9,9 @@ * 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: + * that costs nothing — the driver writes the dialect's diagnostic to its warn + * sink one line earlier, with the statement and its bound values cut (#21385). + * 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"*. @@ -43,24 +44,35 @@ import { describe, expect, it } from 'vitest'; import { operatorFacingErrorText } from './driver-error-classification.js'; +import { redactStatementFromMessage } from './driver-fault-redaction.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.'; + "backend's own diagnostic was written to the server log for an operator to read, with " + + 'the statement and its bound values cut.'; /** `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.'; + "claimed here. The backend's own diagnostic was written to the server log for an " + + 'operator to read, with the compiled statement and its bound values cut.'; /** knex 3.3.0 + better-sqlite3: ` - `. */ const DIALECT_TEXT = 'select "foo" from "sys_metadata" - no such column: foo'; +/** + * [#21418] What the helper answers for text it reached BELOW the raw-path + * sentence: THE driver-fault cut's own answer, under the rule the driver's raw + * terminal writes its log line by. Asserted as identity with the cutter rather + * than as a spelled-out string, so these cases pin "one cutter, one rule" and + * never the cutter's marker wording. + */ +const cutBelowRawPath = (text: string): string => + redactStatementFromMessage(text, { statementSent: true }); + interface Declared extends Error { code?: string; status?: number; @@ -97,9 +109,11 @@ describe('[#16657] operatorFacingErrorText — the raw-path envelope', () => { 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); + // AFTER — the record an operator reads names the column, and [#21418] + // carries the dialect's diagnostic, not the statement that led it. + expect(operatorFacingErrorText(thrown)).toBe(cutBelowRawPath(DIALECT_TEXT)); expect(operatorFacingErrorText(thrown)).toContain('no such column: foo'); + expect(operatorFacingErrorText(thrown)).not.toContain('select "foo" from'); }); it('walks PAST a nested wrapper that re-composed the same sentence', () => { @@ -108,7 +122,7 @@ describe('[#16657] operatorFacingErrorText — the raw-path envelope', () => { // strand the dialect text one level deeper than the walk looks. const thrown = rawStatementFault(rawStatementFault(dialectError())); - expect(operatorFacingErrorText(thrown)).toBe(DIALECT_TEXT); + expect(operatorFacingErrorText(thrown)).toBe(cutBelowRawPath(DIALECT_TEXT)); }); it('skips a node that carries no message channel at all', () => { @@ -120,7 +134,7 @@ describe('[#16657] operatorFacingErrorText — the raw-path envelope', () => { configurable: true, }); // The intermediate node says nothing; the one below it does. - expect(operatorFacingErrorText(silent)).toBe(DIALECT_TEXT); + expect(operatorFacingErrorText(silent)).toBe(cutBelowRawPath(DIALECT_TEXT)); }); it('reads a cause that is a bare string, not an Error', () => { @@ -229,6 +243,142 @@ describe('[#16657] operatorFacingErrorText — the depth bound actually bounds', let atBound: Declared = rawStatementFault(dialectError()); for (let i = 0; i < 3; i += 1) atBound = rawStatementFault(atBound); - expect(operatorFacingErrorText(atBound)).toBe(DIALECT_TEXT); + expect(operatorFacingErrorText(atBound)).toBe(cutBelowRawPath(DIALECT_TEXT)); + }); +}); + +/** + * [#21418] The family's fourth position: the helper's answer is cut by + * construction, through THE driver-fault cut and no copy of it. + * + * Every fixture below binds one synthetic sentinel into a raw statement the + * way knex prints it on SQLite and MySQL, ` - + * `, or inlines it where a dialect's own diagnostic carries a value + * (MySQL's duplicate entry, PostgreSQL's invalid input syntax). Each case + * asserts, in this order: the fixture really carries the sentinel on the + * `cause` the helper reads (so its absence afterwards is a measurement, not a + * property of the fixture); the helper's answer carries none of it; and the + * dialect's diagnostic survives in that answer. The real producer's leg — + * a real `SqlDriver.execute()` refusal with the sentinel bound — is + * `driver-sql`'s `sql-driver-16657-operator-facing-cause-text.test.ts`. + */ +describe('[#21418] operatorFacingErrorText — a bound value reaches no carrier through the helper', () => { + /** Synthetic, and asserted ABSENT from every answer below. */ + const SENTINEL = 'SENTINEL-21418-BOUND-VALUE'; + + /** The `cause` message, read before the helper runs: the non-vacuity leg. */ + const causeMessageOf = (thrown: Declared): string => + String((thrown as { cause?: { message?: unknown } }).cause?.message ?? ''); + + const RAW_PATH_CELLS: Array<[label: string, dialect: string, diagnostic: string]> = [ + [ + 'SQLite, a statement opening with a verb the shared leak predicate lists', + `insert into "crm_account" ("name", "email") values ('Acme', '${SENTINEL}') - ` + + 'UNIQUE constraint failed: crm_account.email', + 'UNIQUE constraint failed: crm_account.email', + ], + [ + // The predicate reads neither the verb nor this diagnostic (#21345's + // class): only the walk's knowledge that a statement was SENT cuts it. + 'SQLite, a statement opening with a verb the predicate does not list', + `with s as (select '${SENTINEL}' as v) select translate(v) from s - ` + + 'no such function: translate', + 'no such function: translate', + ], + [ + 'MySQL, the value inlined in the statement AND in its own diagnostic', + `insert into \`crm_account\` (\`email\`) values ('${SENTINEL}') - ` + + `Duplicate entry '${SENTINEL}' for key 'crm_account.email'`, + "for key 'crm_account.email'", + ], + [ + 'PostgreSQL, the value inlined in its own diagnostic only', + 'insert into "crm_account" ("age") values ($1) - ' + + `invalid input syntax for type integer: "${SENTINEL}"`, + 'invalid input syntax for type integer', + ], + ]; + + for (const [label, dialect, diagnostic] of RAW_PATH_CELLS) { + it(`the raw-path envelope: ${label}`, () => { + const thrown = rawStatementFault(dialectError(dialect)); + expect(causeMessageOf(thrown)).toContain(SENTINEL); + + const answer = operatorFacingErrorText(thrown); + + expect(answer).not.toContain(SENTINEL); + expect(answer).toContain(diagnostic); + // One cutter, one rule: the answer IS the cut the driver's own + // raw-terminal line writes for this fault. + expect(answer).toBe(cutBelowRawPath(dialect)); + }); + } + + it('a re-wrapped envelope, and a dialect text at the depth bound, are cut the same way', () => { + const dialect = RAW_PATH_CELLS[1][1]; + let atBound: Declared = rawStatementFault(dialectError(dialect)); + for (let i = 0; i < 3; i += 1) atBound = rawStatementFault(atBound); + + for (const thrown of [rawStatementFault(rawStatementFault(dialectError(dialect))), atBound]) { + const answer = operatorFacingErrorText(thrown); + expect(answer).not.toContain(SENTINEL); + expect(answer).toContain('no such function: translate'); + } + }); + + it('a `cause` that is a bare string is cut the same way', () => { + const dialect = RAW_PATH_CELLS[0][1]; + const answer = operatorFacingErrorText(rawStatementFault(dialect)); + + expect(answer).not.toContain(SENTINEL); + expect(answer).toBe(cutBelowRawPath(dialect)); + }); + + it('an UNDECLARED driver dump is cut by the shared leak predicate, as the engine\'s own log line is', () => { + // No raw-path sentence above it, so the walk knows nothing about where + // the text came from: the predicate's verdict decides, exactly as it + // does for `redactBoundStatement`'s log line. + const dump = dialectError(`update "crm_account" set "email" = '${SENTINEL}' - NOT NULL constraint failed: crm_account.name`); + dump.code = 'SQLITE_CONSTRAINT'; + expect(dump.message).toContain(SENTINEL); + + const answer = operatorFacingErrorText(dump); + + expect(answer).not.toContain(SENTINEL); + expect(answer).toContain('NOT NULL constraint failed: crm_account.name'); + expect(answer).toBe(redactStatementFromMessage(dump.message)); + }); + + it('the thrown value is not touched: its code, status, class and cause reach every other reader as composed', () => { + class DialectFault extends Error {} + const cause = new DialectFault(RAW_PATH_CELLS[0][1]); + const thrown = rawStatementFault(cause); + const before = { message: thrown.message, stack: thrown.stack, causeStack: cause.stack }; + + expect(operatorFacingErrorText(thrown)).not.toContain(SENTINEL); + + // The cut is of the ANSWER. Classifiers downstream (`isMissingTableError`, + // `classifyIndexFailure`) read the error object, so it must not move. + expect(thrown.message).toBe(before.message); + expect(thrown.stack).toBe(before.stack); + expect(thrown.code).toBe('DATABASE_ERROR'); + expect(thrown.status).toBe(500); + expect((thrown as { cause?: unknown }).cause).toBe(cause); + expect(cause).toBeInstanceOf(DialectFault); + expect(cause.message).toBe(RAW_PATH_CELLS[0][1]); + expect(cause.stack).toBe(before.causeStack); + }); + + it('CONTROL: text that is no driver dump comes back byte-identical, empty text included', () => { + // The cut narrows WHAT is written, never whether: the answers the + // sections above pin for non-dump text are unchanged by it. + expect(operatorFacingErrorText(rawStatementFault('no such table: sys_metadata'))).toBe( + 'no such table: sys_metadata', + ); + expect(operatorFacingErrorText(new Error('connection terminated unexpectedly'))).toBe( + 'connection terminated unexpectedly', + ); + expect(operatorFacingErrorText(rawStatementFault(undefined))).toBe(RAW_PATH_COMPOSED); + expect(operatorFacingErrorText('')).toBe(''); }); }); diff --git a/packages/types/src/driver-error-classification.ts b/packages/types/src/driver-error-classification.ts index 48f442b7995..28e1b68cfa0 100644 --- a/packages/types/src/driver-error-classification.ts +++ b/packages/types/src/driver-error-classification.ts @@ -136,6 +136,10 @@ // module was already this one's dependency across the package boundary; since // commit 6a180e42d moved this file into `@objectstack/types`, the two are siblings. import { isRelationSubObjectPhrase } from './relation-sub-object.js'; +// [#21418] The ONE driver-fault cut, a sibling module of this package since +// #21385 placed it here. `operatorFacingErrorText` below answers only through +// it — see that docblock — so adopting it adds no edge and no copy. +import { redactStatementFromMessage, type DriverFaultOrigin } from './driver-fault-redaction.js'; /** * The relation name each missing-table phrase puts on display, one capture per @@ -678,7 +682,9 @@ const DECLARED_DATABASE_FAULT_CODE = 'DATABASE_ERROR'; * error whole under a non-enumerable `cause`. That is the disclosure clause of * the raw path and ⛔ is not reverted here: the fix for an operator record is * to read the `cause` the driver already attached, never to widen what the - * envelope discloses. + * envelope discloses. [#21418] And that `cause` is read through the one + * driver-fault cut, never whole: it opens with the statement the driver sent + * (see {@link operatorFacingErrorText}). * * ⚠️ Matching the sentence — rather than the declaration alone — is what keeps * the READ-exit envelope (`backendStatementFaultError`, the #8931 / PR #9273 @@ -727,7 +733,9 @@ function messageChannelOf(node: unknown): string { /** * The text an OPERATOR should read for `error` — the dialect's own words when a - * driver composed over them, the error's own message otherwise (commit 5a95b0e93). + * driver composed over them, the error's own message otherwise (commit 5a95b0e93) + * — and in either case CUT, so no statement and no bound value of one reaches + * the record it is written to (#21418). * * # The defect this closes * @@ -738,16 +746,50 @@ function messageChannelOf(node: unknown): string { * into an operator-facing record therefore began storing *"the database refused * to run a raw statement"* where it used to store *"no such column: foo"*. * - * For a LIVE console that is cosmetic — the driver writes the statement and the - * dialect text to its warn sink one line earlier, so the operator has already - * read it. For a STORED record it is not: whoever reads a backfill's `detail` - * field a week later never had that console line, and for them the dialect's - * words are unrecoverable. This helper is for the second class. + * For a LIVE console that is cosmetic — the driver writes the dialect's + * diagnostic to its warn sink one line earlier, with the statement and its + * bound values cut (#21385), so the operator has already read it. For a STORED + * record it is not: whoever reads a backfill's `detail` field a week later + * never had that console line, and for them the dialect's words are + * unrecoverable. This helper is for the second class. + * + * # The answer is cut by construction (#21418) + * + * The dialect error on a raw-statement envelope's `cause` is knex's + * ` - `: it opens with the statement the driver sent, + * compiled with its bound values inlined on SQLite and MySQL, and a dialect may + * inline a value in the diagnostic itself. Returning it whole handed every + * record this helper fills — a log line's meta, a result's `detail` or `error` + * — the values bound into the statement, and those records leave the data's + * trust boundary like any log does. The maintainer's ruling A on #21385 is + * "one cutter for every log face", so the single exit below passes the answer + * through THE driver-fault cut, {@link redactStatementFromMessage} + * (`./driver-fault-redaction.ts`). ⛔ No copy of the cut lives here, and ⛔ none + * is repeated at the callers: a cut at each caller is one more chance per + * caller to miss one. Which rule the cut runs under is what the walk below + * KNOWS about the text, never what the text looks like: + * + * - **text reached BELOW the raw-path sentence** is the fault of a statement + * the driver itself sent — that is the only thing such an envelope wraps — + * so it is cut with `{ statementSent: true }`: the argument, and therefore + * the text, the driver's own raw-terminal warn line writes for the same + * fault, whatever word the statement opens with (#21345); + * - **every other answer** — an undeclared throw, a declared envelope this + * walk does not unwrap, the fallback channel — asks the shared leak + * predicate, as the engine's own log line does: a driver dump is cut, and + * anything else comes back exactly as before. + * + * What survives is what an operator came for: the dialect's own diagnostic, + * minus the value slots the cut's templates own, with the cut's marker where a + * statement was removed. The thrown value is never touched, so its `code`, + * `status`, class and `cause` reach every classifier that reads them exactly as + * the driver composed them. * * # What it does, and the two things that bound it * * It walks the `cause` chain to the first node that says something which is not - * the raw-path composed sentence, and returns that. Both narrowings matter: + * the raw-path composed sentence, and returns that, cut as above. Both + * narrowings matter: * * - **only a DECLARED fault is reinterpreted.** An undeclared throw — anything * without `code: DATABASE_ERROR` — comes back as `messageChannelOf(error) || @@ -776,22 +818,43 @@ function messageChannelOf(node: unknown): string { * 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. + * the fallback rules out is `undefined`, never emptiness. The cut keeps both + * halves of that reading: it answers `''` for `''` and non-empty text for + * non-empty text. * * @param error - the thrown value, of any shape. - * @returns text for an operator; never `undefined`, never empty for a thrown - * value that has any textual channel at all. + * @returns text for an operator, cut; never `undefined`, never empty for a + * thrown value that has any textual channel at all. */ export function operatorFacingErrorText(error: unknown): string { - const surface = messageChannelOf(error) || String(error); + const { text, origin } = uncutOperatorText(error); + return redactStatementFromMessage(text, origin); +} + +/** What the walk knows when it never passed the raw-path sentence: nothing. */ +const NO_STATEMENT_KNOWN: DriverFaultOrigin = {}; + +/** + * [#21418] The text {@link operatorFacingErrorText} answers BEFORE the cut, and + * what the walk learned about where that text came from. Module-private on + * purpose: its answer is not operator-facing text — it may open with a + * statement and the values bound into it — so the only way out of this module + * is through the cut. + */ +function uncutOperatorText(error: unknown): { text: string; origin: DriverFaultOrigin } { + const surface = { text: messageChannelOf(error) || String(error), origin: NO_STATEMENT_KNOWN }; if (typeof error !== 'object' || error === null) return surface; const { code } = error as { code?: unknown }; if (code !== DECLARED_DATABASE_FAULT_CODE) return surface; + // Set once the walk steps past a raw-path envelope: everything below one is + // the fault of a statement the driver sent. + let statementSent = false; let node: unknown = error; for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth += 1) { const text = messageChannelOf(node); - if (text !== '' && !RAW_STATEMENT_FAULT_SENTENCE.test(text)) return text; + if (RAW_STATEMENT_FAULT_SENTENCE.test(text)) statementSent = true; + else if (text !== '') return { text, origin: { statementSent } }; if (node === null || (typeof node !== 'object' && typeof node !== 'function')) break; node = (node as { cause?: unknown }).cause; } From 8987141420636d9c78a65dad9baaa6bb11f8642a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:33:31 +0000 Subject: [PATCH 2/4] test: pin the helper's callers and their carriers with a bound sentinel WIP: enumeration pin of operatorFacingErrorText's callers, sentinel pins at each caller's carriers (cli, metadata-protocol, metadata), the real-producer leg in driver-sql, and the stale envelope prose corrected. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .../db/clean.operator-text-21418.test.ts | 169 ++++++++++ ...r-16657-operator-facing-cause-text.test.ts | 33 ++ .../sql-driver-diagnostic-value-probe.test.ts | 23 +- .../raw-exec-operator-detail-16657.test.ts | 293 +++++++++++++++++- .../raw-exec-operator-detail-16657.test.ts | 81 ++++- ...ngine-find-missing-table-log-level.test.ts | 4 +- .../raw-statement-fault-redaction.test.ts | 11 +- ...oor-16019-raw-statement-fault-code.test.ts | 4 +- ...river-error-classification.callers.test.ts | 147 +++++++++ 9 files changed, 729 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/commands/db/clean.operator-text-21418.test.ts diff --git a/packages/cli/src/commands/db/clean.operator-text-21418.test.ts b/packages/cli/src/commands/db/clean.operator-text-21418.test.ts new file mode 100644 index 00000000000..2f72b96e5e8 --- /dev/null +++ b/packages/cli/src/commands/db/clean.operator-text-21418.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21418] `os db clean` prints a refused `VACUUM` through + * `operatorFacingErrorText`, and a value bound into a raw statement reaches + * none of what it prints. + * + * ## Why this file exists + * + * The command reaches SQLite through the driver's raw seam + * (`driver.execute`), which declares its own fault since #16019: a composed + * `DATABASE_ERROR` envelope with the dialect error whole under its `cause`. + * The command's one carrier is the line it prints for a file it failed to + * clean, and that line embeds the helper's answer. The helper used to answer + * the `cause`'s message whole — knex's ` - `, which + * inlines the statement's bound values on SQLite — so the line carried them. + * The helper now answers through the one driver-fault cut (the maintainer's + * ruling A on #21385, "one cutter for every log face"), and this command cuts + * nothing of its own. + * + * ## What is pinned, and what is stubbed + * + * The REAL oclif command runs with a real argv against a real file on disk. + * Two seams are stubbed, neither of them the mechanism under test: + * + * - `@objectstack/service-datasource`'s `resolveSqliteDriver` answers a + * driver double whose `execute` raises the raw-path envelope, so the case + * needs no SQLite engine and stays in the `unit` tier. The statements the + * command sent are recorded, which proves the refusal came from the + * command's own `execute` call rather than from somewhere earlier; + * - `@objectstack/runtime`'s `resolveProjectDatabaseUrl` is never consulted + * when `--database` is passed, but the command imports the module first, + * and booting it here would cost the tier for nothing. + * + * The statements this command sends bind nothing (the census on #21418), so + * the envelope's `cause` carries a synthetic sentinel in a synthetic bound + * statement, printed the way knex prints one on SQLite. The envelope's shape is + * pinned against the real producer by `driver-sql`'s + * `sql-driver-16657-operator-facing-cause-text.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Synthetic, and asserted ABSENT from everything the command prints. */ +const SENTINEL = 'SENTINEL-21418-BOUND-VALUE'; + +/** `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 was written to the server log for an operator to read, with " + + 'the statement and its bound values cut.'; + +/** knex 3.3.0 + better-sqlite3: ` - `. */ +const BOUND_DIALECT_TEXT = `update "sys_setting" set "value" = '${SENTINEL}' - database is locked`; + +/** The envelope the raw terminal composes, cause carrier and all. */ +function rawStatementFault(): Error { + const err = Object.assign(new Error(COMPOSED), { code: 'DATABASE_ERROR', status: 500 }); + Object.defineProperty(err, 'cause', { + value: Object.assign(new Error(BOUND_DIALECT_TEXT), { code: 'SQLITE_BUSY' }), + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + +/** + * The driver double's state. `vi.hoisted` because `vi.mock`'s factory is + * hoisted above every `import` and runs while `./clean.js` is being evaluated. + */ +const stub = vi.hoisted(() => ({ + statements: [] as string[], + thrown: undefined as unknown, +})); + +vi.mock('@objectstack/service-datasource', () => ({ + resolveSqliteDriver: async () => ({ + engine: 'native', + driver: { + async execute(sql: string) { + stub.statements.push(sql); + throw stub.thrown; + }, + async disconnect() {}, + }, + }), +})); + +vi.mock('@objectstack/runtime', () => ({ + resolveProjectDatabaseUrl: () => undefined, +})); + +import DbClean from './clean.js'; + +/** `packages/cli` — the oclif root the command is loaded against. */ +const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +/** + * `chalk` may or may not emit SGR codes depending on TTY detection. The escape + * is spelled as an escape, never as the byte itself. + */ +const SGR = /\x1b\[[0-9;]*m/g; + +async function runClean(argv: string[]): Promise<{ out: string; exitCode: number }> { + const chunks: string[] = []; + const record = (...args: unknown[]) => { + chunks.push(args.map(String).join(' ')); + }; + const spies = [ + vi.spyOn(console, 'log').mockImplementation(record), + vi.spyOn(console, 'warn').mockImplementation(record), + vi.spyOn(console, 'error').mockImplementation(record), + ]; + const savedExitCode = process.exitCode; + let exitCode = 0; + try { + await DbClean.run(argv, { root: CLI_ROOT }); + } catch (error: unknown) { + const oclif = (error as { oclif?: { exit?: number } })?.oclif; + exitCode = typeof oclif?.exit === 'number' ? oclif.exit : 1; + } finally { + for (const spy of spies) spy.mockRestore(); + // oclif's default `catch` sets `process.exitCode`; leaving it set would + // fail this vitest worker on a case that passed. + process.exitCode = savedExitCode; + } + return { out: chunks.join('\n').replace(SGR, ''), exitCode }; +} + +describe('[#21418] os db clean — a refused VACUUM prints no bound value', () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'os-db-clean-21418-')); + file = path.join(dir, 'app.db'); + writeFileSync(file, ''); + stub.statements = []; + stub.thrown = rawStatementFault(); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('[the fixture] the raw path really carries the sentinel on the cause the helper reads', () => { + const thrown = rawStatementFault(); + expect((thrown as { cause?: Error }).cause?.message).toContain(SENTINEL); + expect(thrown.message).not.toContain(SENTINEL); + }); + + it('the failure line names the file and the dialect diagnostic, and carries no sentinel', async () => { + const { out, exitCode } = await runClean(['--database', file]); + + // The refusal came from the command's own first statement. + expect(stub.statements[0]).toBe('PRAGMA auto_vacuum = INCREMENTAL'); + expect(exitCode).toBe(1); + expect(out).toContain(`VACUUM failed for ${file}`); + expect(out).toContain('database is locked'); + expect(out).not.toContain(SENTINEL); + expect(out).not.toContain('refused to run a raw statement'); + }); +}); 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 31d10e9d843..55dc4969cff 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,6 +81,39 @@ 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('[#21418] the helper answers the cut text: a value bound into the refused statement does not survive', async () => { + // The producer leg of `@objectstack/types`' sentinel cases: a REAL refusal, + // the value bound through knex, and a statement opening with a verb the + // shared leak predicate does not list over a diagnostic it does not + // recognise — so only the helper's knowledge that the raw path SENT a + // statement cuts it. On better-sqlite3 knex inlines the bound value into + // the statement it prefixes to the dialect's words. + const SENTINEL = 'SENTINEL-21418-BOUND-VALUE'; + const lines: string[] = []; + const recording = new QuietSqlDriver(); + (recording as unknown as { logger: unknown }).logger = { warn: (line: string) => void lines.push(line) }; + try { + const thrown = (await faultOf(() => + recording.execute('with s as (select ? as v) select translate(v) from s', [SENTINEL]), + )) as Error; + + // Non-vacuity: the cause the helper reads really carries the value. + expect(String((thrown as { cause?: { message?: unknown } }).cause?.message)).toContain(SENTINEL); + + const operatorText = operatorFacingErrorText(thrown); + expect(operatorText).not.toContain(SENTINEL); + expect(operatorText).toContain('no such function: translate'); + + // One cutter, one rule: the record an operator reads later is the very + // text the driver's own raw-terminal line wrote for this fault. + expect(lines).toHaveLength(1); + expect(lines[0]).not.toContain(SENTINEL); + expect(lines[0].endsWith(`: ${operatorText}`)).toBe(true); + } finally { + await recording.disconnect(); + } + }); + 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. diff --git a/packages/drivers/driver-sql/src/sql-driver-diagnostic-value-probe.test.ts b/packages/drivers/driver-sql/src/sql-driver-diagnostic-value-probe.test.ts index 7c77d985e4c..baa18e32711 100644 --- a/packages/drivers/driver-sql/src/sql-driver-diagnostic-value-probe.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-diagnostic-value-probe.test.ts @@ -6,7 +6,8 @@ * * ## Why this file exists * - * `redactStatementFromMessage` (`@objectstack/objectql`) keeps the database's + * `redactStatementFromMessage` (`@objectstack/types` since #21385, in + * `driver-fault-redaction.ts`) keeps the database's * diagnostic after the statement cut, on the premise that a diagnostic names * IDENTIFIERS. Commit 4dfa369a9 found one family where that is false — MySQL's * `ER_DUP_ENTRY` inlines the conflicting VALUE — and redacted that one slot. @@ -41,12 +42,14 @@ * or a template's phrasing drifted and the entry that matched it no longer does. * Both are the notification #9160 asked for. * - * ⛔ This probe deliberately does NOT import the redactor. `driver-sql` does not - * depend on `@objectstack/objectql`, and widening that package's public surface - * to reach an internal function is a contract change this card does not carry. - * The division is: this file establishes WHAT THE SERVER SAYS; the redactor's own - * suite (`packages/objectql/src/driver-fault-redaction.test.ts`) drives these - * exact recorded strings through the function. The recorded literals below are + * ⛔ This probe deliberately does NOT import the redactor. It was first kept out + * because the redactor lived in `@objectstack/objectql`, which `driver-sql` does + * not depend on; since #21385 it lives in `@objectstack/types`, which this + * package depends on and whose redaction its own refusal lines call, so the + * reason that stands now is the division of labour alone: this file establishes + * WHAT THE SERVER SAYS; the redactor's own suite + * (`packages/objectql/src/driver-fault-redaction.test.ts`) drives these exact + * recorded strings through the function. The recorded literals below are * duplicated there on purpose, with this file named as their warrant. * * Runs in `Temporal Conformance (live PG + MySQL)`, the one job that stands up @@ -114,9 +117,9 @@ interface ProbeCase { * What these measure is the PREMISE, not the remedy: that the server really does * echo the caller's separator-bearing value into its own words, and that the * naive last-separator cut therefore lands inside that value. The redaction half - * lives in `packages/objectql/src/driver-fault-redaction.test.ts`, for the same - * reason the rest of this file states — `driver-sql` does not depend on - * `@objectstack/objectql`, and this file establishes WHAT THE SERVER SAYS. + * lives in `packages/objectql/src/driver-fault-redaction.test.ts`, for the + * division of labour the rest of this file states: this file establishes WHAT + * THE SERVER SAYS. */ interface SeparatorCase { /** The server's own error code, as it identifies the family. */ 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 c421df2b0cb..14ffefca7a6 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 @@ -9,8 +9,9 @@ * 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 + * whole under a non-enumerable `cause`. The driver prints the dialect's + * diagnostic to its warn sink one line earlier, with the statement and its + * bound values cut (#21385), 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. @@ -68,6 +69,8 @@ */ import { describe, it, expect } from 'vitest'; +import { inspect } from 'node:util'; +import { redactStatementFromMessage } from '@objectstack/types'; import { collectRuntimeIndexPreflight } from './runtime-index-preflight.js'; import { probeThenReplaceIndex, type IndexExec } from './partial-index-probe.js'; @@ -77,18 +80,28 @@ import { ORGANIZATION_TABLE, SEQUENCES_TABLE, } from './seed-tenancy-backfill.js'; +import { readTablePresence } from './read-probe.js'; import { TABLE_IS_PRESENT_ROWS, isTablePresenceCatalogSql } from './read-probe.testkit.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.'; + "backend's own diagnostic was written to the server log for an operator to read, with " + + 'the statement and its bound values cut.'; /** knex 3.3.0 + better-sqlite3: ` - `. */ const DIALECT_TEXT = 'select "foo" from "sys_metadata" - no such column: foo'; +/** + * [#21418] What every record below stores for {@link DIALECT_TEXT}: the helper's + * answer, which is THE driver-fault cut's, under the rule the driver's own raw + * terminal writes its log line by. Spelled as the cutter's answer rather than + * as a string, so these cases pin that each site stores the helper's text and + * never pin the cutter's marker wording. + */ +const RECORDED = redactStatementFromMessage(DIALECT_TEXT, { statementSent: true }); + /** 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 }; @@ -139,7 +152,8 @@ describe('[#16657] runtime-index-preflight — the per-probe detail', () => { expect(results.length).toBeGreaterThan(0); for (const probe of results) { expect(probe.status).toBe('unreadable'); - expect(probe.detail).toBe(DIALECT_TEXT); + expect(probe.detail).toBe(RECORDED); + expect(probe.detail).toContain('no such column: foo'); expect(probe.detail).not.toContain('refused to run a raw statement'); } }); @@ -183,7 +197,7 @@ describe('[#16657] partial-index-probe — the detail both callers report', () = const outcome = await probeThenReplaceIndex(exec, options); expect(outcome.failedAt).toBe('probe'); - expect(outcome.detail).toBe(DIALECT_TEXT); + expect(outcome.detail).toBe(RECORDED); }); it('a refused REPLACE build reports the dialect text', async () => { @@ -195,7 +209,7 @@ describe('[#16657] partial-index-probe — the detail both callers report', () = const outcome = await probeThenReplaceIndex(exec, options); expect(outcome.failedAt).toBe('replace'); - expect(outcome.detail).toBe(DIALECT_TEXT); + expect(outcome.detail).toBe(RECORDED); }); it('the VERDICT is still taken from the error object, not the text', async () => { @@ -264,7 +278,7 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => }); expect(result.status).toBe('absent'); - expect(result.detail).toBe(DIALECT_TEXT); + expect(result.detail).toBe(RECORDED); expect(result.detail).not.toContain('refused to run a raw statement'); }); @@ -277,8 +291,8 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => 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); + expect(line?.message).toContain(RECORDED); + expect(line?.meta?.organizationProbeError).toBe(RECORDED); }); it('[#17167] an EMPTY channel at the organization probe is recorded empty, and still FAILED', async () => { @@ -341,7 +355,7 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => ); const line = log.warn.find((w) => w.message.includes('already-minted duplicates')); - expect(line?.meta?.error).toBe(DIALECT_TEXT); + expect(line?.meta?.error).toBe(RECORDED); }); it('[stamp] the warn meta carries the dialect text', async () => { @@ -357,7 +371,7 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => ); const line = log.warn.find((w) => w.message.includes('could not stamp')); - expect(line?.meta?.error).toBe(DIALECT_TEXT); + expect(line?.meta?.error).toBe(RECORDED); }); it('[counter merge] the warn meta carries the dialect text', async () => { @@ -378,7 +392,7 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => ); const line = log.warn.find((w) => w.message.includes('could not merge the counter')); - expect(line?.meta?.error).toBe(DIALECT_TEXT); + expect(line?.meta?.error).toBe(RECORDED); }); it('an UNDECLARED refusal reads its own message channel at every site', async () => { @@ -399,3 +413,256 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () => expect(line?.meta?.error).toBe('connection terminated unexpectedly'); }); }); + +/** + * [#21418] The family's fourth position, pinned at every site in this package + * that writes `operatorFacingErrorText`'s answer: a synthetic sentinel bound + * into a raw statement reaches none of the carriers the site writes — the + * result it returns, a log line's message or its meta — while the dialect's + * diagnostic and the verdict the site takes from the error object survive. + * + * The cut is the helper's, by construction; no site here cuts anything itself, + * and none needs to. These cases pin that each site writes nothing BUT the + * helper's answer, so a site that one day embeds `cause.message` (or the + * statement it sent) beside it reddens here. + * + * Two shapes of fault, matching the census on #21418: + * - where the site binds a VALUE itself (the backfill's stamp and counter + * merge bind the organization id), the fixture composes the dialect text + * from the statement and parameters the site really sent, inlined as knex + * prints them on SQLite and MySQL, and the sentinel IS that organization id; + * - where the site binds identifiers only, the raw path's `cause` carries the + * sentinel in a synthetic bound statement, and — at the unique-index probe — + * in MySQL's value-bearing duplicate-entry diagnostic, the shape a unique + * index built over duplicate stored rows raises. + */ +describe('[#21418] a bound sentinel reaches no carrier at any site in this package', () => { + const SENTINEL = 'SENTINEL-21418-BOUND-VALUE'; + + /** knex's message for a refused raw statement: values inlined, then the dialect's words. */ + function knexDump(sql: string, params: readonly unknown[] = [], diagnostic: string): string { + let i = 0; + const inlined = sql.replace(/\?/g, () => `'${String(params[i++])}'`); + return `${inlined} - ${diagnostic}`; + } + + /** The identifier-only sites' fault: the sentinel bound into a statement on the raw path. */ + const BOUND_DIALECT_TEXT = knexDump( + 'select "v" from "sys_setting" where "v" = ?', + [SENTINEL], + 'no such column: v', + ); + + /** Every text a recorded log call carries, meta rendered the way a logger would. */ + function logged(log: ReturnType): string { + return log.warn.map((w) => `${w.message} ${inspect(w.meta, { depth: 8 })}`).join('\n'); + } + + it('[the fixture] the raw path really carries the sentinel on the cause the helper reads', () => { + const thrown = rawStatementFault(BOUND_DIALECT_TEXT); + expect((thrown as { cause?: Error }).cause?.message).toContain(SENTINEL); + expect(thrown.message).not.toContain(SENTINEL); + }); + + it('read-probe — the catalog arm and the fallback arm both report cut text', async () => { + const exec = async () => { + throw rawStatementFault(BOUND_DIALECT_TEXT); + }; + + const catalog = await readTablePresence(exec, { + table: SEQUENCES_TABLE, + client: 'better-sqlite3', + fallbackSql: `SELECT 1 FROM ${SEQUENCES_TABLE} WHERE 1 = 0`, + }); + // No catalog arm for an unknown client: the caller's own probe runs. + const fallback = await readTablePresence(exec, { + table: SEQUENCES_TABLE, + client: 'no-such-client', + fallbackSql: `SELECT 1 FROM ${SEQUENCES_TABLE} WHERE 1 = 0`, + }); + + for (const [arm, result] of [['catalog', catalog], ['fallback', fallback]] as const) { + expect(result.verdict, arm).toBe('unreadable'); + expect(result.probe, arm).toBe(arm); + expect(inspect(result), arm).not.toContain(SENTINEL); + expect(result.detail, arm).toContain('no such column: v'); + } + }); + + it('runtime-index-preflight — the per-probe detail and the dead-seam fan-out', async () => { + const perProbe = await collectRuntimeIndexPreflight(async (sql: string) => { + if (sql.includes('HAVING')) throw rawStatementFault(BOUND_DIALECT_TEXT); + return []; + }); + const deadSeam = await collectRuntimeIndexPreflight(async () => { + throw rawStatementFault(BOUND_DIALECT_TEXT); + }); + + for (const results of [perProbe, deadSeam]) { + expect(results.length).toBeGreaterThan(0); + expect(inspect(results, { depth: 8 })).not.toContain(SENTINEL); + for (const probe of results) { + expect(probe.status).toBe('unreadable'); + expect(probe.detail).toContain('no such column: v'); + } + } + }); + + it('partial-index-probe — both legs, the verdict still read off the error object', async () => { + // MySQL raises this when a UNIQUE index is built over duplicate stored + // rows: the conflicting value sits in the dialect's own diagnostic. + const duplicate = (sql: string): Error => { + const err = rawStatementFault( + knexDump(sql, [], `Duplicate entry '${SENTINEL}' for key 't.idx_real'`), + ); + Object.assign((err as { cause: object }).cause, { code: 'ER_DUP_ENTRY', errno: 1062 }); + return err; + }; + const options = { + indexName: 'idx_real', + probeIndexName: 'idx_probe', + buildSql: (name: string) => `CREATE UNIQUE INDEX ${name} ON t (a) WHERE b IS NULL`, + }; + + const atProbe = await probeThenReplaceIndex(async (sql: string) => { + if (sql.startsWith('CREATE')) throw duplicate(sql); + return []; + }, options); + const atReplace = await probeThenReplaceIndex(async (sql: string) => { + if (sql.startsWith(`CREATE UNIQUE INDEX ${options.indexName}`)) throw duplicate(sql); + return []; + }, options); + + expect(atProbe.failedAt).toBe('probe'); + expect(atProbe.status).toBe('conflict'); + expect(atReplace.failedAt).toBe('replace'); + for (const outcome of [atProbe, atReplace]) { + expect(inspect(outcome)).not.toContain(SENTINEL); + expect(outcome.detail).toContain("for key 't.idx_real'"); + } + }); + + describe('seed-tenancy-backfill — all five sites', () => { + /** + * The backfill's own fixture shape (see the `[#16657]` block above), + * with the organization the probe finds BEING the sentinel, so the + * stamp and the counter merge bind it themselves, and every refusal + * composed from the statement and parameters actually sent. + */ + function sentinelExec(refuse: (sql: string) => boolean) { + return async (sql: string, params?: unknown[]): Promise => { + if (refuse(sql)) { + throw rawStatementFault(knexDump(sql, params, 'database table is locked')); + } + if (isTablePresenceCatalogSql(sql, SEQUENCES_TABLE)) return TABLE_IS_PRESENT_ROWS; + 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: SENTINEL }]; + if (sql.includes('rows_holding')) return []; + return []; + }; + } + + /** The site's own fault really carried the sentinel: the non-vacuity leg. */ + function boundBy(refuse: (sql: string) => boolean) { + const seen: string[] = []; + const exec = sentinelExec(refuse); + return { + seen, + exec: async (sql: string, params?: unknown[]) => { + try { + return await exec(sql, params); + } catch (e) { + seen.push(String((e as { cause?: Error }).cause?.message)); + throw e; + } + }, + }; + } + + const SITES: Array<[site: string, refuse: (sql: string) => boolean, line: string, binds: boolean]> = [ + ['split probe → result `detail`', (sql) => sql.includes('LEFT JOIN'), '', false], + ['organization probe → warn message and meta', (sql) => sql.includes(ORGANIZATION_TABLE), 'probe FAILED', false], + ['collision probe → warn meta', (sql) => sql.includes('rows_holding'), 'already-minted duplicates', false], + [ + 'stamp → warn meta', + (sql) => sql.startsWith('UPDATE') && !sql.includes(SEQUENCES_TABLE), + 'could not stamp', + true, + ], + [ + 'counter merge → warn meta', + (sql) => + sql === buildGlobalCounterProbeSql(true, 'better-sqlite3') || + sql === buildGlobalCounterProbeSql(false, 'better-sqlite3'), + 'could not merge the counter', + false, + ], + ]; + + for (const [site, refuse, line, binds] of SITES) { + it(site, async () => { + const log = createLogger(); + const fixture = boundBy(refuse); + // The sites that bind nothing of their own are handed the + // synthetic bound statement; the stamp binds the sentinel itself. + const exec = binds + ? fixture.exec + : async (sql: string, params?: unknown[]) => { + if (refuse(sql)) { + fixture.seen.push(BOUND_DIALECT_TEXT); + throw rawStatementFault(BOUND_DIALECT_TEXT); + } + return fixture.exec(sql, params); + }; + + const result = await backfillSeedTenancy({ exec, client: 'better-sqlite3' }, log.logger); + + expect(fixture.seen.length, 'the site was never refused').toBeGreaterThan(0); + expect(fixture.seen.every((m) => m.includes(SENTINEL))).toBe(true); + expect(inspect(result, { depth: 8 })).not.toContain(SENTINEL); + expect(logged(log)).not.toContain(SENTINEL); + if (line === '') { + expect(result.status).toBe('absent'); + expect(result.detail).toContain('no such column: v'); + } else { + const warned = log.warn.find((w) => w.message.includes(line)); + expect(warned, `no warn line for ${site}`).toBeDefined(); + expect(inspect(warned?.meta, { depth: 8 })).toContain( + binds ? 'database table is locked' : 'no such column: v', + ); + } + }); + } + + it('the presence probe → warn meta and result `detail`, where read-probe\'s answer is logged', async () => { + const log = createLogger(); + const result = await backfillSeedTenancy( + { + exec: async (sql: string) => { + if (isTablePresenceCatalogSql(sql, SEQUENCES_TABLE)) { + throw rawStatementFault(BOUND_DIALECT_TEXT); + } + return []; + }, + client: 'better-sqlite3', + }, + log.logger, + ); + + expect(result.status).toBe('unreadable'); + expect(inspect(result, { depth: 8 })).not.toContain(SENTINEL); + expect(logged(log)).not.toContain(SENTINEL); + expect(result.detail).toContain('no such column: v'); + }); + }); +}); 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 090be652c5f..852ba182db3 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 @@ -39,6 +39,8 @@ */ import { describe, expect, it } from 'vitest'; +import { inspect } from 'node:util'; +import { redactStatementFromMessage } from '@objectstack/types'; import { migrateEnvIdToProjectId } from './migrate-env-id-to-project-id.js'; import { @@ -51,12 +53,21 @@ import { dropProjectionTables } from './drop-projection-tables.js'; 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.'; + "backend's own diagnostic was written to the server log for an operator to read, with " + + 'the statement and its bound values cut.'; /** knex 3.3.0 + better-sqlite3: ` - `. */ const DIALECT_TEXT = 'alter table "sys_metadata" rename column - no such column: env_id'; +/** + * [#21418] What each per-table record stores for a raw-path fault: the helper's + * answer, which is THE driver-fault cut's, under the rule the driver's own raw + * terminal writes its log line by. Spelled as the cutter's answer, so these + * cases pin that each site stores the helper's text and never pin the cutter's + * marker wording. + */ +const recorded = (dialect: string): string => redactStatementFromMessage(dialect, { statementSent: true }); + function rawStatementFault(dialect = DIALECT_TEXT): Error { const err = new Error(COMPOSED) as Error & { code?: string; status?: number }; err.code = 'DATABASE_ERROR'; @@ -102,7 +113,8 @@ describe('[#16657] migrateEnvIdToProjectId — the per-table error record', () = 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).toBe(recorded(DIALECT_TEXT)); + expect(row.error).toContain('no such column: env_id'); expect(row.error).not.toContain('refused to run a raw statement'); } }); @@ -127,7 +139,7 @@ describe('[#16657] migrateProjectIdToEnvironmentId — the per-table error recor // 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); + for (const row of errors) expect(row.error).toBe(recorded(DIALECT_TEXT)); }); it('an UNDECLARED refusal is recorded on its own message channel', async () => { @@ -150,7 +162,8 @@ describe('[#16657] dropProjectionTables — the per-table error record', () => { 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'); + expect(row.error).toBe(recorded('drop table "sys_object" - table is locked')); + expect(row.error).toContain('table is locked'); } }); @@ -164,3 +177,61 @@ describe('[#16657] dropProjectionTables — the per-table error record', () => { expect(results.every((r) => r.error === 'database is locked')).toBe(true); }); }); + +/** + * [#21418] The family's fourth position, pinned at the three sites in this + * package that write `operatorFacingErrorText`'s answer into a per-table + * result: a synthetic sentinel bound into a raw statement reaches no result + * row, while the dialect's diagnostic and each row's `status` survive. + * + * Every statement these migrations send binds identifiers only (the census on + * #21418), so the raw path's `cause` carries the sentinel in a synthetic bound + * statement, printed the way knex prints one on SQLite and MySQL. The cut is + * the helper's, by construction; these cases pin that each site stores nothing + * BUT the helper's answer. + */ +describe('[#21418] a bound sentinel reaches no per-table record', () => { + const SENTINEL = 'SENTINEL-21418-BOUND-VALUE'; + const BOUND_DIALECT_TEXT = + `update "sys_metadata" set "env_id" = '${SENTINEL}' - no such column: env_id`; + + it('[the fixture] the raw path really carries the sentinel on the cause the helper reads', () => { + const thrown = rawStatementFault(BOUND_DIALECT_TEXT); + expect((thrown as { cause?: Error }).cause?.message).toContain(SENTINEL); + expect(thrown.message).not.toContain(SENTINEL); + }); + + const SITES: Array<[site: string, run: () => Promise>]> = [ + [ + 'migrateEnvIdToProjectId', + () => migrateEnvIdToProjectId(refusingDriver(['id', 'env_id'], () => rawStatementFault(BOUND_DIALECT_TEXT))), + ], + [ + 'migrateProjectIdToEnvironmentId', + () => + migrateProjectIdToEnvironmentId( + refusingDriver(['id', 'project_id'], () => rawStatementFault(BOUND_DIALECT_TEXT)), + ), + ], + [ + 'dropProjectionTables', + () => + dropProjectionTables({ + async execute() { + throw rawStatementFault(BOUND_DIALECT_TEXT); + }, + } as never), + ], + ]; + + for (const [site, run] of SITES) { + it(site, async () => { + const results = await run(); + const errors = results.filter((r) => r.status === 'error'); + + expect(errors.length, 'the site was never refused').toBeGreaterThan(0); + expect(inspect(results, { depth: 8 })).not.toContain(SENTINEL); + for (const row of errors) expect(row.error).toContain('no such column: env_id'); + }); + } +}); diff --git a/packages/objectql/src/engine-find-missing-table-log-level.test.ts b/packages/objectql/src/engine-find-missing-table-log-level.test.ts index 9d761db15fd..d25960e2d5c 100644 --- a/packages/objectql/src/engine-find-missing-table-log-level.test.ts +++ b/packages/objectql/src/engine-find-missing-table-log-level.test.ts @@ -82,8 +82,8 @@ function envelope(cause: unknown): Error { const err = new Error( `The database refused to run this query for object '${OBJECT}'. 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.', + "claimed here. The backend's own diagnostic was written to the server log for an " + + 'operator to read, with the compiled statement and its bound values cut.', ) as Error & { code?: string; status?: number }; err.code = 'DATABASE_ERROR'; Object.defineProperty(err, 'cause', { diff --git a/packages/qa/dogfood/test/raw-statement-fault-redaction.test.ts b/packages/qa/dogfood/test/raw-statement-fault-redaction.test.ts index d0eb4172c63..9c45c15ca55 100644 --- a/packages/qa/dogfood/test/raw-statement-fault-redaction.test.ts +++ b/packages/qa/dogfood/test/raw-statement-fault-redaction.test.ts @@ -21,10 +21,13 @@ * * ⛔ Asserted here: every carrier of the error the ENGINE propagates, and every * line the ENGINE's logger receives. Not asserted here: `driver-sql`'s own - * server-log line for a refused raw statement, which writes the statement and - * the dialect message before the driver composes its envelope. That line is a - * position of its own in `driver-sql`, outside this card's two positions, and - * it is captured silently below so the run prints nothing. + * server-log line for a refused raw statement, which writes the dialect's + * diagnostic before the driver composes its envelope — since #21385 with the + * statement and its bound values cut by the same redaction, and no longer the + * statement it ran. That line is a position of its own, pinned in `driver-sql` + * by `sql-driver-21385-refusal-log-line-redaction.test.ts`, outside this + * card's two positions, and it is captured silently below so the run prints + * nothing. * * SQLite always runs. The PostgreSQL and MySQL cells of position 1 run where * `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set and are a named skip diff --git a/packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts b/packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts index c482b1703f5..fb891e2b84d 100644 --- a/packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts +++ b/packages/rest/src/package-door-16019-raw-statement-fault-code.test.ts @@ -127,8 +127,8 @@ const DIALECT_LINE = 'insert into `sys_packages` (`id`, …) values (…) - no s 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.'; + "backend's own diagnostic was written to the server log for an operator to read, with " + + 'the statement and its bound values cut.'; /** What `SqlDriver.execute()` raises since #16019, and what the service's branch ② re-throws. */ function rawStatementFault(): Error { diff --git a/packages/types/src/driver-error-classification.callers.test.ts b/packages/types/src/driver-error-classification.callers.test.ts index 8c6179336e8..f0262b60cbf 100644 --- a/packages/types/src/driver-error-classification.callers.test.ts +++ b/packages/types/src/driver-error-classification.callers.test.ts @@ -384,3 +384,150 @@ describe('isMissingTableError — every in-repo call names the object it read (# ).toEqual([]); }); }); + +// ═════════════════════════════════════════════════════════════════════════════ +// [#21418] `operatorFacingErrorText` — the enumeration of its callers +// ═════════════════════════════════════════════════════════════════════════════ +// +// The helper's answer is cut by construction (the maintainer's ruling A on +// #21385: "one cutter for every log face"), so a caller needs no cut of its +// own. What a caller DOES owe is a pin: a synthetic sentinel bound into a raw +// statement reaches none of the carriers it writes the answer to — a log +// line's meta or message, a result's `detail` or `error`. This table is the +// list of those callers, frozen, with the file that holds each one's pin. A +// caller added, removed or given another call site reddens here, and the +// remedy is to pin its carriers and add its row — ⛔ never a cut at the caller, +// which would be a second copy of the one cut. +// +// Same scan, same `packages/**/*.ts` radius and same blind spot as the gate +// above: callees are matched by NAME, so a renamed import binding fails the +// check below rather than shrinking the population. + +const OPERATOR_TEXT_HELPER = 'operatorFacingErrorText'; + +/** A test file is a pin, never a caller of record. */ +const TEST_FILE = /\.test\.ts$/; + +/** + * Every non-test caller under `packages/`, its call-site count, and the test + * that pins its carriers with the sentinel. Re-derived when #21418 landed by + * this scan and, independently, by `git grep -ln` over the same tree. + */ +const OPERATOR_TEXT_CALLERS: Readonly> = { + 'packages/cli/src/commands/db/clean.ts': { + calls: 1, + sentinelPin: 'packages/cli/src/commands/db/clean.operator-text-21418.test.ts', + }, + 'packages/metadata-protocol/src/migrations/partial-index-probe.ts': { + calls: 2, + sentinelPin: 'packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts', + }, + 'packages/metadata-protocol/src/migrations/read-probe.ts': { + calls: 2, + sentinelPin: 'packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts', + }, + 'packages/metadata-protocol/src/migrations/runtime-index-preflight.ts': { + calls: 2, + sentinelPin: 'packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts', + }, + 'packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts': { + calls: 5, + sentinelPin: 'packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts', + }, + 'packages/metadata/src/migrations/drop-projection-tables.ts': { + calls: 1, + sentinelPin: 'packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts', + }, + 'packages/metadata/src/migrations/migrate-env-id-to-project-id.ts': { + calls: 1, + sentinelPin: 'packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts', + }, + 'packages/metadata/src/migrations/migrate-project-id-to-environment-id.ts': { + calls: 1, + sentinelPin: 'packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts', + }, +}; + +/** The sentinel every pin above binds, spelled in each pin file. */ +const OPERATOR_TEXT_SENTINEL = 'SENTINEL-21418-BOUND-VALUE'; + +function operatorTextCallSites(files: readonly string[]): { + calls: Map; + renamed: RenamedImport[]; +} { + const calls = new Map(); + const renamed: RenamedImport[] = []; + for (const file of files) { + const text = readFileSync(file, 'utf8'); + if (!text.includes(OPERATOR_TEXT_HELPER)) continue; + const path = relative(REPO_ROOT, file).split(sep).join('/'); + const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : undefined; + if (name === OPERATOR_TEXT_HELPER) calls.set(path, (calls.get(path) ?? 0) + 1); + } + if (ts.isImportSpecifier(node) && node.propertyName?.text === OPERATOR_TEXT_HELPER) { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + renamed.push({ path, line: line + 1, local: node.name.text }); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + return { calls, renamed }; +} + +const OPERATOR_TEXT_SCAN = operatorTextCallSites(FILES); + +describe('[#21418] operatorFacingErrorText — every caller is enumerated and pinned', () => { + it('POSITIVE CONTROL: the matcher sees the defining contract tests call the helper', () => { + // Zero here means the matcher stopped matching, not that the helper has + // no callers. Measured when this landed: 30 calls in that one file. + const contract = OPERATOR_TEXT_SCAN.calls.get( + 'packages/types/src/driver-error-classification.operator-text.test.ts', + ); + expect(contract ?? 0).toBeGreaterThanOrEqual(20); + }); + + it('no renamed import hides a call from the by-name matcher', () => { + expect(OPERATOR_TEXT_SCAN.renamed).toEqual([]); + }); + + it('the non-test callers are exactly the enumerated ones, call sites included', () => { + const found = Object.fromEntries( + [...OPERATOR_TEXT_SCAN.calls.entries()] + .filter(([path]) => !TEST_FILE.test(path)) + .sort(([a], [b]) => a.localeCompare(b)), + ); + const expected = Object.fromEntries( + Object.entries(OPERATOR_TEXT_CALLERS) + .map(([path, { calls }]) => [path, calls] as const) + .sort(([a], [b]) => a.localeCompare(b)), + ); + expect( + found, + `${OPERATOR_TEXT_HELPER}() is written to an operator-facing carrier at each of these sites. ` + + 'A new caller (or a new call site in an enumerated one) owes a pin that binds a ' + + `synthetic sentinel (${OPERATOR_TEXT_SENTINEL}) into a raw statement and asserts it ` + + 'reaches none of the carriers the site writes, then a row in OPERATOR_TEXT_CALLERS. ' + + 'The helper already answers cut text; never add a cut at the caller.', + ).toEqual(expected); + }); + + it("each caller's pin exists, imports the caller and binds the sentinel", () => { + for (const [caller, { sentinelPin }] of Object.entries(OPERATOR_TEXT_CALLERS)) { + const pin = join(REPO_ROOT, sentinelPin); + expect(existsSync(pin), `${caller}: its pin ${sentinelPin} is missing`).toBe(true); + const text = readFileSync(pin, 'utf8'); + const module = `./${caller.split('/').pop()!.replace(/\.ts$/, '.js')}`; + expect(text.includes(`'${module}'`), `${sentinelPin} does not import ${module}`).toBe(true); + expect(text.includes(OPERATOR_TEXT_SENTINEL), `${sentinelPin} does not bind the sentinel`).toBe(true); + } + }); +}); From 24db8a1c72d47509e5c890053bcf50b165c7e044 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:57:51 +0000 Subject: [PATCH 3/4] test(metadata-protocol): read the backfill receipt's declared organization field apart from the carriers Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .changeset/21418-operator-text-cut.md | 11 +++++++++++ .../raw-exec-operator-detail-16657.test.ts | 13 +++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .changeset/21418-operator-text-cut.md diff --git a/.changeset/21418-operator-text-cut.md b/.changeset/21418-operator-text-cut.md new file mode 100644 index 00000000000..800035e2059 --- /dev/null +++ b/.changeset/21418-operator-text-cut.md @@ -0,0 +1,11 @@ +--- +'@objectstack/types': patch +--- + +fix(types): `operatorFacingErrorText` answers through the driver-fault redaction, so an operator-facing record carries no statement and no bound value + +Clause-②: no + +- **What changed.** `operatorFacingErrorText` passes every text it returns through `redactStatementFromMessage`, the one driver-fault redaction in this package. Text it reads off a raw-statement fault's `cause` is cut with `{ statementSent: true }`, which is the cut `@objectstack/driver-sql` applies to its own log line for the same fault. Every other text asks the shared leak predicate, as the engine's own log line does. +- **What an operator reads now.** The records this helper fills, in `os db clean` and in the metadata migrations and probes, keep the dialect's own diagnostic: the missing column, the failed constraint or the locked database. The value slots the redaction's dialect templates own are cut from it, and the redaction's marker stands where the statement was removed. The records no longer carry the statement or the values bound into it. +- **What does not change.** Text that is not a driver dump comes back exactly as before, empty text included. The thrown error is not touched: its `code`, `status`, class and `cause` reach every other reader as the driver composed them. The function's signature and the package's exports are unchanged. 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 14ffefca7a6..11966339803 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 @@ -515,7 +515,7 @@ describe('[#21418] a bound sentinel reaches no carrier at any site in this packa const err = rawStatementFault( knexDump(sql, [], `Duplicate entry '${SENTINEL}' for key 't.idx_real'`), ); - Object.assign((err as { cause: object }).cause, { code: 'ER_DUP_ENTRY', errno: 1062 }); + Object.assign((err as unknown as { cause: object }).cause, { code: 'ER_DUP_ENTRY', errno: 1062 }); return err; }; const options = { @@ -548,6 +548,13 @@ describe('[#21418] a bound sentinel reaches no carrier at any site in this packa * with the organization the probe finds BEING the sentinel, so the * stamp and the counter merge bind it themselves, and every refusal * composed from the statement and parameters actually sent. + * + * ⚠️ The run's receipt names the organization it adopted, on purpose + * and not through the helper: `organizationId` is a declared field of + * the result (and of the `info` line a repair writes, which this + * logger does not record). So the scan below reads the result WITHOUT + * that one field, and pins the field's value separately, rather than + * calling a declared receipt a leak. */ function sentinelExec(refuse: (sql: string) => boolean) { return async (sql: string, params?: unknown[]): Promise => { @@ -629,7 +636,9 @@ describe('[#21418] a bound sentinel reaches no carrier at any site in this packa expect(fixture.seen.length, 'the site was never refused').toBeGreaterThan(0); expect(fixture.seen.every((m) => m.includes(SENTINEL))).toBe(true); - expect(inspect(result, { depth: 8 })).not.toContain(SENTINEL); + const { organizationId, ...carriers } = result as typeof result & { organizationId?: string }; + expect([undefined, SENTINEL]).toContain(organizationId); + expect(inspect(carriers, { depth: 8 })).not.toContain(SENTINEL); expect(logged(log)).not.toContain(SENTINEL); if (line === '') { expect(result.status).toBe('absent'); From 9f5fba42f8352996539d1967a57c67fd90899395 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:14:39 +0000 Subject: [PATCH 4/4] test(cli): load the oclif Config at module scope in the db clean pin The case loaded the Config inside its clocked window; with the package built that load imports every command module and was the whole cost of the case, past vitest's default budget on a loaded shard. It is now paid once during collection. The assertions are unchanged. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .../db/clean.operator-text-21418.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/db/clean.operator-text-21418.test.ts b/packages/cli/src/commands/db/clean.operator-text-21418.test.ts index 2f72b96e5e8..fee9ef7b976 100644 --- a/packages/cli/src/commands/db/clean.operator-text-21418.test.ts +++ b/packages/cli/src/commands/db/clean.operator-text-21418.test.ts @@ -37,9 +37,33 @@ * statement, printed the way knex prints one on SQLite. The envelope's shape is * pinned against the real producer by `driver-sql`'s * `sql-driver-16657-operator-facing-cause-text.test.ts`. + * + * ## Why the oclif `Config` is loaded at MODULE SCOPE + * + * The case used to hand `DbClean.run` a `{ root }`, so oclif loaded its + * `Config` inside the clocked case. With this package built and no + * `oclif.manifest.json`, that load imports every command module to build the + * manifest, and it was the whole cost of the case. Measured on a shared + * 4-vCPU container at 24db8a1c, phase timers in a throwaway copy, the busy + * loops being CPU-bound `node` processes: + * + * load Config.load the command's own run + * idle, 5 runs 3214-3681 ms 11-13 ms + * 8 busy loops, 3 8709-9712 ms 29-57 ms + * 24 busy loops, 3 26017-36325 ms 56-119 ms + * + * The case as it stood took 3427-3829 ms idle, and timed out at vitest's + * default 5000 ms in 3 of 3 runs at 8 busy loops and 3 of 3 at 24: the CI + * signature. The cost is LOADING, so it is paid once here, during collection, + * which vitest clocks against nothing ("clocked windows measure behaviour, + * never loading", AGENTS.md). ⛔ Not a hook with a bigger timeout: at 24 busy + * loops the load alone took up to 36 s, so any budget around it is a load + * sensor. `src/commands/datasource/envelope-unwrap.test.ts` records the same + * measurement and the same placement for this package. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config } from '@oclif/core'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -101,6 +125,12 @@ import DbClean from './clean.js'; /** `packages/cli` — the oclif root the command is loaded against. */ const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +/** + * Paid HERE, at module scope and not in a hook or a case: see "Why the oclif + * `Config` is loaded at MODULE SCOPE" in this file's header. + */ +const config = await Config.load({ root: CLI_ROOT }); + /** * `chalk` may or may not emit SGR codes depending on TTY detection. The escape * is spelled as an escape, never as the byte itself. @@ -120,7 +150,7 @@ async function runClean(argv: string[]): Promise<{ out: string; exitCode: number const savedExitCode = process.exitCode; let exitCode = 0; try { - await DbClean.run(argv, { root: CLI_ROOT }); + await DbClean.run(argv, config); } catch (error: unknown) { const oclif = (error as { oclif?: { exit?: number } })?.oclif; exitCode = typeof oclif?.exit === 'number' ? oclif.exit : 1;