Skip to content

Commit 85e29b8

Browse files
fix(types): operatorFacingErrorText answers through the one driver-fault cut (#21482)
Fixes #21418 Clause-②: no ## What this changes `operatorFacingErrorText` (`packages/types/src/driver-error-classification.ts`) now returns cut text by construction. Its single exit passes the answer through `redactStatementFromMessage`, the one driver-fault cut in `packages/types/src/driver-fault-redaction.ts`. That module is unedited (empty diff), and no copy of the cut is made. No caller is edited. This carries out the maintainer's ruling A on #21385 (`5950942037`), quoted verbatim: > **A: one cutter for every log face.** > 平台任何一层的日志都不带调用方绑定值;一把刀、一处规则,覆盖所有日志面 It also carries out triage's ruling `5954287249`: "the helper returns cut text by construction", with ⛔ no cut repeated at the callers. The walk decides which rule the cut runs under. It goes by what the walk knows about the text, not by what the text looks like: - **Text reached below the raw-path sentence** is the fault of a statement the driver itself sent. It is cut with `{ statementSent: true }`. That is the same argument the driver's own raw-terminal log line passes, so the operator's stored record equals the text of that line. - **Every other answer** asks the shared leak predicate, as the engine's own log line does. That covers an undeclared throw, a declared envelope the walk does not unwrap, and the fallback channel. A driver dump is cut. Anything else comes back unchanged, empty text included. What survives: - **In the returned text:** the dialect's own diagnostic, minus the value slots the cut's templates own. - **On the thrown value:** nothing is touched. Its `code`, `status`, class and `cause` reach every classifier unchanged. For example, `classifyIndexFailure` still answers `conflict`, and `isMissingTableError` reads the same chain. ## Caller census (for the raise rule in `5954287249`) The tree has **eight** caller files with **15** call sites. Triage and the dispatch said "seven", but the list they gave has eight files. I re-derived the list two ways: - `git grep -ln` at `24db8a1c`, with the defining module and `index.ts` as the control; - the new enumeration pin's AST scan. | Caller | Sites | Binds | Reading (from source) | |---|---|---|---| | `packages/cli/src/commands/db/clean.ts` | 1 | nothing | Two constant statements, no parameters. | | `packages/metadata-protocol/src/migrations/partial-index-probe.ts` | 2 | identifiers only | Unique-index DDL that its three callers compose from platform table and column names and constant state literals. No parameters. A unique index built over duplicate stored rows can carry a stored value in MySQL's own duplicate-entry diagnostic. The cut's templates own that slot. | | `packages/metadata-protocol/src/migrations/read-probe.ts` | 2 | identifiers only | The catalog statements inline the probed platform table name, behind the probeable-name check. The fallback is the caller's constant probe. | | `packages/metadata-protocol/src/migrations/runtime-index-preflight.ts` | 2 | identifiers only | A constant liveness statement, plus duplicate-group reads over platform tables. No parameters. | | `packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts` | 5 | **values, at 2 of the 5 sites** | See the breakdown below this table. | | `packages/metadata/src/migrations/drop-projection-tables.ts` | 1 | identifiers only | A constant table list. | | `packages/metadata/src/migrations/migrate-env-id-to-project-id.ts` | 1 | identifiers only | A constant table list. Its column probe binds table and column names, but it swallows its own errors before any helper call. | | `packages/metadata/src/migrations/migrate-project-id-to-environment-id.ts` | 1 | identifiers only | Same shape as the row above. | What the five `seed-tenancy-backfill.ts` sites bind: - **Split probe:** binds the platform's global-tenant constant. - **Organization probe:** binds nothing. - **Collision probe:** binds nothing. It inlines object and field names read from stored rows, behind an identifier check. - **Stamp:** binds the organization id that the migration read from the organization table. - **Counter merge:** - its first statement binds object and field names and the global-tenant constant; - the statements after it bind the organization id, counter values, a key hash and the sequence scope. **Reading for the seat:** - **No caller binds a value taken from a request.** - One caller, `seed-tenancy-backfill.ts`, binds stored values the migration read itself, at its stamp and counter-merge sites. - One of those values can come from a request caller's data. The sequence scope is a rendered autonumber prefix, which a field-scoped format renders from a record's field value, so a request caller originally wrote it. - What was pinned: the stamp site's own binding. The fixture composes the dialect text from the statement and parameters the site really sent, in knex's inlined shape. The organization id is the sentinel. - Not measured: a real-driver run at that site. - Applying the raise rule is the seat's call. ## Pins - **The helper** (`driver-error-classification.operator-text.test.ts`, new `[#21418]` block). A synthetic sentinel is bound into a raw statement in four dialect shapes: - a listed verb; - a statement opening with a verb the predicate does not list, over a diagnostic it does not recognise; - a value inlined in both the statement and MySQL's diagnostic; - a value inlined only in PostgreSQL's diagnostic. Further cases cover a re-wrapped envelope, the depth bound, a string `cause` and an undeclared driver dump. In every case: - the fixture's `cause` is first shown to carry the sentinel; - the answer carries none of it, and keeps the diagnostic; - the answer equals the cutter's own answer for that text. Two more cases complete the block. One pins that the thrown value is untouched (code, status, class, `cause`, stack). A CONTROL case pins that non-dump text comes back byte-identical. - **The real producer** (`driver-sql`, `sql-driver-16657-operator-facing-cause-text.test.ts`). It runs a real `SqlDriver.execute()` refusal with the sentinel bound through knex, on a statement the predicate cannot read. Three things are asserted: - the `cause` carries the sentinel; - the helper's answer does not; - the answer equals the tail of the driver's own raw-terminal log line. - **Every caller's carriers:** - `metadata-protocol` and `metadata`'s `raw-exec-operator-detail-16657.test.ts`, which have new `[#21418]` blocks; - the new `packages/cli/src/commands/db/clean.operator-text-21418.test.ts`, which runs the real oclif command and stays in the `unit` tier. Each case scans the returned result and every recorded log line, message and meta, for the sentinel. It also asserts that the diagnostic and the site's verdict survive (`unreadable`, `conflict`, `absent`, `error`, the exit code). One field is read separately: the backfill's receipt declares the `organizationId` it adopted, by design and not through the helper. - **The enumeration pin** (`driver-error-classification.callers.test.ts`, the `test:repo` project, on its already-declared `packages/**/*.ts` radius). It freezes the eight callers with their call-site counts. Each caller names its sentinel pin file, and the test checks that file exists, imports the caller and binds the sentinel. A positive control and a renamed-import check close its two blind spots. ## Reverse verification The fix was committed first (`24db8a1c`). Each mutation went through `scripts/ablation-replace.mjs`: - the anchor hit 1 → 0 and the blob changed; - `@objectstack/types` was rebuilt; - `ablation-dist-preflight` found the marker in 2 built files. Each restore was proven: blob == HEAD and `git diff HEAD` empty. The restore leg rebuilt, both markers were absent from all 12 built files, and the tree was clean. - **First attempt void.** The DTS build refused both mutations because each left a binding unused. No pin ran. Both legs were re-run with mutations that keep the binding read. - **Leg A: the call to the cutter reverted, nothing else.** | Package | Red / total | |---|---| | types | 12 / 22 | | metadata-protocol | 17 / 25 | | metadata | 6 / 11 | | cli | 1 / 2 | | driver-sql | 1 / 3 | Every new sentinel case went red. Only the `[the fixture]` cases and the CONTROL case stayed green. The `#16657` cases that now assert the cut answer also went red. - **Leg B: the cut kept, but what the walk knows dropped (predicate only).** Red counts: | Package | Red / total | Which cases | |---|---|---| | types | 2 / 22 | The unlisted-verb cell, and the re-wrapped and depth-bound case built on it. | | driver-sql | 1 / 3 | The real-producer leg. | | metadata | 1 / 11 | A dialect text the predicate does not read as a dump, so its statement came back whole. | | metadata-protocol | 0 / 25 | Its fixtures open with listed verbs. | | cli | 0 / 2 | Its fixtures open with listed verbs. | So the walk's knowledge is load-bearing exactly where the predicate is blind. - **Restore:** 22/22, 25/25, 11/11, 2/2 and 3/3. ## Verification (at `24db8a1c` unless stated) - **`@objectstack/types`:** - `test`: 705 passed; - `test:repo`: 11 passed; - `typecheck`: exit 0. `--listFiles` includes both edited test files. - **`@objectstack/metadata-protocol`:** - The full suite ran at `89871414`: 3,091 passed and 3 failed, all 3 in the new block. The cause was a fixture that read the receipt's declared organization field as a carrier. It is fixed in `24db8a1c`. - At `24db8a1c`: that file passes 25/25, and `typecheck` exits 0. - **`@objectstack/metadata`:** the full suite passed 840 (56 files), and `typecheck` exits 0. - **`@objectstack/cli`:** - The `unit` tier ran the new pin together with `test/vitest-tiers-partition.test.ts`: 24 passed. The `integration` tier is declared to CI. - The full `typecheck` is NOT MEASURED, because the 60-package closure was not built. - A focused `tsc` over the new file found 0 errors in it. The 3 errors it reported are in `clean.ts`, all missing declarations of unbuilt workspace dependencies. - **`@objectstack/driver-sql`:** - the producer pin: 3/3; - the `#21385` refusal-line pin: 16 passed, 2 live skips; - the diagnostic value probe: 2 live skips (this PR changes only comments there); - `typecheck`: exit 0; - live PostgreSQL and MySQL: NOT MEASURED here. - **`objectql` and `rest`:** each edited test file passes, 13/13 and 4/4. - **`qa/dogfood`:** this PR changes only a comment there, so the suite is NOT MEASURED. - **Gates:** `dispatch-gates.mjs --commands` derived 73 at `24db8a1c`. - 69 ran and exited 0. - `check-engine-split-ratio` first refused on the shallow checkout. It exited 0 after the prescribed `--shallow-since` fetch. - 4 are NOT MEASURED, each with prerequisite exit 3: `check:dual-build-cjs-loads` (it needs every package's `dist`), and `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` (they need the built CLI). As a scoped check instead, the `types` dist loads under both `require()` and `import`. - The `--ran` reconciliation: 73 accounted for, 0 UNRUN. - **Lint, by proven narrowing at `24db8a1c`:** - eslint's own config, through `isPathIgnored`, ignores none of the 11 touched TS files; - `--format json` counts 11 files, with 0 errors and 0 warnings; - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), and its only load-time reads are two baseline JSON files this diff does not touch. So the diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Stale prose carried from #21385's ACCEPT (`5957209724`), each verified against the tree | Position | Verdict | |---|---| | `operatorFacingErrorText`'s docblock ("the driver writes the statement…") | stale, corrected | | `metadata-protocol` `raw-exec-operator-detail-16657.test.ts`, raw envelope copy | stale, corrected | | `metadata` `raw-exec-operator-detail-16657.test.ts`, raw envelope copy | stale, corrected | | `rest` `package-door-16019-raw-statement-fault-code.test.ts`, raw envelope copy | stale, corrected; its `looksLikeInternalErrorLeak(COMPOSED) === false` assertion holds for the new sentence | | `types` `driver-error-classification.operator-text.test.ts`, raw and read-exit copies (2) | stale, corrected | | `objectql` `engine-find-missing-table-log-level.test.ts`, read-exit copy | stale, corrected | | `packages/qa/dogfood/test/raw-statement-fault-redaction.test.ts` header | stale, corrected (the driver's line no longer writes the statement) | | `driver-sql` `sql-driver-diagnostic-value-probe.test.ts` rationale | stale, corrected at its three places (the redactor's home and the "does not depend" reason) | All six envelope copies are now byte-equal to the producer's two sentences. I measured this by evaluating each copy against `sql-driver.ts`. The same stale premise was also in the headers of the `types` operator-text test and the `metadata-protocol` test, and both are corrected. These edits change test and comment text only. ## Acceptance notes - **The census count:** eight files, not seven (see above). - **A boundary, not filed.** An undeclared throw is cut under the shared leak predicate, so an undeclared dump that the predicate cannot read would come back whole. No producer reaches a caller that way today: - every caller runs raw statements through `IDataDriver.execute`; - the SQL drivers declare the raw-path envelope, `driver-sql` directly and `driver-turso` through the same composition. Carrier: none. - **Comment drift, not filed.** The `@objectstack/types` entry in `scripts/cross-package-test-inputs.mjs` describes `driver-error-classification.callers.test.ts` as the `isMissingTableError` gate only. The enumeration pin added here rides the same declared radius. Carrier: none. - **Engine-package test paths for the seat to declare:** - test-only edits in `packages/metadata-protocol/src/migrations/raw-exec-operator-detail-16657.test.ts`, `packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts` and `packages/objectql/src/engine-find-missing-table-log-level.test.ts`; - in `packages/drivers/driver-sql/src/`, `sql-driver-diagnostic-value-probe.test.ts` (comment only) and `sql-driver-16657-operator-facing-cause-text.test.ts`. The second is outside the claim's listed surface: it is the real-producer leg. There is no source edit in any `domain:engine` package. ## Patch round 1 (`9f5fba42f8`): the `os db clean` pin no longer pays oclif's load inside its clocked window *Added by the `domain:cli` seat from the dev's patch-round report on #21418; the measurements are the dev's, on the shared 4-vCPU container.* **The red:** on `24db8a1c`, Test Core (6/6) failed because `clean.operator-text-21418.test.ts` › "the failure line names the file and the dialect diagnostic, and carries no sentinel" timed out at vitest's default 5000 ms. **Where the time went.** Phase timers ran in a throwaway copy, which was deleted and never committed. The command's own run took 11–119 ms. The rest of the case was oclif's `Config.load`, which the case paid inside its clocked window by handing `DbClean.run` a `{ root }`: 99.3–99.7% of the case. On a built package with no `oclif.manifest.json`, that load imports every command module. | load | `Config.load` | the command's own run | the case on `24db8a1c` | |:--|--:|--:|:--| | idle, 5 runs | 3214–3681 ms | 11–13 ms | 3427–3829 ms, passes | | 8 busy loops, 3 runs | 8709–9712 ms | 29–57 ms | timed out 3 of 3 | | 24 busy loops, 3 runs | 26017–36325 ms | 56–119 ms | timed out 3 of 3 | **The fix (test file only).** The Config loads once at module scope and is passed to `DbClean.run`, per AGENTS.md's rule "Clocked windows measure behaviour, never loading" and the in-package precedent `src/commands/datasource/envelope-unwrap.test.ts`. The assertions are unchanged. There is no skip, retry, quarantine or timeout change, and the file stays in the `unit` tier. **Before / after, interleaved pairs, same command and load:** | load | BEFORE (`24db8a1c`) | AFTER (`9f5fba42f8`) | |:--|:--|:--| | idle, 3 pairs | 3478–3556 ms, pass | 12–15 ms, pass | | 8 busy loops, 3 pairs | 5021–5972 ms, 3 of 3 timed out | 24–44 ms, 3 of 3 pass | | 24 busy loops, 4 pairs | 5068–5488 ms, 4 of 4 timed out | 100–300 ms, 4 of 4 pass | The load moved into collection, which is not clocked. vitest's import phase for the file went from 6.2–6.8 s to 9.6–10.4 s idle, and from 42.7–63.1 s to 64.9–94.2 s at 24 busy loops. **Gates at `9f5fba42f8`.** The 73 families re-derived with no paths are identical to round 0, and all 73 exit 0. `check:i18n*` and `check:dual-build-cjs-loads` were NOT MEASURED in round 0; they were measured green this round. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 15b29d3 commit 85e29b8

12 files changed

Lines changed: 1016 additions & 60 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/types': patch
3+
---
4+
5+
fix(types): `operatorFacingErrorText` answers through the driver-fault redaction, so an operator-facing record carries no statement and no bound value
6+
7+
Clause-②: no
8+
9+
- **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.
10+
- **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.
11+
- **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.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21418] `os db clean` prints a refused `VACUUM` through
5+
* `operatorFacingErrorText`, and a value bound into a raw statement reaches
6+
* none of what it prints.
7+
*
8+
* ## Why this file exists
9+
*
10+
* The command reaches SQLite through the driver's raw seam
11+
* (`driver.execute`), which declares its own fault since #16019: a composed
12+
* `DATABASE_ERROR` envelope with the dialect error whole under its `cause`.
13+
* The command's one carrier is the line it prints for a file it failed to
14+
* clean, and that line embeds the helper's answer. The helper used to answer
15+
* the `cause`'s message whole — knex's `<statement> - <diagnostic>`, which
16+
* inlines the statement's bound values on SQLite — so the line carried them.
17+
* The helper now answers through the one driver-fault cut (the maintainer's
18+
* ruling A on #21385, "one cutter for every log face"), and this command cuts
19+
* nothing of its own.
20+
*
21+
* ## What is pinned, and what is stubbed
22+
*
23+
* The REAL oclif command runs with a real argv against a real file on disk.
24+
* Two seams are stubbed, neither of them the mechanism under test:
25+
*
26+
* - `@objectstack/service-datasource`'s `resolveSqliteDriver` answers a
27+
* driver double whose `execute` raises the raw-path envelope, so the case
28+
* needs no SQLite engine and stays in the `unit` tier. The statements the
29+
* command sent are recorded, which proves the refusal came from the
30+
* command's own `execute` call rather than from somewhere earlier;
31+
* - `@objectstack/runtime`'s `resolveProjectDatabaseUrl` is never consulted
32+
* when `--database` is passed, but the command imports the module first,
33+
* and booting it here would cost the tier for nothing.
34+
*
35+
* The statements this command sends bind nothing (the census on #21418), so
36+
* the envelope's `cause` carries a synthetic sentinel in a synthetic bound
37+
* statement, printed the way knex prints one on SQLite. The envelope's shape is
38+
* pinned against the real producer by `driver-sql`'s
39+
* `sql-driver-16657-operator-facing-cause-text.test.ts`.
40+
*
41+
* ## Why the oclif `Config` is loaded at MODULE SCOPE
42+
*
43+
* The case used to hand `DbClean.run` a `{ root }`, so oclif loaded its
44+
* `Config` inside the clocked case. With this package built and no
45+
* `oclif.manifest.json`, that load imports every command module to build the
46+
* manifest, and it was the whole cost of the case. Measured on a shared
47+
* 4-vCPU container at 24db8a1c, phase timers in a throwaway copy, the busy
48+
* loops being CPU-bound `node` processes:
49+
*
50+
* load Config.load the command's own run
51+
* idle, 5 runs 3214-3681 ms 11-13 ms
52+
* 8 busy loops, 3 8709-9712 ms 29-57 ms
53+
* 24 busy loops, 3 26017-36325 ms 56-119 ms
54+
*
55+
* The case as it stood took 3427-3829 ms idle, and timed out at vitest's
56+
* default 5000 ms in 3 of 3 runs at 8 busy loops and 3 of 3 at 24: the CI
57+
* signature. The cost is LOADING, so it is paid once here, during collection,
58+
* which vitest clocks against nothing ("clocked windows measure behaviour,
59+
* never loading", AGENTS.md). ⛔ Not a hook with a bigger timeout: at 24 busy
60+
* loops the load alone took up to 36 s, so any budget around it is a load
61+
* sensor. `src/commands/datasource/envelope-unwrap.test.ts` records the same
62+
* measurement and the same placement for this package.
63+
*/
64+
65+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
66+
import { Config } from '@oclif/core';
67+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
68+
import { tmpdir } from 'node:os';
69+
import path from 'node:path';
70+
import { fileURLToPath } from 'node:url';
71+
72+
/** Synthetic, and asserted ABSENT from everything the command prints. */
73+
const SENTINEL = 'SENTINEL-21418-BOUND-VALUE';
74+
75+
/** `rawStatementFaultError`'s composed message, verbatim (`sql-driver.ts`). */
76+
const COMPOSED =
77+
'The database refused to run a raw statement. The driver could not attribute the failure ' +
78+
'to any part of the request, so no verdict about the statement is claimed here. The ' +
79+
"backend's own diagnostic was written to the server log for an operator to read, with " +
80+
'the statement and its bound values cut.';
81+
82+
/** knex 3.3.0 + better-sqlite3: `<statement, values inlined> - <engine diagnostic>`. */
83+
const BOUND_DIALECT_TEXT = `update "sys_setting" set "value" = '${SENTINEL}' - database is locked`;
84+
85+
/** The envelope the raw terminal composes, cause carrier and all. */
86+
function rawStatementFault(): Error {
87+
const err = Object.assign(new Error(COMPOSED), { code: 'DATABASE_ERROR', status: 500 });
88+
Object.defineProperty(err, 'cause', {
89+
value: Object.assign(new Error(BOUND_DIALECT_TEXT), { code: 'SQLITE_BUSY' }),
90+
enumerable: false,
91+
writable: true,
92+
configurable: true,
93+
});
94+
return err;
95+
}
96+
97+
/**
98+
* The driver double's state. `vi.hoisted` because `vi.mock`'s factory is
99+
* hoisted above every `import` and runs while `./clean.js` is being evaluated.
100+
*/
101+
const stub = vi.hoisted(() => ({
102+
statements: [] as string[],
103+
thrown: undefined as unknown,
104+
}));
105+
106+
vi.mock('@objectstack/service-datasource', () => ({
107+
resolveSqliteDriver: async () => ({
108+
engine: 'native',
109+
driver: {
110+
async execute(sql: string) {
111+
stub.statements.push(sql);
112+
throw stub.thrown;
113+
},
114+
async disconnect() {},
115+
},
116+
}),
117+
}));
118+
119+
vi.mock('@objectstack/runtime', () => ({
120+
resolveProjectDatabaseUrl: () => undefined,
121+
}));
122+
123+
import DbClean from './clean.js';
124+
125+
/** `packages/cli` — the oclif root the command is loaded against. */
126+
const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
127+
128+
/**
129+
* Paid HERE, at module scope and not in a hook or a case: see "Why the oclif
130+
* `Config` is loaded at MODULE SCOPE" in this file's header.
131+
*/
132+
const config = await Config.load({ root: CLI_ROOT });
133+
134+
/**
135+
* `chalk` may or may not emit SGR codes depending on TTY detection. The escape
136+
* is spelled as an escape, never as the byte itself.
137+
*/
138+
const SGR = /\x1b\[[0-9;]*m/g;
139+
140+
async function runClean(argv: string[]): Promise<{ out: string; exitCode: number }> {
141+
const chunks: string[] = [];
142+
const record = (...args: unknown[]) => {
143+
chunks.push(args.map(String).join(' '));
144+
};
145+
const spies = [
146+
vi.spyOn(console, 'log').mockImplementation(record),
147+
vi.spyOn(console, 'warn').mockImplementation(record),
148+
vi.spyOn(console, 'error').mockImplementation(record),
149+
];
150+
const savedExitCode = process.exitCode;
151+
let exitCode = 0;
152+
try {
153+
await DbClean.run(argv, config);
154+
} catch (error: unknown) {
155+
const oclif = (error as { oclif?: { exit?: number } })?.oclif;
156+
exitCode = typeof oclif?.exit === 'number' ? oclif.exit : 1;
157+
} finally {
158+
for (const spy of spies) spy.mockRestore();
159+
// oclif's default `catch` sets `process.exitCode`; leaving it set would
160+
// fail this vitest worker on a case that passed.
161+
process.exitCode = savedExitCode;
162+
}
163+
return { out: chunks.join('\n').replace(SGR, ''), exitCode };
164+
}
165+
166+
describe('[#21418] os db clean — a refused VACUUM prints no bound value', () => {
167+
let dir: string;
168+
let file: string;
169+
170+
beforeEach(() => {
171+
dir = mkdtempSync(path.join(tmpdir(), 'os-db-clean-21418-'));
172+
file = path.join(dir, 'app.db');
173+
writeFileSync(file, '');
174+
stub.statements = [];
175+
stub.thrown = rawStatementFault();
176+
});
177+
178+
afterEach(() => {
179+
rmSync(dir, { recursive: true, force: true });
180+
});
181+
182+
it('[the fixture] the raw path really carries the sentinel on the cause the helper reads', () => {
183+
const thrown = rawStatementFault();
184+
expect((thrown as { cause?: Error }).cause?.message).toContain(SENTINEL);
185+
expect(thrown.message).not.toContain(SENTINEL);
186+
});
187+
188+
it('the failure line names the file and the dialect diagnostic, and carries no sentinel', async () => {
189+
const { out, exitCode } = await runClean(['--database', file]);
190+
191+
// The refusal came from the command's own first statement.
192+
expect(stub.statements[0]).toBe('PRAGMA auto_vacuum = INCREMENTAL');
193+
expect(exitCode).toBe(1);
194+
expect(out).toContain(`VACUUM failed for ${file}`);
195+
expect(out).toContain('database is locked');
196+
expect(out).not.toContain(SENTINEL);
197+
expect(out).not.toContain('refused to run a raw statement');
198+
});
199+
});

‎packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,39 @@ describe('[#16657] a real raw-exec refusal still yields the dialect text to an o
8181
expect(operatorText).not.toMatch(/refused to run a raw statement/);
8282
});
8383

84+
it('[#21418] the helper answers the cut text: a value bound into the refused statement does not survive', async () => {
85+
// The producer leg of `@objectstack/types`' sentinel cases: a REAL refusal,
86+
// the value bound through knex, and a statement opening with a verb the
87+
// shared leak predicate does not list over a diagnostic it does not
88+
// recognise — so only the helper's knowledge that the raw path SENT a
89+
// statement cuts it. On better-sqlite3 knex inlines the bound value into
90+
// the statement it prefixes to the dialect's words.
91+
const SENTINEL = 'SENTINEL-21418-BOUND-VALUE';
92+
const lines: string[] = [];
93+
const recording = new QuietSqlDriver();
94+
(recording as unknown as { logger: unknown }).logger = { warn: (line: string) => void lines.push(line) };
95+
try {
96+
const thrown = (await faultOf(() =>
97+
recording.execute('with s as (select ? as v) select translate(v) from s', [SENTINEL]),
98+
)) as Error;
99+
100+
// Non-vacuity: the cause the helper reads really carries the value.
101+
expect(String((thrown as { cause?: { message?: unknown } }).cause?.message)).toContain(SENTINEL);
102+
103+
const operatorText = operatorFacingErrorText(thrown);
104+
expect(operatorText).not.toContain(SENTINEL);
105+
expect(operatorText).toContain('no such function: translate');
106+
107+
// One cutter, one rule: the record an operator reads later is the very
108+
// text the driver's own raw-terminal line wrote for this fault.
109+
expect(lines).toHaveLength(1);
110+
expect(lines[0]).not.toContain(SENTINEL);
111+
expect(lines[0].endsWith(`: ${operatorText}`)).toBe(true);
112+
} finally {
113+
await recording.disconnect();
114+
}
115+
});
116+
84117
it('an UNDECLARED throw from the same seam is returned on its own message channel', async () => {
85118
// The control that proves the pin above reads the declaration and not the
86119
// shape of any error the seam happens to produce.

‎packages/drivers/driver-sql/src/sql-driver-diagnostic-value-probe.test.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
*
77
* ## Why this file exists
88
*
9-
* `redactStatementFromMessage` (`@objectstack/objectql`) keeps the database's
9+
* `redactStatementFromMessage` (`@objectstack/types` since #21385, in
10+
* `driver-fault-redaction.ts`) keeps the database's
1011
* diagnostic after the statement cut, on the premise that a diagnostic names
1112
* IDENTIFIERS. Commit 4dfa369a9 found one family where that is false — MySQL's
1213
* `ER_DUP_ENTRY` inlines the conflicting VALUE — and redacted that one slot.
@@ -41,12 +42,14 @@
4142
* or a template's phrasing drifted and the entry that matched it no longer does.
4243
* Both are the notification #9160 asked for.
4344
*
44-
* ⛔ This probe deliberately does NOT import the redactor. `driver-sql` does not
45-
* depend on `@objectstack/objectql`, and widening that package's public surface
46-
* to reach an internal function is a contract change this card does not carry.
47-
* The division is: this file establishes WHAT THE SERVER SAYS; the redactor's own
48-
* suite (`packages/objectql/src/driver-fault-redaction.test.ts`) drives these
49-
* exact recorded strings through the function. The recorded literals below are
45+
* ⛔ This probe deliberately does NOT import the redactor. It was first kept out
46+
* because the redactor lived in `@objectstack/objectql`, which `driver-sql` does
47+
* not depend on; since #21385 it lives in `@objectstack/types`, which this
48+
* package depends on and whose redaction its own refusal lines call, so the
49+
* reason that stands now is the division of labour alone: this file establishes
50+
* WHAT THE SERVER SAYS; the redactor's own suite
51+
* (`packages/objectql/src/driver-fault-redaction.test.ts`) drives these exact
52+
* recorded strings through the function. The recorded literals below are
5053
* duplicated there on purpose, with this file named as their warrant.
5154
*
5255
* Runs in `Temporal Conformance (live PG + MySQL)`, the one job that stands up
@@ -114,9 +117,9 @@ interface ProbeCase {
114117
* What these measure is the PREMISE, not the remedy: that the server really does
115118
* echo the caller's separator-bearing value into its own words, and that the
116119
* naive last-separator cut therefore lands inside that value. The redaction half
117-
* lives in `packages/objectql/src/driver-fault-redaction.test.ts`, for the same
118-
* reason the rest of this file states — `driver-sql` does not depend on
119-
* `@objectstack/objectql`, and this file establishes WHAT THE SERVER SAYS.
120+
* lives in `packages/objectql/src/driver-fault-redaction.test.ts`, for the
121+
* division of labour the rest of this file states: this file establishes WHAT
122+
* THE SERVER SAYS.
120123
*/
121124
interface SeparatorCase {
122125
/** The server's own error code, as it identifies the family. */

0 commit comments

Comments
 (0)