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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
37 changes: 37 additions & 0 deletions .changeset/driver-sql-fault-envelope-declares-targeted-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
50 changes: 50 additions & 0 deletions .changeset/types-missing-table-prefers-declared-table.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<unknown>): Promise<any> {
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<string, unknown>)[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.<base>` — 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);
// 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.
});
});
});
Loading
Loading