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
90 changes: 90 additions & 0 deletions .changeset/operator-facing-raw-exec-cause-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
'@objectstack/types': minor
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
'@objectstack/cli': patch
'@objectstack/driver-sql': patch
---

fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal

Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer
lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a
COMPOSED message that discloses neither the statement nor the diagnostic, and carries
the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and
is unchanged here.

What changed underneath it is what every consumer STORED. Each migration probe, backfill
and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded
`error.message` into an operator-facing record, so those records began reading

the database refused to run a raw statement

where they used to read

no such column: foo

For a live console that costs nothing — the driver prints the statement and the dialect
text to its warn sink one line earlier. For a record read later it costs everything:
whoever opens a customer install's backfill result a week on never had that line, and the
dialect's words were unrecoverable for them.

`@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk
of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen
stored-record sites plus `os db clean`'s console line read through it:

- `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out;
- `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the
three per-object warnings;
- `partial-index-probe` — the `detail` both callers report (and its two module comments,
which stated the opposite of what happened);
- `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`,
`migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`;
- `os db clean` — the `VACUUM failed` line.

Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned
on its own message channel, its `cause` never walked, and a declared envelope that is not
the raw-path one — the typed read exits' terminal, which composes a different sentence —
is left exactly as it arrived.

That message channel is deliberately NOT byte-identical to what the replaced expressions
computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as
`messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the
string itself when a string was thrown, and `String(error)` when neither yields text. Every
difference from the replaced expressions follows from that rule, so read the rule and not a
list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`,
which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a
thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read
`undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no
record was written at all and the operation aborted; an object carrying a NON-EMPTY string
`message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]`
(one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads
`''`, so this channel is neither always prose nor never empty.

## The levels, and why they are not uniform

`@objectstack/types` takes **`minor`**: it is the one package here that grows a published
surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the
export list. A purely additive widening takes at least `minor`.

The other four take **`patch`**, because none of them widens anything: they are a bug fix in a
released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named
because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper
against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this
PR: no entry point reaches a test file, and `files` packs `dist` only.

**Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter:
what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name
and never a type. The change these sites were made for is the declared raw-path fault, where
the record gains the dialect's words in place of the driver's composed placeholder. Every
other throw now reaches these records through the rule above rather than through the
expression each site spelled out, so its text can move too — a consequence of the rule, not a
bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record,
and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an
`Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is
`seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on
top of the rule, so those same three shapes record `'unknown error'` there rather than `''`;
that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and
whether it should go is tracked by #17167. The sentence being replaced is not a value any
consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these
records gets the dialect's words back where it had been getting a placeholder.
3 changes: 2 additions & 1 deletion packages/cli/src/commands/db/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Command, Flags } from '@oclif/core';
import { statSync, existsSync } from 'node:fs';
import chalk from 'chalk';
import { operatorFacingErrorText } from '@objectstack/types';
import { printError } from '../../utils/format.js';
import { resolveTelemetryDbPath } from '../../utils/telemetry-datasource.js';

Expand Down Expand Up @@ -112,7 +113,7 @@ export default class DbClean extends Command {
);
} catch (error: any) {
failed = true;
printError(`VACUUM failed for ${file}: ${error?.message ?? error}`);
printError(`VACUUM failed for ${file}: ${operatorFacingErrorText(error)}`);
}
}
if (failed) this.exit(1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#16657] The producer↔consumer pin for `operatorFacingErrorText`.
*
* `@objectstack/types` cannot import a driver — every driver depends on it —
* so the helper that reads the raw-path envelope carries its own copy of the
* sentence that identifies one. A copy is a phantom check the moment the
* producer rewords: every fixture that BUILDS the envelope by hand would keep
* passing, and the only symptom would be a customer's backfill record silently
* going back to saying nothing.
*
* This file is the leg that cannot go stale. It takes a REAL `SqlDriver`
* refusal — the composition `TursoDriver` remote mode reaches through
* `SqlDriver.rawStatementFault` as well — and asserts the helper reads the
* dialect's words out of it. If `rawStatementFaultError` is reworded, this
* reddens here, naming the helper, rather than in a customer's log a release
* later.
*
* ⛔ It asserts nothing about what the ENVELOPE discloses. That is #16019's
* disclosure clause and it is unchanged: the message still carries neither the
* statement nor the diagnostic, which the sibling
* `sql-driver-16019-raw-statement-fault-envelope.test.ts` owns and this file
* deliberately does not restate.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { operatorFacingErrorText } from '@objectstack/types';
import { SqlDriver } from './index.js';

/** A column no table has — SQLite answers `no such column: foo`, distinctively. */
const MISSING_COLUMN_SQL = 'select foo';

async function faultOf(run: () => Promise<unknown>): Promise<unknown> {
try {
await run();
} catch (e) {
return e;
}
throw new Error('expected the driver to refuse this statement, but it resolved');
}

/**
* The driver's log sink is `protected`, so the only way to hold it is from a
* subclass — the shape the sibling #16019 suite uses. The dialect text is
* written HERE on any default deployment; a stored record's reader never sees
* this line, which is the whole card.
*/
class QuietSqlDriver extends SqlDriver {
constructor() {
super({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
this.logger = { warn: () => {} };
}
}

describe('[#16657] a real raw-exec refusal still yields the dialect text to an operator', () => {
let driver: SqlDriver;

beforeEach(() => {
driver = new QuietSqlDriver();
});

afterEach(async () => {
await driver.disconnect();
});

it('the envelope says the composed sentence and the helper says `no such column: foo`', async () => {
const thrown = (await faultOf(() => driver.execute(MISSING_COLUMN_SQL))) as Error;

// BEFORE — the message every consumer used to store, unchanged.
expect(thrown.message).toMatch(/refused to run a raw statement/);
expect(thrown.message).not.toMatch(/no such column/);

// AFTER — read off the cause the driver already attached.
const operatorText = operatorFacingErrorText(thrown);
expect(operatorText).toContain('no such column: foo');
expect(operatorText).not.toMatch(/refused to run a raw statement/);
});

it('an UNDECLARED throw from the same seam is returned on its own message channel', async () => {
// The control that proves the pin above reads the declaration and not the
// shape of any error the seam happens to produce.
const bare = new Error('connection terminated unexpectedly');

expect(operatorFacingErrorText(bare)).toBe('connection terminated unexpectedly');
});
});
23 changes: 18 additions & 5 deletions packages/metadata-protocol/src/migrations/partial-index-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,20 @@
* when a tightening fails differs per table (ADR-0120 D4 requires naming the
* key that is not enforced and the consequence of it not being enforced), and
* one generic sentence would be true of neither table. This module hands back
* a classified status plus the driver's own text and stays out of the way.
* a classified status plus the OPERATOR-facing text and stays out of the way.
*
* ⚠️ That text is no longer simply "whatever the seam threw". Since #16019 the
* raw-SQL seam declares its own fault — `DATABASE_ERROR` / 500 with a COMPOSED
* message that discloses neither the statement nor the diagnostic — and carries
* the dialect error whole under a non-enumerable `cause`. Read bare, `detail`
* became *"the database refused to run a raw statement"* for every caller that
* stores it. `operatorFacingErrorText` (`@objectstack/types`, #16657) reads the
* dialect's own words back out of that chain, so a stored record still names
* `no such column: foo`. The envelope itself is left exactly as the driver
* declared it: this is a READ of the cause, never a widening of the disclosure.
*/

import { isUniqueViolationError } from '@objectstack/types';
import { isUniqueViolationError, operatorFacingErrorText } from '@objectstack/types';

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

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