Skip to content

Commit 5a95b0e

Browse files
os-muskclaude
andauthored
fix(types,metadata,cli): read the dialect text out of cause for operator-facing records (#17073)
* fix(types,metadata,cli): read the dialect text out of `cause` for operator records Since #16019 the raw-SQL seam declares its own fault with a composed message and keeps the dialect error under a non-enumerable `cause`, so every consumer that embedded `error.message` into an operator-facing record began storing "the database refused to run a raw statement" instead of "no such column: foo". Add `operatorFacingErrorText` to `@objectstack/types` — a depth-bounded walk of the `cause` chain, shaped like `matchesDriverError` — and apply it at the eleven stored-record sites plus the one console site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test: pin the operator-facing text in both directions at every site Adds the helper's own unit cases (the three narrowings, the depth bound), the site-level records in metadata-protocol and metadata, and the producer pin in driver-sql that drives a real SqlDriver.execute() refusal through the helper so a reworded envelope reddens there rather than in a customer's log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * chore: changeset for the operator-facing cause text Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(driver-sql): hold the log sink from a subclass — `logger` is protected Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * chore(changeset): grade the widened package `minor` and name driver-sql `check-changeset-no-major.mjs`'s LEVEL AXIS refuses a clause-② `yes` PR that grades NO package whose `packages/**/src/**` it moves at `minor` or above. `@objectstack/types` is the package that actually grew — `operatorFacingErrorText` is a new export — so it takes the `minor`; the rest stay `patch`, which is what a bug fix in a released package takes. `@objectstack/driver-sql` joins the entry list because this diff moves its `src/**` (one added test file); its published `dist/` is byte-unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * docs(types): correct the site count, the byte-for-byte claim and the pin pointer Three text-only repairs from the clause-② contract review. No behaviour changes; no package moves that were not already moving. 1. The changeset said "the eleven stored-record sites plus `os db clean`". Re-measured on this tree: `operatorFacingErrorText(` occurs 15 times in non-test source, one of which is the declaration, so 14 call sites — 13 stored-record sites plus the `os db clean` console line. The changeset's own bullet list already summed to 13. Corrected to "thirteen". This file is release-notes input, which is why the number matters. 2. "byte-for-byte for undeclared throws" was false, and it shipped: the docblock it appears in belongs to the exported `operatorFacingErrorText` and reaches `packages/types/dist/index.d.ts`. Two shapes are not byte-identical to what the replaced expressions computed — a thrown non-`Error` now yields prose where `(e as Error).message` yielded `undefined`, and an error with an EMPTY message reads `Error` / `TypeError` through `|| String(error)` where those expressions yielded `''`, or `unknown error` at the one site that ors in a default. Both the docblock and the changeset's two copies of the claim now say what the code does. 3. The `RAW_STATEMENT_FAULT_SENTENCE` docblock cited `driver-error-classification.raw-statement-pin.test.ts`, which does not exist. It now names the real producer pin, `packages/drivers/driver-sql/src/sql-driver-16657-operator-facing-cause-text.test.ts`. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com> * docs(types): state the undeclared-channel RULE instead of enumerating its cases The previous tidy round replaced a false absolute ("byte-for-byte for undeclared throws") with a different false absolute: "Two shapes read differently, and both read better", and "save for the two shapes named above, where the text gets better rather than different in kind". A contract-tier re-verification measured that false in four corners against the built bundle. Re-measured here as a 10x5 matrix (9 shapes plus a custom-named Error, against the helper and the four replaced expression families) run against packages/types/dist/index.mjs; it reproduces the re-verification's table exactly. The wording now states the rule and marks its examples as illustrations: an undeclared throw comes back as `messageChannelOf(error) || String(error)` -- the value's own string `message`, the string itself for a thrown string, `String(error)` otherwise -- with its `cause` never walked. Consequences, not a closed list: an empty-message Error reads its `name` (a named subclass reads the subclass name, not only Error / TypeError); a thrown non-Error reads its own text or String(error) where `(e as Error).message` read undefined, and where null / undefined threw a TypeError out of the catch so no record was written and the operation aborted; an object carrying a string `message` reads it where the instanceof-else-String expression recorded [object Object]; a thrown EMPTY string reads '', which is what makes both "never empty" and "yields prose" false. Four carriers of the claim, not the two the re-verification located. A content grep over the PR's 14 files found two more: - messageChannelOf's own docblock still said "a thrown non-Error still yields prose rather than `undefined`" -- the same false sentence, in the same file, uncorrected by the previous round; - driver-error-classification.operator-text.test.ts's file docblock still carried the ORIGINAL claim verbatim, "an UNDECLARED throw is returned byte-for-byte on its own message channel". The previous round corrected two of that sentence's three copies. No behaviour change: every changed line in both .ts files is a JSDoc ` *` line, and each file's source with comment blocks stripped hashes identical to HEAD. The `@returns` line's "never empty for a thrown value that has any textual channel at all" was measured and left: it is conditional, and the only value returning '' is a thrown empty string, whose channel is empty. No claim is made about which shapes in-repo seams actually throw; that was not measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * docs(types,metadata,metadata-protocol,driver-sql): census by rule — every undeclared-throw claim measured Fourth prose round on #16657. The census is by CLAIM, not by spelling: every sentence in the PR's 14-file set that asserts what happens to an UNDECLARED throw was enumerated and judged against the built bundle. Blocking: - driver-error-classification.ts: "so a record always carries a sentence rather than `undefined` or an empty string" SHIPPED and was false. The fallback now states only what it does — the same surface channel an undeclared throw reads, which is '' exactly when that channel is. The suite carrying the same absolute ("a record is never empty or undefined") is renamed to what it pins. - Both raw-exec-operator-detail-16657.test.ts docblocks said an undeclared throw reaches the record "exactly as it did before". They now state what the pins verify — not unwrapped, `cause` never walked, read on the value's own message channel — and name the measured differences from the replaced expressions. Non-blocking, same commit: the object-message illustration is scoped to a NON-EMPTY `message` (3); the changeset's "one shape" count is corrected to the three measured (4); "at every site this helper replaces" is scoped to the five `(e as Error).message` sites of fourteen (5); the recognizer's "returned exactly as it arrived" is scoped to a non-empty sentence, with the empty-message case stated as measured (6); the eight identity-worded pin titles now name the message channel instead of byte-identity (7). Comments, JSDoc and test titles only: 82 changed lines across five .ts files, 0 not a comment or a title; comment+title-stripped sources hash identical to HEAD in all five; 0 skips, `it()` counts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * docs(types): the empty-message fallback pin names the `name` it reads, not "prose" The by-rule census caught one more carrier of the "always prose" half of the claim, in a title no spelling-census would have matched: "answers prose for a declared envelope whose own message is empty". Measured false in general — a declared envelope whose `message` AND `name` are both empty answers '' — and it contradicted the fallback sentence corrected in the previous commit. True of its own pin, which reads `name` = 'Error'; the title now says that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * docs(types,metadata-protocol,changeset): state the split, and name the one surviving fallback The census by rule found two claims carried in more than one place: a false universal about what `(e as Error).message` did to an undeclared throw, and a record formula that one of the fourteen call sites does not follow. Over the five values pinned at the operator-text fallback case, that expression did not answer one way. It read `undefined` for the string, the number and `{}`, and it threw a `TypeError` for `null` and `undefined`. The pin's title and the comment under it now state that split, which the shipped `operatorFacingErrorText` docblock and both site docblocks already stated. `seed-tenancy-backfill`'s organization probe keeps `operatorFacingErrorText(e) || 'unknown error'`, so for an EMPTY channel it records `'unknown error'`, not `''`. The metadata-protocol docblock, the pin title that claimed every site, and the changeset -- which ships as release notes -- now name that fallback and scope the formula to the other thirteen sites. The fallback is load-bearing rather than leftover: the site reads an empty value as "the probe did not fail", and with it removed an empty channel routes the run down the benign no-organization-yet path. Whether it should go is a behaviour question, tracked by #17167. Two more sentences of the same class: the recognizer docblock now reads "a NON-EMPTY sentence this fragment does not match" rather than "a DIFFERENT, NON-EMPTY sentence", and `messageChannelOf`'s docblock no longer attributes `undefined` to an expression that threw. No behaviour change. 58 changed .ts lines, 54 comment and 4 title, 0 other; comment-stripped and title-blanked hashes identical on all 13 .ts files; and packages/types/dist/index.mjs, dist/index.js and dist/index.d.ts are each byte-identical across this round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea4d164 commit 5a95b0e

14 files changed

Lines changed: 1134 additions & 18 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
'@objectstack/types': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/metadata': patch
5+
'@objectstack/cli': patch
6+
'@objectstack/driver-sql': patch
7+
---
8+
9+
fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal
10+
11+
Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer
12+
lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a
13+
COMPOSED message that discloses neither the statement nor the diagnostic, and carries
14+
the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and
15+
is unchanged here.
16+
17+
What changed underneath it is what every consumer STORED. Each migration probe, backfill
18+
and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded
19+
`error.message` into an operator-facing record, so those records began reading
20+
21+
the database refused to run a raw statement
22+
23+
where they used to read
24+
25+
no such column: foo
26+
27+
For a live console that costs nothing — the driver prints the statement and the dialect
28+
text to its warn sink one line earlier. For a record read later it costs everything:
29+
whoever opens a customer install's backfill result a week on never had that line, and the
30+
dialect's words were unrecoverable for them.
31+
32+
`@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk
33+
of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen
34+
stored-record sites plus `os db clean`'s console line read through it:
35+
36+
- `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out;
37+
- `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the
38+
three per-object warnings;
39+
- `partial-index-probe` — the `detail` both callers report (and its two module comments,
40+
which stated the opposite of what happened);
41+
- `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`,
42+
`migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`;
43+
- `os db clean` — the `VACUUM failed` line.
44+
45+
Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned
46+
on its own message channel, its `cause` never walked, and a declared envelope that is not
47+
the raw-path one — the typed read exits' terminal, which composes a different sentence —
48+
is left exactly as it arrived.
49+
50+
That message channel is deliberately NOT byte-identical to what the replaced expressions
51+
computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as
52+
`messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the
53+
string itself when a string was thrown, and `String(error)` when neither yields text. Every
54+
difference from the replaced expressions follows from that rule, so read the rule and not a
55+
list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`,
56+
which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a
57+
thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read
58+
`undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no
59+
record was written at all and the operation aborted; an object carrying a NON-EMPTY string
60+
`message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]`
61+
(one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads
62+
`''`, so this channel is neither always prose nor never empty.
63+
64+
## The levels, and why they are not uniform
65+
66+
`@objectstack/types` takes **`minor`**: it is the one package here that grows a published
67+
surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the
68+
export list. A purely additive widening takes at least `minor`.
69+
70+
The other four take **`patch`**, because none of them widens anything: they are a bug fix in a
71+
released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named
72+
because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper
73+
against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this
74+
PR: no entry point reaches a test file, and `files` packs `dist` only.
75+
76+
**Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter:
77+
what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name
78+
and never a type. The change these sites were made for is the declared raw-path fault, where
79+
the record gains the dialect's words in place of the driver's composed placeholder. Every
80+
other throw now reaches these records through the rule above rather than through the
81+
expression each site spelled out, so its text can move too — a consequence of the rule, not a
82+
bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record,
83+
and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an
84+
`Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is
85+
`seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on
86+
top of the rule, so those same three shapes record `'unknown error'` there rather than `''`;
87+
that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and
88+
whether it should go is tracked by #17167. The sentence being replaced is not a value any
89+
consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these
90+
records gets the dialect's words back where it had been getting a placeholder.

packages/cli/src/commands/db/clean.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Command, Flags } from '@oclif/core';
44
import { statSync, existsSync } from 'node:fs';
55
import chalk from 'chalk';
6+
import { operatorFacingErrorText } from '@objectstack/types';
67
import { printError } from '../../utils/format.js';
78
import { resolveTelemetryDbPath } from '../../utils/telemetry-datasource.js';
89

@@ -112,7 +113,7 @@ export default class DbClean extends Command {
112113
);
113114
} catch (error: any) {
114115
failed = true;
115-
printError(`VACUUM failed for ${file}: ${error?.message ?? error}`);
116+
printError(`VACUUM failed for ${file}: ${operatorFacingErrorText(error)}`);
116117
}
117118
}
118119
if (failed) this.exit(1);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16657] The producer↔consumer pin for `operatorFacingErrorText`.
5+
*
6+
* `@objectstack/types` cannot import a driver — every driver depends on it —
7+
* so the helper that reads the raw-path envelope carries its own copy of the
8+
* sentence that identifies one. A copy is a phantom check the moment the
9+
* producer rewords: every fixture that BUILDS the envelope by hand would keep
10+
* passing, and the only symptom would be a customer's backfill record silently
11+
* going back to saying nothing.
12+
*
13+
* This file is the leg that cannot go stale. It takes a REAL `SqlDriver`
14+
* refusal — the composition `TursoDriver` remote mode reaches through
15+
* `SqlDriver.rawStatementFault` as well — and asserts the helper reads the
16+
* dialect's words out of it. If `rawStatementFaultError` is reworded, this
17+
* reddens here, naming the helper, rather than in a customer's log a release
18+
* later.
19+
*
20+
* ⛔ It asserts nothing about what the ENVELOPE discloses. That is #16019's
21+
* disclosure clause and it is unchanged: the message still carries neither the
22+
* statement nor the diagnostic, which the sibling
23+
* `sql-driver-16019-raw-statement-fault-envelope.test.ts` owns and this file
24+
* deliberately does not restate.
25+
*/
26+
27+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
28+
import { operatorFacingErrorText } from '@objectstack/types';
29+
import { SqlDriver } from './index.js';
30+
31+
/** A column no table has — SQLite answers `no such column: foo`, distinctively. */
32+
const MISSING_COLUMN_SQL = 'select foo';
33+
34+
async function faultOf(run: () => Promise<unknown>): Promise<unknown> {
35+
try {
36+
await run();
37+
} catch (e) {
38+
return e;
39+
}
40+
throw new Error('expected the driver to refuse this statement, but it resolved');
41+
}
42+
43+
/**
44+
* The driver's log sink is `protected`, so the only way to hold it is from a
45+
* subclass — the shape the sibling #16019 suite uses. The dialect text is
46+
* written HERE on any default deployment; a stored record's reader never sees
47+
* this line, which is the whole card.
48+
*/
49+
class QuietSqlDriver extends SqlDriver {
50+
constructor() {
51+
super({
52+
client: 'better-sqlite3',
53+
connection: { filename: ':memory:' },
54+
useNullAsDefault: true,
55+
});
56+
this.logger = { warn: () => {} };
57+
}
58+
}
59+
60+
describe('[#16657] a real raw-exec refusal still yields the dialect text to an operator', () => {
61+
let driver: SqlDriver;
62+
63+
beforeEach(() => {
64+
driver = new QuietSqlDriver();
65+
});
66+
67+
afterEach(async () => {
68+
await driver.disconnect();
69+
});
70+
71+
it('the envelope says the composed sentence and the helper says `no such column: foo`', async () => {
72+
const thrown = (await faultOf(() => driver.execute(MISSING_COLUMN_SQL))) as Error;
73+
74+
// BEFORE — the message every consumer used to store, unchanged.
75+
expect(thrown.message).toMatch(/refused to run a raw statement/);
76+
expect(thrown.message).not.toMatch(/no such column/);
77+
78+
// AFTER — read off the cause the driver already attached.
79+
const operatorText = operatorFacingErrorText(thrown);
80+
expect(operatorText).toContain('no such column: foo');
81+
expect(operatorText).not.toMatch(/refused to run a raw statement/);
82+
});
83+
84+
it('an UNDECLARED throw from the same seam is returned on its own message channel', async () => {
85+
// The control that proves the pin above reads the declaration and not the
86+
// shape of any error the seam happens to produce.
87+
const bare = new Error('connection terminated unexpectedly');
88+
89+
expect(operatorFacingErrorText(bare)).toBe('connection terminated unexpectedly');
90+
});
91+
});

packages/metadata-protocol/src/migrations/partial-index-probe.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,20 @@
3434
* when a tightening fails differs per table (ADR-0120 D4 requires naming the
3535
* key that is not enforced and the consequence of it not being enforced), and
3636
* one generic sentence would be true of neither table. This module hands back
37-
* a classified status plus the driver's own text and stays out of the way.
37+
* a classified status plus the OPERATOR-facing text and stays out of the way.
38+
*
39+
* ⚠️ That text is no longer simply "whatever the seam threw". Since #16019 the
40+
* raw-SQL seam declares its own fault — `DATABASE_ERROR` / 500 with a COMPOSED
41+
* message that discloses neither the statement nor the diagnostic — and carries
42+
* the dialect error whole under a non-enumerable `cause`. Read bare, `detail`
43+
* became *"the database refused to run a raw statement"* for every caller that
44+
* stores it. `operatorFacingErrorText` (`@objectstack/types`, #16657) reads the
45+
* dialect's own words back out of that chain, so a stored record still names
46+
* `no such column: foo`. The envelope itself is left exactly as the driver
47+
* declared it: this is a READ of the cause, never a widening of the disclosure.
3848
*/
3949

40-
import { isUniqueViolationError } from '@objectstack/types';
50+
import { isUniqueViolationError, operatorFacingErrorText } from '@objectstack/types';
4151

4252
import { driverCanRunSql, resolveDriverExec } from './driver-exec.js';
4353

@@ -356,12 +366,15 @@ export async function probeThenReplaceIndex(
356366
try {
357367
await exec(buildSql(probeIndexName));
358368
} catch (err: unknown) {
359-
// `detail` is the OPERATOR-facing text and stays the driver's own prose.
369+
// `detail` is the OPERATOR-facing text: the dialect's own prose, read
370+
// out of the `cause` the raw seam attaches when it declares its fault
371+
// (#16019/#16657 — see the module header). Callers STORE it, and a
372+
// stored record is the only copy its reader ever gets.
360373
// The VERDICT is taken from the error object itself, so a conflict
361374
// reported on `code` / `errno` / `cause` with unhelpful prose is still
362375
// classified as one (#6699) — unwrapping first is exactly what the
363376
// migration onto the shared predicate exists to stop.
364-
const detail = err instanceof Error ? err.message : String(err);
377+
const detail = operatorFacingErrorText(err);
365378
await dropIndexQuietly(exec, probeIndexName);
366379
return { status: classifyIndexFailure(err), detail, failedAt: 'probe' };
367380
}
@@ -373,7 +386,7 @@ export async function probeThenReplaceIndex(
373386
try {
374387
await exec(buildSql(indexName));
375388
} catch (err: unknown) {
376-
const detail = err instanceof Error ? err.message : String(err);
389+
const detail = operatorFacingErrorText(err);
377390
return { status: 'failed', detail, failedAt: 'replace' };
378391
}
379392
return { status: 'created' };

0 commit comments

Comments
 (0)