Skip to content

Commit 649414d

Browse files
committed
Merge origin/main into claude/issue-24909-missing-driver-peer-remedy
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
2 parents c3c575f + 74fcec1 commit 649414d

8 files changed

Lines changed: 461 additions & 20 deletions
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state
6+
7+
Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)
8+
9+
Five row and record-header actions judged only the caller's standing, so Setup offered both halves of each pair on every row. Each now also reads the record's state, in the form `sys_oauth_application`'s Disable / Enable pair already uses:
10+
11+
- `sys_user` (Setup › Users): *Ban User* is offered when `banned` is not true, *Unban User* when it is. A timed ban past `ban_expires` still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
12+
- `sys_user`: *Unlock Account* is offered when `locked_until` is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on `sys_two_factor` and not on the user row, so a two-factor user keeps the button. An expired `locked_until` keeps it too: the failed-attempt counter stays at the threshold until Unlock or a successful sign-in resets it.
13+
- `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one.
14+
15+
The `sys_user` actions keep their platform-admin standing (`current_user.isPlatformAdmin == true`) first. A row that does not carry the field offers neither action of a pair. The console asks for every field an object action's predicate reads, so the default Users list carries `banned` without showing it.
16+
17+
Unchanged: labels, texts, targets and params, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still decided by the server.
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)