turbine-orm 0.81.0 → 0.90.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 (295) hide show
  1. package/README.md +26 -25
  2. package/dist/checkout.d.ts +27 -2
  3. package/dist/checkout.js +75 -4
  4. package/dist/cjs/checkout.d.ts +27 -2
  5. package/dist/cjs/checkout.js +76 -3
  6. package/dist/cjs/cli/code-first-tags.d.ts +62 -0
  7. package/dist/cjs/cli/code-first-tags.js +124 -0
  8. package/dist/cjs/cli/compile-query.js +9 -0
  9. package/dist/cjs/cli/config.d.ts +35 -6
  10. package/dist/cjs/cli/config.js +17 -1
  11. package/dist/cjs/cli/destructive.d.ts +7 -4
  12. package/dist/cjs/cli/destructive.js +224 -20
  13. package/dist/cjs/cli/error-catalog.js +36 -0
  14. package/dist/cjs/cli/error-text.d.ts +15 -0
  15. package/dist/cjs/cli/error-text.js +40 -0
  16. package/dist/cjs/cli/index.d.ts +62 -4
  17. package/dist/cjs/cli/index.js +568 -108
  18. package/dist/cjs/cli/local-server-guard.d.ts +53 -0
  19. package/dist/cjs/cli/local-server-guard.js +119 -0
  20. package/dist/cjs/cli/local-server-output.d.ts +41 -0
  21. package/dist/cjs/cli/local-server-output.js +103 -0
  22. package/dist/cjs/cli/mcp.d.ts +8 -0
  23. package/dist/cjs/cli/mcp.js +121 -32
  24. package/dist/cjs/cli/migrate.d.ts +113 -5
  25. package/dist/cjs/cli/migrate.js +270 -21
  26. package/dist/cjs/cli/observe.d.ts +6 -1
  27. package/dist/cjs/cli/observe.js +85 -55
  28. package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
  29. package/dist/cjs/cli/pii-predicate-guard.js +98 -37
  30. package/dist/cjs/cli/prisma-resolve.js +17 -13
  31. package/dist/cjs/cli/prisma-schema.js +12 -4
  32. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  33. package/dist/cjs/cli/studio.d.ts +14 -2
  34. package/dist/cjs/cli/studio.js +244 -69
  35. package/dist/cjs/cli/ui.d.ts +1 -1
  36. package/dist/cjs/client.d.ts +558 -85
  37. package/dist/cjs/client.js +1270 -395
  38. package/dist/cjs/connection-guard.d.ts +13 -0
  39. package/dist/cjs/connection-guard.js +28 -0
  40. package/dist/cjs/connection-url.d.ts +51 -16
  41. package/dist/cjs/connection-url.js +67 -34
  42. package/dist/cjs/diagnostics.d.ts +81 -0
  43. package/dist/cjs/diagnostics.js +147 -0
  44. package/dist/cjs/dialect.d.ts +449 -20
  45. package/dist/cjs/dialect.js +117 -76
  46. package/dist/cjs/engine-config.d.ts +7 -6
  47. package/dist/cjs/errors.d.ts +126 -6
  48. package/dist/cjs/errors.js +256 -13
  49. package/dist/cjs/generate.d.ts +57 -7
  50. package/dist/cjs/generate.js +635 -279
  51. package/dist/cjs/global-filters-config.d.ts +59 -0
  52. package/dist/cjs/global-filters-config.js +367 -0
  53. package/dist/cjs/index.d.ts +9 -4
  54. package/dist/cjs/index.js +10 -2
  55. package/dist/cjs/introspect.d.ts +31 -6
  56. package/dist/cjs/introspect.js +108 -50
  57. package/dist/cjs/log-codes.d.ts +12 -0
  58. package/dist/cjs/log-codes.js +77 -0
  59. package/dist/cjs/logger.d.ts +87 -0
  60. package/dist/cjs/logger.js +98 -0
  61. package/dist/cjs/mssql.d.ts +8 -5
  62. package/dist/cjs/mssql.js +352 -65
  63. package/dist/cjs/mysql.d.ts +3 -5
  64. package/dist/cjs/mysql.js +420 -70
  65. package/dist/cjs/nested-write.d.ts +3 -0
  66. package/dist/cjs/nested-write.js +179 -114
  67. package/dist/cjs/pipeline-submittable.d.ts +12 -0
  68. package/dist/cjs/pipeline-submittable.js +118 -44
  69. package/dist/cjs/pipeline.js +21 -26
  70. package/dist/cjs/powdb-introspect.d.ts +5 -2
  71. package/dist/cjs/powdb-introspect.js +20 -15
  72. package/dist/cjs/powdb-shared.d.ts +36 -5
  73. package/dist/cjs/powdb-shared.js +67 -8
  74. package/dist/cjs/powdb.d.ts +4 -0
  75. package/dist/cjs/powdb.js +183 -51
  76. package/dist/cjs/powql.d.ts +394 -51
  77. package/dist/cjs/powql.js +1829 -284
  78. package/dist/cjs/prisma-compat.d.ts +23 -15
  79. package/dist/cjs/prisma-compat.js +507 -372
  80. package/dist/cjs/query/aggregates.d.ts +17 -1
  81. package/dist/cjs/query/aggregates.js +261 -29
  82. package/dist/cjs/query/batched-loader.d.ts +25 -6
  83. package/dist/cjs/query/batched-loader.js +103 -20
  84. package/dist/cjs/query/builder.d.ts +325 -184
  85. package/dist/cjs/query/builder.js +1452 -490
  86. package/dist/cjs/query/column-ref.d.ts +61 -0
  87. package/dist/cjs/query/column-ref.js +73 -0
  88. package/dist/cjs/query/compound-unique.d.ts +21 -0
  89. package/dist/cjs/query/compound-unique.js +98 -18
  90. package/dist/cjs/query/deferred.d.ts +53 -16
  91. package/dist/cjs/query/expr.d.ts +217 -0
  92. package/dist/cjs/query/expr.js +859 -0
  93. package/dist/cjs/query/filters.d.ts +155 -53
  94. package/dist/cjs/query/filters.js +613 -76
  95. package/dist/cjs/query/index.d.ts +2 -0
  96. package/dist/cjs/query/index.js +7 -1
  97. package/dist/cjs/query/json-value.d.ts +43 -0
  98. package/dist/cjs/query/json-value.js +59 -0
  99. package/dist/cjs/query/normalize-args.d.ts +87 -0
  100. package/dist/cjs/query/normalize-args.js +527 -0
  101. package/dist/cjs/query/option-surface.d.ts +16 -4
  102. package/dist/cjs/query/option-surface.js +57 -14
  103. package/dist/cjs/query/relation-names.d.ts +14 -0
  104. package/dist/cjs/query/relation-names.js +77 -18
  105. package/dist/cjs/query/relations.d.ts +30 -3
  106. package/dist/cjs/query/relations.js +219 -63
  107. package/dist/cjs/query/row-lock.d.ts +49 -0
  108. package/dist/cjs/query/row-lock.js +138 -0
  109. package/dist/cjs/query/sql-comment.d.ts +161 -0
  110. package/dist/cjs/query/sql-comment.js +381 -0
  111. package/dist/cjs/query/stream-idle.d.ts +132 -0
  112. package/dist/cjs/query/stream-idle.js +212 -0
  113. package/dist/cjs/query/table-client.d.ts +101 -0
  114. package/dist/cjs/query/table-client.js +74 -0
  115. package/dist/cjs/query/types.d.ts +677 -298
  116. package/dist/cjs/query/utils.d.ts +105 -6
  117. package/dist/cjs/query/utils.js +426 -19
  118. package/dist/cjs/query/warn-registry.d.ts +18 -0
  119. package/dist/cjs/query/warn-registry.js +18 -0
  120. package/dist/cjs/query/where-compile.d.ts +138 -14
  121. package/dist/cjs/query/where-compile.js +513 -29
  122. package/dist/cjs/query/where.d.ts +159 -59
  123. package/dist/cjs/query/where.js +716 -246
  124. package/dist/cjs/query/writes.d.ts +112 -50
  125. package/dist/cjs/query/writes.js +1498 -200
  126. package/dist/cjs/query-events.d.ts +4 -2
  127. package/dist/cjs/query-events.js +3 -1
  128. package/dist/cjs/realtime.d.ts +7 -3
  129. package/dist/cjs/realtime.js +13 -2
  130. package/dist/cjs/schema-builder.d.ts +55 -4
  131. package/dist/cjs/schema-builder.js +382 -18
  132. package/dist/cjs/schema-metadata.d.ts +21 -1
  133. package/dist/cjs/schema-metadata.js +29 -7
  134. package/dist/cjs/schema-sql.d.ts +75 -5
  135. package/dist/cjs/schema-sql.js +497 -45
  136. package/dist/cjs/schema.d.ts +26 -5
  137. package/dist/cjs/schema.js +110 -13
  138. package/dist/cjs/sql-placeholders.d.ts +56 -0
  139. package/dist/cjs/sql-placeholders.js +204 -0
  140. package/dist/cjs/sqlite.d.ts +75 -20
  141. package/dist/cjs/sqlite.js +593 -95
  142. package/dist/cjs/supplied-pool-types.d.ts +48 -0
  143. package/dist/cjs/supplied-pool-types.js +256 -0
  144. package/dist/cjs/transaction-control.d.ts +51 -0
  145. package/dist/cjs/transaction-control.js +316 -0
  146. package/dist/cjs/transaction-state.d.ts +342 -0
  147. package/dist/cjs/transaction-state.js +707 -0
  148. package/dist/cjs/typed-sql.d.ts +37 -3
  149. package/dist/cjs/typed-sql.js +102 -8
  150. package/dist/cli/code-first-tags.d.ts +62 -0
  151. package/dist/cli/code-first-tags.js +120 -0
  152. package/dist/cli/compile-query.js +9 -0
  153. package/dist/cli/config.d.ts +35 -6
  154. package/dist/cli/config.js +16 -1
  155. package/dist/cli/destructive.d.ts +7 -4
  156. package/dist/cli/destructive.js +224 -20
  157. package/dist/cli/error-catalog.js +36 -0
  158. package/dist/cli/error-text.d.ts +15 -0
  159. package/dist/cli/error-text.js +37 -0
  160. package/dist/cli/index.d.ts +62 -4
  161. package/dist/cli/index.js +569 -112
  162. package/dist/cli/local-server-guard.d.ts +53 -0
  163. package/dist/cli/local-server-guard.js +113 -0
  164. package/dist/cli/local-server-output.d.ts +41 -0
  165. package/dist/cli/local-server-output.js +98 -0
  166. package/dist/cli/mcp.d.ts +8 -0
  167. package/dist/cli/mcp.js +121 -34
  168. package/dist/cli/migrate.d.ts +113 -5
  169. package/dist/cli/migrate.js +261 -21
  170. package/dist/cli/observe.d.ts +6 -1
  171. package/dist/cli/observe.js +84 -54
  172. package/dist/cli/pii-predicate-guard.d.ts +43 -23
  173. package/dist/cli/pii-predicate-guard.js +100 -35
  174. package/dist/cli/prisma-resolve.js +15 -11
  175. package/dist/cli/prisma-schema.js +12 -4
  176. package/dist/cli/studio-ui.generated.js +1 -1
  177. package/dist/cli/studio.d.ts +14 -2
  178. package/dist/cli/studio.js +246 -71
  179. package/dist/cli/ui.d.ts +1 -1
  180. package/dist/client.d.ts +558 -85
  181. package/dist/client.js +1275 -400
  182. package/dist/connection-guard.d.ts +13 -0
  183. package/dist/connection-guard.js +27 -0
  184. package/dist/connection-url.d.ts +51 -16
  185. package/dist/connection-url.js +63 -34
  186. package/dist/diagnostics.d.ts +81 -0
  187. package/dist/diagnostics.js +144 -0
  188. package/dist/dialect.d.ts +449 -20
  189. package/dist/dialect.js +115 -43
  190. package/dist/engine-config.d.ts +7 -6
  191. package/dist/errors.d.ts +126 -6
  192. package/dist/errors.js +252 -12
  193. package/dist/generate.d.ts +57 -7
  194. package/dist/generate.js +635 -281
  195. package/dist/global-filters-config.d.ts +59 -0
  196. package/dist/global-filters-config.js +361 -0
  197. package/dist/index.d.ts +9 -4
  198. package/dist/index.js +4 -2
  199. package/dist/introspect.d.ts +31 -6
  200. package/dist/introspect.js +106 -50
  201. package/dist/log-codes.d.ts +12 -0
  202. package/dist/log-codes.js +74 -0
  203. package/dist/logger.d.ts +87 -0
  204. package/dist/logger.js +93 -0
  205. package/dist/mssql.d.ts +8 -5
  206. package/dist/mssql.js +354 -67
  207. package/dist/mysql.d.ts +3 -5
  208. package/dist/mysql.js +423 -73
  209. package/dist/nested-write.d.ts +3 -0
  210. package/dist/nested-write.js +177 -112
  211. package/dist/pipeline-submittable.d.ts +12 -0
  212. package/dist/pipeline-submittable.js +117 -44
  213. package/dist/pipeline.js +21 -26
  214. package/dist/powdb-introspect.d.ts +5 -2
  215. package/dist/powdb-introspect.js +21 -16
  216. package/dist/powdb-shared.d.ts +36 -5
  217. package/dist/powdb-shared.js +65 -8
  218. package/dist/powdb.d.ts +4 -0
  219. package/dist/powdb.js +183 -51
  220. package/dist/powql.d.ts +394 -51
  221. package/dist/powql.js +1836 -291
  222. package/dist/prisma-compat.d.ts +23 -15
  223. package/dist/prisma-compat.js +508 -373
  224. package/dist/query/aggregates.d.ts +17 -1
  225. package/dist/query/aggregates.js +263 -32
  226. package/dist/query/batched-loader.d.ts +25 -6
  227. package/dist/query/batched-loader.js +101 -21
  228. package/dist/query/builder.d.ts +325 -184
  229. package/dist/query/builder.js +1458 -495
  230. package/dist/query/column-ref.d.ts +61 -0
  231. package/dist/query/column-ref.js +66 -0
  232. package/dist/query/compound-unique.d.ts +21 -0
  233. package/dist/query/compound-unique.js +97 -19
  234. package/dist/query/deferred.d.ts +53 -16
  235. package/dist/query/expr.d.ts +217 -0
  236. package/dist/query/expr.js +848 -0
  237. package/dist/query/filters.d.ts +155 -53
  238. package/dist/query/filters.js +597 -72
  239. package/dist/query/index.d.ts +2 -0
  240. package/dist/query/index.js +4 -0
  241. package/dist/query/json-value.d.ts +43 -0
  242. package/dist/query/json-value.js +54 -0
  243. package/dist/query/normalize-args.d.ts +87 -0
  244. package/dist/query/normalize-args.js +517 -0
  245. package/dist/query/option-surface.d.ts +16 -4
  246. package/dist/query/option-surface.js +56 -13
  247. package/dist/query/relation-names.d.ts +14 -0
  248. package/dist/query/relation-names.js +77 -19
  249. package/dist/query/relations.d.ts +30 -3
  250. package/dist/query/relations.js +220 -67
  251. package/dist/query/row-lock.d.ts +49 -0
  252. package/dist/query/row-lock.js +131 -0
  253. package/dist/query/sql-comment.d.ts +161 -0
  254. package/dist/query/sql-comment.js +374 -0
  255. package/dist/query/stream-idle.d.ts +132 -0
  256. package/dist/query/stream-idle.js +203 -0
  257. package/dist/query/table-client.d.ts +101 -0
  258. package/dist/query/table-client.js +70 -0
  259. package/dist/query/types.d.ts +677 -298
  260. package/dist/query/utils.d.ts +105 -6
  261. package/dist/query/utils.js +414 -18
  262. package/dist/query/warn-registry.d.ts +18 -0
  263. package/dist/query/warn-registry.js +18 -0
  264. package/dist/query/where-compile.d.ts +138 -14
  265. package/dist/query/where-compile.js +508 -31
  266. package/dist/query/where.d.ts +159 -59
  267. package/dist/query/where.js +711 -248
  268. package/dist/query/writes.d.ts +112 -50
  269. package/dist/query/writes.js +1496 -202
  270. package/dist/query-events.d.ts +4 -2
  271. package/dist/query-events.js +3 -1
  272. package/dist/realtime.d.ts +7 -3
  273. package/dist/realtime.js +13 -2
  274. package/dist/schema-builder.d.ts +55 -4
  275. package/dist/schema-builder.js +381 -18
  276. package/dist/schema-metadata.d.ts +21 -1
  277. package/dist/schema-metadata.js +28 -7
  278. package/dist/schema-sql.d.ts +75 -5
  279. package/dist/schema-sql.js +494 -44
  280. package/dist/schema.d.ts +26 -5
  281. package/dist/schema.js +109 -13
  282. package/dist/sql-placeholders.d.ts +56 -0
  283. package/dist/sql-placeholders.js +200 -0
  284. package/dist/sqlite.d.ts +75 -20
  285. package/dist/sqlite.js +597 -99
  286. package/dist/supplied-pool-types.d.ts +48 -0
  287. package/dist/supplied-pool-types.js +247 -0
  288. package/dist/transaction-control.d.ts +51 -0
  289. package/dist/transaction-control.js +311 -0
  290. package/dist/transaction-state.d.ts +342 -0
  291. package/dist/transaction-state.js +696 -0
  292. package/dist/typed-sql.d.ts +37 -3
  293. package/dist/typed-sql.js +100 -8
  294. package/package.json +7 -4
  295. package/skills/turbine-orm/SKILL.md +56 -6
@@ -108,13 +108,18 @@ export function schemaToSQL(schema, options) {
108
108
  : 'CREATE EXTENSION IF NOT EXISTS vector;');
109
109
  }
110
110
  // CREATE TYPE for every schema-level enum, before the tables that use them.
111
- for (const [enumName, labels] of Object.entries(schema.enums ?? {})) {
112
- statements.push(generateCreateEnumType(enumName, labels, dialect));
111
+ // Only where enums are named types (PostgreSQL): every other engine declares
112
+ // the labels on the column itself (see `enumColumnType`), and has no CREATE
113
+ // TYPE to run.
114
+ if (dialect.capabilities.namedEnumTypes) {
115
+ for (const [enumName, labels] of Object.entries(schema.enums ?? {})) {
116
+ statements.push(generateCreateEnumType(enumName, labels, dialect));
117
+ }
113
118
  }
114
119
  // Generate CREATE TABLE statements
115
120
  for (const tableName of sorted) {
116
121
  const table = schema.tables[tableName];
117
- statements.push(generateCreateTable(table, resolveRef, dialect));
122
+ statements.push(generateCreateTable(table, resolveRef, dialect, schema.enums));
118
123
  }
119
124
  // Generate CREATE INDEX for foreign key columns
120
125
  for (const tableName of sorted) {
@@ -220,13 +225,22 @@ function topologicalSort(schema) {
220
225
  * to their snake_case DDL form, so users can write either camelCase JS
221
226
  * accessor names or snake_case DDL names.
222
227
  */
223
- function generateCreateTable(table, resolveRef, dialect = postgresDialect) {
228
+ function generateCreateTable(table, resolveRef, dialect = postgresDialect, enums) {
224
229
  const tableName = table.name;
225
230
  const columnDefs = [];
226
231
  const compositePk = table.primaryKey && table.primaryKey.length > 0 ? table.primaryKey : null;
232
+ const keyed = keyedColumnNames(table);
233
+ const inline = dialect.capabilities.inlineForeignKeys;
234
+ const foreignKeys = [];
227
235
  for (const [fieldName, config] of Object.entries(table.columns)) {
228
- columnDefs.push(generateColumnDef(fieldName, config, resolveRef, dialect));
236
+ columnDefs.push(generateColumnDef(fieldName, config, resolveRef, dialect, keyed.has(camelToSnake(fieldName)), inline, enums));
237
+ if (!inline) {
238
+ const fk = generateForeignKeyConstraint(fieldName, config, resolveRef, dialect);
239
+ if (fk)
240
+ foreignKeys.push(fk);
241
+ }
229
242
  }
243
+ columnDefs.push(...foreignKeys);
230
244
  // Append a table-level PRIMARY KEY constraint when a composite PK is set.
231
245
  if (compositePk) {
232
246
  const cols = compositePk.map((c) => dialect.quoteIdentifier(camelToSnake(c)));
@@ -246,11 +260,80 @@ function generateCreateTable(table, resolveRef, dialect = postgresDialect) {
246
260
  definitions: columnDefs,
247
261
  });
248
262
  }
263
+ /**
264
+ * The snake_case names of the columns of `table` that sit in a key: a primary
265
+ * key (single or composite), a UNIQUE column, a foreign key (Turbine indexes
266
+ * those), or a member of a declared index.
267
+ */
268
+ function keyedColumnNames(table) {
269
+ const keyed = new Set();
270
+ for (const [fieldName, config] of Object.entries(table.columns)) {
271
+ if (config.isPrimaryKey || config.isUnique || config.referencesTarget)
272
+ keyed.add(camelToSnake(fieldName));
273
+ }
274
+ for (const pk of table.primaryKey ?? [])
275
+ keyed.add(camelToSnake(pk));
276
+ for (const idx of table.indexes ?? []) {
277
+ if (isDocFieldIndexDef(idx))
278
+ continue;
279
+ for (const col of idx.columns)
280
+ keyed.add(camelToSnake(col));
281
+ }
282
+ return keyed;
283
+ }
284
+ /** A table-level `FOREIGN KEY` clause for a column's `references`, or null. */
285
+ function generateForeignKeyConstraint(fieldName, config, resolveRef, dialect) {
286
+ if (!config.referencesTarget)
287
+ return null;
288
+ const refParts = config.referencesTarget.split('.');
289
+ if (refParts.length !== 2)
290
+ return null;
291
+ const refTable = resolveRef ? resolveRef(refParts[0]) : refParts[0];
292
+ let clause = `FOREIGN KEY (${dialect.quoteIdentifier(camelToSnake(fieldName))}) REFERENCES ${dialect.quoteIdentifier(refTable)}(${dialect.quoteIdentifier(refParts[1])})`;
293
+ if (config.onDelete)
294
+ clause += ` ON DELETE ${referentialActionToSql(config.onDelete)}`;
295
+ if (config.onUpdate)
296
+ clause += ` ON UPDATE ${referentialActionToSql(config.onUpdate)}`;
297
+ return clause;
298
+ }
299
+ /**
300
+ * The type (and CHECK) of an enum column on an engine with no named enum type,
301
+ * from the dialect's `buildEnumColumn`, or null on PostgreSQL, where the column
302
+ * names the `CREATE TYPE`. The labels come from the schema's `enums`: off
303
+ * PostgreSQL there is no existing type a column could name instead.
304
+ */
305
+ function enumColumnType(config, dialect, column, enums) {
306
+ if (!config.enumName || dialect.capabilities.namedEnumTypes)
307
+ return null;
308
+ if (!dialect.buildEnumColumn) {
309
+ throw new UnsupportedFeatureError('enum columns', dialect.name, `Column "${column}" is of enum type "${config.enumName}". Declare it as text with a CHECK constraint instead.`);
310
+ }
311
+ if (config.isArray) {
312
+ throw new UnsupportedFeatureError('enum array columns', dialect.name, `Column "${column}" is an array of enum "${config.enumName}"; ${dialect.name} has no array columns.`);
313
+ }
314
+ const labels = enums?.[config.enumName];
315
+ if (!labels) {
316
+ throw new ValidationError(`Column "${column}" is of enum type "${config.enumName}", which the schema does not declare. ` +
317
+ `${dialect.name} has no enum type to refer to, so the labels must be in the schema: ` +
318
+ `defineSchema(tables, { enums: { ${JSON.stringify(config.enumName)}: ['a', 'b'] } }).`);
319
+ }
320
+ if (labels.length === 0) {
321
+ throw new ValidationError(`Enum "${config.enumName}" declares no labels, so column "${column}" could hold nothing.`);
322
+ }
323
+ return dialect.buildEnumColumn(dialect.quoteIdentifier(column), labels);
324
+ }
249
325
  /**
250
326
  * Generate a single column definition line (e.g. "id BIGSERIAL PRIMARY KEY").
327
+ *
328
+ * `keyed` marks a column that sits in a key (see {@link keyedColumnNames}); it
329
+ * only matters to dialects that cannot key an unbounded text type. `inlineReferences`
330
+ * is false for a dialect that declares foreign keys at table level instead.
331
+ * `enums` is the schema's enum declarations, needed off PostgreSQL to declare
332
+ * an enum column's labels on the column itself.
251
333
  */
252
- function generateColumnDef(fieldName, config, resolveRef, dialect = postgresDialect) {
334
+ function generateColumnDef(fieldName, config, resolveRef, dialect = postgresDialect, keyed = false, inlineReferences = true, enums) {
253
335
  const snakeName = camelToSnake(fieldName);
336
+ const enumColumn = enumColumnType(config, dialect, snakeName, enums);
254
337
  // NOT NULL, serial types are implicitly NOT NULL, but explicit is fine.
255
338
  // A column is NOT NULL if:
256
339
  // 1. Explicitly marked .notNull(), OR
@@ -260,15 +343,13 @@ function generateColumnDef(fieldName, config, resolveRef, dialect = postgresDial
260
343
  const isSerial = isSerialType(config.type);
261
344
  const implicitNotNull = isSerial || config.isPrimaryKey;
262
345
  const notNull = config.isNotNull && !implicitNotNull;
263
- // DEFAULT
264
- let defaultValue;
265
- if (config.defaultValue != null) {
266
- defaultValue = normalizeDefault(config.defaultValue);
267
- }
346
+ // DEFAULT, normalized here and spelled for the dialect once the column's
347
+ // final type is known (a MySQL default depends on it).
348
+ const normalizedDefault = config.defaultValue != null ? normalizeDefault(config.defaultValue) : undefined;
268
349
  // REFERENCES, resolve the raw table name through the optional resolver so
269
350
  // both camelCase accessor names and snake_case DDL names work.
270
351
  let references;
271
- if (config.referencesTarget) {
352
+ if (config.referencesTarget && inlineReferences) {
272
353
  const refParts = config.referencesTarget.split('.');
273
354
  if (refParts.length === 2) {
274
355
  const rawTable = refParts[0];
@@ -279,14 +360,43 @@ function generateColumnDef(fieldName, config, resolveRef, dialect = postgresDial
279
360
  };
280
361
  }
281
362
  }
282
- // Resolve the DDL type (enum name / vector(n) / scalar, plus [] for arrays).
283
- // Passed as a fully-formed `type` token with no maxLength so the dialect
284
- // doesn't re-apply VARCHAR(n) on top of it.
285
- const ddlType = resolveDdlType(config, dialect, snakeName);
363
+ // The type is mapped ONCE, by the dialect's own `buildColumnDefinition`. A
364
+ // column that is a plain scalar hands it the schema-level token (`SERIAL`,
365
+ // `VARCHAR` + maxLength, `TEXT`), because every non-Postgres dialect keeps
366
+ // its auto-increment / identity marker, its length and its bounded string
367
+ // type INSIDE that mapping. Handing it the already-mapped token (`INT
368
+ // AUTO_INCREMENT`, `VARCHAR(12)`) made the dialect map the output of its own
369
+ // mapper, which turned `INT AUTO_INCREMENT` into `INT`, `VARCHAR(12)` into
370
+ // `VARCHAR(255)` and `NVARCHAR(MAX)` into `NVARCHAR(255)`. An enum name, a
371
+ // `vector(n)` and an array are Postgres tokens with no schema-level spelling,
372
+ // so they are passed through resolved.
373
+ const plainScalar = !config.enumName && config.vectorDimensions == null && !config.isArray;
374
+ let typeInput;
375
+ if (enumColumn) {
376
+ typeInput = { type: enumColumn.type, maxLength: enumColumn.maxLength ?? null };
377
+ }
378
+ else if (!plainScalar) {
379
+ typeInput = { type: resolveDdlType(config, dialect, snakeName), maxLength: null };
380
+ }
381
+ else if (keyed && config.type === 'TEXT' && !dialect.capabilities.unboundedTextKeys) {
382
+ // A keyed text / bytea column takes the dialect's bounded type
383
+ // (`VARCHAR(255)` / `NVARCHAR(255)`, `VARBINARY(255)`) where an unbounded
384
+ // one cannot be a key, or its own `maxLength` when it declares one.
385
+ typeInput = { type: 'VARCHAR', maxLength: config.maxLength };
386
+ }
387
+ else if (keyed && config.type === 'BYTEA' && !dialect.capabilities.unboundedTextKeys) {
388
+ typeInput = { type: 'VARBINARY', maxLength: config.maxLength };
389
+ }
390
+ else {
391
+ typeInput = { type: config.type, maxLength: config.maxLength };
392
+ }
393
+ const defaultValue = normalizedDefault === undefined
394
+ ? undefined
395
+ : dialectDefault(normalizedDefault, dialect, dialect.name === 'mysql' ? dialect.buildColumnType(typeInput) : '');
286
396
  let def = dialect.buildColumnDefinition({
287
397
  name: dialect.quoteIdentifier(snakeName),
288
- type: ddlType,
289
- maxLength: null,
398
+ type: typeInput.type,
399
+ maxLength: typeInput.maxLength,
290
400
  primaryKey: config.isPrimaryKey,
291
401
  unique: config.isUnique,
292
402
  notNull,
@@ -307,6 +417,10 @@ function generateColumnDef(fieldName, config, resolveRef, dialect = postgresDial
307
417
  if (config.check) {
308
418
  def += ` CHECK (${config.check})`;
309
419
  }
420
+ // The enum's labels, on an engine whose column type does not hold them.
421
+ if (enumColumn?.check) {
422
+ def += ` CHECK (${enumColumn.check})`;
423
+ }
310
424
  return def;
311
425
  }
312
426
  /**
@@ -333,15 +447,136 @@ function normalizeDefault(val) {
333
447
  if (/^-?\d+(\.\d+)?$/.test(val.trim())) {
334
448
  return val.trim();
335
449
  }
336
- // Simple single-quoted string literals (no semicolons, no SQL statement keywords)
337
- if (/^'[^']*'$/.test(val.trim())) {
338
- const inner = val.trim().slice(1, -1);
450
+ // Single-quoted string literals, a quote inside written doubled (`'it''s'`),
451
+ // optionally followed by a PostgreSQL cast as the catalog prints a default
452
+ // (`'x'::text`, `'{}'::jsonb`). The cast is dropped: the literal is assigned
453
+ // to the column, which converts it to the column's type on every engine,
454
+ // and the other engines have no `::`. No semicolons, no statement keywords.
455
+ const literal = defaultStringLiteral(val.trim());
456
+ if (literal !== null) {
457
+ const inner = literal.slice(1, -1);
339
458
  if (/[;]/.test(inner) || /\b(DROP|ALTER|CREATE|INSERT|UPDATE|DELETE|GRANT|REVOKE|TRUNCATE)\b/i.test(inner)) {
340
- throw new Error(`Suspicious default value: ${val}. String literals must not contain SQL statements.`);
459
+ throw new ValidationError(`Suspicious default value: ${val}. String literals must not contain SQL statements.`);
341
460
  }
342
- return val.trim();
461
+ return literal;
462
+ }
463
+ throw new ValidationError(`Unsupported default value: ${val}. Use a SQL function, numeric, string literal (a quote inside it written ` +
464
+ `twice, as in 'it''s'), or NULL.`);
465
+ }
466
+ /**
467
+ * The string literal a default is, with any trailing PostgreSQL cast removed,
468
+ * or null when it is not one. Both patterns are linear: `[^']` and `''` cannot
469
+ * match the same text, and a cast's words are separated by single spaces.
470
+ */
471
+ function defaultStringLiteral(val) {
472
+ const cast = /^('(?:[^']|'')*')::[a-z_][a-z0-9_]*(?: [a-z_][a-z0-9_]*)*(?:\(\d+(?:, ?\d+)?\))?(?:\[\])?$/i.exec(val);
473
+ if (cast)
474
+ return cast[1];
475
+ return /^'(?:[^']|'')*'$/.test(val) ? val : null;
476
+ }
477
+ /**
478
+ * MySQL column types that accept no literal default: a default has to be written
479
+ * as an expression, in parentheses (8.0.13+), even when its value is a constant.
480
+ */
481
+ const MYSQL_EXPRESSION_DEFAULT_TYPES = /^(TINY|MEDIUM|LONG)?(TEXT|BLOB)\b|^JSON\b/i;
482
+ /**
483
+ * A MySQL expression default for a string literal, as its UTF-8 bytes in hex:
484
+ * `(X'...')` on a BLOB column, `(CONVERT(X'...' USING utf8mb4))` on a TEXT or
485
+ * JSON one. `normalized` is a single-quoted literal with its quotes doubled.
486
+ */
487
+ function mysqlHexDefault(normalized, columnType) {
488
+ const value = normalized.slice(1, -1).replace(/''/g, "'");
489
+ let hex = '';
490
+ for (const byte of new TextEncoder().encode(value))
491
+ hex += byte.toString(16).padStart(2, '0');
492
+ return /BLOB/i.test(columnType) ? `(X'${hex}')` : `(CONVERT(X'${hex}' USING utf8mb4))`;
493
+ }
494
+ /** What a clock default means on a MySQL date or time column. */
495
+ function mysqlClockDefault(key, ddlType) {
496
+ const isClock = key === 'NOW()' || key === 'CURRENT_TIMESTAMP' || key === 'CURRENT_DATE' || key === 'CURRENT_TIME';
497
+ if (!isClock)
498
+ return null;
499
+ const datetime = /^(?:DATETIME|TIMESTAMP)(?:\((\d)\))?$/i.exec(ddlType);
500
+ if (datetime) {
501
+ // MySQL 8 rejects a default whose fractional precision differs from the
502
+ // column's, and a bare CURRENT_DATE / CURRENT_TIME is not a valid default
503
+ // for a DATETIME (only the parenthesized expression is).
504
+ if (key === 'NOW()' || key === 'CURRENT_TIMESTAMP') {
505
+ return datetime[1] ? `CURRENT_TIMESTAMP(${datetime[1]})` : 'CURRENT_TIMESTAMP';
506
+ }
507
+ return `(${key})`;
508
+ }
509
+ // DATE and TIME columns accept neither NOW() nor a bare CURRENT_*, so the
510
+ // default is the matching clock function as an expression.
511
+ if (/^DATE$/i.test(ddlType))
512
+ return '(CURRENT_DATE)';
513
+ if (/^TIME(?:\(\d\))?$/i.test(ddlType))
514
+ return '(CURRENT_TIME)';
515
+ return null;
516
+ }
517
+ /**
518
+ * The spelling of an already-normalized default for `dialect`. The schema
519
+ * vocabulary (`now()`, `true`, `gen_random_uuid()`) is Postgres's, and some of
520
+ * it is not SQL on the other engines: SQLite has no `NOW()` default, SQLite and
521
+ * MySQL have no `GEN_RANDOM_UUID()`, and SQL Server has none of `NOW()`, boolean
522
+ * literals or `CURRENT_DATE`. A literal (number, string, NULL) is the same
523
+ * everywhere and passes through, except on a MySQL TEXT, BLOB or JSON column,
524
+ * where it has to be an expression. `columnType` is the column's MySQL type as
525
+ * the dialect spells it (`DATETIME(3)`, `TEXT`); other dialects ignore it.
526
+ */
527
+ function dialectDefault(normalized, dialect, columnType = '') {
528
+ const key = normalized.toUpperCase();
529
+ switch (dialect.name) {
530
+ case 'sqlite':
531
+ if (key === 'NOW()')
532
+ return 'CURRENT_TIMESTAMP';
533
+ if (key === 'GEN_RANDOM_UUID()') {
534
+ return "(lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)), 2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))))";
535
+ }
536
+ return normalized;
537
+ case 'mysql': {
538
+ if (key === 'GEN_RANDOM_UUID()')
539
+ return '(UUID())';
540
+ const clock = mysqlClockDefault(key, columnType);
541
+ if (clock)
542
+ return clock;
543
+ // `DEFAULT NULL` is the one default these types accept as a literal.
544
+ const expression = key !== 'NULL' && MYSQL_EXPRESSION_DEFAULT_TYPES.test(columnType);
545
+ if (normalized.startsWith("'") && /['\\]/.test(normalized.slice(1, -1))) {
546
+ // A quote or a backslash inside the literal. Whether `\` escapes depends
547
+ // on the session's NO_BACKSLASH_ESCAPES, which Turbine's own connections
548
+ // set and other clients do not. An EXPRESSION default is worse: MySQL
549
+ // stores it re-escaped with backslashes and parses it again on every
550
+ // insert under the inserting session's mode, so `('it''s')` created
551
+ // fine and then failed every insert on a Turbine connection (measured
552
+ // on MySQL 8). Spelled as hex bytes, the value has no quote or
553
+ // backslash to read either way.
554
+ if (expression)
555
+ return mysqlHexDefault(normalized, columnType);
556
+ if (normalized.includes('\\')) {
557
+ throw new ValidationError(`The default ${normalized} holds a backslash, which MySQL reads differently with and without ` +
558
+ 'NO_BACKSLASH_ESCAPES. Use a default without one.');
559
+ }
560
+ }
561
+ if (expression)
562
+ return `(${normalized})`;
563
+ return normalized;
564
+ }
565
+ case 'mssql':
566
+ if (key === 'NOW()')
567
+ return 'CURRENT_TIMESTAMP';
568
+ if (key === 'GEN_RANDOM_UUID()')
569
+ return 'NEWID()';
570
+ if (key === 'TRUE')
571
+ return '1';
572
+ if (key === 'FALSE')
573
+ return '0';
574
+ if (key === 'CURRENT_DATE')
575
+ return '(CAST(SYSDATETIME() AS DATE))';
576
+ return normalized;
577
+ default:
578
+ return normalized;
343
579
  }
344
- throw new Error(`Unsupported default value: ${val}. Use a SQL function, numeric, string literal, or NULL.`);
345
580
  }
346
581
  /**
347
582
  * Generate CREATE INDEX statements for foreign key columns.
@@ -496,6 +731,16 @@ function generateDeclaredIndexes(table, dialect = postgresDialect) {
496
731
  }
497
732
  return out;
498
733
  }
734
+ /**
735
+ * The warnings in a diff that report a real difference between the database and
736
+ * the schema file: everything in `warnings` except the entries the diff marked
737
+ * as possibly cosmetic ({@link DiffResult.inexactWarnings}). `push --dry-run
738
+ * --exit-code` fails on these.
739
+ */
740
+ export function driftWarnings(diff) {
741
+ const inexact = new Set(diff.inexactWarnings ?? []);
742
+ return (diff.warnings ?? []).filter((w) => !inexact.has(w));
743
+ }
499
744
  /**
500
745
  * Build the `ADD CONSTRAINT ... FOREIGN KEY` statement for a FK with the given
501
746
  * referential actions. Default (`no action`) clauses are omitted, matching how
@@ -526,18 +771,49 @@ export function diffReferentialAction(table, db, schemaOnDelete, schemaOnUpdate,
526
771
  return { statements: [drop, add], reverseStatements: [drop, reverseAdd] };
527
772
  }
528
773
  /**
529
- * Compute append-only enum value changes. Returns `ALTER TYPE ... ADD VALUE`
530
- * statements for labels present in the schema but not the DB (in order), plus a
531
- * destructive warning for any DB label the schema dropped or any reorder -
532
- * Postgres cannot remove or reorder enum values without recreating the type.
774
+ * Compute the enum changes needed to make the database's labels match the
775
+ * schema's, KEEPING THE DECLARED ORDER.
776
+ *
777
+ * An enum's order is observable: it decides `ORDER BY`, `<` and `MIN`/`MAX` on
778
+ * the column. A new label therefore goes where the schema declares it, not
779
+ * wherever the end of the type happens to be: `ADD VALUE 'x' AFTER 'prev'`, or
780
+ * `BEFORE 'next'` when it leads. A label that belongs at the end emits the bare
781
+ * `ADD VALUE`, which is what every earlier version emitted for it.
782
+ *
783
+ * Postgres cannot remove or reorder values in place, so a database label the
784
+ * schema dropped, and a declared order the database does not already have, are
785
+ * warnings (never statements): the type has to be recreated by hand.
533
786
  */
534
787
  export function diffEnumValues(enumName, schemaLabels, dbLabels, dialect = postgresDialect) {
535
788
  const statements = [];
536
789
  const warnings = [];
537
- const dbSet = new Set(dbLabels);
538
- for (const label of schemaLabels) {
539
- if (!dbSet.has(label)) {
540
- statements.push(`ALTER TYPE ${dialect.quoteIdentifier(enumName)} ADD VALUE ${quoteEnumLabel(label)};`);
790
+ const type = dialect.quoteIdentifier(enumName);
791
+ // The type's label order as the statements below will leave it.
792
+ const order = [...dbLabels];
793
+ const schemaIndex = schemaLabels.map((label, i) => ({ label, i }));
794
+ for (const { label, i } of schemaIndex) {
795
+ if (order.includes(label))
796
+ continue;
797
+ const previous = i > 0 ? schemaLabels[i - 1] : undefined;
798
+ const next = schemaLabels.slice(i + 1).find((l) => order.includes(l));
799
+ const prevAt = previous === undefined ? -1 : order.indexOf(previous);
800
+ if (previous !== undefined && prevAt !== -1) {
801
+ if (prevAt === order.length - 1) {
802
+ statements.push(`ALTER TYPE ${type} ADD VALUE ${quoteEnumLabel(label)};`);
803
+ order.push(label);
804
+ }
805
+ else {
806
+ statements.push(`ALTER TYPE ${type} ADD VALUE ${quoteEnumLabel(label)} AFTER ${quoteEnumLabel(previous)};`);
807
+ order.splice(prevAt + 1, 0, label);
808
+ }
809
+ }
810
+ else if (next !== undefined) {
811
+ statements.push(`ALTER TYPE ${type} ADD VALUE ${quoteEnumLabel(label)} BEFORE ${quoteEnumLabel(next)};`);
812
+ order.splice(order.indexOf(next), 0, label);
813
+ }
814
+ else {
815
+ statements.push(`ALTER TYPE ${type} ADD VALUE ${quoteEnumLabel(label)};`);
816
+ order.push(label);
541
817
  }
542
818
  }
543
819
  const schemaSet = new Set(schemaLabels);
@@ -546,6 +822,14 @@ export function diffEnumValues(enumName, schemaLabels, dbLabels, dialect = postg
546
822
  warnings.push(`Enum "${enumName}": labels [${removed.join(', ')}] exist in the database but not the schema. ` +
547
823
  `Postgres cannot remove enum values in place, recreate the type manually if intended.`);
548
824
  }
825
+ // After the adds, the labels both sides share must already be in declared
826
+ // order; if not, no ADD VALUE can fix it.
827
+ const shared = order.filter((l) => schemaSet.has(l));
828
+ if (shared.some((label, i) => label !== schemaLabels[i])) {
829
+ warnings.push(`Enum "${enumName}": the schema declares the order [${schemaLabels.join(', ')}] but the database has ` +
830
+ `[${shared.join(', ')}]. Postgres cannot reorder enum values in place, recreate the type manually if the ` +
831
+ `order matters (it decides ORDER BY and comparisons on the column).`);
832
+ }
549
833
  return { statements, warnings };
550
834
  }
551
835
  /**
@@ -737,7 +1021,17 @@ export async function schemaDiff(schema, connectionString, options = {}) {
737
1021
  const schemaDdlNames = new Set();
738
1022
  for (const def of Object.values(schema.tables))
739
1023
  schemaDdlNames.add(def.name);
740
- const result = { create: [], alter: [], drop: [], statements: [], reverseStatements: [], warnings: [] };
1024
+ const result = {
1025
+ create: [],
1026
+ alter: [],
1027
+ drop: [],
1028
+ statements: [],
1029
+ reverseStatements: [],
1030
+ warnings: [],
1031
+ inexactWarnings: [],
1032
+ widenings: [],
1033
+ notCompared: [],
1034
+ };
741
1035
  const resolveRef = makeRefResolver(schema);
742
1036
  // --- Enums: CREATE TYPE for new enums (before tables), ADD VALUE for grown
743
1037
  // ones, and a warning for any destructive removal/reorder. New CREATE
@@ -771,9 +1065,10 @@ export async function schemaDiff(schema, connectionString, options = {}) {
771
1065
  result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${dialect.quoteIdentifier(ddlName)} CASCADE;`);
772
1066
  }
773
1067
  }
774
- // Tables to drop (in DB but not in schema)
1068
+ // Tables to drop (in DB but not in schema). Turbine's own bookkeeping tables
1069
+ // (`_turbine_migrations`, ...) are never the user's to drop.
775
1070
  for (const existingTable of existingTables) {
776
- if (!schemaDdlNames.has(existingTable)) {
1071
+ if (!schemaDdlNames.has(existingTable) && !existingTable.startsWith('_turbine_')) {
777
1072
  result.drop.push(existingTable);
778
1073
  // We don't auto-generate DROP statements for safety
779
1074
  }
@@ -787,6 +1082,7 @@ export async function schemaDiff(schema, connectionString, options = {}) {
787
1082
  const dbCols = dbColumns[tableName] ?? {};
788
1083
  const tableUniques = dbUniques[tableName] ?? {};
789
1084
  const alterDef = { table: tableName, columns: [] };
1085
+ const sequenceDefaults = [];
790
1086
  for (const [fieldName, config] of Object.entries(tableDef.columns)) {
791
1087
  const snakeName = camelToSnake(fieldName);
792
1088
  const dbCol = dbCols[snakeName];
@@ -798,6 +1094,13 @@ export async function schemaDiff(schema, connectionString, options = {}) {
798
1094
  alterDef.columns.push({ column: snakeName, action: 'add', sql, reverseSql });
799
1095
  result.statements.push(sql);
800
1096
  result.reverseStatements.unshift(reverseSql);
1097
+ // NOT NULL with nothing to fill existing rows with: Postgres runs the
1098
+ // statement fine on an empty table and rejects it on any other.
1099
+ if ((config.isNotNull || config.isPrimaryKey) && !isSerialType(config.type) && config.defaultValue == null) {
1100
+ result.warnings.push(`column "${tableName}"."${snakeName}" is added NOT NULL with no default: the ALTER fails on a table ` +
1101
+ `that already has rows. Give it a default, add it nullable first and backfill it, or scaffold the ` +
1102
+ `two-phase pattern with \`npx turbine migrate create <name> --recipe backfill\`.`);
1103
+ }
801
1104
  continue;
802
1105
  }
803
1106
  // Check type mismatch. Serial/bigserial columns are exempt: their
@@ -841,6 +1144,9 @@ export async function schemaDiff(schema, connectionString, options = {}) {
841
1144
  alterDef.columns.push({ column: snakeName, action: 'alter_type', sql, reverseSql });
842
1145
  result.statements.push(sql);
843
1146
  result.reverseStatements.unshift(reverseSql);
1147
+ if (!typePlan.needsUsing && !typePlan.loss && isLosslessWidening(oldSqlType, sqlType)) {
1148
+ result.widenings.push({ statement: sql, from: oldSqlType, to: sqlType });
1149
+ }
844
1150
  // The destructive gate sees the statement and refuses by default; its
845
1151
  // label is necessarily generic ("cast may truncate or fail"). This
846
1152
  // warning names what THIS conversion does to THESE rows.
@@ -870,6 +1176,8 @@ export async function schemaDiff(schema, connectionString, options = {}) {
870
1176
  if (!isSerial) {
871
1177
  const schemaDefault = config.defaultValue ? normalizeDefault(config.defaultValue) : null;
872
1178
  const dbDefault = dbCol.columnDefault;
1179
+ if (dbDefault && isSequenceDefault(dbDefault))
1180
+ sequenceDefaults.push(snakeName);
873
1181
  if (schemaDefault && !dbDefault) {
874
1182
  // Schema has default, DB doesn't
875
1183
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${dialect.quoteIdentifier(snakeName)} SET DEFAULT ${schemaDefault};`;
@@ -927,18 +1235,23 @@ export async function schemaDiff(schema, connectionString, options = {}) {
927
1235
  // gate, not by hiding the statement: withholding it here previously made
928
1236
  // `push` report "already in sync" for a destructive-only diff and print a
929
1237
  // false "Altered" for a mixed one, since the 0.36 guard never received it.
930
- // No reverse is emitted (a dropped column and its data cannot be recreated
931
- // automatically), so diff-generated migrations fall back to the documented
932
- // irreversible-DOWN placeholder for this change.
1238
+ // A dropped column and its data cannot be recreated automatically, so the
1239
+ // reverse is irreversibleDropPlaceholder: an IRREVERSIBLE note in the DOWN
1240
+ // section describing the column, never SQL that pretends to restore it.
933
1241
  for (const dbColName of Object.keys(dbCols)) {
934
1242
  const hasField = Object.entries(tableDef.columns).some(([fieldName]) => camelToSnake(fieldName) === dbColName);
935
1243
  if (!hasField) {
936
1244
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} DROP COLUMN ${dialect.quoteIdentifier(dbColName)};`;
937
- const reverseSql = `-- Cannot auto-reverse DROP COLUMN for "${dbColName}"; add it back manually`;
1245
+ const reverseSql = irreversibleDropPlaceholder(tableName, dbColName, dbCols[dbColName], dialect);
938
1246
  alterDef.columns.push({ column: dbColName, action: 'drop', sql, reverseSql });
939
1247
  result.statements.push(sql);
1248
+ result.reverseStatements.unshift(reverseSql);
940
1249
  }
941
1250
  }
1251
+ if (sequenceDefaults.length > 0) {
1252
+ result.notCompared.push(`sequence-backed defaults on "${tableName}" (${sequenceDefaults.join(', ')}): the database default is a ` +
1253
+ `sequence, which is never compared or changed`);
1254
+ }
942
1255
  if (alterDef.columns.length > 0) {
943
1256
  result.alter.push(alterDef);
944
1257
  }
@@ -975,6 +1288,17 @@ export async function schemaDiff(schema, connectionString, options = {}) {
975
1288
  .map((c) => ({ name: c.name, expression: c.expression }));
976
1289
  const tableDbChecks = dbChecks[tableName] ?? [];
977
1290
  const dbCheckByName = new Map(tableDbChecks.map((c) => [c.name, c]));
1291
+ const namedCheckNames = new Set(namedSchemaChecks.map((c) => c.name));
1292
+ const unmatchedChecks = tableDbChecks.filter((c) => !namedCheckNames.has(c.name)).map((c) => c.name);
1293
+ if (unmatchedChecks.length > 0) {
1294
+ result.notCompared.push(`CHECK constraints on "${tableName}" (${unmatchedChecks.join(', ')}): the schema does not name them, so ` +
1295
+ `they are not compared and never dropped`);
1296
+ }
1297
+ const unnamedSchemaChecks = (tableDef.checks ?? []).filter((c) => !c.name).length;
1298
+ if (unnamedSchemaChecks > 0) {
1299
+ result.notCompared.push(`${unnamedSchemaChecks} unnamed CHECK constraint(s) declared on "${tableName}": with no name there is nothing ` +
1300
+ `to match them against the database`);
1301
+ }
978
1302
  const normExpr = (e) => e.replace(/\s+/g, ' ').trim();
979
1303
  for (const sc of namedSchemaChecks) {
980
1304
  const existing = dbCheckByName.get(sc.name);
@@ -987,9 +1311,11 @@ export async function schemaDiff(schema, connectionString, options = {}) {
987
1311
  result.reverseStatements.unshift(dropSql);
988
1312
  }
989
1313
  else if (normExpr(existing.expression) !== normExpr(sc.expression)) {
990
- result.warnings.push(`check constraint "${sc.name}" on "${tableName}": stored expression differs from the schema text ` +
1314
+ const checkWarning = `check constraint "${sc.name}" on "${tableName}": stored expression differs from the schema text ` +
991
1315
  `(Postgres canonicalizes CHECK expressions, so this is usually cosmetic). ` +
992
- `To intentionally replace it, rename the constraint or drop/re-add it in a manual migration.`);
1316
+ `To intentionally replace it, rename the constraint or drop/re-add it in a manual migration.`;
1317
+ result.warnings.push(checkWarning);
1318
+ result.inexactWarnings.push(checkWarning);
993
1319
  }
994
1320
  }
995
1321
  // --- User-declared indexes (TableDef.indexes) ---
@@ -1006,6 +1332,18 @@ export async function schemaDiff(schema, connectionString, options = {}) {
1006
1332
  // worth surfacing. Deleting the last declared index must NOT silence the
1007
1333
  // warning pass. Tables with no `indexes` key stay silent (not managed).
1008
1334
  const declaredPlain = (tableDef.indexes ?? []).filter((i) => !isDocFieldIndexDef(i));
1335
+ if (tableDef.indexes === undefined) {
1336
+ const dbIdxNames = [...(dbIndexes[tableName]?.keys() ?? [])];
1337
+ const recognized = new Set(Object.values(dbUniques[tableName] ?? {}));
1338
+ for (const [fieldName, config] of Object.entries(tableDef.columns)) {
1339
+ if (config.referencesTarget)
1340
+ recognized.add(`idx_${tableName}_${camelToSnake(fieldName)}`);
1341
+ }
1342
+ const unmanaged = dbIdxNames.filter((n) => !recognized.has(n) && !n.endsWith('_pkey'));
1343
+ if (unmanaged.length > 0) {
1344
+ result.notCompared.push(`indexes on "${tableName}" (${unmanaged.join(', ')}): the table declares no \`indexes\`, so they are not compared`);
1345
+ }
1346
+ }
1009
1347
  if (tableDef.indexes !== undefined) {
1010
1348
  const dbIdx = dbIndexes[tableName] ?? new Map();
1011
1349
  const dbIdxNames = new Set(dbIdx.keys());
@@ -1054,12 +1392,34 @@ export async function schemaDiff(schema, connectionString, options = {}) {
1054
1392
  }
1055
1393
  }
1056
1394
  }
1395
+ result.notCompared.push(NEVER_COMPARED);
1057
1396
  return result;
1058
1397
  }
1059
1398
  finally {
1060
1399
  await client.end();
1061
1400
  }
1062
1401
  }
1402
+ /**
1403
+ * What {@link schemaDiff} never looks at, whatever the database holds. Kept
1404
+ * beside the diff so the sentence cannot outlive the code it describes.
1405
+ */
1406
+ export const NEVER_COMPARED = 'primary keys, multi-column UNIQUE constraints, and whether a foreign key exists on an existing column ' +
1407
+ '(only the referential actions of foreign keys that already exist are compared)';
1408
+ /**
1409
+ * The DOWN-side stand-in for a `DROP COLUMN`: a comment block, because the data
1410
+ * the column held cannot be put back by any statement. It names the column and
1411
+ * its old type so the person writing the real rollback has the facts, and it is
1412
+ * a comment rather than SQL so a rollback never invents a column of NULLs and
1413
+ * calls it restored.
1414
+ */
1415
+ function irreversibleDropPlaceholder(table, column, dbCol, dialect = postgresDialect) {
1416
+ const q = (name) => dialect.quoteIdentifier(name);
1417
+ return [
1418
+ `-- IRREVERSIBLE: this migration drops ${q(table)}.${q(column)}, and the data in it cannot be restored automatically.`,
1419
+ '-- To roll back, restore the data from a backup and re-add the column by hand, for example:',
1420
+ `-- ALTER TABLE ${q(table)} ADD COLUMN ${q(column)} ${udtToSqlType(dbCol)};`,
1421
+ ].join('\n');
1422
+ }
1063
1423
  /**
1064
1424
  * Rough type families. Within a family Postgres has an ASSIGNMENT cast between
1065
1425
  * every pair the schema builder can produce, which is what lets the diff emit a
@@ -1348,6 +1708,89 @@ export function planTypeChange(config, dbCol, column) {
1348
1708
  }
1349
1709
  return { kind: 'none' };
1350
1710
  }
1711
+ const WIDENING_BASES = {
1712
+ VARCHAR: 'varchar',
1713
+ 'CHARACTER VARYING': 'varchar',
1714
+ TEXT: 'text',
1715
+ SMALLINT: 'int2',
1716
+ INT2: 'int2',
1717
+ INTEGER: 'int4',
1718
+ INT: 'int4',
1719
+ INT4: 'int4',
1720
+ BIGINT: 'int8',
1721
+ INT8: 'int8',
1722
+ NUMERIC: 'numeric',
1723
+ DECIMAL: 'numeric',
1724
+ };
1725
+ /** Digits an integer type can hold, for the integer-to-numeric(p, s) rule. */
1726
+ const INTEGER_DIGITS = { int2: 5, int4: 10, int8: 19 };
1727
+ /** Parse a SQL type string, or null for any type outside the widening vocabulary. */
1728
+ function parseSqlType(raw) {
1729
+ const m = /^([A-Za-z][A-Za-z0-9 ]*?)\s*(?:\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\))?\s*(\[\])?$/.exec(raw.trim().replace(/\s+/g, ' '));
1730
+ if (!m)
1731
+ return null;
1732
+ const base = WIDENING_BASES[m[1].toUpperCase()];
1733
+ if (!base)
1734
+ return null;
1735
+ const a = m[2] === undefined ? null : Number(m[2]);
1736
+ const b = m[3] === undefined ? (a === null ? null : 0) : Number(m[3]);
1737
+ if ((base === 'text' || base.startsWith('int')) && a !== null)
1738
+ return null;
1739
+ if (base === 'varchar' && m[3] !== undefined)
1740
+ return null;
1741
+ return { base, a, b: base === 'numeric' ? b : null, array: m[4] !== undefined };
1742
+ }
1743
+ /**
1744
+ * True when changing a column from `from` to `to` can never lose, round,
1745
+ * truncate or reject a value: every value of the old type is representable
1746
+ * unchanged in the new one. The vocabulary is deliberately small, and anything
1747
+ * outside it answers false (the destructive gate then treats the statement as
1748
+ * it always has):
1749
+ *
1750
+ * - `varchar(n)` to `varchar(m)` with m >= n, to bare `varchar`, to `text`
1751
+ * - `smallint` to `integer` to `bigint`
1752
+ * - any integer type to `numeric`, or to `numeric(p, s)` when it still holds
1753
+ * the integer's digits
1754
+ * - `numeric(p, s)` to a numeric with at least as many integer digits and at
1755
+ * least as many decimal places (a bare `numeric` has no bound)
1756
+ * - arrays of the above, element by element
1757
+ *
1758
+ * Not a widening, on purpose: `text` to `varchar(n)`, anything to a smaller
1759
+ * type, `timestamp` to `timestamptz` (re-reads the value in the session zone),
1760
+ * `real` to `double precision` (the stored digits change when printed), and
1761
+ * `json` to `jsonb` (drops duplicate keys).
1762
+ */
1763
+ export function isLosslessWidening(from, to) {
1764
+ const f = parseSqlType(from);
1765
+ const t = parseSqlType(to);
1766
+ if (!f || !t || f.array !== t.array)
1767
+ return false;
1768
+ if (f.base === 'varchar') {
1769
+ if (t.base === 'text')
1770
+ return true;
1771
+ if (t.base !== 'varchar')
1772
+ return false;
1773
+ return t.a === null || (f.a !== null && t.a >= f.a);
1774
+ }
1775
+ if (f.base === 'text')
1776
+ return t.base === 'text';
1777
+ if (f.base === 'numeric') {
1778
+ if (t.base !== 'numeric')
1779
+ return false;
1780
+ if (t.a === null)
1781
+ return true;
1782
+ if (f.a === null)
1783
+ return false;
1784
+ return t.b >= f.b && t.a - t.b >= f.a - f.b;
1785
+ }
1786
+ // Integer source.
1787
+ const digits = INTEGER_DIGITS[f.base];
1788
+ if (t.base === 'numeric')
1789
+ return t.a === null || t.a - t.b >= digits;
1790
+ if (!t.base.startsWith('int'))
1791
+ return false;
1792
+ return INTEGER_DIGITS[t.base] >= digits;
1793
+ }
1351
1794
  /**
1352
1795
  * Map a schema column type to its expected PostgreSQL UDT name.
1353
1796
  */
@@ -1471,10 +1914,17 @@ function defaultsMatch(schemaDefault, dbDefault) {
1471
1914
  * `ALTER COLUMN ... TYPE` cast, exactly the kind of silent data loss `push`
1472
1915
  * must never apply without an explicit opt-in.
1473
1916
  */
1474
- export function findDestructivePushStatements(statements) {
1917
+ export function findDestructivePushStatements(statements, widenings = []) {
1918
+ // A lossless widening is the one `ALTER COLUMN ... TYPE` that destroys nothing.
1919
+ // It is skipped by statement identity, so a hand-edited or look-alike
1920
+ // statement is still scanned.
1921
+ const lossless = new Set(widenings.map((w) => w.statement));
1475
1922
  const hits = [];
1476
- for (const stmt of statements)
1923
+ for (const stmt of statements) {
1924
+ if (lossless.has(stmt))
1925
+ continue;
1477
1926
  hits.push(...scanDestructiveSql(stmt));
1927
+ }
1478
1928
  return hits;
1479
1929
  }
1480
1930
  /**
@@ -1613,7 +2063,7 @@ export async function schemaPush(schema, connectionString, options = {}) {
1613
2063
  }
1614
2064
  // Destructive-statement gate: refuse silent data loss unless opted in.
1615
2065
  if (!options.allowDestructive) {
1616
- const destructive = findDestructivePushStatements(diff.statements);
2066
+ const destructive = findDestructivePushStatements(diff.statements, diff.widenings);
1617
2067
  if (destructive.length > 0) {
1618
2068
  throw new DestructivePushRefusal(destructive);
1619
2069
  }