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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/21418-operator-text-cut.md
Original file line number Diff line number Diff line change
@@ -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.
199 changes: 199 additions & 0 deletions packages/cli/src/commands/db/clean.operator-text-21418.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// 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 `<statement> - <diagnostic>`, 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`.
*
* ## 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';
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: `<statement, values inlined> - <engine diagnostic>`. */
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)), '..', '..', '..');

/**
* 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.
*/
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, config);
} 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand 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. */
Expand Down
Loading
Loading