Skip to content

Commit b179084

Browse files
refactor(driver-sql): move introspectSchema's body out of sql-driver.ts into sql-driver-introspection.ts (#25521 cut 4) (#25698)
Part of #25521 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Ruling-ref: 6348891160 Cut 4 of the `sql-driver.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6386825345`. The body of the public `SqlDriver#introspectSchema` moves unedited into cut 1's module, `packages/drivers/driver-sql/src/sql-driver-introspection.ts`, and reaches the class through cut 1's host, `IntrospectionHost`. The method keeps its name, signature and position. `listPhysicalColumns`, the other member of the class's Schema Introspection section, stays in the class (see "Why only `introspectSchema`"). It is move-only, and the card stays open for the later cuts. Branch `claude/issue-25521-sql-driver-cut-4`, cut from `origin/main` at `6021b5e83` (cut 3 merged as `645ede5e1` before it). **Net line count (the seat's rule from ACCEPT `6385306916`).** `sql-driver.ts` goes from 21,678 lines at base to 21,595 at head: **-83**. `sql-driver-introspection.ts` goes from 665 to 800 lines. **Serial with the file's in-flight PRs.** At claim the PM read no open PR touching `sql-driver.ts`. Re-measured at my start (15 open PRs) and again just before this PR was opened (12 open), each PR's file list read in full. No PR touches `packages/drivers/driver-sql/**`, `packages/drivers/driver-turso/**` or `packages/drivers/driver-sqlite-wasm/**`, except the Version Packages PR #25640, which touches only those packages' `CHANGELOG.md` and `package.json` (its 183 files read page by page, twice). `origin/main` moved two commits past the base meanwhile (#25603, #25684, now `1fbd9de05`). Neither touches the three driver packages or a gate script this diff derives, so `main` is not merged in. ## Seam table (fresh, at `6021b5e83`) `sql-driver.ts` is 21,678 lines. 30-day lines are `git blame --line-porcelain` lines whose committer time is on or after 2026-09-24T15:56:03Z, 30 days before the base commit. The clone's history floor is 2026-09-15, and `scripts/pm/git-history.mjs count` proves 69 commits on the file in that window. In all, 3,815 lines (17.6%) are from the last 30 days. The class `SqlDriver` spans lines 5499-21603 with 347 members: 251 methods (49 public), 32 statics. No public method has an inferred return type; the one inferred public member is the getter `supports`, in the class head. Sections are the class's own banner comments, cut by contiguous line range. "Public" counts public methods, plus public accessors where there are any: | Lines | Section | Lines | 30-day lines | Members (public) | |:--|:--|--:|--:|:--| | 1-5498 | Module preamble: imports, types, top-level helpers (202 statements) | 5,498 | 1,420 | - | | 5499-7090 | Class head: fields, statics, constructor, capability getters (`supports`) | 1,592 | 290 | 88 (1 + 2 accessors) | | 7091-7522 | Lifecycle | 432 | 155 | 15 (3) | | 7523-10201 | CRUD (IDataDriver core) | 2,679 | 691 | 45 (7) | | 10202-10813 | Bulk and batch operations | 612 | 140 | 9 (6) | | 10814-10910 | Raw execution (cut 2's three stubs and the `runtimeCreatedIndexes` field) | 97 | 28 | 4 (1) | | 10911-11003 | Transactions (cut 3's six stubs and `releaseTransaction`) | 93 | 46 | 7 (5) | | 11004-11535 | Aggregation | 532 | 61 | 5 (1) | | 11536-11823 | Distinct | 288 | 20 | 3 (1) | | 11824-11939 | Window functions | 116 | 4 | 1 (1) | | 11940-12038 | Query plan analysis | 99 | 1 | 2 (2) | | 12039-15793 | Schema sync (syncSchema / init) | 3,755 | 256 | 62 (14 + 3 accessors) | | **15794-15929** | **Schema introspection (this cut moves `introspectSchema`, 15825-15928)** | **136** | **65** | **2 (2)** | | 15930-21603 | Internal helpers, including cut 1's five introspection stubs | 5,674 | 638 | 104 (5) | | 21604-21678 | After the class: `UniqueConstraintMember`, `singleColumnUniqueColumns` | 75 | 0 | - | Of the 65 Schema introspection lines from the last 30 days, 27 are `listPhysicalColumns` with its docblock and the banner (15794-15824), and 38 are `introspectSchema` (15825-15928). At head the section is 52 lines (15795-15846). The limits per area are cut 1's (PR #25614), re-read on this tree: - **`getBuilder` builders.** Aggregation, Distinct, Window functions, Query plan analysis, CRUD and Bulk build through `getBuilder`, so a cut of any of them must first carry the new module into `check:tenant-chokepoint`'s `SCAN_FILES`, or rule 1 goes silently narrower. None of them is cut here. - **Protected statics.** The hash-shadow family and the row-size explainers (Internal helpers) read protected statics, so those bodies stay. - **`this.FIELD.delete/insert/update(`.** `releaseTransaction` has one, and Schema sync has three. Those bodies stay. `introspectSchema` has none: it calls `.get(`, `.set(` and `.push(` on local maps and arrays only. - **The net-line rule** (ACCEPT `6385306916`). An area moves only when `sql-driver.ts`'s net line count drops. - `insertOnlyUpsertColumns` stays in `sql-driver.ts`, because #8740's restart line names that symbol in that file. This cut does not touch it. **Proposed cut 5: `introspectIndexes`** (Schema sync, 15097-15249, 153 lines, 0 from the last 30 days), into the same `sql-driver-introspection.ts`. It is protected, with an annotated return type (a promise of `PhysicalIndex[]`). It reads no static and does not build through `getBuilder`, and it has no `this.FIELD.delete/insert/update(` site. Its body reads `knex`, `isMysql`, `isPostgres` and `isSqlite`, which `IntrospectionHost` already lists, plus `resolveHashShadowKeys`, which would go as a late-bound closure. That method reads the hash-shadow statics, so it stays in the class. `TursoDriver` overrides `introspectIndexes` and calls `super` on its local arm. Priced against the net-line rule: the stub needs one closure and four getters (13 lines) in place of a body of 96 lines, plus one name in the import, so the file drops by about 82 lines. Its docblock cites ADR-0048 and stays on the class, so the anchored decision-letter count should not move. The next candidate after it is the SQLite rebuild pair `applyDriftOpInPlace` and `rebuildSqliteTablePatched` (14858-15048, 0 from 30 days; 6 and 10 host members). Every other cold area left builds through `getBuilder`. ## Why `introspectSchema`, and why cut 1's module - **The area.** A1 holds on re-measure, with one change: only `introspectSchema` moves. It is public, its return type is annotated (a promise of `IntrospectedSchema`), it reads no static and no `getBuilder`, and it has no `this.FIELD.delete/insert/update(` site. Its 104 lines include 38 from the last 30 days. - **Cut 1's module, not a new one.** `introspectSchema` is the public whole-schema read that cut 1's five catalog helpers serve, and that module's header already says so. Its body calls four of those helpers. Four of the five fields and getters it reads (`knex`, `isMysql`, `isPostgres`, `isSqlite`) are already `IntrospectionHost` members. A new module would declare a second host typing the same four members, and card ask 2 asks for one named host per shared surface, not a second copy. One name joins the existing import line in `sql-driver.ts`. A new module would add an import block and its comment, which counts against the net-line rule. ### Why only `introspectSchema` The seat's ACCEPT on cut 3 keeps a body in the class when it is shorter than its host literal. `listPhysicalColumns` is that case. Its body is 6 lines, and it reads `knex`, `physicalTableByObject` and `physicalSchemaByObject`. A stub would need three getters: 10 lines in place of 6, plus one more name in the import. So `sql-driver.ts` would grow by 5 lines. Moving it with `introspectSchema` would still leave the area net-negative (-78 instead of -83). But that member alone would make the file longer, so it stays. This is open question 1 in the report. ### Overrides and spies, measured before the move - `TursoDriver` overrides `introspectSchema` (its `'refused'` door row) and calls `super.introspectSchema()` on its local arm. The class method is still there and hands the body its host. `TursoDriver` also overrides `introspectColumns` (one parameter, calls `super`). The moved body reaches `introspectColumns` through a late-bound closure, so that override is still the one it calls. `TursoDriver`'s door table lists `dialectName` as `'inherited'`. - `SqliteWasmDriver` overrides the getter `isSqlite`. The host reads it through a getter that reads the driver, so the override is the one the body sees. - `sql-driver-12732-varchar-emitter-parity-wiring.test.ts` spies on `introspectColumns` on the instance, and the closure reaches that spy. `service-datasource` tests spy on `introspectSchema` itself, which is still the class method. - A scratch probe against the built `dist` gave 14 readings, identical at base and at head. It covered the plain SQLite answer (tables, columns, keys, foreign keys, `dialect`, `introspectedAt`), instance spies on all four helpers (2 calls each), a spy on `introspectColumns` that answers its own columns, a subclass override of `introspectColumns` with one parameter, a subclass override of the `dialectName` getter, a subclass `introspectSchema` that calls `super`, and a helper that rejects, which rejects the whole read. The probe is not committed. ## The shape (#23050 shape B, as cuts 1-3 apply it) - The class keeps `introspectSchema` with its name, signature and position. Its body is now one host literal plus `introspectSchema.call(host)`, marked `[#25521 cut 4]`. It drops `async` and returns the moved function's promise. The method has no docblock, at base or at head. - `IntrospectionHost` gains five members, each typed as the class member (an indexed access on `SqlDriver`): `dialectName` (read-only, a getter) and `introspectColumns`, `introspectForeignKeys`, `introspectPrimaryKeys` and `introspectUniqueConstraints` (late-bound closures). Cut 1's five member lines are unchanged. The moved function types its `this` as the `Pick` of the host over exactly the nine members its body reads. The generator measures those reads from the AST and asserts them, and the stub annotates its literal as the `ThisParameterType` of the moved function. There is no cast, and no member becomes public. - **Module-level declarations.** The body reads `IntrospectedSchema` and `IntrospectedTable`, which `sql-driver.ts` already exports, and `postgresTableNameCollisionError`, which only this body reads. That function stays in `sql-driver.ts` and gains a leading `export`. This is cut 2's shape, accepted there as option A: it moves only method bodies and keeps every path citation true. The value import is read only inside the function body, so the module cycle is evaluation-safe, as in cut 1. `index.ts` re-exports nothing new. - The move was produced by a script from the source text, not retyped. It took the method's parameter list, return type and body lines, re-indented two columns, except the 16 lines that continue one of the body's three multi-line SQL template literals. Those keep their bytes, because there the indentation is statement text (cut 1's rule). - Cut 1's module header gains prose describing this cut. The import comment in `sql-driver.ts` is re-worded to name `introspectSchema`. Both are comment-only. ## The published `.d.ts` does not change Under the series bar (ruling `6348891160`, letter A), measured on base `6021b5e83` and on head `f73c54587`. Both were built against the same dependency tree. At base: `turbo run build --filter=@objectstack/driver-turso... --filter=@objectstack/driver-sqlite-wasm...`, 9/9 tasks, 0 cached, with the base `sql-driver.ts` and module restored into the worktree after the cut commit. At head: `pnpm --filter @objectstack/driver-sql build`. - This package emits `dist/index.d.ts` and `dist/index.d.mts` (there is no `.d.cts`). The two are identical to each other at both ends. - Raw sha256 of both files, base and head, all four the same: `93da45168e82136b7613944525e47402d5dc6f9f8daa9efa4bdceeaa11b52a89` (cuts 1-3's hash, unchanged). The raw diff is 0 lines. - Normalised with the ruling's one-liner verbatim (double-quoted unions): base = head = `93da45168e82136b7613944525e47402d5dc6f9f8daa9efa4bdceeaa11b52a89`. The file has 0 double-quoted unions, so that normaliser rewrites nothing. - Normalised with the same one-liner, with each double quote in the regex swapped for a single quote (21 lines of the file carry a single-quoted union): base = head = `118e992ad8aad590044eb31509dca65e6b321e3410ce846e1e5f5dd34b956e95`. The raw diff is 0 lines, so the proof rests on raw identity and neither normaliser is relied on. - Positive control on a copy of the head `dist/index.d.ts`, in the same block: line 5899, `introspectSchema(): Promise...;`, made `protected`. That gives raw and double-quote-normalised `402f862acf50c10714bd3aef080c5b397a15b9ef1712180ee86cf30a25e6709c`, and single-quote-normalised `e12430bcae9b43a1e4e9ff7914e5c12a534cc1765bc3c32a642a11977e4812ae`. Each differs from the real hash. - Export tables: the ESM and CJS bundles each export the same 56 names at base and head, loaded at runtime (`import()` and `require` of the built bundles). The sorted list hashes to `d61a9dd3a02661602b1bb2dbe18152ac43986148f36b2746f40c0884bd8ffdec` on all four. - The head build carries the move: `dist/index.mjs` and `dist/index.js` each contain 15 host `.call(` sites (14 at base, plus this one). No changeset: `skip-changeset`, per ruling `6348891160` item 4. ## Proof of move-only - **Body, by AST token** (recursive `getChildren` leaves). The moved function's parameters (less the added `this`), return type and body have the same token sequence as the base method, and `async` matches. 567 tokens compared, 0 differ. - **Lines**: 104 lines compared, from the method's name to its closing brace. 87 are the base line with two leading spaces removed. 16 are byte-identical template-literal continuation lines. The first line is byte-identical once the added `this` parameter is removed. - **Negative control**: in a copy of the module, `'sqlite_%'` becomes `'sqlite%'` inside the SQLite arm's template literal. The check then reports 1 token and 1 line different, and it exits `PROOF FAILED`, exit 1. - **Class side**: 347 members at base and head. The 346 members that did not move are byte-identical to base, leading trivia included. The stub keeps its signature byte-identical, less `async`. - **Top level of `sql-driver.ts`**: 202 non-class statements at base and head. 200 are byte-identical, in order. `postgresTableNameCollisionError` is identical except for a leading `export`. The import from `./sql-driver-introspection.js` gains exactly one specifier, `introspectSchema`, and its leading comment is re-worded. - **The module**: cut 1's five functions are byte-identical, leading trivia included. `IntrospectionHost` keeps all five of its member lines and gains the five above. The imports gain the type imports `IntrospectedSchema` and `IntrospectedTable` and the value import `postgresTableNameCollisionError`, and lose nothing. The module has no other statement. - **Size**: +165 / -113 over 2 files, far under the 3,000-line manual-merge line. ## Readers of `sql-driver.ts` by path, checked against the moved text None needed an edit. Each was read against the moved region (15825-15928) and run at both ends where it is a gate or test: - **ADR anchors.** `scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver.ts.json` carries ADR-0053 (`formatOutput`, `presentReadValue`) and ADR-0120 D4. Neither paragraph describes the moved body, so per card ask 3 no entry is owed. The moved body cites no ADR, and only issue numbers. `listPhysicalColumns`'s docblock cites ADR-0131 D7, and it stays. `check:adr-anchors`: 85 anchored files at base and head. Decision-letter citations on the anchored surface: 1,847 at base and 1,847 at head. - **Symbol anchors.** `docs/qa/platform-checklist/areas/integration-system.json` names `sql-driver.ts#introspectSchema` as a source of one item. The method declaration is still in `sql-driver.ts`, so the anchor resolves. Its parenthetical, "SQLite branch reads sqlite_master", now describes a body one module over (see the acceptance notes). `check:adr-symbol-anchors`: 2,384 anchors resolve at both ends. - **`check:tenant-chokepoint`.** Rule 1 reads `getBuilder` bindings, and the moved text has none: 24 bindings across 3 files at both ends. Rule 2 visits every `raw`-family call in `SCAN_FILES` whose text says `IS NULL` against a tenant column. The moved body holds three such calls, all catalog listings (`information_schema.tables`, `sqlite_master`). None says `IS NULL` or names a tenant column. So rule 2's verdict is unchanged, but its inspected population in `sql-driver.ts` drops from 76 literal raw fragments to 73 (see the acceptance notes). - **Logger receivers.** The moved text reads no logger. `check:logger-receiver-detach`: 0 detaches at both ends. - **Other source scans**, none of whose patterns occurs in the moved text: `logger-receiver-detach.test.ts` ③, `sql-driver-compile-refusal-seam.test.ts` (no `INVALID_FILTER` builder; `postgresTableNameCollisionError` builds a `RESOURCE_CONFLICT` error and did not move), the five `packages/cli` `generate-*.pin.test.ts` files, `check-keyed-text-bounds`, `check-object-def-param-keys`. - **Path lists and comment citations** that name the file (`turbo.json`, `scripts/cross-package-test-inputs.mjs`, `scripts/pm/check-half-states.mjs`, `scripts/check-changeset-no-major.mjs`, `packages/spec/liveness/*.json` evidence). None reads the moved body. ADR-0015 lists `sql-driver.ts (introspectSchema, …)` among its files. That is still true of the method, and no `docs/adr/**` edit is made here. - `check:driver-conformance` at base and head: 55 covered, 0 in the DEBT ledger, 0 exempt, both times. ## Verification - `tsc --noEmit` (driver-sql) exit 0. `eslint --no-inline-config` over `packages/drivers/driver-sql/src` (265 files, the new module included): 0 errors, 0 warnings. - `packages/drivers/driver-sql` suite: base 3,999 tests (3,787 passed, 0 failed, 212 skipped; 247 files), head the same. Compared per test name and status from vitest's JSON reporter: no test lost, none changed status, none added. - `packages/drivers/driver-turso` suite, against the rebuilt `dist` at each end: base 2,526 tests (2,492 passed, 0 failed, 34 skipped; 95 files), head the same. Per test name and status there is no difference (2,513 distinct names; 13 names repeat). - `packages/drivers/driver-sqlite-wasm` suite, against the rebuilt `dist`: base 704 tests (704 passed; 39 files), head the same, with no difference per test name. - `dispatch-gates --commands` on this diff, at head `f73c54587`: 55 derived commands, each run with its exit code. `--ran` reconciles 55 derived, 54 run, 1 NOT MEASURED, 0 unrun. The NOT MEASURED one is `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package's `dist`), which CI runs. `check:lean-entry-closure` ran after `objectql` was built. - No reverse verification or ablation: the diff changes no tested behaviour. ## Acceptance notes - **`check:tenant-chokepoint` rule 2 reads 3 fewer raw fragments.** `SCAN_FILES` names `sql-driver.ts` and not the seam modules, so rule 2 (no `IS NULL` arm on a tenant column) no longer visits the three catalog-listing `knex.raw(` calls that moved. None of the three is an arm on a tenant column today. A future edit that added one in the module would not be caught by rule 2. Cut 1 already moved 16 catalog raw fragments into this module the same way. Widening `SCAN_FILES` is a gate edit, and it is the open precondition the series carries for the `getBuilder` areas. It is not made here. - **The platform-checklist source line** (`integration-system.json`, "`sql-driver.ts#introspectSchema` (introspectSchema; SQLite branch reads sqlite_master …)") still resolves to the method, which still answers the same way. The SQLite branch it describes now lives in `sql-driver-introspection.ts`. The file is outside this claim's file surface, and a checklist item edit takes a history revision. So it is left as is and raised in the report as open question 2. - `TursoDriver`'s `introspectSchema` override still calls `super.introspectSchema()`. That now returns the moved function's promise instead of an `async` method's promise. A caller awaits the same settlement. - Lines that continue a SQL template literal keep their base indentation inside the module, so they sit two columns deeper than the code around them. This is cut 1's rule: there the whitespace is statement text. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b77bb37 commit b179084

2 files changed

Lines changed: 165 additions & 113 deletions

File tree

‎packages/drivers/driver-sql/src/sql-driver-introspection.ts‎

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
* catalog reads `introspectSchema` and the schema-sync drift checks are built
88
* on — moved out of `sql-driver.ts` whole.
99
*
10+
* [#25521 cut 4] `introspectSchema`, the public whole-schema read those
11+
* helpers serve, moved here too: it lists the tables per dialect and reads each
12+
* one through four of them. `listPhysicalColumns`, the other member of the
13+
* class's Schema Introspection section, stayed in the class: its body is
14+
* shorter than the host literal a stub would need, so moving it would make
15+
* `sql-driver.ts` longer, not shorter.
16+
*
1017
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
1118
* method of the same name as it stood, re-indented one level and otherwise
1219
* unedited: the same catalog statements, the same dialect arms, the same
@@ -27,22 +34,39 @@
2734
*
2835
* `introspectColumns` reads `introspectColumnOrder` through a closure that
2936
* calls it on the driver at call time, so a subclass override or a test spy on
30-
* the instance is still the one the body reaches.
37+
* the instance is still the one the body reaches. `introspectSchema` reads the
38+
* four helpers it calls the same way, so `TursoDriver`'s override of
39+
* `introspectColumns` (and a test spy on the instance) is still the one it
40+
* reaches. `TursoDriver` also overrides `introspectSchema` itself and calls
41+
* `super`, which reaches the class method, which hands this body its host.
3142
*
32-
* The field `knex` and the three dialect getters go as GETTERS, so each read
33-
* happens when the body makes it, as it did on the class.
43+
* The field `knex`, the three dialect getters and the getter `dialectName` go
44+
* as GETTERS, so each read happens when the body makes it, as it did on the
45+
* class.
3446
*
3547
* The two module-level declarations the bodies read, `UniqueConstraintMember`
3648
* and `singleColumnUniqueColumns`, stay in `sql-driver.ts`, which exports them
3749
* already (a test imports `singleColumnUniqueColumns` from there), and are
3850
* imported from there: the module cycle is evaluation-safe because the one
3951
* value import is read only inside a function body, never while the two
40-
* modules are first evaluated. `index.ts` re-exports nothing from this module,
52+
* modules are first evaluated. `introspectSchema` also reads the types
53+
* `IntrospectedSchema` and `IntrospectedTable`, which `sql-driver.ts` exports
54+
* already, and the function `postgresTableNameCollisionError`, which only
55+
* this body reads: it stays in `sql-driver.ts` and gained a leading `export`
56+
* there (the shape cut 2 applied), so it is imported, not copied, and is read
57+
* only inside the body too. `index.ts` re-exports nothing from this module,
4158
* so the published surface does not move.
4259
*/
4360

44-
import type { IntrospectedColumn, IntrospectedForeignKey, SqlDriver, UniqueConstraintMember } from './sql-driver.js';
45-
import { singleColumnUniqueColumns } from './sql-driver.js';
61+
import type {
62+
IntrospectedColumn,
63+
IntrospectedForeignKey,
64+
IntrospectedSchema,
65+
IntrospectedTable,
66+
SqlDriver,
67+
UniqueConstraintMember,
68+
} from './sql-driver.js';
69+
import { postgresTableNameCollisionError, singleColumnUniqueColumns } from './sql-driver.js';
4670

4771
/**
4872
* The `SqlDriver` members the functions below read — every one of them, and
@@ -53,7 +77,12 @@ import { singleColumnUniqueColumns } from './sql-driver.js';
5377
* for which members are getters.
5478
*/
5579
export interface IntrospectionHost {
80+
readonly dialectName: SqlDriver['dialectName'];
5681
introspectColumnOrder: SqlDriver['introspectColumnOrder'];
82+
introspectColumns: SqlDriver['introspectColumns'];
83+
introspectForeignKeys: SqlDriver['introspectForeignKeys'];
84+
introspectPrimaryKeys: SqlDriver['introspectPrimaryKeys'];
85+
introspectUniqueConstraints: SqlDriver['introspectUniqueConstraints'];
5786
readonly isMysql: SqlDriver['isMysql'];
5887
readonly isPostgres: SqlDriver['isPostgres'];
5988
readonly isSqlite: SqlDriver['isSqlite'];
@@ -663,3 +692,109 @@ export async function introspectUniqueConstraints(
663692

664693
return singleColumnUniqueColumns(members);
665694
}
695+
696+
/** The body of `SqlDriver#introspectSchema`; the method's signature there is its contract. */
697+
export async function introspectSchema(this: Pick<IntrospectionHost, 'dialectName' | 'introspectColumns' | 'introspectForeignKeys' | 'introspectPrimaryKeys' | 'introspectUniqueConstraints' | 'isMysql' | 'isPostgres' | 'isSqlite' | 'knex'>): Promise<IntrospectedSchema> {
698+
const tables: Record<string, IntrospectedTable> = {};
699+
// Stamped BEFORE the reads, not after: a consumer asking "has the remote
700+
// changed since this snapshot?" must not be told the snapshot covers a
701+
// moment later than the first table it actually read.
702+
const introspectedAt = new Date().toISOString();
703+
let tableNames: string[] = [];
704+
// PostgreSQL only (#24707): the schema each listed table lives in, so its
705+
// columns are read THERE. Every other dialect leaves it empty.
706+
const schemaByTable = new Map<string, string>();
707+
708+
if (this.isPostgres) {
709+
// Every schema the session can actually reach unqualified, rather than
710+
// the literal `'public'` — same answer for a default deployment, where
711+
// `current_schemas(false)` is exactly `{public}`, and the right answer for
712+
// a session pointed at another schema, where the literal listed nothing.
713+
//
714+
// `table_schema` and its `search_path` position are selected too
715+
// (#24707). The result is keyed by BARE name, so two schemas holding the
716+
// same name used to collapse into one key with the later table silently
717+
// dropped — refused below instead. And the schema is what the column read
718+
// needs: knex `columnInfo()` on a bare name reads `current_schema()`, the
719+
// FIRST schema on the path only, so a table in a later schema reported
720+
// zero columns. No `ORDER BY` is added: the key order of a single-schema
721+
// answer stays exactly what it was.
722+
const result = await this.knex.raw(`
723+
SELECT table_schema, table_name,
724+
array_position(current_schemas(false), table_schema::name) AS path_position
725+
FROM information_schema.tables
726+
WHERE table_schema = ANY (current_schemas(false))
727+
AND table_type = 'BASE TABLE'
728+
`);
729+
const rows = result.rows as { table_schema: string; table_name: string; path_position: number }[];
730+
const schemasByTable = new Map<string, { schema: string; position: number }[]>();
731+
for (const row of rows) {
732+
const seen = schemasByTable.get(row.table_name);
733+
const entry = { schema: row.table_schema, position: Number(row.path_position) };
734+
if (seen) seen.push(entry);
735+
else schemasByTable.set(row.table_name, [entry]);
736+
}
737+
const collisions = [...schemasByTable]
738+
.filter(([, schemas]) => schemas.length > 1)
739+
.map(([table, schemas]) => ({
740+
table,
741+
schemas: schemas.sort((a, b) => a.position - b.position).map((s) => s.schema),
742+
}))
743+
.sort((a, b) => (a.table < b.table ? -1 : a.table > b.table ? 1 : 0));
744+
if (collisions.length > 0) throw postgresTableNameCollisionError(collisions);
745+
746+
tableNames = rows.map((row) => row.table_name);
747+
for (const row of rows) schemaByTable.set(row.table_name, row.table_schema);
748+
} else if (this.isMysql) {
749+
const result = await this.knex.raw(`
750+
SELECT table_name
751+
FROM information_schema.tables
752+
WHERE table_schema = DATABASE()
753+
AND table_type = 'BASE TABLE'
754+
`);
755+
tableNames = result[0].map((row: any) => row.TABLE_NAME);
756+
} else if (this.isSqlite) {
757+
const result = await this.knex.raw(`
758+
SELECT name as table_name
759+
FROM sqlite_master
760+
WHERE type='table'
761+
AND name NOT LIKE 'sqlite_%'
762+
`);
763+
tableNames = result.map((row: any) => row.table_name);
764+
}
765+
766+
for (const tableName of tableNames) {
767+
// All four reads take the #7332 default (`onFailure: 'throw'`): this
768+
// caller cannot correct a short read, and `introspectColumns` has never
769+
// swallowed — so a partially-readable database fails the whole-schema
770+
// introspection loudly instead of emitting tables whose keys silently
771+
// read as absent (#11161). Every in-tree caller of `introspectSchema`
772+
// already handles a throw (the datasource health check reports
773+
// `{ ok: false }`, the REST/CLI seams surface the error).
774+
// On PostgreSQL the columns are read in the table's OWN schema (#24707);
775+
// the three key reads below already scope to `current_schemas(false)`
776+
// or resolve along `search_path`, which the refusal above makes
777+
// unambiguous among base tables.
778+
const columns = await this.introspectColumns(tableName, schemaByTable.get(tableName));
779+
const foreignKeys = await this.introspectForeignKeys(tableName);
780+
const primaryKeys = await this.introspectPrimaryKeys(tableName);
781+
const uniqueConstraints = await this.introspectUniqueConstraints(tableName);
782+
783+
for (const col of columns) {
784+
if (primaryKeys.includes(col.name)) col.primaryKey = true;
785+
if (uniqueConstraints.includes(col.name)) col.isUnique = true;
786+
}
787+
788+
tables[tableName] = { name: tableName, columns, foreignKeys, primaryKeys };
789+
}
790+
791+
// `dialectName` — not the raw Knex client, and not the spec's
792+
// `SQLDialectSchema` enum. The only in-tree consumer of this key is
793+
// `suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect)`,
794+
// whose `SqlDialect` vocabulary (`packages/spec/src/data/type-compat.ts`)
795+
// spells PostgreSQL `postgres`, exactly as `dialectName` does. Emitting
796+
// the enum's `postgresql` instead would put the key in `Object.keys()`
797+
// while leaving every per-dialect type alias unreachable — the omission
798+
// this repairs, wearing a fix's clothes.
799+
return { tables, dialect: this.dialectName, introspectedAt };
800+
}

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 24 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,15 @@ import {
160160
} from './builtin-column-collision.js';
161161
import { recoverUnencodedJsonText } from './unencoded-json-text.js';
162162
// [#25521 cut 1] The catalog-introspection helpers (`introspectColumnOrder`,
163-
// `introspectColumns` and the foreign-key, primary-key and unique-constraint reads);
164-
// the methods of the same names below hand each body the members it reads
165-
// (`IntrospectionHost`).
163+
// `introspectColumns` and the foreign-key, primary-key and unique-constraint reads),
164+
// and [cut 4] `introspectSchema`, the whole-schema read built on them; the methods
165+
// of the same names below hand each body the members it reads (`IntrospectionHost`).
166166
import {
167167
introspectColumnOrder,
168168
introspectColumns,
169169
introspectForeignKeys,
170170
introspectPrimaryKeys,
171+
introspectSchema,
171172
introspectUniqueConstraints,
172173
} from './sql-driver-introspection.js';
173174
// [#25521 cut 2] The raw-execution path (`execute`, its refusal terminal
@@ -4960,7 +4961,7 @@ function classifyFilterKey(
49604961
*
49614962
* `collisions` is non-empty; each entry's `schemas` has two or more members.
49624963
*/
4963-
function postgresTableNameCollisionError(collisions: ReadonlyArray<{ table: string; schemas: string[] }>): Error {
4964+
export function postgresTableNameCollisionError(collisions: ReadonlyArray<{ table: string; schemas: string[] }>): Error {
49644965
const list = (names: string[]) => names.map((s) => `'${s}'`).join(', ');
49654966
const [first] = collisions;
49664967
const err = new Error(
@@ -15822,109 +15823,25 @@ export class SqlDriver implements IDataDriver {
1582215823
return Object.keys(info ?? {});
1582315824
}
1582415825

15825-
async introspectSchema(): Promise<IntrospectedSchema> {
15826-
const tables: Record<string, IntrospectedTable> = {};
15827-
// Stamped BEFORE the reads, not after: a consumer asking "has the remote
15828-
// changed since this snapshot?" must not be told the snapshot covers a
15829-
// moment later than the first table it actually read.
15830-
const introspectedAt = new Date().toISOString();
15831-
let tableNames: string[] = [];
15832-
// PostgreSQL only (#24707): the schema each listed table lives in, so its
15833-
// columns are read THERE. Every other dialect leaves it empty.
15834-
const schemaByTable = new Map<string, string>();
15835-
15836-
if (this.isPostgres) {
15837-
// Every schema the session can actually reach unqualified, rather than
15838-
// the literal `'public'` — same answer for a default deployment, where
15839-
// `current_schemas(false)` is exactly `{public}`, and the right answer for
15840-
// a session pointed at another schema, where the literal listed nothing.
15841-
//
15842-
// `table_schema` and its `search_path` position are selected too
15843-
// (#24707). The result is keyed by BARE name, so two schemas holding the
15844-
// same name used to collapse into one key with the later table silently
15845-
// dropped — refused below instead. And the schema is what the column read
15846-
// needs: knex `columnInfo()` on a bare name reads `current_schema()`, the
15847-
// FIRST schema on the path only, so a table in a later schema reported
15848-
// zero columns. No `ORDER BY` is added: the key order of a single-schema
15849-
// answer stays exactly what it was.
15850-
const result = await this.knex.raw(`
15851-
SELECT table_schema, table_name,
15852-
array_position(current_schemas(false), table_schema::name) AS path_position
15853-
FROM information_schema.tables
15854-
WHERE table_schema = ANY (current_schemas(false))
15855-
AND table_type = 'BASE TABLE'
15856-
`);
15857-
const rows = result.rows as { table_schema: string; table_name: string; path_position: number }[];
15858-
const schemasByTable = new Map<string, { schema: string; position: number }[]>();
15859-
for (const row of rows) {
15860-
const seen = schemasByTable.get(row.table_name);
15861-
const entry = { schema: row.table_schema, position: Number(row.path_position) };
15862-
if (seen) seen.push(entry);
15863-
else schemasByTable.set(row.table_name, [entry]);
15864-
}
15865-
const collisions = [...schemasByTable]
15866-
.filter(([, schemas]) => schemas.length > 1)
15867-
.map(([table, schemas]) => ({
15868-
table,
15869-
schemas: schemas.sort((a, b) => a.position - b.position).map((s) => s.schema),
15870-
}))
15871-
.sort((a, b) => (a.table < b.table ? -1 : a.table > b.table ? 1 : 0));
15872-
if (collisions.length > 0) throw postgresTableNameCollisionError(collisions);
15873-
15874-
tableNames = rows.map((row) => row.table_name);
15875-
for (const row of rows) schemaByTable.set(row.table_name, row.table_schema);
15876-
} else if (this.isMysql) {
15877-
const result = await this.knex.raw(`
15878-
SELECT table_name
15879-
FROM information_schema.tables
15880-
WHERE table_schema = DATABASE()
15881-
AND table_type = 'BASE TABLE'
15882-
`);
15883-
tableNames = result[0].map((row: any) => row.TABLE_NAME);
15884-
} else if (this.isSqlite) {
15885-
const result = await this.knex.raw(`
15886-
SELECT name as table_name
15887-
FROM sqlite_master
15888-
WHERE type='table'
15889-
AND name NOT LIKE 'sqlite_%'
15890-
`);
15891-
tableNames = result.map((row: any) => row.table_name);
15892-
}
15893-
15894-
for (const tableName of tableNames) {
15895-
// All four reads take the #7332 default (`onFailure: 'throw'`): this
15896-
// caller cannot correct a short read, and `introspectColumns` has never
15897-
// swallowed — so a partially-readable database fails the whole-schema
15898-
// introspection loudly instead of emitting tables whose keys silently
15899-
// read as absent (#11161). Every in-tree caller of `introspectSchema`
15900-
// already handles a throw (the datasource health check reports
15901-
// `{ ok: false }`, the REST/CLI seams surface the error).
15902-
// On PostgreSQL the columns are read in the table's OWN schema (#24707);
15903-
// the three key reads below already scope to `current_schemas(false)`
15904-
// or resolve along `search_path`, which the refusal above makes
15905-
// unambiguous among base tables.
15906-
const columns = await this.introspectColumns(tableName, schemaByTable.get(tableName));
15907-
const foreignKeys = await this.introspectForeignKeys(tableName);
15908-
const primaryKeys = await this.introspectPrimaryKeys(tableName);
15909-
const uniqueConstraints = await this.introspectUniqueConstraints(tableName);
15910-
15911-
for (const col of columns) {
15912-
if (primaryKeys.includes(col.name)) col.primaryKey = true;
15913-
if (uniqueConstraints.includes(col.name)) col.isUnique = true;
15914-
}
15915-
15916-
tables[tableName] = { name: tableName, columns, foreignKeys, primaryKeys };
15917-
}
15918-
15919-
// `dialectName` — not the raw Knex client, and not the spec's
15920-
// `SQLDialectSchema` enum. The only in-tree consumer of this key is
15921-
// `suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect)`,
15922-
// whose `SqlDialect` vocabulary (`packages/spec/src/data/type-compat.ts`)
15923-
// spells PostgreSQL `postgres`, exactly as `dialectName` does. Emitting
15924-
// the enum's `postgresql` instead would put the key in `Object.keys()`
15925-
// while leaving every per-dialect type alias unreachable — the omission
15926-
// this repairs, wearing a fix's clothes.
15927-
return { tables, dialect: this.dialectName, introspectedAt };
15826+
introspectSchema(): Promise<IntrospectedSchema> {
15827+
// [#25521 cut 4] The body moved to `sql-driver-introspection.ts` (see `IntrospectionHost`).
15828+
const readDialectName = () => this.dialectName;
15829+
const readIsMysql = () => this.isMysql;
15830+
const readIsPostgres = () => this.isPostgres;
15831+
const readIsSqlite = () => this.isSqlite;
15832+
const readKnex = () => this.knex;
15833+
const host: ThisParameterType<typeof introspectSchema> = {
15834+
get dialectName() { return readDialectName(); },
15835+
introspectColumns: (...a) => this.introspectColumns(...a),
15836+
introspectForeignKeys: (...a) => this.introspectForeignKeys(...a),
15837+
introspectPrimaryKeys: (...a) => this.introspectPrimaryKeys(...a),
15838+
introspectUniqueConstraints: (...a) => this.introspectUniqueConstraints(...a),
15839+
get isMysql() { return readIsMysql(); },
15840+
get isPostgres() { return readIsPostgres(); },
15841+
get isSqlite() { return readIsSqlite(); },
15842+
get knex() { return readKnex(); },
15843+
};
15844+
return introspectSchema.call(host);
1592815845
}
1592915846

1593015847
// ===================================

0 commit comments

Comments
 (0)