Skip to content

Commit 74fcec1

Browse files
fix(driver-sql)!: PG introspectSchema reads each table's columns in its own schema and refuses a same-named pair across schemas (#24920)
Fixes #24707 Clause-②: no (narrowing) Dev session `session_013timUNNCthRfQkhy9Hz2fV`, dispatched by the `domain:engine` seat 1 (`session_01JtSQQ3EWsbNPzyYMug1tXy`) under claim 6292170810. The review of record is owed at `CONTRACT_REVIEW_TIER`, on the narrowing face. ## What changes `SqlDriver.introspectSchema`, PostgreSQL branch only (`packages/drivers/driver-sql/src/sql-driver.ts`): - **Columns read in the table's own schema.** The table listing now also selects `table_schema` (and its position in `current_schemas(false)`). Each table's columns are read with knex `withSchema(schema).columnInfo()`, and the declared-order read `introspectColumnOrder` uses `table_schema = ?` for that schema. Before, both read by bare name, which knex scopes to `current_schema()`, the first schema on the path only. `introspectColumns` / `introspectColumnOrder` gain an optional trailing `schema` parameter. Both are `protected`, so no new public member. Every other caller passes none, and its SQL is byte-identical to before. - **A same-named pair is refused, never dropped.** When the session's `search_path` reaches one bare table name in more than one schema, `introspectSchema` throws `RESOURCE_CONFLICT` / `status: 409`, naming each colliding table with its schemas in `search_path` order. The message gives the remedy: one schema per connection (the datasource's `schema` setting sets its `search_path`), or a rename. - **Unchanged:** a single-schema connection (the default `public` deployment), and the SQLite and MySQL branches. The listing query gains no `ORDER BY`, so a single-schema answer keeps its key order. Not touched, per the claim: no `schema` member on `IntrospectedTable`, no `packages/spec` change, no edit to `service-datasource` (read only, as the consumer). ## Measured before and after (throwaway PostgreSQL 16.15 cluster, local `postgres` binary) The card's database: `public.customers`, `sales.orders`, `sales.customers`, and `ALTER DATABASE … SET search_path = public, sales`. It was read through `SqlDriver.introspectSchema` and through `ExternalDatasourceService.listRemoteTables`, the service class constructed with the driver as its `introspect`, with no server. | database | before (`fcb40ad673`) | after (`1eeab8089`) | |---|---|---| | card (`public, sales`, `customers` in both) | tables `customers` (public's 3 columns), `orders` with `columns: []` and `primaryKeys: ['id']`; `sales.customers` absent; `listRemoteTables` → `customers` columnCount 3, `orders` columnCount 0 | `RESOURCE_CONFLICT` / 409: `Colliding: 'customers' ('public', 'sales')`, from both `introspectSchema` and `listRemoteTables` | | card without `sales.customers` | (same zero-column `orders`) | `orders` reads its 4 columns (`id`, `customer_id`, `total`, `placed_at`); `listRemoteTables` → columnCount 3 and 4 | | single schema (`public` only, `customers` + `orders` with an FK) | sha256 of the JSON answer (with `introspectedAt` masked) `a273d463…f8` | the same `a273d463…f8`: byte-identical | ## The collision answer: refusal (four axes) Callers of `introspectSchema`: `ExternalDatasourceService` (`listRemoteTables`, `generateObjectDraft`, `refreshCatalog`, `validateObject*`, and `testConnection` for a non-primary datasource), through `ObjectQL.introspectDatasource` or the driver directly (`service-datasource/src/plugin.ts`). Also the datasource admin plugin's connection test, only as a last fallback after `ping` / `checkHealth`, which `SqlDriver` has. `driver-turso` overrides it (SQLite). No migration differ or `os` command calls it (`packages/cli/src/utils/unmanaged-tables.ts` mirrors its listing statement and does not call it). Every caller already surfaces a throw: the listing, draft and catalog doors return the error, and `testConnection` answers `{ ok: false, error }`. - **Business need:** the only connection that can collide is an external PostgreSQL datasource whose `search_path` names two or more schemas holding a same-named table, which is the finder's measured shape. A single-schema connection can never collide, so no internal caller that sees one schema is refused. - **Long-term soundness:** "keep the first, name the other" has no channel to name it in without a spec change (`IntrospectedSchema` carries no warnings, and a `schema` member on `IntrospectedTable` is the spec decision the triage withheld). That leaves a server log line, a workaround in all but name. - **Making AI mistakes hard:** a log line is invisible to the HTTP / AI caller, so for them "keep the first" is the same silent drop this card exists to end. A loud refusal with a named remedy cannot be mistaken for a complete listing. - **Startup focus:** no new surface, no new option, no spec key, and no new gate. Rejected third shape, recorded: keying the shadowed twin as a qualified `sales.customers`. The consumers' `parseQualified` would read it, but a key whose spelling depends on context is exactly what this file's `IntrospectedForeignKey.referencedTable` note rejects (#11377), and it is outside the two options the claim names. ## Pins New `packages/drivers/driver-sql/src/sql-driver-24707-introspect-pg-multischema.test.ts`, a live-PG cell declared through `declareDialectCell` (a named skip without `OS_TEST_POSTGRES_URL`). In CI it runs in `Temporal Conformance (live PG + MySQL)`'s driver-sql step (`ci.yml`, "Run driver-sql suite against both live servers"), which is in `main`'s full battery. In PR runs the file is a named skip. 1. non-vacuity: the session's path is both schemas, and the bare-name `columnInfo()` reads zero columns for the later schema's table; 2. the later schema's table reads its own columns (exact array) and its key; 3. a same-named pair is refused: `{ code: 'RESOURCE_CONFLICT', status: 409 }`, and the message names the table and both schemas; 4. control: a single-schema session lists only its own table, and the schema-scoped column read equals the bare-name read. Ablation (committed fix, `node scripts/ablation-replace.mjs`, one leg per mutation, each restored blob-equal to `HEAD` `74cd151dd2be` with `git diff HEAD` empty; the test imports `../src`, so no dist leg): - leg A, the column read reverted to the bare name: pin 2 red (1 failed / 3 passed); - leg B, the refusal deleted: pin 3 red (`expected undefined to be defined`), 1 failed / 3 passed. Pin sweep (`introspectSchema`, `introspectColumns`, `columnInfo`, `current_schemas`, `columnCount` across `packages/`, `qa/`, `docs/qa/platform-checklist/`, `content/docs/`): no existing pin moves. `unmanaged-tables.test.ts` asserts `current_schemas(false)` in the CLI's own mirror statement, which is unchanged. The checklist's external-datasource items run on a SQLite fixture, and their knownGaps text about bare names from `current_schemas(false)` stays true. No `docs/qa/platform-checklist/**` edit. ## Verification record (head `1eeab8089`, after merging `origin/main` `a8b44d407`) - `pnpm --filter @objectstack/driver-sql typecheck`: exit 0 (its tsconfig includes `src/**/*`, so the new test is type-checked). - `pnpm --filter @objectstack/driver-sql test`: exit 0, 224 files passed / 12 skipped, 3687 tests passed / 209 skipped (no live URL, as in `Test Core`). It ran on `b61cf5026`; the merge brought only `platform-objects` files. - The introspection live files (21 files) with `OS_TEST_POSTGRES_URL` on the local cluster: exit 0, 170 passed / 10 skipped (MySQL cells, no MySQL URL). - The new pin file on the merged head, live PG: exit 0, 4 passed. - `pnpm --filter @objectstack/service-datasource test` (the consumer), after building its dependency closure: exit 0, 48 files / 862 tests passed. - `pnpm exec turbo run build --concurrency=2` on the merged head: exit 0, 77/77 tasks. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 70 families, each run with its exit code captured before any pipe, all 0. `check:dual-build-cjs-loads` first answered 3 (PREREQUISITE NOT MET, no dist) and was re-run after the full build: exit 0. `check:type-check-debt` first hit my per-command 280 s cap (124) while the box was busy and was re-run: exit 0. `--ran` reconciliation: 70 derived, 70 run, 0 NOT-MEASURED, all 70 carrying an exit code. - `pnpm --filter @objectstack/core test:repo`: exit 0, 6 files / 82 tests passed. ## Acceptance notes - The three key reads `introspectSchema` makes after the columns (`introspectPrimaryKeys` by `?::regclass`, `introspectForeignKeys` and `introspectUniqueConstraints` scoped to `current_schemas(false)`) are unchanged. With base-table collisions refused, each now answers for the one base table of that name on the path. One residual shape is a read-only inference, not measured: a VIEW (or other relation) named like a later schema's table but sitting in an earlier schema would still capture the `regclass` resolution of the primary-key read. Noted, not filed. - The CLI's `unmanaged-tables` sweep lists names across the same schemas and is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_013timUNNCthRfQkhy9Hz2fV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d606fc1 commit 74fcec1

3 files changed

Lines changed: 271 additions & 11 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
---
4+
5+
fix(driver-sql)!: on PostgreSQL, `introspectSchema` reads each table's columns in the table's own schema, and refuses a `search_path` that reaches one table name in two schemas instead of silently dropping one
6+
7+
`introspectSchema` lists the base tables of every schema in `current_schemas(false)` and keys the answer by bare table name. Two things went wrong when a connection's `search_path` named more than one schema (`ALTER DATABASE … SET search_path = public, sales`). First, each table's columns came from knex `columnInfo()` on the bare name, which reads only the first schema on the path, so a table that lives only in a later schema (`sales.orders`) was listed with no columns. `listRemoteTables` answered it `columnCount: 0`, and a draft built from it had no fields. Second, a same-named table in a later schema (`sales.customers` behind `public.customers`) was dropped without a word, because one key cannot hold two tables. The PostgreSQL branch now reads every table's columns in its own schema. When one bare name is reached in more than one schema, it refuses the whole introspection with `RESOURCE_CONFLICT` / `409`, naming each colliding table with its schemas in `search_path` order. A connection that reaches one schema, the default `public` deployment included, answers exactly what it answered before. The SQLite and MySQL branches are unchanged.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the SQL driver's PostgreSQL introspection gives for a connection whose search_path reaches more than one schema: a table in a later schema now carries its own columns, and a table name reached in two schemas is refused rather than answered with one of the two tables. The remedy is connection configuration (one schema per connection) or a rename on the remote database, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (introspection's accept set narrows on PostgreSQL), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** a PostgreSQL connection whose `search_path` reached a table name in two schemas got a successful `introspectSchema()` answer holding one of the two tables (the one in the earliest schema on the path), and so did every caller built on it: `listRemoteTables`, object drafting, catalog refresh and the external datasource's connection test.
16+
- **TO:** the same call throws `RESOURCE_CONFLICT` (`status: 409`), naming each colliding table and its schemas, for example `'customers' ('public', 'sales')`. Those callers surface that error.
17+
- **Also changed (a wrong answer made right, no accept set moved):** a table that lives only in a later schema on the path now reports its own columns where it reported none.
18+
- **Unchanged:** a connection that reaches one schema, the default `public` deployment included. One schema cannot hold two same-named tables, so it is never refused, and its answer is byte-identical to before.
19+
- **Fix:** point the connection at one schema (the datasource's `schema` setting sets its `search_path`), or rename one of the two tables on the remote database.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24707] `introspectSchema` on a PostgreSQL session whose `search_path`
5+
* names MORE THAN ONE schema.
6+
*
7+
* The table listing spans every `current_schemas(false)` schema, but the
8+
* answer is keyed by BARE name, and each table's columns were read through
9+
* knex `columnInfo()` on that bare name — which scopes to `current_schema()`,
10+
* the FIRST schema on the path only. Two wrong answers followed, both measured
11+
* on PostgreSQL 16 with `search_path = public, sales`:
12+
*
13+
* 1. a table living only in the later schema (`sales.orders`) was listed with
14+
* `columns: []` — `listRemoteTables` answered it `columnCount: 0`, and a
15+
* draft built from it would carry no fields;
16+
* 2. a same-named table in the later schema (`sales.customers` behind
17+
* `public.customers`) was silently dropped: one key, two tables.
18+
*
19+
* The fix reads each table's columns in its own schema, and refuses the
20+
* listing — `RESOURCE_CONFLICT` / 409, naming the table and both schemas —
21+
* when one bare key would have to hold two tables. The third case is the
22+
* control: a session that reaches ONE schema answers exactly what the
23+
* bare-name read always answered.
24+
*
25+
* ## Why this file is PG-only
26+
*
27+
* SQLite has no schemas and the MySQL arm lists `DATABASE()` alone, so neither
28+
* can put two schemas on one session's resolution path. The cell list is
29+
* exactly `pg`, declared through `declareDialectCell` so an unprovisioned run
30+
* is a NAMED skip, never a silent pass.
31+
*/
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import { SqlDriver } from '../src/index.js';
35+
import {
36+
PG_CELL,
37+
currentLiveSchema,
38+
declareDialectCell,
39+
type DialectCell,
40+
} from './live-dialect-matrix.testkit.js';
41+
42+
const MATRIX = 'introspectSchema multi-schema search_path';
43+
44+
/** Lives in this file's own schema — the FIRST on the path. */
45+
const CUSTOMERS = 'os24707_customers';
46+
/** Lives only in the SECOND schema on the path. */
47+
const ORDERS = 'os24707_orders';
48+
49+
/** `introspectColumns` is `protected`; this is the narrowest way to reach it. */
50+
class ColumnProbeDriver extends SqlDriver {
51+
columns(table: string, schema?: string) {
52+
return this.introspectColumns(table, schema);
53+
}
54+
}
55+
56+
function declareMultiSchemaSuite(cell: DialectCell): void {
57+
describe(`introspectSchema multi-schema search_path — ${cell.label} (#24707)`, () => {
58+
/** Session whose `search_path` is `here, there`. */
59+
let driver: ColumnProbeDriver;
60+
/** Session whose `search_path` is `here` alone — the control. */
61+
let single: ColumnProbeDriver;
62+
let here: string;
63+
let there: string;
64+
65+
beforeAll(async () => {
66+
here = currentLiveSchema();
67+
there = `${here}_b`;
68+
// Postgres TRUNCATES an over-long identifier silently, which would fold
69+
// the second schema back onto this file's own.
70+
expect(there.length, `${there} exceeds Postgres' 63-byte identifier limit`).toBeLessThanOrEqual(63);
71+
72+
single = new ColumnProbeDriver(cell.config());
73+
driver = new ColumnProbeDriver({ ...cell.config(), searchPath: [here, there] });
74+
75+
await single.execute(`drop schema if exists "${there}" cascade`);
76+
await single.execute(`create schema "${there}"`);
77+
await single.execute(`drop table if exists "${here}".${CUSTOMERS} cascade`);
78+
await single.execute(
79+
`create table "${here}".${CUSTOMERS} (id serial primary key, name text not null, email varchar(120) unique)`,
80+
);
81+
await single.execute(
82+
`create table "${there}".${ORDERS} (id serial primary key, customer_id integer, total numeric(10, 2), placed_at timestamptz)`,
83+
);
84+
});
85+
86+
afterAll(async () => {
87+
await single.execute(`drop schema if exists "${there}" cascade`).catch(() => {});
88+
await single.execute(`drop table if exists "${here}".${CUSTOMERS} cascade`).catch(() => {});
89+
await driver.disconnect().catch(() => {});
90+
await single.disconnect().catch(() => {});
91+
});
92+
93+
it('the fixture really reaches two schemas, and a bare-name read sees only the first', async () => {
94+
// Non-vacuity: the session's path is both schemas, in this order…
95+
const path: any = await driver.execute(
96+
`select s::text as s from unnest(current_schemas(false)) with ordinality as t(s, ord) order by ord`,
97+
);
98+
expect(path.rows.map((r: any) => r.s)).toEqual([here, there]);
99+
// …and the pre-fix read — `columnInfo()` on the bare name — answers
100+
// nothing for the later schema's table. If this ever reads columns, the
101+
// next case is measuring nothing.
102+
expect(Object.keys(await driver.getKnex()(ORDERS).columnInfo())).toEqual([]);
103+
});
104+
105+
it("reads a later schema's table in its own schema, not as zero columns", async () => {
106+
const schema = await driver.introspectSchema();
107+
108+
expect(Object.keys(schema.tables).sort()).toEqual([CUSTOMERS, ORDERS]);
109+
expect(schema.tables[ORDERS].columns.map((c) => [c.name, c.type, c.primaryKey])).toEqual([
110+
['id', 'integer', true],
111+
['customer_id', 'integer', false],
112+
['total', 'numeric', false],
113+
['placed_at', 'timestamp with time zone', false],
114+
]);
115+
expect(schema.tables[ORDERS].primaryKeys).toEqual(['id']);
116+
// The first schema's table is unaffected by the second being on the path.
117+
expect(schema.tables[CUSTOMERS].columns.map((c) => c.name)).toEqual(['id', 'name', 'email']);
118+
});
119+
120+
it('refuses a same-named pair across schemas instead of dropping one', async () => {
121+
await single.execute(`create table "${there}".${CUSTOMERS} (code varchar(20) primary key, region text)`);
122+
try {
123+
let thrown: any;
124+
await driver.introspectSchema().catch((e) => {
125+
thrown = e;
126+
});
127+
expect(thrown, 'a same-named pair must not be answered with one of the two tables').toBeDefined();
128+
expect({ code: thrown.code, status: thrown.status }).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
129+
// The named subjects: the colliding name and both schemas, first on
130+
// the path first — what the operator needs to act on.
131+
expect(thrown.message).toContain(`'${CUSTOMERS}' ('${here}', '${there}')`);
132+
} finally {
133+
await single.execute(`drop table if exists "${there}".${CUSTOMERS} cascade`);
134+
}
135+
});
136+
137+
it('a session that reaches ONE schema answers exactly what the bare-name read does', async () => {
138+
const schema = await single.introspectSchema();
139+
140+
// The second schema is not on this session's path, so it lists nothing.
141+
expect(Object.keys(schema.tables)).toEqual([CUSTOMERS]);
142+
// And the schema-scoped column read equals the bare-name one on the
143+
// first schema — the single-schema answer the fix must not move.
144+
// (`introspectSchema` then flips `primaryKey` / `isUnique` from the key
145+
// reads, so the comparison is of the read itself.)
146+
expect(await single.columns(CUSTOMERS, here)).toEqual(await single.columns(CUSTOMERS));
147+
const unflagged = (cols: { primaryKey?: boolean; isUnique?: boolean }[]) =>
148+
cols.map(({ primaryKey: _pk, isUnique: _uq, ...rest }) => rest);
149+
expect(unflagged(schema.tables[CUSTOMERS].columns)).toEqual(unflagged(await single.columns(CUSTOMERS)));
150+
expect(schema.tables[CUSTOMERS].columns.map((c) => [c.name, c.primaryKey, c.isUnique])).toEqual([
151+
['id', true, false],
152+
['name', false, false],
153+
['email', false, true],
154+
]);
155+
});
156+
});
157+
}
158+
159+
declareDialectCell(PG_CELL, MATRIX, declareMultiSchemaSuite);

0 commit comments

Comments
 (0)