turbine-orm 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +34 -32
  2. package/dist/adapters/cockroachdb.js +21 -3
  3. package/dist/adapters/index.d.ts +15 -0
  4. package/dist/adapters/yugabytedb.js +20 -3
  5. package/dist/cjs/adapters/cockroachdb.js +21 -3
  6. package/dist/cjs/adapters/index.d.ts +15 -0
  7. package/dist/cjs/adapters/yugabytedb.js +20 -3
  8. package/dist/cjs/cli/destructive.d.ts +18 -4
  9. package/dist/cjs/cli/destructive.js +230 -122
  10. package/dist/cjs/cli/index.d.ts +21 -4
  11. package/dist/cjs/cli/index.js +119 -22
  12. package/dist/cjs/cli/mcp.d.ts +28 -8
  13. package/dist/cjs/cli/mcp.js +170 -127
  14. package/dist/cjs/cli/migrate.d.ts +134 -13
  15. package/dist/cjs/cli/migrate.js +349 -241
  16. package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
  17. package/dist/cjs/cli/pii-predicate-guard.js +390 -0
  18. package/dist/cjs/cli/prisma-resolve.js +75 -4
  19. package/dist/cjs/cli/prisma-schema.d.ts +17 -1
  20. package/dist/cjs/cli/prisma-schema.js +83 -17
  21. package/dist/cjs/cli/sql-statements.d.ts +125 -0
  22. package/dist/cjs/cli/sql-statements.js +378 -0
  23. package/dist/cjs/cli/studio.js +49 -118
  24. package/dist/cjs/cli/ui.d.ts +1 -1
  25. package/dist/cjs/client.d.ts +43 -0
  26. package/dist/cjs/client.js +125 -6
  27. package/dist/cjs/dialect.d.ts +123 -0
  28. package/dist/cjs/dialect.js +33 -0
  29. package/dist/cjs/errors.d.ts +74 -1
  30. package/dist/cjs/errors.js +239 -25
  31. package/dist/cjs/index-advisor.d.ts +33 -1
  32. package/dist/cjs/index-advisor.js +32 -1
  33. package/dist/cjs/introspect.d.ts +48 -0
  34. package/dist/cjs/introspect.js +222 -91
  35. package/dist/cjs/mssql.js +43 -1
  36. package/dist/cjs/mysql.d.ts +5 -2
  37. package/dist/cjs/mysql.js +202 -17
  38. package/dist/cjs/nested-write.js +6 -1
  39. package/dist/cjs/pipeline-submittable.js +17 -3
  40. package/dist/cjs/pipeline.js +75 -9
  41. package/dist/cjs/powdb.d.ts +23 -0
  42. package/dist/cjs/powdb.js +33 -1
  43. package/dist/cjs/powql.d.ts +61 -9
  44. package/dist/cjs/powql.js +186 -49
  45. package/dist/cjs/prisma-compat.js +160 -41
  46. package/dist/cjs/query/aggregates.d.ts +1 -1
  47. package/dist/cjs/query/aggregates.js +80 -18
  48. package/dist/cjs/query/batched-loader.d.ts +10 -0
  49. package/dist/cjs/query/batched-loader.js +268 -7
  50. package/dist/cjs/query/builder.d.ts +73 -0
  51. package/dist/cjs/query/builder.js +225 -28
  52. package/dist/cjs/query/filters.d.ts +162 -0
  53. package/dist/cjs/query/filters.js +250 -1
  54. package/dist/cjs/query/relations.d.ts +10 -10
  55. package/dist/cjs/query/relations.js +93 -12
  56. package/dist/cjs/query/types.d.ts +14 -1
  57. package/dist/cjs/query/utils.d.ts +146 -2
  58. package/dist/cjs/query/utils.js +210 -4
  59. package/dist/cjs/query/warn-registry.d.ts +10 -0
  60. package/dist/cjs/query/warn-registry.js +10 -0
  61. package/dist/cjs/query/where-compile.d.ts +30 -0
  62. package/dist/cjs/query/where-compile.js +41 -0
  63. package/dist/cjs/query/where.d.ts +128 -13
  64. package/dist/cjs/query/where.js +215 -77
  65. package/dist/cjs/query/writes.d.ts +1 -1
  66. package/dist/cjs/query/writes.js +39 -15
  67. package/dist/cjs/schema-builder.d.ts +2 -1
  68. package/dist/cjs/schema-sql.d.ts +94 -4
  69. package/dist/cjs/schema-sql.js +506 -30
  70. package/dist/cjs/schema.d.ts +3 -1
  71. package/dist/cjs/sqlite.d.ts +6 -0
  72. package/dist/cjs/sqlite.js +151 -10
  73. package/dist/cjs/typed-sql.d.ts +29 -1
  74. package/dist/cjs/typed-sql.js +30 -12
  75. package/dist/cli/destructive.d.ts +18 -4
  76. package/dist/cli/destructive.js +229 -121
  77. package/dist/cli/index.d.ts +21 -4
  78. package/dist/cli/index.js +120 -24
  79. package/dist/cli/mcp.d.ts +28 -8
  80. package/dist/cli/mcp.js +172 -129
  81. package/dist/cli/migrate.d.ts +134 -13
  82. package/dist/cli/migrate.js +347 -238
  83. package/dist/cli/pii-predicate-guard.d.ts +112 -0
  84. package/dist/cli/pii-predicate-guard.js +386 -0
  85. package/dist/cli/prisma-resolve.js +75 -4
  86. package/dist/cli/prisma-schema.d.ts +17 -1
  87. package/dist/cli/prisma-schema.js +83 -17
  88. package/dist/cli/sql-statements.d.ts +125 -0
  89. package/dist/cli/sql-statements.js +373 -0
  90. package/dist/cli/studio.js +49 -118
  91. package/dist/cli/ui.d.ts +1 -1
  92. package/dist/client.d.ts +43 -0
  93. package/dist/client.js +126 -7
  94. package/dist/dialect.d.ts +123 -0
  95. package/dist/dialect.js +33 -0
  96. package/dist/errors.d.ts +74 -1
  97. package/dist/errors.js +228 -19
  98. package/dist/index-advisor.d.ts +33 -1
  99. package/dist/index-advisor.js +31 -1
  100. package/dist/introspect.d.ts +48 -0
  101. package/dist/introspect.js +221 -91
  102. package/dist/mssql.js +44 -2
  103. package/dist/mysql.d.ts +5 -2
  104. package/dist/mysql.js +203 -18
  105. package/dist/nested-write.js +7 -2
  106. package/dist/pipeline-submittable.js +18 -4
  107. package/dist/pipeline.js +76 -10
  108. package/dist/powdb.d.ts +23 -0
  109. package/dist/powdb.js +33 -2
  110. package/dist/powql.d.ts +61 -9
  111. package/dist/powql.js +187 -50
  112. package/dist/prisma-compat.js +160 -41
  113. package/dist/query/aggregates.d.ts +1 -1
  114. package/dist/query/aggregates.js +82 -20
  115. package/dist/query/batched-loader.d.ts +10 -0
  116. package/dist/query/batched-loader.js +270 -9
  117. package/dist/query/builder.d.ts +73 -0
  118. package/dist/query/builder.js +226 -30
  119. package/dist/query/filters.d.ts +162 -0
  120. package/dist/query/filters.js +246 -1
  121. package/dist/query/relations.d.ts +10 -10
  122. package/dist/query/relations.js +94 -14
  123. package/dist/query/types.d.ts +14 -1
  124. package/dist/query/utils.d.ts +146 -2
  125. package/dist/query/utils.js +204 -3
  126. package/dist/query/warn-registry.d.ts +10 -0
  127. package/dist/query/warn-registry.js +10 -0
  128. package/dist/query/where-compile.d.ts +30 -0
  129. package/dist/query/where-compile.js +40 -1
  130. package/dist/query/where.d.ts +128 -13
  131. package/dist/query/where.js +216 -80
  132. package/dist/query/writes.d.ts +1 -1
  133. package/dist/query/writes.js +40 -16
  134. package/dist/schema-builder.d.ts +2 -1
  135. package/dist/schema-sql.d.ts +94 -4
  136. package/dist/schema-sql.js +505 -30
  137. package/dist/schema.d.ts +3 -1
  138. package/dist/sqlite.d.ts +6 -0
  139. package/dist/sqlite.js +151 -10
  140. package/dist/typed-sql.d.ts +29 -1
  141. package/dist/typed-sql.js +30 -12
  142. package/package.json +6 -4
@@ -46,7 +46,19 @@ export interface CreateIndexSqlOptions {
46
46
  * it must also carry the `-- turbine:no-transaction` directive.
47
47
  */
48
48
  concurrently?: boolean;
49
- /** Emit `IF NOT EXISTS` (default true, required for idempotent no-transaction migrations). */
49
+ /**
50
+ * Emit `IF NOT EXISTS`. Defaults to true for the plain (in-transaction) form
51
+ * and FALSE when `concurrently` is set.
52
+ *
53
+ * `IF NOT EXISTS` matches on the index NAME, never on its validity, so over
54
+ * the INVALID index a failed concurrent build leaves behind it silently
55
+ * no-ops: the migration records as applied, the index doctor reported is
56
+ * still missing, and the documented remedy (DROP INDEX CONCURRENTLY, then
57
+ * rerun) is unreachable through `migrate up` because the rerun no-ops too.
58
+ * The concurrent form gets its idempotency from a preceding
59
+ * `DROP INDEX CONCURRENTLY IF EXISTS` instead; see
60
+ * {@link buildCreateIndexStatements}, which is what the CLI emits.
61
+ */
50
62
  ifNotExists?: boolean;
51
63
  /**
52
64
  * Emit a partial index `... WHERE <col> IS NOT NULL`. Only applied for a
@@ -63,6 +75,26 @@ export interface CreateIndexSqlOptions {
63
75
  * module topology-only.
64
76
  */
65
77
  export declare function buildCreateIndexSql(table: string, columns: string[], indexName: string, options?: CreateIndexSqlOptions): string;
78
+ /**
79
+ * The statement SEQUENCE that builds one fix index and converges on a VALID
80
+ * index however many times it is rerun.
81
+ *
82
+ * A no-transaction migration is recorded only after ALL its statements succeed,
83
+ * so a mid-file failure leaves earlier indexes built and the migration
84
+ * unrecorded: a rerun must be safe. The old answer was `CREATE INDEX
85
+ * CONCURRENTLY IF NOT EXISTS`, which is safe but not CONVERGENT: a concurrent
86
+ * build that fails partway leaves an INVALID index with the right name, and
87
+ * every subsequent run skips it. Measured: the index was INVALID before the fix
88
+ * migration, `migrate up` reported 1 applied and 0 errors, and the index was
89
+ * still INVALID after.
90
+ *
91
+ * So the concurrent form drops first instead. `DROP INDEX CONCURRENTLY IF
92
+ * EXISTS` is a no-op on the first run (the index is missing, which is why
93
+ * doctor proposed it), and on a rerun it clears the corpse so the CREATE
94
+ * actually rebuilds. Neither statement takes a blocking lock, and both are
95
+ * legal only outside a transaction, which this file already is.
96
+ */
97
+ export declare function buildCreateIndexStatements(table: string, columns: string[], indexName: string, options?: CreateIndexSqlOptions): string[];
66
98
  /** Build the matching `DROP INDEX` statement. `concurrently` requires no-transaction execution. */
67
99
  export declare function buildDropIndexSql(indexName: string, options?: {
68
100
  concurrently?: boolean;
@@ -25,7 +25,12 @@ import { normalizeKeyColumns } from './schema.js';
25
25
  */
26
26
  export function buildCreateIndexSql(table, columns, indexName, options = {}) {
27
27
  const concurrently = options.concurrently ? 'CONCURRENTLY ' : '';
28
- const ifNotExists = options.ifNotExists === false ? '' : 'IF NOT EXISTS ';
28
+ // Default differs by form: the plain statement runs inside a transaction and
29
+ // can never leave an INVALID corpse, so IF NOT EXISTS is a pure win there.
30
+ // The CONCURRENTLY form can, and IF NOT EXISTS would then match that corpse
31
+ // by name and skip the rebuild forever (see CreateIndexSqlOptions).
32
+ const wantIfNotExists = options.ifNotExists ?? !options.concurrently;
33
+ const ifNotExists = wantIfNotExists ? 'IF NOT EXISTS ' : '';
29
34
  const cols = columns.map(quoteIdent).join(', ');
30
35
  let sql = `CREATE INDEX ${concurrently}${ifNotExists}${quoteIdent(indexName)} ON ${quoteIdent(table)} (${cols})`;
31
36
  if (options.partialNotNull && columns.length === 1 && columns[0] !== undefined) {
@@ -33,6 +38,31 @@ export function buildCreateIndexSql(table, columns, indexName, options = {}) {
33
38
  }
34
39
  return `${sql};`;
35
40
  }
41
+ /**
42
+ * The statement SEQUENCE that builds one fix index and converges on a VALID
43
+ * index however many times it is rerun.
44
+ *
45
+ * A no-transaction migration is recorded only after ALL its statements succeed,
46
+ * so a mid-file failure leaves earlier indexes built and the migration
47
+ * unrecorded: a rerun must be safe. The old answer was `CREATE INDEX
48
+ * CONCURRENTLY IF NOT EXISTS`, which is safe but not CONVERGENT: a concurrent
49
+ * build that fails partway leaves an INVALID index with the right name, and
50
+ * every subsequent run skips it. Measured: the index was INVALID before the fix
51
+ * migration, `migrate up` reported 1 applied and 0 errors, and the index was
52
+ * still INVALID after.
53
+ *
54
+ * So the concurrent form drops first instead. `DROP INDEX CONCURRENTLY IF
55
+ * EXISTS` is a no-op on the first run (the index is missing, which is why
56
+ * doctor proposed it), and on a rerun it clears the corpse so the CREATE
57
+ * actually rebuilds. Neither statement takes a blocking lock, and both are
58
+ * legal only outside a transaction, which this file already is.
59
+ */
60
+ export function buildCreateIndexStatements(table, columns, indexName, options = {}) {
61
+ const create = buildCreateIndexSql(table, columns, indexName, options);
62
+ if (!options.concurrently)
63
+ return [create];
64
+ return [buildDropIndexSql(indexName, { concurrently: true, ifExists: true }), create];
65
+ }
36
66
  /** Build the matching `DROP INDEX` statement. `concurrently` requires no-transaction execution. */
37
67
  export function buildDropIndexSql(indexName, options = {}) {
38
68
  const concurrently = options.concurrently ? 'CONCURRENTLY ' : '';
@@ -283,6 +283,54 @@ export declare function buildRelationsFromForeignKeys(foreignKeys: ForeignKeyEnt
283
283
  * instead of silently dropping the relation.
284
284
  */
285
285
  export declare function addAutoManyToManyRelations(tableNames: Iterable<string>, foreignKeys: ForeignKeyEntry[], pkByTable: Map<string, string[]>, columnNamesByTable: Map<string, string[]>, relationsByTable: Map<string, Record<string, RelationDef>>, columnFieldsByTable?: Map<string, Set<string>>, unknownTypedFieldsByTable?: Map<string, Set<string>>, uniqueIndexColsByTable?: Map<string, string[][]>): void;
286
+ /** Everything the catalog relation derivation reads. See {@link deriveCatalogRelations}. */
287
+ export interface CatalogRelationInputs {
288
+ /** The introspected table set, post include/exclude filtering. */
289
+ tableNames: string[];
290
+ /** FK rows already grouped per constraint (one entry per declared constraint). */
291
+ foreignKeys: ForeignKeyEntry[];
292
+ /** Primary-key columns per table. */
293
+ pkByTable: Map<string, string[]>;
294
+ /** Column metadata per table (only name/field/tsType/pgType are read). */
295
+ columnsByTable: Map<string, Pick<ColumnMetadata, 'name' | 'field' | 'tsType' | 'pgType'>[]>;
296
+ /** UNIQUE-constraint column sets per table. */
297
+ uniqueByTable: Map<string, string[][]>;
298
+ /** Indexes per table, `definition` must be the raw `pg_indexes.indexdef`. */
299
+ indexesByTable: Map<string, IndexMetadata[]>;
300
+ /** Enum types in the schema, so an enum column is not mistaken for a json shadow. */
301
+ enums: Record<string, string[]>;
302
+ /** Referential actions keyed "<table>::<constraint>". Optional. */
303
+ fkActions?: Map<string, {
304
+ onDelete: ReferentialAction;
305
+ onUpdate: ReferentialAction;
306
+ }>;
307
+ /** Opt out of the unique-FK → `hasOne` flip (F2), see {@link IntrospectOptions.legacyToManyUniques}. */
308
+ legacyToManyUniques?: boolean;
309
+ }
310
+ /**
311
+ * One-stop relation derivation for every surface that reads a live PostgreSQL
312
+ * CATALOG: `turbine generate` (via {@link introspectPostgresCatalog}) and the
313
+ * MCP server, which introspects for itself because it cannot assume generated
314
+ * metadata exists.
315
+ *
316
+ * THE REASON THIS IS ONE FUNCTION and not two call sites: the pipeline it drives
317
+ * is `buildRelationsFromForeignKeys` (five parameters, two optional) plus
318
+ * `addAutoManyToManyRelations` (eight parameters, three optional), and every
319
+ * optional one CHANGES THE ANSWER while omitting it stays silently type-correct.
320
+ * Hand-mirroring them drifted exactly that way: MCP passed four arguments and so
321
+ * never received `uniqueSetsByTable`, which meant a UNIQUE foreign key produced
322
+ * `users.profile` (hasOne) under `turbine generate` and `users.profiles`
323
+ * (hasMany) under `turbine mcp`, against the same database. An MCP client
324
+ * following its own schema tool then queried `with: { profiles: true }` and got
325
+ * `TURBINE_E005 Unknown relation`. MCP also omitted `uniqueIndexColsByTable`,
326
+ * losing every auto-m2m relation through a Prisma-style PK-less junction. Adding
327
+ * an argument here now reaches both surfaces or neither.
328
+ *
329
+ * The engine introspectors (SQLite / MySQL / MSSQL) keep their own
330
+ * {@link deriveEngineRelations} because they deliberately do NOT do the
331
+ * unique-FK → `hasOne` flip.
332
+ */
333
+ export declare function deriveCatalogRelations(inputs: CatalogRelationInputs): Map<string, Record<string, RelationDef>>;
286
334
  /**
287
335
  * One-stop relation derivation for the engine introspectors (SQLite / MySQL /
288
336
  * MSSQL): filters the FK list to the introspected table set, seeds the
@@ -71,22 +71,88 @@ const SQL_PRIMARY_KEYS = `
71
71
  AND tc.table_schema = $1
72
72
  ORDER BY tc.table_name, kcu.ordinal_position
73
73
  `;
74
+ // Foreign keys, read from pg_catalog rather than information_schema.
75
+ //
76
+ // This CANNOT be expressed against information_schema. The obvious formulation
77
+ // joins key_column_usage (the constrained columns) to constraint_column_usage
78
+ // (the referenced columns) on the constraint NAME, and that is wrong twice:
79
+ //
80
+ // 1. The two column lists have no positional link there, so the join is an
81
+ // N-by-N cross product. A composite FK cities(country, region_code) ->
82
+ // regions(country, code) came back as four rows, and grouping them gave
83
+ // foreignKey ['country','country','region_code','region_code'] against
84
+ // referenceKey ['country','code','country','code'] - four AND-ed
85
+ // correlations, two of them pairing the wrong columns. Every read through
86
+ // the relation silently returned nothing, with no error.
87
+ // 2. Postgres only requires a constraint name to be unique per TABLE
88
+ // (conrelid, conname), so two tables in one schema may both have a
89
+ // `shared_fk`. Joining on the name alone crosses them: each table's FK
90
+ // picks up the other's referenced column, so one relation is lost and the
91
+ // other points at a column that does not exist on its target (42703 at
92
+ // query time). Which one won depended on catalog row order.
93
+ //
94
+ // conkey and confkey are parallel arrays, so unnesting BOTH `WITH ORDINALITY`
95
+ // and joining on the ordinal is the pairing, exactly. The constraint OID is the
96
+ // grouping key: it is unique catalog-wide, unlike the name.
97
+ //
98
+ // `target_schema` is selected so a cross-schema reference can be recognized
99
+ // rather than mistaken for a same-named local table (see the FK grouping loop).
100
+ // Referential actions come from the same row, which also removes the separate
101
+ // name-keyed actions query that shared bug 2.
102
+ //
103
+ // `conparentid = 0` keeps only DECLARED constraints. Declaring one foreign key
104
+ // against a PARTITIONED table makes Postgres materialize an extra constraint per
105
+ // partition, each pointing at that partition rather than at the parent, and each
106
+ // with `conparentid` set to the declared constraint's OID. Without this filter a
107
+ // single `items(bucket_id) REFERENCES buckets(id)` against a two-partition
108
+ // `buckets` introspected as THREE belongsTo relations (`bucket`, `bucketsLo`,
109
+ // `bucketsHi`), measured on PG 16. The two extras are fully generated, typed and
110
+ // autocompleting, and resolve to `null` for every row whose parent lives in the
111
+ // other partition, so they read as an intermittently-empty relation rather than
112
+ // as an error. The same clone exists when the REFERENCING side is partitioned
113
+ // (verified: `conparentid` is set there too), where the partition inherits the
114
+ // parent table's declared FK and needs no relation of its own.
115
+ //
116
+ // ORDER BY is (source table, constraint name), NOT `con.oid`. The OID is
117
+ // ALLOCATION order, so it encodes the order the DDL happened to run in, and the
118
+ // FK walk order decides which relation wins a contested NAME: relation naming in
119
+ // buildRelationsFromForeignKeys accumulates `taken` names as it walks, and the
120
+ // loser gets a `Rel` suffix. On a `users` table with both a `profiles` child
121
+ // (UNIQUE FK, so hasOne) and a `profile` child (plain FK, so hasMany), both
122
+ // derive the name `profile`, and the two creation orders produced
123
+ // `users.profile = hasOne -> profiles` versus `users.profile = hasMany ->
124
+ // profile`. Same logical schema, different cardinality and a different TABLE
125
+ // behind the same relation name, so a database restored from a dump disagreed
126
+ // with one built by running the migrations. Sorting by name makes the walk a
127
+ // function of the schema instead of its history. (conrelid, conname) is unique
128
+ // in Postgres and relname is unique per namespace, so the pair is a total order
129
+ // here, and sk.ord still pairs conkey to confkey within a constraint.
74
130
  const SQL_FOREIGN_KEYS = `
75
131
  SELECT
76
- tc.table_name AS source_table,
77
- kcu.column_name AS source_column,
78
- ccu.table_name AS target_table,
79
- ccu.column_name AS target_column,
80
- tc.constraint_name
81
- FROM information_schema.table_constraints tc
82
- JOIN information_schema.key_column_usage kcu
83
- ON tc.constraint_name = kcu.constraint_name
84
- AND tc.table_schema = kcu.table_schema
85
- JOIN information_schema.constraint_column_usage ccu
86
- ON tc.constraint_name = ccu.constraint_name
87
- AND tc.table_schema = ccu.table_schema
88
- WHERE tc.constraint_type = 'FOREIGN KEY'
89
- AND tc.table_schema = $1
132
+ con.oid::text AS constraint_oid,
133
+ con.conname AS constraint_name,
134
+ src.relname AS source_table,
135
+ src_att.attname AS source_column,
136
+ tgt_ns.nspname AS target_schema,
137
+ tgt.relname AS target_table,
138
+ tgt_att.attname AS target_column,
139
+ con.confdeltype,
140
+ con.confupdtype
141
+ FROM pg_catalog.pg_constraint con
142
+ JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
143
+ JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
144
+ JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
145
+ JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
146
+ JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS sk(attnum, ord) ON TRUE
147
+ JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tk(attnum, ord) ON tk.ord = sk.ord
148
+ JOIN pg_catalog.pg_attribute src_att
149
+ ON src_att.attrelid = con.conrelid AND src_att.attnum = sk.attnum
150
+ JOIN pg_catalog.pg_attribute tgt_att
151
+ ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = tk.attnum
152
+ WHERE con.contype = 'f'
153
+ AND con.conparentid = 0
154
+ AND src_ns.nspname = $1
155
+ ORDER BY src.relname, con.conname, sk.ord
90
156
  `;
91
157
  const SQL_UNIQUE_CONSTRAINTS = `
92
158
  SELECT
@@ -102,19 +168,19 @@ const SQL_UNIQUE_CONSTRAINTS = `
102
168
  AND tc.table_schema = $1
103
169
  ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position
104
170
  `;
171
+ // Both of the next two queries are ordered for the same reason the FK query is:
172
+ // their rows land in metadata.ts as ARRAYS (`indexes`, `checks`), so an unordered
173
+ // read makes the generated file a function of physical catalog order rather than
174
+ // of the schema. A `DROP INDEX` + `CREATE INDEX` of an unchanged index, or a
175
+ // VACUUM FULL, is enough to permute them, which shows up as generated-file diff
176
+ // noise between one developer's machine and CI and defeats the byte-identical
177
+ // claim that makes a regenerate safe to commit. Index and check constraint names
178
+ // are both unique per schema, so each sort is total.
105
179
  const SQL_INDEXES = `
106
180
  SELECT tablename, indexname, indexdef
107
181
  FROM pg_indexes
108
182
  WHERE schemaname = $1
109
- `;
110
- // Foreign-key referential actions (ON DELETE / ON UPDATE) live in pg_catalog,
111
- // not information_schema. Keyed by constraint name for join with SQL_FOREIGN_KEYS.
112
- const SQL_FK_ACTIONS = `
113
- SELECT con.conname, con.confdeltype, con.confupdtype
114
- FROM pg_constraint con
115
- JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
116
- WHERE con.contype = 'f'
117
- AND n.nspname = $1
183
+ ORDER BY tablename, indexname
118
184
  `;
119
185
  // CHECK constraints (contype = 'c'). NOT NULL is stored as attnotnull, not a
120
186
  // check constraint, so it never appears here.
@@ -125,6 +191,7 @@ const SQL_CHECKS = `
125
191
  JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
126
192
  WHERE con.contype = 'c'
127
193
  AND n.nspname = $1
194
+ ORDER BY rel.relname, con.conname
128
195
  `;
129
196
  // Views (relkind 'v'), column metadata comes free from information_schema.columns.
130
197
  const SQL_VIEWS = `
@@ -312,12 +379,11 @@ export async function introspectPostgresCatalog(options) {
312
379
  });
313
380
  try {
314
381
  // Run all information_schema queries in parallel
315
- const [tablesResult, columnsResult, pkResult, fkResult, fkActionsResult, uniqueResult, indexResult, checkResult, enumResult,] = await Promise.all([
382
+ const [tablesResult, columnsResult, pkResult, fkResult, uniqueResult, indexResult, checkResult, enumResult] = await Promise.all([
316
383
  pool.query(SQL_TABLES, [schema]),
317
384
  pool.query(SQL_COLUMNS, [schema]),
318
385
  pool.query(SQL_PRIMARY_KEYS, [schema]),
319
386
  pool.query(SQL_FOREIGN_KEYS, [schema]),
320
- pool.query(SQL_FK_ACTIONS, [schema]),
321
387
  pool.query(SQL_UNIQUE_CONSTRAINTS, [schema]),
322
388
  pool.query(SQL_INDEXES, [schema]),
323
389
  pool.query(SQL_CHECKS, [schema]),
@@ -340,14 +406,6 @@ export async function introspectPostgresCatalog(options) {
340
406
  viewNameSet.add(r.table_name);
341
407
  matviewColumnRows.push(...matviewColsResult.rows);
342
408
  }
343
- // constraint_name → { onDelete, onUpdate } referential actions.
344
- const fkActions = new Map();
345
- for (const row of fkActionsResult.rows) {
346
- fkActions.set(row.conname, {
347
- onDelete: pgConfActionToReferential(row.confdeltype),
348
- onUpdate: pgConfActionToReferential(row.confupdtype),
349
- });
350
- }
351
409
  // Filter tables by include/exclude + default bookkeeping-table exclusions
352
410
  // (F12). Views/matviews join the base tables as candidates so the filters
353
411
  // apply uniformly.
@@ -478,20 +536,39 @@ export async function introspectPostgresCatalog(options) {
478
536
  enums[row.typname].push(row.enumlabel);
479
537
  }
480
538
  // ----- Build foreign key map -----
481
- // Group FK rows by constraint_name to correctly handle multi-column composite FKs.
482
- // Each constraint becomes one FKEntry with arrays of columns.
539
+ // Group FK rows by constraint OID, NOT by constraint name: Postgres only
540
+ // requires a name to be unique per table, so two tables in one schema may
541
+ // both own a `shared_fk` and grouping by name merges them into one entry
542
+ // (see SQL_FOREIGN_KEYS). Rows arrive ordered by (oid, ordinal), so pushing
543
+ // in arrival order preserves the column pairing the query established.
544
+ //
545
+ // A reference to a table in ANOTHER schema is skipped DELIBERATELY, and by
546
+ // its schema rather than by tableSet membership. Generated metadata keys
547
+ // tables by bare name and emits unqualified SQL, so a relation to
548
+ // `other.things` has no table to point at; worse, checking only
549
+ // `tableSet.has(target_table)` would bind it to a same-named table in THIS
550
+ // schema and generate a relation that reads the wrong table entirely.
551
+ // Skipped references are reported once, so they are visible rather than
552
+ // silently absent.
483
553
  const fkGroups = new Map();
554
+ const crossSchemaRefs = [];
484
555
  for (const row of fkResult.rows) {
485
- if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
556
+ if (!tableSet.has(row.source_table))
486
557
  continue;
487
- const key = row.constraint_name;
558
+ if (row.target_schema !== schema) {
559
+ crossSchemaRefs.push(`${row.source_table}.${row.source_column} -> ${row.target_schema}.${row.target_table}.${row.target_column}`);
560
+ continue;
561
+ }
562
+ if (!tableSet.has(row.target_table))
563
+ continue;
564
+ const key = row.constraint_oid;
488
565
  if (!fkGroups.has(key)) {
489
566
  fkGroups.set(key, {
490
567
  sourceTable: row.source_table,
491
568
  sourceColumns: [],
492
569
  targetTable: row.target_table,
493
570
  targetColumns: [],
494
- constraintName: key,
571
+ constraintName: row.constraint_name,
495
572
  });
496
573
  }
497
574
  const entry = fkGroups.get(key);
@@ -499,60 +576,43 @@ export async function introspectPostgresCatalog(options) {
499
576
  entry.targetColumns.push(row.target_column);
500
577
  }
501
578
  const foreignKeys = Array.from(fkGroups.values());
502
- // ----- Build relations from foreign keys -----
503
- // Delegated to the pure, unit-testable builder. Relation names are derived
504
- // per-FK-column when several FKs point at the same target, and every name
505
- // is collision-checked against the table's scalar column fields so a
506
- // relation can never shadow a column (which generated unsound types and
507
- // made both surfaces unusable).
508
- const columnFieldsByTable = new Map();
509
- const unknownTypedFieldsByTable = new Map();
510
- for (const [tbl, cols] of columnsByTable) {
511
- columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
512
- // Enum-typed columns also report tsType 'unknown' here, but generate.ts
513
- // gives them a concrete union type, a shadow of one was type-broken on
514
- // main, so only genuine json/jsonb columns qualify as historical shadows.
515
- unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
579
+ if (crossSchemaRefs.length > 0) {
580
+ console.warn(`[turbine] Skipped ${crossSchemaRefs.length} foreign key(s) referencing a table outside schema "${schema}": ` +
581
+ `${crossSchemaRefs.join(', ')}. Generated clients address tables by bare name within one schema, so no ` +
582
+ `relation is emitted for these. Introspect the other schema separately, or add the target table to this one.`);
516
583
  }
517
- // F2: unless the caller opts out, detect child FK column sets that a unique
518
- // constraint / plain unique index exactly covers, so the reverse relation is
519
- // emitted as a one-to-one (`hasOne`) instead of `hasMany`.
520
- const uniqueSetsByTable = options.legacyToManyUniques
521
- ? undefined
522
- : detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable);
523
- const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable, uniqueSetsByTable);
524
- // ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
525
- //
526
- // Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
527
- // junction, but a `enrollments(student_id, course_id, grade, enrolled_at)`
528
- // table is a first-class entity, not a join table. Prisma and Drizzle both
529
- // require explicit m2m declaration for exactly this reason.
530
- //
531
- // We only treat a table J as a PURE junction when ALL of these hold:
532
- // 1. J's primary key is exactly two columns.
533
- // 2. J has exactly two FKs, each single-column.
534
- // 3. Each FK's source column is one of J's two PK columns (the PK *is* the
535
- // two FK columns, no surrogate PK, no extra identity).
536
- // 4. The two FKs target two DISTINCT tables (A and B).
537
- // 5. J has no columns beyond those two FK/PK columns (no payload columns
538
- // like `grade` or `created_at`).
539
- //
540
- // For such a J linking A and B we ADD a `manyToMany` relation on A → B and
541
- // symmetrically on B → A, both routed `through` J. The existing belongsTo /
542
- // hasMany relations derived from J's FKs are left untouched, this block
543
- // never removes or renames anything. Naming/collision handling lives in the
544
- // shared addAutoManyToManyRelations helper.
545
- //
546
- // Prisma's implicit m2m junctions have no primary key (just a two-column
547
- // UNIQUE index over the FK columns), so pass the introspected two-column
548
- // unique indexes as the fallback junction-key source.
549
- const uniqueIndexColsByTable = new Map();
550
- for (const [tbl, idxs] of indexesByTable) {
551
- const twoColUniques = idxs.filter((idx) => idx.unique && idx.columns.length === 2).map((idx) => idx.columns);
552
- if (twoColUniques.length > 0)
553
- uniqueIndexColsByTable.set(tbl, twoColUniques);
584
+ // Referential actions (ON DELETE / ON UPDATE) per constraint. Keyed
585
+ // "<table>::<constraint>" because the NAME alone is not unique (the same
586
+ // collision SQL_FOREIGN_KEYS documents); buildRelationsFromForeignKeys
587
+ // prefers that key and falls back to the bare name for callers that build
588
+ // the map from a code-first schema, where names are synthesized per table.
589
+ const fkActions = new Map();
590
+ for (const row of fkResult.rows) {
591
+ fkActions.set(`${row.source_table}::${row.constraint_name}`, {
592
+ onDelete: pgConfActionToReferential(row.confdeltype),
593
+ onUpdate: pgConfActionToReferential(row.confupdtype),
594
+ });
554
595
  }
555
- addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable, uniqueIndexColsByTable);
596
+ // ----- Build relations from foreign keys -----
597
+ // Delegated to the shared catalog derivation, which `turbine mcp` also
598
+ // calls. Relation names are derived per-FK-column when several FKs point at
599
+ // the same target, every name is collision-checked against the table's
600
+ // scalar column fields so a relation can never shadow a column (which
601
+ // generated unsound types and made both surfaces unusable), a UNIQUE FK
602
+ // flips the reverse side to `hasOne`, and pure junction tables additionally
603
+ // get a `manyToMany` on each side. See deriveCatalogRelations for why the
604
+ // whole pipeline is one function rather than a call site per surface.
605
+ const relationsByTable = deriveCatalogRelations({
606
+ tableNames,
607
+ foreignKeys,
608
+ pkByTable,
609
+ columnsByTable,
610
+ uniqueByTable,
611
+ indexesByTable,
612
+ enums,
613
+ fkActions,
614
+ legacyToManyUniques: options.legacyToManyUniques,
615
+ });
556
616
  // ----- Assemble TableMetadata for each table -----
557
617
  const tables = {};
558
618
  for (const tableName of tableNames) {
@@ -917,7 +977,24 @@ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable,
917
977
  takenFor(fk.sourceTable).add(belongsToName);
918
978
  assignedFor(fk.sourceTable).add(belongsToName);
919
979
  // Referential actions (omit the 'no action' default to keep metadata lean).
920
- const actions = fkActions?.get(fk.constraintName);
980
+ // A constraint NAME is only unique per table in Postgres, so the catalog
981
+ // introspector keys this map "<table>::<constraint>". Callers that
982
+ // synthesize names from a code-first schema key it by bare name, so both
983
+ // spellings resolve.
984
+ //
985
+ // The bare-name fallback is LIVE, not defensive: `schemaDefToMetadata` keys
986
+ // its map by the synthesized `<table>_<column>_fkey` alone, so the qualified
987
+ // lookup always misses there and this second lookup is the ONLY thing that
988
+ // carries a `defineSchema` relation's onDelete/onUpdate into the metadata.
989
+ // Deleting it would silently drop referential actions from every code-first
990
+ // schema (verified: a `references: { onDelete: 'cascade' }` resolves through
991
+ // this branch and through no other). It is safe for that producer precisely
992
+ // because the name it synthesizes already embeds the source table, so a bare
993
+ // hit cannot belong to a different table's constraint. A future producer
994
+ // emitting TABLE-AGNOSTIC constraint names would break that property and
995
+ // could mis-attribute one table's ON DELETE to another's relation; such a
996
+ // producer must key the map "<table>::<constraint>" like the catalog reader.
997
+ const actions = fkActions?.get(`${fk.sourceTable}::${fk.constraintName}`) ?? fkActions?.get(fk.constraintName) ?? undefined;
921
998
  const actionFields = {};
922
999
  if (actions?.onDelete && actions.onDelete !== 'no action')
923
1000
  actionFields.onDelete = actions.onDelete;
@@ -1089,6 +1166,59 @@ export function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, c
1089
1166
  addM2M(fkB, fkA); // B → A
1090
1167
  }
1091
1168
  }
1169
+ /**
1170
+ * One-stop relation derivation for every surface that reads a live PostgreSQL
1171
+ * CATALOG: `turbine generate` (via {@link introspectPostgresCatalog}) and the
1172
+ * MCP server, which introspects for itself because it cannot assume generated
1173
+ * metadata exists.
1174
+ *
1175
+ * THE REASON THIS IS ONE FUNCTION and not two call sites: the pipeline it drives
1176
+ * is `buildRelationsFromForeignKeys` (five parameters, two optional) plus
1177
+ * `addAutoManyToManyRelations` (eight parameters, three optional), and every
1178
+ * optional one CHANGES THE ANSWER while omitting it stays silently type-correct.
1179
+ * Hand-mirroring them drifted exactly that way: MCP passed four arguments and so
1180
+ * never received `uniqueSetsByTable`, which meant a UNIQUE foreign key produced
1181
+ * `users.profile` (hasOne) under `turbine generate` and `users.profiles`
1182
+ * (hasMany) under `turbine mcp`, against the same database. An MCP client
1183
+ * following its own schema tool then queried `with: { profiles: true }` and got
1184
+ * `TURBINE_E005 Unknown relation`. MCP also omitted `uniqueIndexColsByTable`,
1185
+ * losing every auto-m2m relation through a Prisma-style PK-less junction. Adding
1186
+ * an argument here now reaches both surfaces or neither.
1187
+ *
1188
+ * The engine introspectors (SQLite / MySQL / MSSQL) keep their own
1189
+ * {@link deriveEngineRelations} because they deliberately do NOT do the
1190
+ * unique-FK → `hasOne` flip.
1191
+ */
1192
+ export function deriveCatalogRelations(inputs) {
1193
+ const { tableNames, foreignKeys, pkByTable, columnsByTable, uniqueByTable, indexesByTable, enums } = inputs;
1194
+ const columnFieldsByTable = new Map();
1195
+ const unknownTypedFieldsByTable = new Map();
1196
+ for (const [tbl, cols] of columnsByTable) {
1197
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
1198
+ // Enum-typed columns also report tsType 'unknown' here, but generate.ts
1199
+ // gives them a concrete union type, so a shadow of one is type-broken and
1200
+ // must NOT be preserved as a historical json/jsonb shadow.
1201
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
1202
+ }
1203
+ // F2: unless the caller opts out, detect child FK column sets that a unique
1204
+ // constraint / plain unique index exactly covers, so the reverse relation is
1205
+ // emitted as a one-to-one (`hasOne`) instead of `hasMany`.
1206
+ const uniqueSetsByTable = inputs.legacyToManyUniques
1207
+ ? undefined
1208
+ : detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable);
1209
+ const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, inputs.fkActions, unknownTypedFieldsByTable, uniqueSetsByTable);
1210
+ // Prisma's implicit m2m junctions have no primary key (just a two-column
1211
+ // UNIQUE index over the FK columns), so pass the introspected two-column
1212
+ // unique indexes as the fallback junction-key source.
1213
+ const uniqueIndexColsByTable = new Map();
1214
+ for (const [tbl, idxs] of indexesByTable) {
1215
+ const twoColUniques = idxs.filter((idx) => idx.unique && idx.columns.length === 2).map((idx) => idx.columns);
1216
+ if (twoColUniques.length > 0)
1217
+ uniqueIndexColsByTable.set(tbl, twoColUniques);
1218
+ }
1219
+ addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable, uniqueIndexColsByTable);
1220
+ return relationsByTable;
1221
+ }
1092
1222
  /**
1093
1223
  * One-stop relation derivation for the engine introspectors (SQLite / MySQL /
1094
1224
  * MSSQL): filters the FK list to the introspected table set, seeds the
package/dist/mssql.js CHANGED
@@ -91,7 +91,7 @@
91
91
  */
92
92
  import { TurbineClient } from './client.js';
93
93
  import { postgresDialect, } from './dialect.js';
94
- import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
94
+ import { ConnectionError, malformedConnectionStringMessage, markValueBearingMessage, RelationError, UnsupportedFeatureError, ValidationError, } from './errors.js';
95
95
  import { applyTableFilters, deriveEngineRelations } from './introspect.js';
96
96
  import importOptionalPeer from './optional-peer-import.cjs';
97
97
  import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
@@ -208,6 +208,16 @@ function augmentMssqlError(err) {
208
208
  const m = /(?:constraint|index)\s+'([^']+)'/i.exec(msg) ?? /'([^']+)'/.exec(msg);
209
209
  if (m?.[1])
210
210
  target.constraint = m[1];
211
+ // The message ends `The duplicate key value is (alice@example.com).`, i.e.
212
+ // SQL Server reports the conflicting ROW VALUE in the message text and has
213
+ // no `detail` field for 'safe' mode to redact, so this error used to reach
214
+ // `.cause` verbatim. Flag it so the message is withheld in 'safe' mode
215
+ // (see markValueBearingMessage); the constraint NAME is already captured
216
+ // above and survives in both modes.
217
+ //
218
+ // Deliberately only 2627/2601: 547 (FK/CHECK conflict) and 515 (NULL into
219
+ // a non-nullable column) name the database/table/column and never a value.
220
+ markValueBearingMessage(err);
211
221
  return err;
212
222
  }
213
223
  // 547 = FOREIGN KEY / CHECK constraint conflict (message distinguishes them).
@@ -524,6 +534,26 @@ export const mssqlDialect = {
524
534
  quoteIdentifier(name) {
525
535
  return `[${name.replace(/]/g, ']]')}]`;
526
536
  },
537
+ /**
538
+ * T-SQL's `LIKE` has a FOURTH metacharacter the SQL-standard set does not:
539
+ * `[` opens a character class (`[abc]`, `[a-z]`, `[^x]`). So the shared
540
+ * `escapeLike` left `contains: '[draft]'` meaning "contains any one of d, r,
541
+ * a, f or t" here, while PostgreSQL, MySQL and SQLite all read the value as a
542
+ * literal (verified: none of the other three treat `[` specially). Not
543
+ * injection, the operand is still a bound parameter; a wrong ANSWER, and one
544
+ * that gets broader the longer the bracketed text is.
545
+ *
546
+ * Only `[` needs escaping. A `]` outside a class is a literal to T-SQL, and
547
+ * escaping the opener is enough to stop a class from ever being opened, so
548
+ * `]` is deliberately left alone (escaping it would be harmless but is not
549
+ * required, and the smaller pattern is the one that stays obviously correct).
550
+ * The prefix is the same backslash the emitted `ESCAPE '\'` clause names, and
551
+ * the backslash pass runs FIRST so an escape character in the value is itself
552
+ * escaped before any is added.
553
+ */
554
+ escapeLikePattern(value) {
555
+ return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_').replace(/\[/g, '\\[');
556
+ },
527
557
  // SQL Server aggregate casts: COUNT → INT, AVG/float → FLOAT.
528
558
  jsonWireRule(columnType) {
529
559
  const t = columnType.toLowerCase();
@@ -553,6 +583,17 @@ export const mssqlDialect = {
553
583
  },
554
584
  };
555
585
  }
586
+ // NO json rule here, and that is a checked answer rather than an omission.
587
+ // SQL Server on the supported floor (2016 through 2022) has no JSON column
588
+ // type: `mssqlColumnType` maps JSON/JSONB to NVARCHAR(MAX), and
589
+ // introspection reports the carrier as `nvarchar`. FOR JSON PATH escapes an
590
+ // nvarchar cell as a JSON *string* (only `buildRelationSubquery`'s nested
591
+ // relations are JSON_QUERY-wrapped, never a scalar column), so parsing the
592
+ // row hands back exactly the string the driver returns for a top-level
593
+ // read. Both routes already agree, and casting would only add noise. This
594
+ // is the divergence MySQL DOES have, where JSON_OBJECT embeds a real JSON
595
+ // column as a nested value while the driver hands back text.
596
+ //
556
597
  // Binary columns come out of FOR JSON PATH as BASE64 text ("AQL/") rather
557
598
  // than bytes. Style 2 converts to bare hex, which rebuilds exactly.
558
599
  if (t === 'binary' || t === 'varbinary' || t === 'image') {
@@ -1228,7 +1269,8 @@ function parseMssqlConfig(connectionString) {
1228
1269
  return config;
1229
1270
  }
1230
1271
  catch {
1231
- throw new ConnectionError(`[turbine] Invalid MSSQL connection string: "${connectionString}"`);
1272
+ // Never echo the value, see malformedConnectionStringMessage.
1273
+ throw new ConnectionError(malformedConnectionStringMessage('SQL Server', 'mssql://user:password@localhost:1433/app'));
1232
1274
  }
1233
1275
  }
1234
1276
  /**
package/dist/mysql.d.ts CHANGED
@@ -164,8 +164,11 @@ export interface TurbineMysqlOptions extends EngineClientConfig {
164
164
  * `disconnect()` is a no-op, advanced config like SSL lives here).
165
165
  *
166
166
  * When Turbine builds the pool (string/config), it pins the correct mysql2 flags
167
- * (named placeholders, bignum, UTC dates, JSON-as-string), probes `SELECT VERSION()`
168
- * to reject MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created.
167
+ * (named placeholders, bignum, UTC dates), probes `SELECT VERSION()` to reject
168
+ * MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created. None of
169
+ * those flags changes a column's TYPE, so an injected pool reads every value
170
+ * the same way (see {@link MYSQL_DRIVER_FLAGS}); `namedPlaceholders: true` is
171
+ * the one an injected pool must set for itself.
169
172
  *
170
173
  * @example
171
174
  * ```ts