From abbdf61aa03a6c385606a075d89e90937df97366 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 13:25:33 +0000 Subject: [PATCH 1/2] fix(driver-sql,types): declare the targeted table on the backend-fault envelope; isMissingTableError prefers it over readObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — implementation, pins and changesets; verification pending. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...-fault-envelope-declares-targeted-table.md | 37 +++ ...es-missing-table-prefers-declared-table.md | 50 ++++ ...-federated-missing-remote-envelope.test.ts | 230 ++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 40 ++- ...rror-classification.targeted-table.test.ts | 250 ++++++++++++++++++ .../types/src/driver-error-classification.ts | 132 ++++++++- 6 files changed, 727 insertions(+), 12 deletions(-) create mode 100644 .changeset/driver-sql-fault-envelope-declares-targeted-table.md create mode 100644 .changeset/types-missing-table-prefers-declared-table.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts create mode 100644 packages/types/src/driver-error-classification.targeted-table.test.ts diff --git a/.changeset/driver-sql-fault-envelope-declares-targeted-table.md b/.changeset/driver-sql-fault-envelope-declares-targeted-table.md new file mode 100644 index 0000000000..7a0afcfc06 --- /dev/null +++ b/.changeset/driver-sql-fault-envelope-declares-targeted-table.md @@ -0,0 +1,37 @@ +--- +'@objectstack/driver-sql': patch +--- + +fix(driver-sql): the terminal backend-fault envelope declares the table the statement targeted, so a genuinely absent federated remote reads benign again (#13438) + +`isMissingTableError(error, readObject)` compares the dialect's missing-table +phrase against the name the **caller** read — its API object name (#13324). For a +federated object (ADR-0015) that is not the name in the statement: +`registerExternalObject` records `external.remoteName` and `getBuilder` targets +it. So a caller reading `crm_order` from an absent `legacy_orders` got a phrase +naming `legacy_orders`, compared it against `crm_order`, and was told the failure +was about some other relation — the **loud** verdict, for the one case the benign +licence exists for. Nothing at the call site knows the mapping; it lives on the +driver instance. + +Maintainer ruling 2026-09-01 (option 2 on the card): the driver declares the table +it targeted on the envelope. `backendStatementFaultError` — the terminal of the +`find` / `count` / `aggregate` read exits — now stamps the physical table the +statement was compiled against (a federated object's `external.remoteName`, +otherwise the object's own name, resolved exactly as `getBuilder` resolves it) +onto the envelope under `@objectstack/types`' `DRIVER_TARGETED_TABLE` symbol. + +The member is **code-readable and serialisation-invisible** — a non-enumerable +symbol key, the same discipline the envelope already applies to `cause`: +`JSON.stringify(err)`, `{ ...err }` and `Object.keys(err)` never carry it. ⛔ It is +never written into the message: #8931's disclosure clause stands, and the +composed message still names only the caller's object. No new export from this +package and no new error code; the envelope's `code` / `status` / `message` are +byte-identical to before. + +Pinned live on SQLite, Postgres and MySQL: the declared table is the name the +dialect's own phrase carries; an absent remote now reads benign through the real +predicate while the same envelope without the declaration still reads loud (the +control); a native object declares its own name and matches as before; and a +relation the statement did **not** target (a view over a dropped base table) +stays loud with the declaration present — the #13324 narrowing does not reopen. diff --git a/.changeset/types-missing-table-prefers-declared-table.md b/.changeset/types-missing-table-prefers-declared-table.md new file mode 100644 index 0000000000..09423c898c --- /dev/null +++ b/.changeset/types-missing-table-prefers-declared-table.md @@ -0,0 +1,50 @@ +--- +'@objectstack/types': minor +--- + +fix(types): `isMissingTableError` prefers the table a driver declared it targeted over the caller-supplied `readObject` — new `DRIVER_TARGETED_TABLE` / `declareTargetedTable` / `targetedTableOf` (#13438) + +`minor` because the public entry gains three exports; the predicate's signature +`(error, readObject?)` is **unchanged**, and every existing caller compiles and +behaves as before unless the error it holds carries a declaration. + +**The residual #13324 left behind.** `readObject` lets a caller say which table it +read, so a phrase naming a *different* relation no longer earns the benign "not +provisioned yet" verdict. But a caller names its **object**, and a driver compiles +the statement against the **physical** table — for a federated object (ADR-0015, +`external.remoteName`) two different names. A genuinely absent remote therefore +raised a phrase naming `legacy_orders` against a caller naming `crm_order`, and the +comparison read a real missing table as loud. The mapping lives on the driver +instance; no call site can fold it away. + +**The channel (maintainer ruling 2026-09-01, option 2).** A driver that knows the +table it targeted declares it on the error it composes: + +- `DRIVER_TARGETED_TABLE` — `Symbol.for('objectstack.driver.targetedTable')`, the + well-known key, from the global registry so a duplicated package resolves it; +- `declareTargetedTable(error, table)` — the producer's half: defines the name + **non-enumerable and non-writable** (invisible to `JSON.stringify`, `{ ...err }`, + `Object.keys`), first declaration wins, an empty or non-string name declares + nothing; +- `targetedTableOf(error)` — the reading half, `string | null`. + +`isMissingTableError` now compares the phrase against the **declared** table at +any node of the `cause` chain that carries one — the nearest declaration to the +dialect phrase wins — and ignores the caller-supplied `readObject` from that node +down. Without a declaration the comparison is the #13324 one, byte-for-byte. The +callers stay as they are: `crm_order` is still what they pass, and they never +learn a federated object's remote name. + +**Two consequences, both pinned.** A genuinely absent federated remote reads +benign again. And because a declaration is evidence the caller did not have, an +envelope whose phrase names a relation *other* than its declared table reads +**not benign even through the one-argument published form** — the #13324 verdict, +reached without the caller's help, in the direction the module docblock calls +cheap (one error line, never silent data loss). The #13324 narrowing itself does +not reopen: a different relation's error — a view over a dropped base, a join +target, a `sys_*` table hit inside the same statement — stays loud with the +declaration present, on every dialect fixture the existing pins carry. + +`@objectstack/driver-sql` adopts the channel in the same release; the pattern is +one call at any future driver's envelope. `isSchemaAlreadyExistsError` is +untouched. diff --git a/packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts b/packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts new file mode 100644 index 0000000000..dc1107a120 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#13438 — the terminal backend-fault envelope DECLARES the table the + * statement targeted, so a genuinely absent federated remote reads benign again. + * + * ## The residual #13324 left behind + * + * `isMissingTableError(error, readObject)` refuses the benign "not provisioned + * yet" verdict when the dialect phrase names a relation OTHER than the one the + * caller read. Call sites pass the object's API name. For a federated object + * (ADR-0015) that is not the name in the statement: `registerExternalObject` + * records `external.remoteName` in `physicalTableByObject`, and `getBuilder` + * targets it. So a caller reading `crm_order` from an absent `legacy_orders` + * got a phrase naming `legacy_orders`, compared it against `crm_order`, and + * was told the failure was about something else — loud, for the one case the + * licence exists for. Nothing at the call site knows the mapping; it lives on + * this driver instance. + * + * ## The ruling (maintainer, 2026-09-01, option 2 on the card) + * + * The driver declares the table it targeted on the envelope, and the predicate + * prefers a declared name over the caller-supplied object name. The predicate's + * half — precedence, the dialect fixtures, the #13324 fence — is pinned in + * `packages/types/src/driver-error-classification.targeted-table.test.ts`. This + * suite pins the DRIVER's half, live, on every dialect it speaks: + * + * 1. the declared table IS the remote — the same name the dialect's own + * phrase carries, which is the measurement that makes the fix a fix; + * 2. the composed message still withholds it (#8931's disclosure clause); + * 3. the carrier is invisible to `JSON.stringify`, a spread and `Object.keys` + * — the same discipline the envelope's `cause` already keeps; + * 4. end to end through the real predicate (`@objectstack/types` is a + * dependency of this package): benign for the absent remote, and — on the + * one dialect where a view can outlive its base table — still loud for a + * relation the statement did NOT target. + * + * The undeclared shape of the same error is asserted loud as a CONTROL, so the + * benign verdict is measured as a consequence of the declaration rather than + * of some wider change in the predicate. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { DRIVER_TARGETED_TABLE, isMissingTableError, targetedTableOf } from '@objectstack/types'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +/** The API object name the caller reads. */ +const OBJECT = 'os13438_order'; +/** `external.remoteName` — never created on any cell. */ +const REMOTE = 'os13438_legacy_orders'; +/** A native (non-federated) object that was never provisioned. */ +const NATIVE_MISSING = 'os13438_never_created'; + +async function caught(run: () => Promise): Promise { + try { + await run(); + } catch (err) { + return err; + } + return expect.fail('expected the query to fail, but it resolved'); +} + +/** + * The same envelope with the declaration REMOVED — code, status, message and + * the non-enumerable `cause` copied, the symbol not. What the predicate saw on + * `origin/main`, reconstructed from the live error so the control is about + * this dialect's real phrase and not a hand-written fixture. + */ +function undeclared(err: any): Error { + const copy = Object.assign(new Error(String(err.message)), { code: err.code, status: err.status }); + Object.defineProperty(copy, 'cause', { value: err.cause, enumerable: false, writable: true, configurable: true }); + return copy; +} + +function declareSweep(cell: DialectCell): void { +describe(`[#13438] driver-sql — the envelope declares the targeted table (${cell.label})`, () => { + let driver: SqlDriver; + + // A full connect cycle plus a drop against the cell's live server: budgeted + // like every live-matrix hook in this package (#14100), NOT a claim that it + // is known to time out. + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${REMOTE}`).catch(() => {}); + await driver.execute(`drop table if exists ${NATIVE_MISSING}`).catch(() => {}); + // The federated view of a remote that does not exist. No DDL runs here — + // that is what makes an external object external — so the remote stays + // absent and the first read hits the dialect's missing-table phrase. + driver.registerExternalObject({ + name: OBJECT, + external: { remoteName: REMOTE }, + fields: { title: { type: 'string' } }, + }); + }, 60_000); + + afterAll(async () => { + await driver.disconnect(); + }); + + // ─────────────────────────────────────────────────────────────── + // THE CARD — the declared table is the remote, on both read halves + // ─────────────────────────────────────────────────────────────── + + it('declares `external.remoteName` — the name the dialect itself put in its phrase', async () => { + for (const [half, run] of [ + ['find', () => driver.find(OBJECT, {})], + ['count', () => driver.count(OBJECT, {})], + ] as const) { + const err = await caught(run); + expect(err.code, `${half}: code`).toBe('DATABASE_ERROR'); + expect(err.status, `${half}: status`).toBe(500); + expect(targetedTableOf(err), `${half}: the declared target`).toBe(REMOTE); + + // POSITIVE CONTROL — the mismatch was real: the dialect named the + // REMOTE and not the object, which is exactly what the caller's + // `readObject` could never have matched. + const phrase = String(err.cause?.message); + expect(phrase, `${half}: the dialect names the remote`).toContain(REMOTE); + expect(phrase, `${half}: the dialect does not name the object`).not.toContain(OBJECT); + } + }); + + it('reads BENIGN again through the real predicate, with the caller passing its own API name', async () => { + const err = await caught(() => driver.find(OBJECT, {})); + expect(isMissingTableError(err, OBJECT), 'the card: an absent remote is truthful emptiness').toBe(true); + + // CONTROL — the same error without the declaration is what `origin/main` + // produced, and it reads loud: the benign verdict above is a consequence + // of the declaration, not of a wider predicate. + expect(isMissingTableError(undeclared(err), OBJECT), 'undeclared: the pre-#13438 verdict').toBe(false); + }); + + // ─────────────────────────────────────────────────────────────── + // THE DISCLOSURE CLAUSE — declared on the envelope, never in the message + // ─────────────────────────────────────────────────────────────── + + it('the composed message still withholds the physical table (#8931)', async () => { + const err = await caught(() => driver.find(OBJECT, {})); + expect(String(err.message)).toContain(OBJECT); + expect(String(err.message)).not.toContain(REMOTE); + }); + + it('the carrier is code-readable and serialisation-invisible, like `cause`', async () => { + const err = await caught(() => driver.find(OBJECT, {})); + expect(Object.getOwnPropertySymbols(err)).toContain(DRIVER_TARGETED_TABLE); + expect(Object.keys(err), 'own enumerable keys').toEqual(['code', 'status']); + expect(JSON.stringify(err), 'a serialised envelope carries no physical table').not.toContain(REMOTE); + const spread = { ...err }; + expect(targetedTableOf(spread), 'a spread copy declares nothing').toBeNull(); + for (const key of Object.keys(spread)) { + const value = (spread as Record)[key]; + if (typeof value === 'string') expect(value, `spread property '${key}'`).not.toContain(REMOTE); + } + }); + + // ─────────────────────────────────────────────────────────────── + // CONTROL — a native object declares its own name, and matches as before + // ─────────────────────────────────────────────────────────────── + + it('a native object never provisioned declares its own name (the table `getBuilder` targeted)', async () => { + const err = await caught(() => driver.find(NATIVE_MISSING, {})); + expect(err.code).toBe('DATABASE_ERROR'); + expect(targetedTableOf(err)).toBe(NATIVE_MISSING); + expect(String(err.cause?.message)).toContain(NATIVE_MISSING); + expect(isMissingTableError(err, NATIVE_MISSING)).toBe(true); + }); +}); +} + +// A matrix that silently finds zero cells reports OK — assert the axis is real +// before iterating it. +describe('[#13438] the dialect axis this suite runs', () => { + it('runs every dialect this driver speaks', () => { + expect(DIALECT_CELLS.map((c) => c.id)).toEqual(['sqlite', 'pg', 'mysql']); + }); +}); + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'federated missing-remote envelope', declareSweep); +} + +// ───────────────────────────────────────────────────────────────── +// SQLITE-ONLY — the #13324 fence, live, WITH the declaration present +// ───────────────────────────────────────────────────────────────── + +/** + * The defect #13324 closed, reproduced live: a VIEW whose base table is gone + * raises `no such table: main.` — a phrase that answers the shape test + * perfectly and names a relation the statement did NOT target. The envelope + * now declares the view (what `getBuilder` targeted); the phrase names the + * base; they differ; the verdict stays loud. SQLite is the one dialect where a + * view outlives its base table — Postgres refuses the `DROP` without `CASCADE` + * (which drops the view), and MySQL answers a different error class (an + * invalid-view refusal, not a missing table) that the predicate never + * recognised in the first place. + */ +const SQLITE = DIALECT_CELLS.find((c) => c.id === 'sqlite')!; +const VIEW = 'os13438_view_over_dropped_base'; +const BASE = 'os13438_dropped_base'; + +declareDialectCell(SQLITE, 'federated missing-remote envelope — the #13324 fence', (cell) => { +describe('[#13438] sqlite — a relation the statement did NOT target is still loud', () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`create table ${BASE} (id text primary key, title text)`); + await driver.execute(`create view ${VIEW} as select * from ${BASE}`); + await driver.execute(`drop table ${BASE}`); + }, 60_000); + + afterAll(async () => { + await driver.execute(`drop view if exists ${VIEW}`).catch(() => {}); + await driver.disconnect(); + }); + + it('declares the VIEW, the phrase names the BASE, and the verdict is NOT benign', async () => { + const err = await caught(() => driver.find(VIEW, {})); + expect(err.code).toBe('DATABASE_ERROR'); + expect(targetedTableOf(err), 'the statement targeted the view').toBe(VIEW); + const phrase = String(err.cause?.message); + expect(phrase, 'the dialect names the dropped base').toContain(BASE); + expect(isMissingTableError(err, VIEW), 'a view that exists is not "not provisioned yet"').toBe(false); + // The one-argument published form reaches the same verdict from the + // declaration alone — the driver supplied what the caller could not. + expect(isMissingTableError(err)).toBe(false); + }); +}); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 296279babc..b285c8c380 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -70,6 +70,7 @@ import { isUnbackedConflictTargetError, uniqueViolationColumn, resolveTenancyPosture, + declareTargetedTable, } from '@objectstack/types'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { nextUtcCalendarDay } from '@objectstack/core'; @@ -828,12 +829,39 @@ function unresolvableFilterColumnError(object: string, column: string | null): E * invisible to serialisation, which is the same reasoning * {@link WITHHELD_FILTER_DIAGNOSTIC} applies one refusal over. * + * # The targeted table is declared on the envelope, and never in the message + * + * [#13438] `isMissingTableError(error, readObject)` compares the dialect phrase + * against the name the CALLER read — its API object name — and for a federated + * object (ADR-0015) that is not the name in the statement: {@link + * SqlDriver.getBuilder} targets `external.remoteName`. A genuinely absent + * remote therefore raised a phrase naming `legacy_orders` against a caller + * naming `crm_order`, and the #13324 comparison read a real missing table as + * "about something else" — loud, for the one case the licence exists for. + * Nothing at a call site can fold that away: the mapping lives on this + * instance. + * + * Maintainer ruling 2026-09-01 (option 2 on the card): the driver DECLARES the + * table its statement targeted, and the predicate prefers the declared name + * over the caller's. `targetedTable` is that name, resolved by + * {@link SqlDriver.backendStatementFault} exactly as `getBuilder` resolves it, + * so it is the table the statement was compiled against and not a + * re-derivation. It travels under `@objectstack/types`' `DRIVER_TARGETED_TABLE` + * symbol, non-enumerable — the same carrier discipline as `cause` above, for + * the same reason: the physical table is precisely what the composed message + * withholds, and a member that serialised would put it back on the wire. ⛔ It + * is never written into the message; the disclosure clause below stands. + * * @param object - the API object name the caller asked for. Its own vocabulary, * never the physical table: see the message note below. * @param cause - the dialect error, kept whole for the log and for * cause-following predicates. + * @param targetedTable - the physical table the failed statement was compiled + * against: a federated object's `external.remoteName`, + * otherwise the object's own name. Declared on the envelope for + * `isMissingTableError`; never echoed. */ -function backendStatementFaultError(object: string, cause: unknown): Error { +function backendStatementFaultError(object: string, cause: unknown, targetedTable: string): Error { // ⛔ No dialect text reaches the caller — not the statement, not the quoted // references, not the `$n` placeholders, not the backend's own prose. The // ruling's disclosure clause, and it is why this message is COMPOSED rather @@ -862,6 +890,8 @@ function backendStatementFaultError(object: string, cause: unknown): Error { writable: true, configurable: true, }); + // [#13438] Code-readable, serialisation-invisible — see the section above. + declareTargetedTable(err, targetedTable); return err; } @@ -8027,7 +8057,13 @@ export class SqlDriver implements IDataDriver { 'and on the dialects that inline them the bound literals too (#7929, #8931): ' + `${typeof detail === 'string' ? detail : String(error)}`, ); - return backendStatementFaultError(object, error); + // [#13438] The table the statement was compiled against, resolved the way + // {@link SqlDriver.getBuilder} resolves it — a federated object's + // `external.remoteName`, otherwise the object's own name — because every + // read exit that reaches here built its statement through `getBuilder`. + // Declared on the envelope for `isMissingTableError`; never in the message. + const targetedTable = this.physicalTableByObject[object] ?? object; + return backendStatementFaultError(object, error, targetedTable); } async count(object: string, query?: DriverQuery, options?: DriverOptions): Promise { diff --git a/packages/types/src/driver-error-classification.targeted-table.test.ts b/packages/types/src/driver-error-classification.targeted-table.test.ts new file mode 100644 index 0000000000..a60a93626c --- /dev/null +++ b/packages/types/src/driver-error-classification.targeted-table.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13438 — `isMissingTableError` prefers the table a driver DECLARED it targeted + * over the caller-supplied `readObject`. + * + * The residual #13324 left behind: a caller names its OBJECT, a driver compiles + * the statement against the PHYSICAL table, and for a federated object + * (ADR-0015, `external.remoteName`) the two differ. `crm_order` reads + * `legacy_orders`; when that remote is genuinely absent the phrase names + * `legacy_orders`, the caller names `crm_order`, and the repaired predicate + * answered NOT benign for the one case the licence exists for. + * + * Maintainer ruling 2026-09-01 (option 2 on the card): the driver declares the + * table it targeted on the envelope, the predicate prefers it. The pair the + * ruling asks for is pinned here — an absent remote reads benign again, and a + * DIFFERENT relation's error still reads not-benign (the #13324 narrowing must + * not reopen) — with the declaration present. The driver's half (that + * `driver-sql` really stamps `external.remoteName`, live, on each dialect) is + * `packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts`. + * + * This file matches the callers gate's exemption glob on purpose + * (`driver-error-classification*.test.ts`): it exercises the one-argument + * PUBLISHED form, because the declaration changes what that form can see. + */ + +import { describe, expect, it } from 'vitest'; +import { + DRIVER_TARGETED_TABLE, + declareTargetedTable, + isMissingTableError, + isSchemaAlreadyExistsError, + targetedTableOf, +} from './driver-error-classification.js'; + +/** The API object name the caller reads. */ +const OBJECT = 'crm_order'; +/** `external.remoteName` — the table the driver actually put in the statement. */ +const REMOTE = 'legacy_orders'; + +/** + * The envelope `driver-sql`'s `backendStatementFaultError` composes: the + * ADR-0112 pair, a composed message that names the OBJECT and withholds the + * physical table, the dialect error as a non-enumerable `cause`, and — when + * `targeted` is given — the declared target. + */ +function envelope(cause: unknown, targeted?: string, object = OBJECT): Error { + const err = Object.assign( + new Error(`The database refused to run this query for object '${object}'.`), + { code: 'DATABASE_ERROR', status: 500 }, + ); + Object.defineProperty(err, 'cause', { value: cause, enumerable: false, writable: true, configurable: true }); + return targeted === undefined ? err : declareTargetedTable(err, targeted); +} + +/** + * Each dialect's spelling of "the remote table is not there", carrying the name + * the DRIVER used. The Postgres row is the schema-qualified spelling + * `getBuilder`'s `.withSchema(remoteSchema)` produces; the MySQL row carries + * the database qualifier the server always prints. Both fold away. + */ +const ABSENT_REMOTE: ReadonlyArray = [ + ['sqlite `no such table: X`', Object.assign(new Error(`no such table: ${REMOTE}`), { code: 'SQLITE_ERROR' })], + ['sqlite schema-qualified `no such table: main.X`', new Error(`no such table: main.${REMOTE}`)], + [ + 'PG `relation "X" does not exist`', + Object.assign(new Error(`relation "${REMOTE}" does not exist`), { code: '42P01' }), + ], + [ + 'PG schema-qualified `relation "public.X" does not exist`', + Object.assign(new Error(`relation "public.${REMOTE}" does not exist`), { code: '42P01' }), + ], + [ + "MySQL `Table 'db.X' doesn't exist`", + Object.assign(new Error(`Table 'db.${REMOTE}' doesn't exist`), { code: 'ER_NO_SUCH_TABLE', errno: 1146 }), + ], + ['MySQL `Unknown table`', new Error(`Unknown table 'db.${REMOTE}'`)], +]; + +/** The same dialects naming a relation that is NEITHER the object nor the remote. */ +const OTHER_RELATION: ReadonlyArray = [ + ['sqlite, a view over a dropped base', new Error('no such table: main.absent_base')], + [ + 'PG, a join target', + Object.assign(new Error('relation "sys_other_table" does not exist'), { code: '42P01' }), + ], + [ + 'MySQL, a sys_* table hit inside the same statement', + Object.assign(new Error("Table 'db.sys_other_table' doesn't exist"), { code: 'ER_NO_SUCH_TABLE' }), + ], +]; + +describe('isMissingTableError — a declared targeted table beats the caller-supplied readObject (#13438)', () => { + describe('the defect, as a control — without a declaration the #13324 comparison is loud', () => { + it.each(ABSENT_REMOTE)('%s: object name vs remote name reads NOT benign', (_name, cause) => { + // This is today's verdict on `origin/main`, and the reason the card + // exists: the phrase names the remote, the caller names the object, + // nothing at the call site knows they are the same table. + expect(isMissingTableError(envelope(cause), OBJECT)).toBe(false); + }); + }); + + describe('benign again — the driver declared the remote it targeted', () => { + it.each(ABSENT_REMOTE)('%s', (_name, cause) => { + expect(isMissingTableError(envelope(cause, REMOTE), OBJECT)).toBe(true); + }); + + it('the caller still passes its own API name and never learns the mapping', () => { + // The whole point of option 2: `crm_order` is what the four in-repo + // call sites pass, unchanged, and it is not consulted. + const err = envelope(new Error(`no such table: ${REMOTE}`), REMOTE); + expect(isMissingTableError(err, OBJECT)).toBe(true); + expect(isMissingTableError(err, 'some_other_caller_name')).toBe(true); + }); + + it('a native object declares its own name, and matches exactly as before', () => { + const err = envelope(new Error('no such table: sys_file'), 'sys_file', 'sys_file'); + expect(isMissingTableError(err, 'sys_file')).toBe(true); + }); + }); + + describe('the #13324 narrowing does not reopen — a DIFFERENT relation is still loud', () => { + it.each(OTHER_RELATION)('%s, declared target present', (_name, cause) => { + expect(isMissingTableError(envelope(cause, REMOTE), OBJECT)).toBe(false); + }); + + it('the declared target is compared, the caller-supplied name is ignored', () => { + // A phrase naming the OBJECT while the statement targeted the REMOTE + // is about something else — without the declaration this read + // benign, because the caller's name happened to match. + const namesTheObject = new Error(`no such table: ${OBJECT}`); + expect(isMissingTableError(envelope(namesTheObject), OBJECT)).toBe(true); + expect(isMissingTableError(envelope(namesTheObject, REMOTE), OBJECT)).toBe(false); + }); + + it('narrows the one-argument form too — the declaration is evidence the caller lacked', () => { + // Published form, no `readObject`. Pre-#13438 this was the wide + // verdict; with a declared target the driver has said which table + // the statement was about, and a phrase about another one is not + // evidence for it. + expect(isMissingTableError(envelope(new Error('no such table: main.absent_base'), REMOTE))).toBe(false); + expect(isMissingTableError(envelope(new Error(`no such table: ${REMOTE}`), REMOTE))).toBe(true); + // CONTROL — undeclared, the one-argument verdict is byte-for-byte + // the pre-#13438 one. + expect(isMissingTableError(envelope(new Error('no such table: main.absent_base')))).toBe(true); + }); + }); + + describe('the comparison folds the declared name the way it folds readObject', () => { + it('ignores case', () => { + expect(isMissingTableError(envelope(new Error('no such table: LEGACY_ORDERS'), REMOTE), OBJECT)).toBe(true); + }); + + it('ignores a qualifier on the declaration itself', () => { + // A driver that declares `schema.table` is not penalised for it. + expect( + isMissingTableError(envelope(new Error(`no such table: ${REMOTE}`), `public.${REMOTE}`), OBJECT), + ).toBe(true); + }); + }); + + describe('where the declaration sits in the cause chain', () => { + it('is found below an undeclared wrapper', () => { + // The engine wraps the driver's envelope; the driver's declaration + // is still the one compared. + const wrapped = new Error('engine: read failed', { + cause: envelope(new Error(`no such table: ${REMOTE}`), REMOTE), + }); + expect(isMissingTableError(wrapped, OBJECT)).toBe(true); + }); + + it('the declaration NEAREST the dialect phrase wins', () => { + // An outer actor re-declaring a different target is farther from + // the statement than the driver that compiled it. + const inner = envelope(new Error(`no such table: ${REMOTE}`), REMOTE); + const outer = declareTargetedTable(new Error('outer wrapper', { cause: inner }), 'not_the_target'); + expect(isMissingTableError(outer, OBJECT)).toBe(true); + }); + + it('a declared node whose phrase mismatches is NOT rescued by a matching cause', () => { + // Same disposition #6347 and #13324 gave the exclusion: recognition + // ends the question rather than descending. + const err = envelope( + Object.assign(new Error('no such table: main.absent_base'), { + cause: new Error(`no such table: ${REMOTE}`), + }), + REMOTE, + ); + expect(isMissingTableError(err, OBJECT)).toBe(false); + }); + }); + + describe('the carrier — readable by code, invisible to serialisation', () => { + const err = envelope(new Error(`no such table: ${REMOTE}`), REMOTE); + + it('is read back by `targetedTableOf`', () => { + expect(targetedTableOf(err)).toBe(REMOTE); + expect(Object.getOwnPropertySymbols(err)).toContain(DRIVER_TARGETED_TABLE); + }); + + it('never reaches `JSON.stringify`, a spread, or `Object.keys`', () => { + expect(JSON.stringify(err)).not.toContain(REMOTE); + expect(Object.keys(err)).toEqual(['code', 'status']); + const spread = { ...err }; + expect(targetedTableOf(spread)).toBeNull(); + expect(Object.getOwnPropertySymbols(spread)).not.toContain(DRIVER_TARGETED_TABLE); + }); + + it('is the global-registry symbol, so a duplicated package resolves the same key', () => { + expect(DRIVER_TARGETED_TABLE).toBe(Symbol.for('objectstack.driver.targetedTable')); + }); + + it('is non-writable and the first declaration wins', () => { + expect(declareTargetedTable(err, 'later_guess')).toBe(err); + expect(targetedTableOf(err)).toBe(REMOTE); + }); + }); + + describe('no declaration means "as we were", never "be loud"', () => { + it('an empty or non-string table declares nothing', () => { + const empty = declareTargetedTable(new Error(`no such table: ${REMOTE}`), ''); + expect(targetedTableOf(empty)).toBeNull(); + const bogus = declareTargetedTable(new Error(`no such table: ${REMOTE}`), 0 as never); + expect(targetedTableOf(bogus)).toBeNull(); + // …and the predicate falls back to the caller's name exactly as before. + expect(isMissingTableError(empty, REMOTE)).toBe(true); + expect(isMissingTableError(empty, OBJECT)).toBe(false); + }); + + it('`targetedTableOf` is tolerant of bare input', () => { + expect(targetedTableOf(null)).toBeNull(); + expect(targetedTableOf(undefined)).toBeNull(); + expect(targetedTableOf('no such table: x')).toBeNull(); + expect(targetedTableOf(42)).toBeNull(); + expect(targetedTableOf({})).toBeNull(); + expect(targetedTableOf({ [DRIVER_TARGETED_TABLE]: 7 })).toBeNull(); + }); + + it('leaves the DDL predicate untouched', () => { + // The declaration is a fact about the MISSING_TABLE channel; + // `isSchemaAlreadyExistsError` takes no relation and must not have + // acquired one. + const err = declareTargetedTable( + Object.assign(new Error('table legacy_orders already exists'), { code: 'SQLITE_ERROR' }), + 'something_else', + ); + expect(isSchemaAlreadyExistsError(err)).toBe(true); + }); + }); +}); diff --git a/packages/types/src/driver-error-classification.ts b/packages/types/src/driver-error-classification.ts index b15fdbf4b2..206459b741 100644 --- a/packages/types/src/driver-error-classification.ts +++ b/packages/types/src/driver-error-classification.ts @@ -404,22 +404,104 @@ const MISSING_TABLE: DriverErrorSignature = { /** How far to follow an `error.cause` chain — drivers wrap, but not deeply. */ const MAX_CAUSE_DEPTH = 4; +/** + * [#13438] The physical table a driver's statement TARGETED, declared on the + * error envelope by the producer that knows it. + * + * `readObject` closed the #13324 hole for callers that can name what they read + * — and left a residual one layer down. A caller names its OBJECT (the API + * name); a driver compiles the statement against the PHYSICAL table, and for a + * federated object (ADR-0015, `external.remoteName`) those are two different + * names. `driver-sql` reads `crm_order` from `legacy_orders`, so when that + * remote is genuinely absent the dialect phrase names `legacy_orders`, the + * caller names `crm_order`, and the comparison called a real missing table + * "about something else" — loud, for the one case the licence was built for. + * + * Nothing at a call site can fold that away: the mapping lives on the driver + * instance, and asking every caller to consult it is the guessing this channel + * exists to remove (maintainer ruling 2026-09-01, option 2 on the card). So the + * fact is declared where it is known — the driver that composed the envelope + * stamps the table its statement targeted onto it — and the predicate PREFERS + * a declared table over the caller-supplied `readObject`. The caller never + * needs to know a federated object's remote name, and a driver that declares + * nothing gets exactly the #13324 behaviour. + * + * A symbol key from the global registry, held non-enumerable: the carrier + * discipline `driver-sql` already applies to its withheld-diagnostic symbols + * and to the envelope's own `cause`. Readable by code; invisible to + * `JSON.stringify`, `{ ...err }`, `Object.keys`, `for…in` and the + * structured-clone boundary — so the physical table name, the very thing the + * envelope's composed message withholds, can never ride back onto a wire that + * serialises the error. `Symbol.for` so a duplicated copy of this package + * resolves the same key. + * + * ⚠️ A declaration is EVIDENCE, so it also narrows the one-argument form: an + * envelope declaring `legacy_orders` whose dialect phrase names some other + * relation reads not-benign even with no `readObject` — the driver supplied + * the fact the caller could not. That is the #13324 verdict reached without + * the caller's help, in the direction the module docblock calls cheap. + */ +export const DRIVER_TARGETED_TABLE: symbol = Symbol.for('objectstack.driver.targetedTable'); + +/** + * Declare, on `error`, the physical table the statement that raised it targeted. + * + * The producer's half of {@link DRIVER_TARGETED_TABLE} — for a driver composing + * an error envelope over a dialect failure. `table` is the name the statement + * was compiled against (a federated object's `external.remoteName`, otherwise + * the object's own table), bare: the comparison folds away schema and database + * qualifiers on both sides, so none is needed here. + * + * Non-enumerable and non-writable, and the FIRST declaration wins: the actor + * that compiled the statement is the one that knows its target, and a later, + * more distant wrapper re-declaring it would be re-introducing the guess. (The + * predicate applies the same rule across a `cause` chain: the declaration + * NEAREST the dialect phrase is the one compared.) An empty or non-string + * `table` declares nothing — silently, because this runs on an error path + * where a thrown `TypeError` would replace the envelope it was meant to + * annotate; the predicate then falls back to `readObject` exactly as if no + * driver had spoken. + * + * @returns `error`, for chaining. + */ +export function declareTargetedTable(error: E, table: string): E { + if (typeof table !== 'string' || table === '') return error; + if (targetedTableOf(error) !== null) return error; + Object.defineProperty(error, DRIVER_TARGETED_TABLE, { value: table, enumerable: false }); + return error; +} + +/** + * The table `error` declares its statement targeted, or `null` when it declares + * none — the reading half of {@link declareTargetedTable}. Tolerant of bare + * input: any non-object, and any object without a non-empty string under the + * key, is "no declaration". + */ +export function targetedTableOf(error: unknown): string | null { + if (error === null || (typeof error !== 'object' && typeof error !== 'function')) return null; + const table = (error as Record)[DRIVER_TARGETED_TABLE]; + return typeof table === 'string' && table !== '' ? table : null; +} + /** * The {@link DriverErrorSignature.excludes.namesAnotherRelation} channel, in the * one place both the string and the object node reach it. * - * Guards the caller-supplied half rather than trusting it: the parameter is - * optional on the public predicate, so `undefined` (a caller that cannot name - * what it read) and a non-string (a stale positional `depth` argument from - * before this parameter existed) must both mean "no evidence", never "loud". + * `relation` is the name the phrase must be about: the table the node (or an + * outer node) DECLARED its statement targeted (#13438), else the caller's + * `readObject`. Guards the caller-supplied half rather than trusting it: the + * parameter is optional on the public predicate, so `undefined` (a caller that + * cannot name what it read) and a non-string (a stale positional `depth` + * argument from before this parameter existed) must both mean "no evidence", + * never "loud". */ function excludedByReadObject( message: string, signature: DriverErrorSignature, - readObject: string | undefined, + relation: string | undefined, ): boolean { - if (typeof readObject !== 'string' || readObject === '') return false; - return signature.excludes?.namesAnotherRelation?.(message, readObject) === true; + if (typeof relation !== 'string' || relation === '') return false; + return signature.excludes?.namesAnotherRelation?.(message, relation) === true; } /** @@ -435,6 +517,12 @@ function excludedByReadObject( * direction: an error that positively identifies as "a column of an existing * relation" *is* that error, whatever it wraps; and stopping can only ever * subtract benign verdicts, never add one. + * + * `readObject` is the relation the phrase is compared against at this node — + * the caller's own name at the top of the chain, or, once a node has DECLARED + * the table its statement targeted (#13438), that declaration for the node and + * everything it wraps. A string node cannot declare anything and compares + * against what it inherited. */ function matchesDriverError( error: unknown, @@ -458,11 +546,17 @@ function matchesDriverError( cause?: unknown; }; + // [#13438] A declared target replaces the caller's name outright — from + // this node down, the phrase is compared against what the driver compiled + // the statement for. The NEAREST declaration to the phrase wins, because + // that is the actor that knows. + const relation = targetedTableOf(err) ?? readObject; + const excludes = signature.excludes; if (excludes) { if (typeof err.code === 'string' && excludes.codes.has(err.code)) return false; if (typeof err.message === 'string' && excludes.matchesMessage(err.message)) return false; - if (typeof err.message === 'string' && excludedByReadObject(err.message, signature, readObject)) + if (typeof err.message === 'string' && excludedByReadObject(err.message, signature, relation)) return false; } @@ -471,7 +565,7 @@ function matchesDriverError( if (typeof err.message === 'string' && signature.message.test(err.message)) return true; // Drivers commonly re-throw with the original attached as `cause`. - return matchesDriverError(err.cause, signature, depth + 1, readObject); + return matchesDriverError(err.cause, signature, depth + 1, relation); } /** @@ -528,14 +622,32 @@ export function isSchemaAlreadyExistsError(error: unknown, depth = 0): boolean { * untouched: the signature below is unchanged, and the gate binds only callers * inside this repository. * + * [#13438] `readObject` is the caller's name for what it read, and for a + * federated object (ADR-0015) that is not the name the driver put in the + * statement — `crm_order` reads `external.remoteName: 'legacy_orders'`, so a + * genuinely absent remote raised a phrase naming `legacy_orders` against a + * caller naming `crm_order`, and the #13324 comparison read it loud. A driver + * that knows the table it targeted now DECLARES it on the envelope + * ({@link declareTargetedTable}), and a declared table is preferred over + * `readObject` outright: the phrase is compared against the declared name, and + * the caller-supplied one is not consulted at that node or below it. Absent a + * declaration the comparison is the #13324 one, unchanged. Two consequences, + * both pinned: a genuinely absent federated remote reads benign again without + * the caller learning the mapping; and — because a declaration is evidence the + * caller did not have — an envelope whose phrase names a relation other than + * its declared table reads NOT benign even through the one-argument form. + * * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …). * @param readObject - The object/table whose emptiness the caller is about to * treat as the truth — its own API name is fine, the comparison folds * away schema qualifiers, the legacy `ns__short` prefix and case. * Omitted (or not a string) means "cannot say", never "be loud". + * Superseded, at any node of the `cause` chain that declares the + * table its statement targeted, by that declaration (#13438). * @param depth - Internal `cause`-chain recursion counter; callers pass nothing. * @returns `true` only when the error positively identifies as - * table/relation-does-not-exist **for `readObject`**. + * table/relation-does-not-exist **for the table that was read** — + * the declared target where a driver supplied one, else `readObject`. */ export function isMissingTableError(error: unknown, readObject?: string, depth = 0): boolean { return matchesDriverError(error, MISSING_TABLE, depth, readObject); From b8e458a8ce41d7fa9f511ab3e6fc6740f88acc0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 13:42:03 +0000 Subject: [PATCH 2/2] test(driver-sql,types): spell the wrappers without the ES2022 Error options bag; keep the one-argument pin inside the defining package Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...-driver-13438-federated-missing-remote-envelope.test.ts | 6 +++--- .../src/driver-error-classification.targeted-table.test.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts b/packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts index dc1107a120..1b87f38cdf 100644 --- a/packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-13438-federated-missing-remote-envelope.test.ts @@ -222,9 +222,9 @@ describe('[#13438] sqlite — a relation the statement did NOT target is still l const phrase = String(err.cause?.message); expect(phrase, 'the dialect names the dropped base').toContain(BASE); expect(isMissingTableError(err, VIEW), 'a view that exists is not "not provisioned yet"').toBe(false); - // The one-argument published form reaches the same verdict from the - // declaration alone — the driver supplied what the caller could not. - expect(isMissingTableError(err)).toBe(false); + // That the one-argument published form reaches the same verdict from the + // declaration alone is pinned in the predicate's own contract tests — the + // one place the #13440 callers gate lets that form be spelled. }); }); }); diff --git a/packages/types/src/driver-error-classification.targeted-table.test.ts b/packages/types/src/driver-error-classification.targeted-table.test.ts index a60a93626c..fc1a5d13a6 100644 --- a/packages/types/src/driver-error-classification.targeted-table.test.ts +++ b/packages/types/src/driver-error-classification.targeted-table.test.ts @@ -163,7 +163,7 @@ describe('isMissingTableError — a declared targeted table beats the caller-sup it('is found below an undeclared wrapper', () => { // The engine wraps the driver's envelope; the driver's declaration // is still the one compared. - const wrapped = new Error('engine: read failed', { + const wrapped = Object.assign(new Error('engine: read failed'), { cause: envelope(new Error(`no such table: ${REMOTE}`), REMOTE), }); expect(isMissingTableError(wrapped, OBJECT)).toBe(true); @@ -173,7 +173,10 @@ describe('isMissingTableError — a declared targeted table beats the caller-sup // An outer actor re-declaring a different target is farther from // the statement than the driver that compiled it. const inner = envelope(new Error(`no such table: ${REMOTE}`), REMOTE); - const outer = declareTargetedTable(new Error('outer wrapper', { cause: inner }), 'not_the_target'); + const outer = declareTargetedTable( + Object.assign(new Error('outer wrapper'), { cause: inner }), + 'not_the_target', + ); expect(isMissingTableError(outer, OBJECT)).toBe(true); });