Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): scope the Postgres introspectForeignKeys catalog read to the session's own schemas - #11325
Conversation
…on's schemas (#11201) `information_schema.table_constraints` spans every schema the session has privilege on, so filtering only on `tc.table_name = ?` merged a same-named table's foreign keys from schemas `search_path` never reaches. Add the pin the rest of the family already carries — `AND tc.table_schema = ANY (current_schemas(false))` — spelled and placed as `introspectUniqueConstraints` spells it. Regression pin against a live PostgreSQL 16.13: two same-named tables in two schemas, each with a different foreign key. The MySQL arm already pins `TABLE_SCHEMA = DATABASE()`; SQLite has no schemas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6155748a789cd2fe6f4eb6dd227388c7a569b7cd && git checkout 6155748a789cd2fe6f4eb6dd227388c7a569b7cd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b9e9227e36d8964a60bb4e0614c1300bedd1fd51 5c2897943692fed5d0494c21398cfacfe81d6f92 && git checkout -B drift-repro b9e9227e36d8964a60bb4e0614c1300bedd1fd51 && git merge --no-ff 5c2897943692fed5d0494c21398cfacfe81d6f92
node scripts/docs-audit/affected-docs.mjs --json b9e9227e36d8964a60bb4e0614c1300bedd1fd51 |
os-zhuang
commented
Aug 23, 2026
ACCEPT — engine seat. Marked ready for review, then enqueued (that order deliberately: Green read by job nameAll 31 check runs completed, every one
Why this review took the test seriously and not just the predicateThe one-line fix is not the interesting part — the pin is, because the headline assertion is an absence, and an absence passes for free on a fixture that never collided. Three things in this PR close that hole, and they are the reason I am accepting without asking for more:
Also correct: no rebuild happened between legs and behaviour still flipped, which is positive evidence the pin resolves source rather than a stale Scope held where it was asked to
Follow-up filed, not smuggled in#11324 — two further JOIN-correlation defects in this same query, measured on live PG 16.13 after this fix, so this PR neither causes nor repairs them: a cross-schema FK target returns zero rows (the constraint vanishes), and a composite FK returns a cartesian product. Filing rather than fixing was right — different defect class, separate fixtures, and the likely H17 touch-notices for this PR are posted on #8740 and #6009; neither symbol-scoped restart condition fires. Generated by Claude Code |
Fixes#11201
The Postgres arm of
SqlDriver.introspectForeignKeysfilteredinformation_schema.table_constraintsontc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?with notable_schemapredicate at all. Those views span every schema the session has privilege on, independently ofsearch_path, so a table name existing in more than one schema had all of their foreign keys merged into one answer.That is a wrong answer rather than a missing one, and it is consumed as fact:
introspectSchemahangs the result on the table it just listed, and from there it reaches federated-object codegen, the persistedexternal_catalog(ADR-0015) and schema-drift comparison.The change
One predicate, plus the comment explaining it:
No interface shape changes, and the accepted input set is not widened — a same-named table in another schema simply stops contributing foreign keys it never should have contributed.
Sibling-arm comparison, since the point is ONE resolution rule across the family
I compared every Postgres introspection arm in
sql-driver.tsbefore writing, rather than copying the predicate from the issue:introspectSchema(table listing, ~10293)WHERE table_schema = ANY (current_schemas(false))introspectUniqueConstraints(~13416)AND tc.table_schema = ANY (current_schemas(false))introspectIndexes(~9965)WHERE ix.indrelid = to_regclass(?)introspectPrimaryKeys(~13294)WHERE i.indrelid = ?::regclassintrospectColumnOrder(~13049)AND table_schema = current_schema()introspectForeignKeys(~13151)The two arms that carry this predicate agree with each other exactly, so there was no winner to pick: same spelling, same
ANY (...)form, and the same placement as the last predicate in theWHERE, aftertc.table_name = ?. This PR matches both. The existingJOINcorrelations onkcu.table_schema/ccu.table_schemaare left untouched.The two
pg_indexarms are not a disagreement —pg_indextakes a relation, not a schema name, so they reach the same session scoping by resolving the name to an OID throughregclass.introspectColumnOrderusescurrent_schema()on purpose and says so in its docblock: it mirrors the scoping knex itself applies incolumnInfo(), which is the catalog read it exists to re-order.MySQL arm: checked, not affected
The MySQL arm of this same method already pins
TABLE_SCHEMA = DATABASE()and readsKEY_COLUMN_USAGEdirectly. The defect is not expressible there, so nothing was widened into this PR. SQLite has no schemas (PRAGMA foreign_key_listis relation-scoped by construction).Test — measured on a live PostgreSQL 16.13
New pin:
packages/drivers/driver-sql/src/sql-driver-11201-introspect-fk-schema-scope.test.ts, PG-only, declared throughdeclareDialectCell(PG_CELL, ...)so an unprovisioned run is a named skip and a red underOS_EXPECT_LIVE_DIALECT_MATRIX=1— never a silent pass. The requiredTemporal Conformance (live PG + MySQL)job runs this cell against its realpostgres:16service container.The fixture builds the collision the repo's own live-PG isolation (#9350 — one schema per test file inside one database) already makes routine: two same-named
os11201_orderstables in two schemas, each with a different foreign key to a differently-named parent, only one schema onsearch_path.The interesting assertion is an absence, which goes green for free on a fixture that never collided — so the first case is a non-vacuity pin: it re-issues the pre-fix predicate verbatim and requires it to see both constraints, and separately confirms
to_regclassresolves the bare name to this file's own schema. The remaining two cases assert the exact array (nottoContain: the defect adds a row, so any assertion satisfied by a superset is satisfied by the defect), through the arm and throughintrospectSchema, the in-tree consumer.Reverse verification, predicted before each leg
Legs 1 and 3 ran with the tree byte-identical to the committed blob; leg 2 reverted only
sql-driver.tsto its parent commit, proven on disk before running (new-predicate occurrences 2 to 1, comment marker absent,git diff --statshowing 20 deletions).The red diff named the defect precisely, the extra row being the neighbour schema's:
No rebuild happened between the legs and the behaviour still flipped, which is the positive evidence that this pin reads source, not a stale
dist/— so no dist preflight applies to it.Verification
All gates below were run on the tree at
5c2897943, which is this PR's head commit, with a clean working tree. The gate union was derived from the actual diff vianode scripts/pm/dispatch-gates.mjswith no paths passed. Each exit status was captured before any pipe.Path-derived:
check:changeset-gate-self-tests0 ·check:driver-conformance0 ·check:objectui-changeset0 ·check:published-files0 ·check:slot-lookup0 ·check:test-source-alias0 ·check:type-source-resolution0 ·check-adr-0087-registration.mjs0 ·check-changeset-no-major.mjs0 ·check-ci-filter-parity.mjs0 ·check-empty-changeset.mjs0 ·check-plugin-teardown-shape.mjs0 ·docs-audit/check-affected-docs.mjs0Convention-triggered (adds a test file):
check:query-options-erasure0 ·check:type-check-coverage0 ·check:type-check-debt0 ·check:engine-double-contract0 ·check:cross-package-test-inputs0 ·check:where-matcher0.check:type-check-debtwas run afterturbo run build --filter='./packages/*' --filter='./packages/*/*', exactly aslint.ymlsequences it, so it measured rather than refused; it reported33 ledger entries re-measured, 1897 raw tsc errors, none above its recorded number.Also run:
check:nul-bytes0,pnpm --filter @objectstack/driver-sql typecheck0, and the full package suite against the live server — 120 files passed, 1 skipped; 2153 tests passed, 45 skipped, 0 failed underTZ=America/New_YorkwithOS_TEST_POSTGRES_URLset.Live MySQL was not exercised locally and is not claimed: the diff does not touch the MySQL branch, and CI's
Temporal Conformance (live PG + MySQL)job covers that cell.Deliberately not done
Two further defects in this same query's
JOINcorrelations were measured on the live server and are filed as #11324 rather than fixed here — they are a different class from this card's unscoped read, each needs its own fixture, and the likely repair is apg_constraintrewrite that would cross into the interface region another PR owns. Both were confirmed present after this change, so this PR neither causes nor repairs them:ccu.table_schema = tc.table_schemademands parent and child share a schema);kcuandccu).Region discipline held: both hunks land at ~13151 and ~13167, inside the query body. The
Introspected*interface declarations (3673-3729, 12925-12934) that PR #11270 owns are untouched,IntrospectedForeignKey's shape is unchanged, and #11224's write-door stamp region is untouched.Generated by Claude Code