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
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 */
5579export 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+ }
0 commit comments