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
package/dist/cli/index.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * npx turbine init --url postgres://...
26
26
  * npx turbine migrate create add_users_table
27
27
  */
28
- import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
28
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
29
29
  import { tmpdir } from 'node:os';
30
30
  import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
31
31
  import { pathToFileURL } from 'node:url';
@@ -35,24 +35,28 @@ import { ValidationError } from '../errors.js';
35
35
  import { generate, generatePrismaMap, resolveImportExtension } from '../generate.js';
36
36
  import { buildCreateIndexSql, buildCreateIndexStatements, buildDropIndexSql, collectDoctorProbeIndexNames, collectRelationProbeColumns, findMissingRelationIndexes, } from '../index-advisor.js';
37
37
  import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
38
- import { introspect } from '../introspect.js';
38
+ import { applyTableFilters, introspect, printableCatalogName } from '../introspect.js';
39
39
  import { collectDivergenceCandidateColumns, collectDivergenceOrderColumns, findPlanDivergence, PLAN_DIVERGENCE_THRESHOLDS, } from '../plan-divergence.js';
40
40
  import { applyFlipVerdicts, emptyFlipProbeResult, needsFlipProbe, probePlanFlips } from '../plan-flip-probe.js';
41
41
  import { fingerprintPrismaSchema } from '../prisma-schema-fingerprint.js';
42
42
  import { closestName } from '../query/utils.js';
43
43
  import { snakeToCamel } from '../schema.js';
44
- import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
44
+ import { DestructivePushRefusal, driftWarnings, schemaDiff, schemaPush } from '../schema-sql.js';
45
+ import { applyCodeFirstColumnTags, extractCodeFirstColumnTags } from './code-first-tags.js';
45
46
  import { configTemplate, connectionStringHasPassword, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
46
47
  import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
48
+ import { errorText } from './error-text.js';
47
49
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
48
50
  import { runMcpServer } from './mcp.js';
49
- import { assertNoEmbeddedTransactions, assertPinnableSchema, assertSchemaExists, assertUpHasStatements, buildDiffMigrationBody, collectUpDestructive, connectionStringForSchema, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listIgnoredSqlFiles, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, parseMigrationContent, planMigrationRollback, } from './migrate.js';
51
+ import { assertNoEmbeddedTransactions, assertPinnableSchema, assertSchemaExists, assertUpHasStatements, buildDiffMigrationBody, collectUpDestructive, connectionStringForSchema, createMigration, formatChecksumMismatchError, formatPendingAutoRefusal, inspectMigrationDeploy, listIgnoredSqlFiles, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, parseMigrationContent, pendingMigrationFiles, planMigrationRollback, rollbacksWithoutSql, statusGateProblems, summarizeMigrationStatus, } from './migrate.js';
50
52
  import { startObserve } from './observe.js';
51
53
  import { formatPrismaReport, summaryLines } from './prisma-report.js';
52
54
  import { DEFAULT_EXCLUDED_TABLES, resolvePrismaSchema } from './prisma-resolve.js';
53
55
  import { PrismaParseError, parsePrismaSchema, resolvePrismaDatasourceUrl, } from './prisma-schema.js';
54
56
  import { startStudio } from './studio.js';
55
57
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, errorBanner, errorLine, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
58
+ /** How long a bare `migrate deploy --wait` waits for a concurrent deploy to finish. */
59
+ export const DEFAULT_LOCK_WAIT_SECONDS = 300;
56
60
  /**
57
61
  * Fail an argument-parse error the way every other CLI failure looks: banner,
58
62
  * red error line, then indented hint lines. The flag validators used to call
@@ -107,6 +111,7 @@ const GLOBAL_FLAGS = [
107
111
  const COMMAND_FLAGS = {
108
112
  init: ['--yes', '--schema-file', '--skip-schema', '--with-schema', '--skip-seed', '--skip-push', '--skip-generate'],
109
113
  generate: [
114
+ '--schema-file',
110
115
  '--zod',
111
116
  '--include-views',
112
117
  '--no-timestamp',
@@ -116,7 +121,7 @@ const COMMAND_FLAGS = {
116
121
  '--allow-empty',
117
122
  ],
118
123
  'migrate-from-prisma': ['--no-db', '--if-db', '--allow-partial', '--no-timestamp'],
119
- push: ['--schema-file', '--dry-run', '--allow-destructive'],
124
+ push: ['--schema-file', '--dry-run', '--exit-code', '--allow-destructive'],
120
125
  migrate: [
121
126
  '--schema-file',
122
127
  '--auto',
@@ -124,8 +129,12 @@ const COMMAND_FLAGS = {
124
129
  '--recipe',
125
130
  '--step',
126
131
  '--dry-run',
132
+ '--exit-code',
133
+ '--wait',
127
134
  '--allow-drift',
128
135
  '--allow-destructive',
136
+ '--allow-pending',
137
+ '--allow-pooler',
129
138
  ],
130
139
  seed: [],
131
140
  status: [],
@@ -303,6 +312,8 @@ export function parseArgs(argv = process.argv.slice(2)) {
303
312
  const unknownFlags = [];
304
313
  /** The raw `--step` operand, judged after the loop. Absent means unset. */
305
314
  let stepRaw;
315
+ /** The raw `--wait` operand, judged after the loop: undefined absent, null bare `--wait`. */
316
+ let waitRaw;
306
317
  for (; i < args.length; i++) {
307
318
  const arg = args[i];
308
319
  const next = args[i + 1];
@@ -310,8 +321,15 @@ export function parseArgs(argv = process.argv.slice(2)) {
310
321
  // (`--step -1`, `--host -x`) is consumed by its own case and never reaches
311
322
  // this test. A bare `-`-prefixed token that is not a flag reaches it and is
312
323
  // rejected: no command takes a negative-number positional.
313
- if (accepted && arg.startsWith('-') && !accepted.has(arg))
324
+ // `--wait=30` is the one flag whose value attaches with `=`, so it is
325
+ // matched by its name and handled here rather than by a `case` label.
326
+ const attachedWait = arg.startsWith('--wait=');
327
+ if (accepted && arg.startsWith('-') && !accepted.has(attachedWait ? '--wait' : arg))
314
328
  unknownFlags.push(arg);
329
+ if (attachedWait) {
330
+ waitRaw = arg.slice('--wait='.length);
331
+ continue;
332
+ }
315
333
  switch (arg) {
316
334
  case '--url':
317
335
  case '-u':
@@ -355,6 +373,23 @@ export function parseArgs(argv = process.argv.slice(2)) {
355
373
  case '--dry-run':
356
374
  result.dryRun = true;
357
375
  break;
376
+ case '--exit-code':
377
+ result.exitCode = true;
378
+ break;
379
+ case '--allow-pending':
380
+ result.allowPending = true;
381
+ break;
382
+ case '--wait':
383
+ // `--wait 30` reads the next token only when it is all digits: `deploy`
384
+ // takes no positional, so a bare number after the flag is its value.
385
+ if (next !== undefined && /^\d+$/.test(next)) {
386
+ waitRaw = next;
387
+ i++;
388
+ }
389
+ else {
390
+ waitRaw = null;
391
+ }
392
+ break;
358
393
  case '--auto':
359
394
  result.auto = true;
360
395
  break;
@@ -437,8 +472,8 @@ export function parseArgs(argv = process.argv.slice(2)) {
437
472
  break;
438
473
  case '--import-ext':
439
474
  case '--import-extension':
440
- if (next !== 'js' && next !== 'none' && next !== 'auto') {
441
- failArg(`${cyan('--import-ext')} requires one of: js, none, auto ${dim(`(got ${next ?? 'nothing'})`)}`, 'Example: npx turbine generate --import-ext none');
475
+ if (next !== 'js' && next !== 'ts' && next !== 'none' && next !== 'auto') {
476
+ failArg(`${cyan('--import-ext')} requires one of: js, ts, none, auto ${dim(`(got ${next ?? 'nothing'})`)}`, 'Example: npx turbine generate --import-ext ts');
442
477
  }
443
478
  result.importExtension = next;
444
479
  i++;
@@ -529,6 +564,18 @@ export function parseArgs(argv = process.argv.slice(2)) {
529
564
  }
530
565
  result.step = parsed;
531
566
  }
567
+ if (waitRaw !== undefined && accepted !== undefined) {
568
+ if (waitRaw === null) {
569
+ result.wait = DEFAULT_LOCK_WAIT_SECONDS;
570
+ }
571
+ else {
572
+ const parsed = /^\d+$/.test(waitRaw.trim()) ? Number.parseInt(waitRaw, 10) : Number.NaN;
573
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
574
+ failArg(`${cyan('--wait')} expects a positive whole number of seconds, got ${bold(waitRaw === '' ? '(nothing)' : `"${waitRaw}"`)}.`, `Bare ${'--wait'} waits ${DEFAULT_LOCK_WAIT_SECONDS} seconds. To not wait at all, leave the flag off.`, 'Example: npx turbine migrate deploy --wait=120');
575
+ }
576
+ result.wait = parsed;
577
+ }
578
+ }
532
579
  return result;
533
580
  }
534
581
  // ---------------------------------------------------------------------------
@@ -727,6 +774,11 @@ export function loadDotEnvForCli(deps = {}) {
727
774
  if (!fileExists(envPath)) {
728
775
  return { fileExists: false, loaded: false, unsupported: false, databaseUrlProvenance: shellOrNone };
729
776
  }
777
+ // An exported-but-empty `DATABASE_URL=` means "not configured", and the loader
778
+ // never overrides a variable that is already set, so it would otherwise block
779
+ // the value in the `.env` for good.
780
+ if (env.DATABASE_URL === '')
781
+ delete env.DATABASE_URL;
730
782
  const loader = deps.loadEnvFile !== undefined
731
783
  ? deps.loadEnvFile
732
784
  : typeof process.loadEnvFile === 'function'
@@ -744,7 +796,7 @@ export function loadDotEnvForCli(deps = {}) {
744
796
  loaded: false,
745
797
  unsupported: false,
746
798
  databaseUrlProvenance: shellOrNone,
747
- loadError: err instanceof Error ? err.message : String(err),
799
+ loadError: errorText(err),
748
800
  };
749
801
  }
750
802
  // `.env`-sourced only if DATABASE_URL was absent before and present after.
@@ -1143,42 +1195,94 @@ DATABASE_URL=postgres://user:password@localhost:5432/database
1143
1195
  * @internal exported for tests.
1144
1196
  */
1145
1197
  export function scaffoldEnvForUrl(url) {
1198
+ // The plan is read from disk and the writes come after it, so a file can
1199
+ // appear in between. Creation is exclusive (see createFileExclusive); when it
1200
+ // loses, plan again from what is there now. The cap only ends the loop for a
1201
+ // name that cannot be read but cannot be created either, a dangling symlink.
1202
+ for (let attempt = 0; attempt < 3; attempt++) {
1203
+ const plan = applyEnvScaffold(url);
1204
+ if (plan)
1205
+ return plan;
1206
+ }
1207
+ throw new ValidationError('Could not write .env, .env.example and .gitignore: one of those names is taken by something that cannot be read ' +
1208
+ '(a dangling symlink?). Remove it and run the command again.');
1209
+ }
1210
+ /** One planning pass plus its writes; undefined when a create found its name already taken. */
1211
+ function applyEnvScaffold(url) {
1146
1212
  const envPath = '.env';
1147
1213
  const envExamplePath = '.env.example';
1148
1214
  const gitignorePath = '.gitignore';
1149
- const envExists = existsSync(envPath);
1215
+ const env = readIfPresent(envPath);
1216
+ const gitignore = readIfPresent(gitignorePath);
1150
1217
  const plan = planEnvScaffold({
1151
- envExists,
1152
- envHasDatabaseUrl: envExists ? /^\s*(export\s+)?DATABASE_URL\s*=/m.test(readFileSync(envPath, 'utf-8')) : false,
1153
- envExampleExists: existsSync(envExamplePath),
1154
- gitignoreExists: existsSync(gitignorePath),
1155
- gitignoreIgnoresEnv: existsSync(gitignorePath) ? gitignoreIgnoresEnv(readFileSync(gitignorePath, 'utf-8')) : false,
1218
+ envExists: env !== undefined,
1219
+ envHasDatabaseUrl: env !== undefined && /^\s*(export\s+)?DATABASE_URL\s*=/m.test(env),
1220
+ envExampleExists: readIfPresent(envExamplePath) !== undefined,
1221
+ gitignoreExists: gitignore !== undefined,
1222
+ gitignoreIgnoresEnv: gitignore !== undefined && gitignoreIgnoresEnv(gitignore),
1156
1223
  });
1157
1224
  // .gitignore FIRST: the ignore rule has to exist before the file holding the
1158
1225
  // password does, or a `git add -A` in between commits it.
1159
- if (plan.gitignore === 'created')
1160
- writeFileSync(gitignorePath, '# Local environment (secrets)\n.env\n', 'utf-8');
1161
- else if (plan.gitignore === 'appended')
1226
+ if (plan.gitignore === 'created') {
1227
+ if (!createFileExclusive(gitignorePath, '# Local environment (secrets)\n.env\n'))
1228
+ return undefined;
1229
+ }
1230
+ else if (plan.gitignore === 'appended') {
1162
1231
  appendFileSync(gitignorePath, '\n# Local environment (secrets)\n.env\n');
1232
+ }
1233
+ // 0600 on CREATION only. The whole point of this scaffold is moving a live
1234
+ // password out of a committed file, and the default 0644 would leave it
1235
+ // world-readable to every account on a shared box or CI runner. `mode` is
1236
+ // applied by the OS only when the open() actually creates the file, so a
1237
+ // `.env` that exists keeps whatever mode its owner chose.
1163
1238
  if (plan.env === 'created') {
1164
- // 0600 on CREATION only. The whole point of this scaffold is moving a live
1165
- // password out of a committed file, and the default 0644 would leave it
1166
- // world-readable to every account on a shared box or CI runner. `mode` is
1167
- // applied by the OS only when the open() actually creates the file, so if
1168
- // one appeared since the existsSync above we write into it without widening
1169
- // or narrowing whatever mode its owner chose.
1170
- writeFileSync(envPath, `# Turbine database connection. Never commit this file.\nDATABASE_URL=${url}\n`, {
1171
- encoding: 'utf-8',
1172
- mode: 0o600,
1173
- });
1239
+ const body = `# Turbine database connection. Never commit this file.\nDATABASE_URL=${url}\n`;
1240
+ if (!createFileExclusive(envPath, body, 0o600))
1241
+ return undefined;
1174
1242
  }
1175
1243
  else if (plan.env === 'appended') {
1176
- appendFileSync(envPath, `\n# Added by turbine init\nDATABASE_URL=${url}\n`);
1244
+ appendFileSync(envPath, `\n# Added by turbine init\nDATABASE_URL=${url}\n`, { mode: 0o600 });
1177
1245
  }
1178
- if (plan.envExample === 'created')
1179
- writeFileSync(envExamplePath, ENV_EXAMPLE_TEMPLATE, 'utf-8');
1246
+ if (plan.envExample === 'created' && !createFileExclusive(envExamplePath, ENV_EXAMPLE_TEMPLATE))
1247
+ return undefined;
1180
1248
  return plan;
1181
1249
  }
1250
+ /**
1251
+ * A file's text, or undefined when there is no such file. One read in place of
1252
+ * an existence check followed by a read, so there is no window between the two.
1253
+ * A directory under that name counts as present: the write step then fails on
1254
+ * it by name, which is the error that was always raised.
1255
+ */
1256
+ function readIfPresent(path) {
1257
+ try {
1258
+ return readFileSync(path, 'utf-8');
1259
+ }
1260
+ catch (err) {
1261
+ const code = err.code;
1262
+ if (code === 'ENOENT')
1263
+ return undefined;
1264
+ if (code === 'EISDIR')
1265
+ return '';
1266
+ throw err;
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Create `path` only if nothing is there; false when the name is taken.
1271
+ * `wx` is O_CREAT | O_EXCL: the kernel refuses (EEXIST) any existing name,
1272
+ * a symlink included, so a file that appeared after the plan was made is never
1273
+ * truncated and a link planted at the name is never written through.
1274
+ */
1275
+ function createFileExclusive(path, body, mode) {
1276
+ try {
1277
+ writeFileSync(path, body, { encoding: 'utf-8', flag: 'wx', ...(mode === undefined ? {} : { mode }) });
1278
+ return true;
1279
+ }
1280
+ catch (err) {
1281
+ if (err.code === 'EEXIST')
1282
+ return false;
1283
+ throw err;
1284
+ }
1285
+ }
1182
1286
  /** Say exactly what was written where, so nobody has to guess where the password went. */
1183
1287
  function reportEnvScaffold(plan, url) {
1184
1288
  warn(`The connection string passed to ${cyan('--url')} contains a password.`);
@@ -1214,8 +1318,8 @@ function ensureInitScaffoldDirs(config) {
1214
1318
  success(`Created ${cyan(`${config.out}/`)}`);
1215
1319
  }
1216
1320
  const gitignorePath = '.gitignore';
1217
- if (existsSync(gitignorePath)) {
1218
- const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
1321
+ const gitignoreContent = readIfPresent(gitignorePath);
1322
+ if (gitignoreContent !== undefined) {
1219
1323
  const additions = [];
1220
1324
  if (!gitignoreContent.includes('generated/turbine'))
1221
1325
  additions.push('generated/turbine/');
@@ -1321,8 +1425,19 @@ async function runInitGenerate(config, url) {
1321
1425
  });
1322
1426
  const tableNames = Object.keys(schema.tables);
1323
1427
  spinner.succeed(`Found ${bold(String(tableNames.length))} tables`);
1428
+ // The schema file init just pushed is where `pii` / `updatedAt` live.
1429
+ const appliedTags = await overlayCodeFirstTags(schema, config);
1430
+ if (appliedTags && describeAppliedTags(appliedTags)) {
1431
+ info(`Applied column tags from ${cyan(config.schemaFile)}: ${describeAppliedTags(appliedTags)}`);
1432
+ }
1324
1433
  const genSpinner = new Spinner('Generating TypeScript client').start();
1325
- const result = generate({ schema, outDir: config.out, connectionString: url });
1434
+ const result = generate({
1435
+ schema,
1436
+ outDir: config.out,
1437
+ connectionString: url,
1438
+ importExtension: config.importExtension,
1439
+ columnTypes: await generatorColumnTypesFor(appliedTags, config, schema, url),
1440
+ });
1326
1441
  genSpinner.succeed(`Generated ${bold(String(result.files.length))} files to ${cyan(`${config.out}/`)}`);
1327
1442
  // The accessor name the generator emits is snakeToCamel of the table name
1328
1443
  // (generate.ts, generateIndex), so this is the spelling that autocompletes.
@@ -1331,8 +1446,7 @@ async function runInitGenerate(config, url) {
1331
1446
  }
1332
1447
  catch (err) {
1333
1448
  spinner.fail('Could not generate client');
1334
- if (err instanceof Error)
1335
- errorLine(dim(redactUrl(err.message)));
1449
+ errorLine(dim(redactUrl(errorText(err))));
1336
1450
  info(`Run generation later with: ${cyan('npx turbine generate')}`);
1337
1451
  return { tableCount: null };
1338
1452
  }
@@ -1346,13 +1460,14 @@ async function runInitSeed(config) {
1346
1460
  }
1347
1461
  const spinner = new Spinner('Running seed file').start();
1348
1462
  try {
1349
- await runSeedPlan(getSeedExecutionPlan(seedFile), config);
1463
+ const outcome = await runSeedPlan(getSeedExecutionPlan(seedFile), config);
1350
1464
  spinner.succeed('Seed completed');
1465
+ if (!outcome.confirmed)
1466
+ warn(UNCONFIRMED_SEED_NOTICE);
1351
1467
  }
1352
1468
  catch (err) {
1353
1469
  spinner.fail('Seed failed');
1354
- if (err instanceof Error)
1355
- console.log(` ${dim(redactUrl(err.message))}`);
1470
+ console.log(` ${dim(redactUrl(errorText(err)))}`);
1356
1471
  }
1357
1472
  }
1358
1473
  /** Human label for a step, used in prompts and skip messages. */
@@ -1663,7 +1778,7 @@ async function cmdInit(args, config) {
1663
1778
  console.log(` ${dim('Nothing to connect to yet?')} ${cyan('npx turbine studio --demo')} ${dim('needs no database.')}`);
1664
1779
  }
1665
1780
  else {
1666
- const accessor = generated.sampleAccessor ?? 'users';
1781
+ const accessor = shown(generated.sampleAccessor ?? 'users');
1667
1782
  console.log(` ${dim('1.')} Import the generated client:`);
1668
1783
  console.log(` ${cyan(generatedClientImport(config))}`);
1669
1784
  newline();
@@ -1712,7 +1827,7 @@ export function refuseSchemaFilePath(config, options = {}) {
1712
1827
  errorLine();
1713
1828
  errorLine(`${dim('Or set it once in')} ${cyan('turbine.config.ts')}${dim(':')}`);
1714
1829
  errorLine(` ${green('schema:')} ${cyan("'public'")}${dim(", // or omit, reads the 'public' schema")}`);
1715
- errorLine(` ${green('schemaFile:')} ${cyan(`'${config.schema}'`)}${dim(', // your defineSchema() file (used by `turbine push`)')}`);
1830
+ errorLine(` ${green('schemaFile:')} ${cyan(`'${config.schema}'`)}${dim(', // your defineSchema() file (read by push and generate)')}`);
1716
1831
  if (options.escapeHatch) {
1717
1832
  errorLine();
1718
1833
  errorLine(`${dim('Re-run with')} ${cyan(options.escapeHatch)} ${dim('to use this literal schema name anyway.')}`);
@@ -1721,6 +1836,113 @@ export function refuseSchemaFilePath(config, options = {}) {
1721
1836
  process.exit(1);
1722
1837
  }
1723
1838
  // ---------------------------------------------------------------------------
1839
+ // Code-first column tags
1840
+ // ---------------------------------------------------------------------------
1841
+ /**
1842
+ * Overlay the column tags the defineSchema() file declares (`pii`, `updatedAt`,
1843
+ * `maxLength`) onto an introspected schema, before it is generated.
1844
+ *
1845
+ * Introspection reads the database, and the database has no column for these
1846
+ * flags, so a client generated from introspection alone returns PII by default,
1847
+ * never stamps `updatedAt`, and leaves Studio and the MCP server with nothing to
1848
+ * redact. The schema file is the only place the tags exist.
1849
+ *
1850
+ * Loaded when the file was named (`--schema-file` / config `schemaFile`) or sits
1851
+ * at the default path. A named file that is missing, or any file that cannot be
1852
+ * loaded, fails the command: carrying on would generate the unredacted client
1853
+ * this exists to prevent. Only a missing DEFAULT file is skipped, which is a
1854
+ * database-first project with no schema file at all.
1855
+ *
1856
+ * Returns what was applied, or `null` when there was no schema file to read.
1857
+ *
1858
+ * @internal exported for tests.
1859
+ */
1860
+ export async function overlayCodeFirstTags(schema, config) {
1861
+ if (config.schemaFileConfigured !== true && !existsSync(resolve(config.schemaFile)))
1862
+ return null;
1863
+ const def = await loadSchemaFile(config.schemaFile);
1864
+ const filters = {
1865
+ include: config.include.length ? config.include : undefined,
1866
+ exclude: config.exclude.length ? config.exclude : undefined,
1867
+ };
1868
+ return applyCodeFirstColumnTags(schema, extractCodeFirstColumnTags(def), {
1869
+ source: config.schemaFile,
1870
+ // A table the caller filtered out of the client has no client to tag.
1871
+ inScope: (table) => applyTableFilters([table], filters).length > 0,
1872
+ });
1873
+ }
1874
+ /** "2 pii, 1 updatedAt" for the line that reports an {@link overlayCodeFirstTags} result. */
1875
+ function describeAppliedTags(applied) {
1876
+ const parts = [];
1877
+ if (applied.pii > 0)
1878
+ parts.push(`${applied.pii} pii`);
1879
+ if (applied.updatedAt > 0)
1880
+ parts.push(`${applied.updatedAt} updatedAt`);
1881
+ if (applied.maxLength > 0)
1882
+ parts.push(`${applied.maxLength} maxLength`);
1883
+ const tsTypes = Object.keys(applied.columnTypes).length;
1884
+ if (tsTypes > 0)
1885
+ parts.push(`${tsTypes} tsType`);
1886
+ return parts.join(', ');
1887
+ }
1888
+ /**
1889
+ * The generator's `columnTypes`: the schema file's `tsType` declarations, then
1890
+ * the config file's `columnTypes`, which wins on the same key. A config key
1891
+ * whose table the run's include / exclude filters leave out is dropped, since
1892
+ * that table has no generated type to override; every other key goes to the
1893
+ * generator, which rejects one naming no table or column.
1894
+ *
1895
+ * An `include` list leaves out every table it does not name, so a misspelt
1896
+ * table looks exactly like a real table that was left out. `databaseTables`
1897
+ * (every table the schema holds, before filtering) tells them apart: only a
1898
+ * table the database has is dropped, and a name it does not have goes on to the
1899
+ * generator, which names the closest match. Without it every key outside the
1900
+ * filters is dropped.
1901
+ *
1902
+ * @internal exported for tests.
1903
+ */
1904
+ export function generatorColumnTypes(applied, config, schema, databaseTables) {
1905
+ const filters = {
1906
+ include: config.include.length ? config.include : undefined,
1907
+ exclude: config.exclude.length ? config.exclude : undefined,
1908
+ };
1909
+ const merged = { ...applied?.columnTypes };
1910
+ for (const [key, type] of Object.entries(config.columnTypes ?? {})) {
1911
+ const table = key.slice(0, key.lastIndexOf('.'));
1912
+ const leftOut = !Object.hasOwn(schema.tables, table) && applyTableFilters([table], filters).length === 0;
1913
+ if (leftOut && (databaseTables === undefined || databaseTables.has(table)))
1914
+ continue;
1915
+ merged[key] = type;
1916
+ }
1917
+ return Object.keys(merged).length > 0 ? merged : undefined;
1918
+ }
1919
+ /** The names of every table and view in `schema`, before any include / exclude filter. */
1920
+ async function listDatabaseTables(url, schema) {
1921
+ const { default: pg } = await import('pg');
1922
+ const client = new pg.Client({ connectionString: url });
1923
+ guardConnection(client);
1924
+ await client.connect();
1925
+ try {
1926
+ const res = await client.query('SELECT table_name FROM information_schema.tables WHERE table_schema = $1', [schema]);
1927
+ return new Set(res.rows.map((r) => r.table_name));
1928
+ }
1929
+ finally {
1930
+ await client.end();
1931
+ }
1932
+ }
1933
+ /**
1934
+ * {@link generatorColumnTypes} for a run against a database. The catalog is read
1935
+ * only when an `include` list is set and a config key names a table outside the
1936
+ * generated set, which is the one case where a typo and a table left out look
1937
+ * alike.
1938
+ */
1939
+ async function generatorColumnTypesFor(applied, config, schema, url, namespace = config.schema) {
1940
+ const keys = Object.keys(config.columnTypes ?? {});
1941
+ const outsideInclude = config.include.length > 0 && keys.some((key) => !Object.hasOwn(schema.tables, key.slice(0, key.lastIndexOf('.'))));
1942
+ const databaseTables = outsideInclude && url ? await listDatabaseTables(url, namespace) : undefined;
1943
+ return generatorColumnTypes(applied, config, schema, databaseTables);
1944
+ }
1945
+ // ---------------------------------------------------------------------------
1724
1946
  // Command: generate (pull)
1725
1947
  // ---------------------------------------------------------------------------
1726
1948
  async function cmdGenerate(args, config) {
@@ -1759,6 +1981,10 @@ async function cmdGenerate(args, config) {
1759
1981
  for (const t of skippedInternalTables) {
1760
1982
  console.log(` ${dim(`${symbols.teeEnd} skipped internal table ${t} (add it to include to keep it)`)}`);
1761
1983
  }
1984
+ const appliedTags = await overlayCodeFirstTags(schema, config);
1985
+ if (appliedTags && describeAppliedTags(appliedTags)) {
1986
+ console.log(` ${dim(`${symbols.teeEnd} applied column tags from ${config.schemaFile}: ${describeAppliedTags(appliedTags)}`)}`);
1987
+ }
1762
1988
  // Guard: zero tables means the generated client would be empty. That is almost
1763
1989
  // always a misconfiguration (wrong `schema`, an include/exclude that filtered
1764
1990
  // everything, or a database with no tables yet) rather than intent. Fail loudly
@@ -1783,12 +2009,12 @@ async function cmdGenerate(args, config) {
1783
2009
  for (const tbl of Object.values(schema.tables)) {
1784
2010
  const relCount = Object.keys(tbl.relations).length;
1785
2011
  const pk = tbl.primaryKey.join(', ') || '(none)';
1786
- console.log(` ${symbols.tee} ${bold(tbl.name)} ${dim(`${tbl.columns.length} cols, PK: ${pk}`)}${relCount > 0 ? dim(`, ${relCount} rels`) : ''}`);
2012
+ console.log(` ${symbols.tee} ${bold(shown(tbl.name))} ${dim(`${tbl.columns.length} cols, PK: ${shown(pk)}`)}${relCount > 0 ? dim(`, ${relCount} rels`) : ''}`);
1787
2013
  }
1788
2014
  newline();
1789
2015
  }
1790
2016
  if (Object.keys(schema.enums).length > 0) {
1791
- info(`Enums: ${Object.keys(schema.enums).join(', ')}`);
2017
+ info(`Enums: ${Object.keys(schema.enums).map(shown).join(', ')}`);
1792
2018
  }
1793
2019
  // Generate
1794
2020
  const genSpinner = new Spinner('Generating TypeScript client').start();
@@ -1800,6 +2026,7 @@ async function cmdGenerate(args, config) {
1800
2026
  noTimestamp: args.noTimestamp,
1801
2027
  importExtension: config.importExtension,
1802
2028
  keepColumnNames: config.keepColumnNames,
2029
+ columnTypes: await generatorColumnTypesFor(appliedTags, config, schema, url),
1803
2030
  });
1804
2031
  genSpinner.succeed(`Generated ${bold(String(result.files.length))} files in ${elapsed(startTime)}`);
1805
2032
  // List files
@@ -1816,7 +2043,12 @@ async function cmdGenerate(args, config) {
1816
2043
  console.log(` ${dim('const db = turbine({ connectionString: process.env.DATABASE_URL });')}`);
1817
2044
  // Name a table the client just emitted rather than a hardcoded `users`.
1818
2045
  const sampleAccessor = snakeToCamel(tableNames[0] ?? 'users');
1819
- console.log(` ${dim(`const row = await db.${sampleAccessor}.findFirst();`)}`);
2046
+ console.log(` ${dim(`const row = await db.${shown(sampleAccessor)}.findFirst();`)}`);
2047
+ if (config.importExtension === 'ts') {
2048
+ newline();
2049
+ console.log(` ${dim('Run it with Node type stripping:')} ${cyan('node --env-file=.env app.ts')} ${dim('(Node 22.18+ or 23.6+; earlier 22.x needs --experimental-strip-types)')}`);
2050
+ console.log(` ${dim('A type checker needs')} ${cyan('allowImportingTsExtensions')} ${dim('(or')} ${cyan('rewriteRelativeImportExtensions')}${dim(') in tsconfig.json.')}`);
2051
+ }
1820
2052
  newline();
1821
2053
  }
1822
2054
  /**
@@ -1854,8 +2086,15 @@ export function resolveMigrateFromPrismaUrl(configUrl, ast, env) {
1854
2086
  * concatenation, so each file is introduced by a comment naming it.
1855
2087
  */
1856
2088
  export function readPrismaSchemaSource(path) {
1857
- if (!statSync(path).isDirectory())
2089
+ // Read first, ask what it is only if that fails: a stat followed by a read
2090
+ // can disagree with itself when the path is replaced in between.
2091
+ try {
1858
2092
  return readFileSync(path, 'utf-8');
2093
+ }
2094
+ catch (err) {
2095
+ if (err.code !== 'EISDIR')
2096
+ throw err;
2097
+ }
1859
2098
  const files = readdirSync(path)
1860
2099
  .filter((f) => f.endsWith('.prisma'))
1861
2100
  .sort();
@@ -1890,7 +2129,7 @@ async function cmdMigrateFromPrisma(args, config) {
1890
2129
  catch (err) {
1891
2130
  newline();
1892
2131
  error(`Could not read ${cyan(prismaPath)}`);
1893
- errorLine(`${red(err instanceof Error ? err.message : String(err))}`);
2132
+ errorLine(`${red(errorText(err))}`);
1894
2133
  errorLine();
1895
2134
  process.exit(1);
1896
2135
  }
@@ -2013,6 +2252,8 @@ async function cmdMigrateFromPrisma(args, config) {
2013
2252
  noTimestamp: args.noTimestamp,
2014
2253
  importExtension: config.importExtension,
2015
2254
  keepColumnNames: config.keepColumnNames,
2255
+ // No schema file is read here, so only the config file's overrides apply.
2256
+ columnTypes: await generatorColumnTypesFor(null, config, schemaMeta, url, 'public'),
2016
2257
  });
2017
2258
  for (const file of gen.files) {
2018
2259
  console.log(` ${dim(symbols.teeEnd)} ${cyan(join(outDir, file))} ${dim('(client)')}`);
@@ -2045,9 +2286,26 @@ async function cmdMigrateFromPrisma(args, config) {
2045
2286
  // ---------------------------------------------------------------------------
2046
2287
  // Command: push
2047
2288
  // ---------------------------------------------------------------------------
2289
+ /**
2290
+ * Print what a diff did NOT compare, under an "in sync" style message.
2291
+ *
2292
+ * "Already in sync" only means the compared surface matches. The rest is listed
2293
+ * so the sentence is not read as a guarantee about the whole database.
2294
+ */
2295
+ function printNotCompared(items) {
2296
+ if (!items || items.length === 0)
2297
+ return;
2298
+ console.log(` ${dim('Not compared, so this says nothing about:')}`);
2299
+ for (const item of items)
2300
+ console.log(` ${dim(symbols.dot)} ${dim(item)}`);
2301
+ newline();
2302
+ }
2048
2303
  async function cmdPush(args, config) {
2049
2304
  banner();
2050
2305
  refuseSchemaFilePath(config);
2306
+ if (args.exitCode && !args.dryRun) {
2307
+ failArg(`${cyan('--exit-code')} needs ${cyan('--dry-run')} on push.`, 'It reports whether the database differs from the schema file (exit 2) without changing it.', 'Example: npx turbine push --dry-run --exit-code');
2308
+ }
2051
2309
  const url = requireUrl(config);
2052
2310
  label('Database', redactUrl(url));
2053
2311
  label('Schema file', config.schemaFile);
@@ -2059,8 +2317,32 @@ async function cmdPush(args, config) {
2059
2317
  const diffSpinner = new Spinner('Computing schema diff').start();
2060
2318
  const diff = await schemaDiff(schemaDef, url, { schema: config.schema });
2061
2319
  if (diff.statements.length === 0 && diff.drop.length === 0) {
2062
- diffSpinner.succeed('Database is already in sync');
2320
+ // "In sync" is a claim about everything the diff looked at, so it is made
2321
+ // only when the diff also has nothing to say. A difference it cannot apply
2322
+ // (an undeclared index, a reordered enum) is reported as exactly that.
2323
+ const advisories = diff.warnings ?? [];
2324
+ if (advisories.length === 0) {
2325
+ diffSpinner.succeed('Database is already in sync');
2326
+ }
2327
+ else {
2328
+ diffSpinner.succeed('Nothing to apply, but the database is not fully in sync');
2329
+ }
2063
2330
  newline();
2331
+ for (const w of advisories)
2332
+ warn(w);
2333
+ if (advisories.length > 0)
2334
+ newline();
2335
+ printNotCompared(diff.notCompared);
2336
+ // A warning is a difference the diff found and cannot apply (a unique index
2337
+ // that is not unique, a removed enum label), so a gate fails on it. What the
2338
+ // diff only discloses as "not compared" is not a difference it found, and a
2339
+ // CHECK expression that merely reads differently is a warning it cannot
2340
+ // confirm (driftWarnings leaves it out).
2341
+ if (args.exitCode && driftWarnings(diff).length > 0) {
2342
+ warn(`The database differs from the schema file ${dim('(--exit-code: exiting 2)')}.`);
2343
+ newline();
2344
+ process.exit(2);
2345
+ }
2064
2346
  return;
2065
2347
  }
2066
2348
  diffSpinner.succeed('Found changes');
@@ -2117,6 +2399,11 @@ async function cmdPush(args, config) {
2117
2399
  if (args.dryRun) {
2118
2400
  info('Dry run, no changes applied.');
2119
2401
  newline();
2402
+ if (args.exitCode) {
2403
+ warn(`The database differs from the schema file ${dim('(--exit-code: exiting 2)')}.`);
2404
+ newline();
2405
+ process.exit(2);
2406
+ }
2120
2407
  return;
2121
2408
  }
2122
2409
  if (args.allowDestructive) {
@@ -2190,8 +2477,12 @@ async function cmdMigrate(args, config) {
2190
2477
  console.log(` ${cyan('--recipe <name>')} Scaffold a sanctioned migration pattern`);
2191
2478
  console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
2192
2479
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
2480
+ console.log(` ${cyan('--exit-code')} Exit 2 when ${cyan('status')} finds pending, modified, missing or misnamed migrations`);
2481
+ console.log(` ${cyan('--wait')}${dim('[=<seconds>]')} Make ${cyan('deploy')} wait for a concurrent deploy's lock`);
2482
+ console.log(` ${cyan('--allow-pending')} Let ${cyan('--auto')} / ${cyan('--from-diff')} run with migrations pending`);
2193
2483
  console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting`);
2194
2484
  console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums on ${cyan('up')} / ${cyan('deploy')} / ${cyan('down')} ${dim('(advanced)')}`);
2485
+ console.log(` ${cyan('--allow-pooler')} Run ${cyan('up')} / ${cyan('deploy')} / ${cyan('down')} against a transaction pooler anyway ${dim('(refused by default: the migration lock is session-level and a pooler can release it on another backend)')}`);
2195
2486
  newline();
2196
2487
  console.log(` ${bold('Recipes')} ${dim('(--recipe):')}`);
2197
2488
  for (const [key, recipe] of Object.entries(MIGRATION_RECIPES)) {
@@ -2209,6 +2500,23 @@ async function cmdMigrate(args, config) {
2209
2500
  newline();
2210
2501
  return;
2211
2502
  }
2503
+ // Flags that change what a subcommand does are refused where they do nothing,
2504
+ // rather than accepted and ignored: a CI gate that silently never gates is
2505
+ // worse than one that will not start.
2506
+ if (args.exitCode && sub !== 'status' && sub !== 'list') {
2507
+ failArg(`${cyan('--exit-code')} only applies to ${cyan('migrate status')} (and ${cyan('push --dry-run')}).`, 'Example: npx turbine migrate status --exit-code');
2508
+ }
2509
+ if (args.wait !== undefined && sub !== 'deploy') {
2510
+ failArg(`${cyan('--wait')} only applies to ${cyan('migrate deploy')}.`, 'Example: npx turbine migrate deploy --wait=120');
2511
+ }
2512
+ // The commands that take the migration lock, before the banner and before any
2513
+ // connection. A dry run takes no lock, so it may use any endpoint.
2514
+ if ((sub === 'up' || sub === 'deploy' || sub === 'down') && args.dryRun !== true && config.url) {
2515
+ refusePoolerConnection(config.url, args, {
2516
+ command: `turbine migrate ${sub}`,
2517
+ explanation: MIGRATE_POOLER_EXPLANATION,
2518
+ });
2519
+ }
2212
2520
  switch (sub) {
2213
2521
  case 'create':
2214
2522
  await cmdMigrateCreate(args, config);
@@ -2232,6 +2540,58 @@ async function cmdMigrate(args, config) {
2232
2540
  process.exit(1);
2233
2541
  }
2234
2542
  }
2543
+ /**
2544
+ * Stop `migrate create --auto` / `--from-diff` while migrations are pending.
2545
+ *
2546
+ * Both diff the schema file against the live database, and a pending migration
2547
+ * is a change the file already describes and the database does not have yet. The
2548
+ * new migration would repeat it and fail on the first database that runs the
2549
+ * pending one first. See {@link formatPendingAutoRefusal}.
2550
+ */
2551
+ async function refuseWhenMigrationsPending(args, config, url, flag) {
2552
+ if (listMigrationFiles(config.migrationsDir).length === 0)
2553
+ return;
2554
+ const pending = pendingMigrationFiles(await migrateStatus(url, config.migrationsDir, { schema: config.schema }));
2555
+ if (pending.length === 0)
2556
+ return;
2557
+ if (args.allowPending) {
2558
+ warn(`--allow-pending is set: ${bold(String(pending.length))} pending migration(s) are NOT reflected in the diff.`);
2559
+ newline();
2560
+ return;
2561
+ }
2562
+ const [first, ...rest] = formatPendingAutoRefusal(pending, flag).split('\n');
2563
+ error(first);
2564
+ for (const line of rest)
2565
+ errorLine(line);
2566
+ errorLine();
2567
+ process.exit(1);
2568
+ }
2569
+ /**
2570
+ * Explain an empty `--auto` / `--from-diff` diff without claiming more than it
2571
+ * knows: "in sync" only when nothing, extra tables and advisories included, was
2572
+ * left over.
2573
+ */
2574
+ function reportNothingToMigrate(diff, spinner) {
2575
+ const advisories = diff.warnings ?? [];
2576
+ if (diff.drop.length === 0 && advisories.length === 0) {
2577
+ spinner.succeed('Database is already in sync: nothing to migrate');
2578
+ newline();
2579
+ printNotCompared(diff.notCompared);
2580
+ return;
2581
+ }
2582
+ spinner.succeed('Nothing to migrate: no statement to write, but the database is not fully in sync');
2583
+ newline();
2584
+ if (diff.drop.length > 0) {
2585
+ warn(`${bold(String(diff.drop.length))} table(s) exist in the database but not in the schema file ` +
2586
+ `(${diff.drop.join(', ')}). A migration never drops a table on its own: write that one by hand.`);
2587
+ newline();
2588
+ }
2589
+ for (const w of advisories)
2590
+ warn(w);
2591
+ if (advisories.length > 0)
2592
+ newline();
2593
+ printNotCompared(diff.notCompared);
2594
+ }
2235
2595
  async function cmdMigrateCreate(args, config) {
2236
2596
  banner();
2237
2597
  // `--auto` / `--from-diff` diff the schema FILE against the namespace, so both
@@ -2261,18 +2621,21 @@ async function cmdMigrateCreate(args, config) {
2261
2621
  errorLine();
2262
2622
  process.exit(1);
2263
2623
  }
2624
+ if (args.allowPending && !args.auto && !args.fromDiff) {
2625
+ failArg(`${cyan('--allow-pending')} only applies to ${cyan('--auto')} and ${cyan('--from-diff')}.`, 'It lets a diff-generated migration proceed while earlier migrations are still pending.');
2626
+ }
2264
2627
  if (args.auto) {
2265
2628
  // Auto-generate migration from schema diff
2266
2629
  const url = requireUrl(config);
2267
2630
  label('Database', redactUrl(url));
2268
2631
  label('Schema file', config.schemaFile);
2269
2632
  newline();
2633
+ await refuseWhenMigrationsPending(args, config, url, '--auto');
2270
2634
  const schemaDef = await loadSchemaFile(config.schemaFile);
2271
2635
  const diffSpinner = new Spinner('Computing schema diff').start();
2272
2636
  const diff = await schemaDiff(schemaDef, url, { schema: config.schema });
2273
2637
  if (diff.statements.length === 0) {
2274
- diffSpinner.succeed('Database is already in sync: nothing to migrate');
2275
- newline();
2638
+ reportNothingToMigrate(diff, diffSpinner);
2276
2639
  return;
2277
2640
  }
2278
2641
  diffSpinner.succeed(`Found ${bold(String(diff.statements.length))} change(s)`);
@@ -2324,7 +2687,9 @@ async function cmdMigrateCreate(args, config) {
2324
2687
  console.log(` ${red(symbols.warning)} ${dim('DOWN')} [${h.kind}] ${h.target}`);
2325
2688
  }
2326
2689
  newline();
2327
- console.log(` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`);
2690
+ console.log(body.destructiveUp.length > 0
2691
+ ? ` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`
2692
+ : ` ${dim('Only the DOWN section is destructive: `migrate up` runs this file without a prompt, `migrate down` asks first.')}`);
2328
2693
  newline();
2329
2694
  }
2330
2695
  if (diff.warnings && diff.warnings.length > 0) {
@@ -2344,12 +2709,12 @@ async function cmdMigrateCreate(args, config) {
2344
2709
  label('Database', redactUrl(url));
2345
2710
  label('Schema file', config.schemaFile);
2346
2711
  newline();
2712
+ await refuseWhenMigrationsPending(args, config, url, '--from-diff');
2347
2713
  const schemaDef = await loadSchemaFile(config.schemaFile);
2348
2714
  const diffSpinner = new Spinner('Computing schema diff').start();
2349
2715
  const diff = await schemaDiff(schemaDef, url, { schema: config.schema });
2350
2716
  if (diff.statements.length === 0) {
2351
- diffSpinner.succeed('Database is already in sync: nothing to migrate');
2352
- newline();
2717
+ reportNothingToMigrate(diff, diffSpinner);
2353
2718
  return;
2354
2719
  }
2355
2720
  diffSpinner.succeed(`Found ${bold(String(diff.statements.length))} change(s)`);
@@ -2396,7 +2761,9 @@ async function cmdMigrateCreate(args, config) {
2396
2761
  console.log(` ${red(symbols.warning)} ${dim('DOWN')} [${h.kind}] ${h.target}`);
2397
2762
  }
2398
2763
  newline();
2399
- console.log(` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`);
2764
+ console.log(body.destructiveUp.length > 0
2765
+ ? ` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`
2766
+ : ` ${dim('Only the DOWN section is destructive: `migrate up` runs this file without a prompt, `migrate down` asks first.')}`);
2400
2767
  newline();
2401
2768
  }
2402
2769
  if (diff.warnings && diff.warnings.length > 0) {
@@ -2717,7 +3084,18 @@ async function cmdMigrateDeploy(args, config) {
2717
3084
  if (destructive.length > 0)
2718
3085
  printDestructiveNotice(destructive);
2719
3086
  const runSpinner = new Spinner('Deploying migrations').start();
2720
- const result = await migrateDeploy(url, config.migrationsDir, { allowDrift: args.allowDrift, schema: config.schema });
3087
+ const result = await migrateDeploy(url, config.migrationsDir, {
3088
+ allowDrift: args.allowDrift,
3089
+ schema: config.schema,
3090
+ lockWaitMs: args.wait !== undefined ? args.wait * 1000 : undefined,
3091
+ onLockWait: () => {
3092
+ runSpinner.stop();
3093
+ warn(`Another migration holds the lock: waiting up to ${bold(String(args.wait))}s for it to finish.`);
3094
+ console.log(` ${dim('Whatever it applies is then no longer pending, so this run will skip it.')}`);
3095
+ newline();
3096
+ runSpinner.start();
3097
+ },
3098
+ });
2721
3099
  if (result.applied.length > 0) {
2722
3100
  runSpinner.succeed(`${bold(String(result.applied.length))} applied`);
2723
3101
  for (const file of result.applied) {
@@ -2844,6 +3222,15 @@ async function cmdMigrateDown(args, config) {
2844
3222
  for (const file of result.rolledBack) {
2845
3223
  console.log(` ${yellow(symbols.arrowRight)} ${file.filename}`);
2846
3224
  }
3225
+ const noSql = rollbacksWithoutSql(result.rolledBack);
3226
+ if (noSql.length > 0) {
3227
+ newline();
3228
+ warn(`${bold(String(noSql.length))} rolled-back migration(s) have a DOWN section that runs no SQL:`);
3229
+ for (const file of noSql)
3230
+ console.log(` ${yellow(symbols.dot)} ${file.filename}`);
3231
+ console.log(` ${dim('Their tracking rows are removed, but nothing they changed was undone (a dropped column')}`);
3232
+ console.log(` ${dim('cannot be restored automatically). Restore that data from a backup.')}`);
3233
+ }
2847
3234
  }
2848
3235
  else if (result.errors.length === 0) {
2849
3236
  spinner.succeed('No migrations to roll back');
@@ -2867,20 +3254,24 @@ async function cmdMigrateDown(args, config) {
2867
3254
  }
2868
3255
  newline();
2869
3256
  }
2870
- async function cmdMigrateStatus(_args, config) {
3257
+ async function cmdMigrateStatus(args, config) {
2871
3258
  banner();
2872
3259
  const url = requireUrl(config);
2873
3260
  label('Database', redactUrl(url));
2874
3261
  label('Migrations', config.migrationsDir);
2875
3262
  newline();
2876
3263
  const allFiles = listMigrationFiles(config.migrationsDir);
2877
- if (allFiles.length === 0) {
3264
+ // A gate still has to ask the database when there are no files: applied
3265
+ // migrations whose files are gone are exactly the drift it exists to catch.
3266
+ if (allFiles.length === 0 && !args.exitCode) {
2878
3267
  warn('No migration files found.');
2879
3268
  console.log(` ${dim('Create one with:')} ${cyan('npx turbine migrate create <name>')}`);
2880
3269
  newline();
2881
3270
  return;
2882
3271
  }
2883
3272
  warnIgnoredMigrationFiles(config.migrationsDir);
3273
+ if (allFiles.length === 0)
3274
+ warn('No migration files found.');
2884
3275
  const statuses = await migrateStatus(url, config.migrationsDir, { schema: config.schema });
2885
3276
  const appliedCount = statuses.filter((s) => s.applied).length;
2886
3277
  const pendingCount = statuses.filter((s) => !s.applied).length;
@@ -2935,6 +3326,14 @@ async function cmdMigrateStatus(_args, config) {
2935
3326
  console.log(` ${dim('Run')} ${cyan('npx turbine migrate up')} ${dim('to apply pending migrations.')}`);
2936
3327
  newline();
2937
3328
  }
3329
+ if (args.exitCode) {
3330
+ const problems = statusGateProblems(summarizeMigrationStatus(statuses), listIgnoredSqlFiles(config.migrationsDir).map(printableCatalogName));
3331
+ if (problems.length > 0) {
3332
+ warn(`Migration history is not clean: ${problems.join(', ')} ${dim('(--exit-code: exiting 2)')}.`);
3333
+ newline();
3334
+ process.exit(2);
3335
+ }
3336
+ }
2938
3337
  }
2939
3338
  export function getSeedExecutionPlan(seedFile) {
2940
3339
  const ext = extname(seedFile).toLowerCase();
@@ -2993,6 +3392,9 @@ async function seedConnectionString(config) {
2993
3392
  }
2994
3393
  return connectionStringForSchema(config.url, schema, inherited);
2995
3394
  }
3395
+ /** The notice for a {@link SeedRunOutcome} that could not be confirmed. */
3396
+ const UNCONFIRMED_SEED_NOTICE = 'The seed file ran to completion, but it does not call defineSeed(), so Turbine cannot tell whether it wrote anything. ' +
3397
+ 'Wrap the work in defineSeed(async (db) => { ... }) to have it confirmed.';
2996
3398
  async function runSeedPlan(plan, config) {
2997
3399
  const oldDatabaseUrl = process.env.DATABASE_URL;
2998
3400
  // The seed connects through DATABASE_URL, whichever way it runs: `defineSeed`
@@ -3012,9 +3414,11 @@ async function runSeedPlan(plan, config) {
3012
3414
  }
3013
3415
  // The seed runs in a child process, so we cannot observe its callback
3014
3416
  // directly. Hand it a sentinel path: `defineSeed`'s runner writes the file
3015
- // only after a callback executes to completion. If the child exits cleanly
3016
- // but the sentinel never appears, the seed module loaded without running
3017
- // anything: a silent no-op we must report as a failure, not success.
3417
+ // only after a callback executes to completion. A non-zero exit is a
3418
+ // failure. A clean exit with no sentinel is a script that did its work at
3419
+ // the top level (a typed-client seed), which succeeds and says so, unless
3420
+ // the file names `defineSeed`: then its callback was expected to run and
3421
+ // did not, which is a no-op to report as a failure.
3018
3422
  const sentinelDir = mkdtempSync(join(tmpdir(), 'turbine-seed-'));
3019
3423
  const sentinel = join(sentinelDir, 'ran');
3020
3424
  const { execFileSync } = await import('node:child_process');
@@ -3027,15 +3431,18 @@ async function runSeedPlan(plan, config) {
3027
3431
  TURBINE_SEED_SENTINEL: sentinel,
3028
3432
  },
3029
3433
  });
3030
- if (!existsSync(sentinel)) {
3434
+ if (existsSync(sentinel))
3435
+ return { confirmed: true };
3436
+ const seedFile = plan.args[plan.args.length - 1] ?? '';
3437
+ if (/\bdefineSeed\b/.test(readFileSync(seedFile, 'utf-8'))) {
3031
3438
  throw new Error('The seed file completed without running a seed callback. ' +
3032
3439
  'Make sure it calls defineSeed(async (db) => { ... }) at the top level.');
3033
3440
  }
3441
+ return { confirmed: false };
3034
3442
  }
3035
3443
  finally {
3036
3444
  rmSync(sentinelDir, { recursive: true, force: true });
3037
3445
  }
3038
- return;
3039
3446
  }
3040
3447
  if (plan.kind === 'js') {
3041
3448
  const mod = await import(pathToFileURL(plan.file).href);
@@ -3044,7 +3451,7 @@ async function runSeedPlan(plan, config) {
3044
3451
  'Export your seed with `export default defineSeed(async (db) => { ... })`.');
3045
3452
  }
3046
3453
  await mod.default();
3047
- return;
3454
+ return { confirmed: true };
3048
3455
  }
3049
3456
  // `seedUrl` is already pinned and already schema-checked; `requireUrl` is
3050
3457
  // only reached when no url was configured at all, in which case there is no
@@ -3060,6 +3467,7 @@ async function runSeedPlan(plan, config) {
3060
3467
  finally {
3061
3468
  await client.end();
3062
3469
  }
3470
+ return { confirmed: true };
3063
3471
  }
3064
3472
  finally {
3065
3473
  if (oldDatabaseUrl === undefined) {
@@ -3085,14 +3493,14 @@ async function cmdSeed(_args, config) {
3085
3493
  }
3086
3494
  const spinner = new Spinner('Running seed file').start();
3087
3495
  try {
3088
- await runSeedPlan(getSeedExecutionPlan(seedFile), config);
3496
+ const outcome = await runSeedPlan(getSeedExecutionPlan(seedFile), config);
3089
3497
  spinner.succeed('Seed completed');
3498
+ if (!outcome.confirmed)
3499
+ warn(UNCONFIRMED_SEED_NOTICE);
3090
3500
  }
3091
3501
  catch (err) {
3092
3502
  spinner.fail('Seed failed');
3093
- if (err instanceof Error) {
3094
- console.log(` ${dim(redactUrl(err.message))}`);
3095
- }
3503
+ console.log(` ${dim(redactUrl(errorText(err)))}`);
3096
3504
  newline();
3097
3505
  process.exit(1);
3098
3506
  }
@@ -3123,7 +3531,7 @@ async function cmdStatus(_args, config) {
3123
3531
  for (const tbl of Object.values(schema.tables)) {
3124
3532
  const relCount = Object.keys(tbl.relations).length;
3125
3533
  const _pk = tbl.primaryKey.join(', ') || dim('(none)');
3126
- console.log(` ${bold(cyan(tbl.name))}`);
3534
+ console.log(` ${bold(cyan(shown(tbl.name)))}`);
3127
3535
  for (let i = 0; i < tbl.columns.length; i++) {
3128
3536
  const col = tbl.columns[i];
3129
3537
  const isLast = i === tbl.columns.length - 1 && relCount === 0;
@@ -3131,7 +3539,7 @@ async function cmdStatus(_args, config) {
3131
3539
  const nullable = col.nullable ? dim('?') : '';
3132
3540
  const def = col.hasDefault ? dim(' (default)') : '';
3133
3541
  const pkLabel = tbl.primaryKey.includes(col.name) ? ` ${magenta('PK')}` : '';
3134
- console.log(` ${dim(prefix)} ${col.field}${nullable}: ${green(col.tsType)}${pkLabel}${def} ${gray(`${symbols.arrow} ${col.pgType}`)}`);
3542
+ console.log(` ${dim(prefix)} ${shown(col.field)}${nullable}: ${green(shown(col.tsType))}${pkLabel}${def} ${gray(`${symbols.arrow} ${shown(col.pgType)}`)}`);
3135
3543
  }
3136
3544
  const rels = Object.entries(tbl.relations);
3137
3545
  if (rels.length > 0) {
@@ -3141,7 +3549,7 @@ async function cmdStatus(_args, config) {
3141
3549
  const prefix = isLast ? symbols.teeEnd : symbols.tee;
3142
3550
  const relColor = rel.type === 'hasMany' ? blue : yellow;
3143
3551
  const fkDisplay = Array.isArray(rel.foreignKey) ? rel.foreignKey.join(', ') : rel.foreignKey;
3144
- console.log(` ${dim(prefix)} ${relColor(relName)} ${dim(symbols.arrow)} ${rel.to} ${dim(`(${rel.type}, FK: ${fkDisplay})`)}`);
3552
+ console.log(` ${dim(prefix)} ${relColor(shown(relName))} ${dim(symbols.arrow)} ${shown(rel.to)} ${dim(`(${rel.type}, FK: ${shown(fkDisplay)})`)}`);
3145
3553
  }
3146
3554
  }
3147
3555
  newline();
@@ -3149,7 +3557,7 @@ async function cmdStatus(_args, config) {
3149
3557
  if (Object.keys(schema.enums).length > 0) {
3150
3558
  console.log(` ${bold('Enums:')}`);
3151
3559
  for (const [enumName, labels] of Object.entries(schema.enums)) {
3152
- console.log(` ${cyan(enumName)}: ${labels.map((l) => green(`'${l}'`)).join(dim(' | '))}`);
3560
+ console.log(` ${cyan(shown(enumName))}: ${labels.map((l) => green(`'${shown(l)}'`)).join(dim(' | '))}`);
3153
3561
  }
3154
3562
  newline();
3155
3563
  }
@@ -3207,6 +3615,23 @@ const CONCURRENTLY_RECIPE_COMMENT = [
3207
3615
  '-- makes "migrate up" look hung. For bounded waits, SET lock_timeout /',
3208
3616
  '-- statement_timeout in a psql session.',
3209
3617
  ].join('\n');
3618
+ /**
3619
+ * Why `migrate up`, `deploy` and `down` refuse a pooled endpoint. The reason is
3620
+ * the migration lock, not the doctor's session-state argument.
3621
+ */
3622
+ export const MIGRATE_POOLER_EXPLANATION = [
3623
+ 'turbine migrate serializes runs with a session-level advisory lock (pg_try_advisory_lock).',
3624
+ 'That lock belongs to one server backend and only means something while the same backend',
3625
+ 'serves every statement of the run. A transaction pooler (PgBouncer, a Neon "-pooler"',
3626
+ "endpoint, Supabase's pooler) hands each transaction to whichever backend is free, so:",
3627
+ '',
3628
+ ' 1. The lock is not tied to this run. It can be taken on one backend and released on',
3629
+ ' another, which leaves it held on a shared backend (later runs are told another',
3630
+ ' migration is in progress) and lets two runs apply the same file.',
3631
+ ' 2. Session state a migration file sets (SET lock_timeout, SET search_path) is left on',
3632
+ ' a shared backend for other clients, and state an earlier client left there applies',
3633
+ ' to this run.',
3634
+ ];
3210
3635
  /**
3211
3636
  * Refuse a `doctor` run whose connection string points at a transaction pooler.
3212
3637
  *
@@ -3228,13 +3653,13 @@ const CONCURRENTLY_RECIPE_COMMENT = [
3228
3653
  * Exported for the unit test only (like {@link isLoopbackHost}): a gate whose
3229
3654
  * failure path is `process.exit` is untestable through the command itself.
3230
3655
  */
3231
- export function refusePoolerConnection(url, args) {
3656
+ export function refusePoolerConnection(url, args, refusal = { command: 'turbine doctor' }) {
3232
3657
  if (args.allowPooler === true)
3233
3658
  return;
3234
3659
  const detection = detectPooler(url);
3235
3660
  if (!detection.pooled)
3236
3661
  return;
3237
- const lines = poolerRefusalMessage(detection, { command: 'turbine doctor', allowFlag: '--allow-pooler' });
3662
+ const lines = poolerRefusalMessage(detection, { ...refusal, allowFlag: '--allow-pooler' });
3238
3663
  console.error('');
3239
3664
  console.error(` ${red(symbols.cross)} ${lines[0]}`);
3240
3665
  for (const line of lines.slice(1))
@@ -3302,7 +3727,7 @@ async function cmdDoctor(args, config) {
3302
3727
  tables: {},
3303
3728
  indexes: [],
3304
3729
  nullFrac: {},
3305
- notices: [`statistics collection failed: ${err instanceof Error ? err.message : String(err)}`],
3730
+ notices: [`statistics collection failed: ${errorText(err)}`],
3306
3731
  };
3307
3732
  }
3308
3733
  // Table-heat boost: read _turbine_metrics (app DB or --metrics-url) and use
@@ -3527,6 +3952,12 @@ function renderUnusedCaveats(minScans, snapshot) {
3527
3952
  }
3528
3953
  newline();
3529
3954
  }
3955
+ /**
3956
+ * Catalog names go to the terminal through this. A database lets its creator
3957
+ * pick any characters for a table or index name, and a raw ESC or bidi control
3958
+ * in one rewrites the line it is printed on.
3959
+ */
3960
+ const shown = printableCatalogName;
3530
3961
  /** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
3531
3962
  function renderUnusedIndexes(unused, minScans, snapshot) {
3532
3963
  if (unused.length === 0)
@@ -3535,12 +3966,12 @@ function renderUnusedIndexes(unused, minScans, snapshot) {
3535
3966
  warn(`Found ${bold(String(unused.length))} never-scanned index(es) (${formatBytes(total)} reclaimable).`);
3536
3967
  renderUnusedCaveats(minScans, snapshot);
3537
3968
  for (const u of unused) {
3538
- console.log(` ${yellow(symbols.warning)} ${bold(cyan(u.table))} ${dim(`(${u.columns.join(', ') || '?'})`)} ${gray(`${u.indexName} · ${u.idxScan} scans · ${formatBytes(u.sizeBytes)}`)}`);
3969
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(u.table)))} ${dim(`(${shown(u.columns.join(', ') || '?')})`)} ${gray(`${shown(u.indexName)} · ${u.idxScan} scans · ${formatBytes(u.sizeBytes)}`)}`);
3539
3970
  // A functional / partial / non-btree index is not a plain `(col)` rebuild:
3540
3971
  // say what it actually is before anyone runs the DROP.
3541
3972
  if (u.caveat)
3542
3973
  console.log(` ${dim(symbols.tee)} ${yellow(u.caveat)}`);
3543
- console.log(` ${dim(symbols.teeEnd)} ${green(u.dropSql)}`);
3974
+ console.log(` ${dim(symbols.teeEnd)} ${green(shown(u.dropSql))}`);
3544
3975
  newline();
3545
3976
  }
3546
3977
  }
@@ -3553,9 +3984,9 @@ function renderRedundantIndexes(redundant) {
3553
3984
  console.log(` ${dim('Each is a leading prefix of a wider index that already serves the same lookups. Report-only, never auto-dropped.')}`);
3554
3985
  newline();
3555
3986
  for (const r of redundant) {
3556
- console.log(` ${yellow(symbols.warning)} ${bold(cyan(r.table))} ${dim(`(${r.columns.join(', ')})`)} ${gray(`${r.indexName} · ${formatBytes(r.sizeBytes)}`)}`);
3557
- console.log(` ${dim(symbols.tee)} covered by ${blue(r.coveredBy)} ${dim(`(${r.coveredByColumns.join(', ')})`)}`);
3558
- console.log(` ${dim(symbols.teeEnd)} ${green(r.dropSql)}`);
3987
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(r.table)))} ${dim(`(${shown(r.columns.join(', '))})`)} ${gray(`${shown(r.indexName)} · ${formatBytes(r.sizeBytes)}`)}`);
3988
+ console.log(` ${dim(symbols.tee)} covered by ${blue(shown(r.coveredBy))} ${dim(`(${shown(r.coveredByColumns.join(', '))})`)}`);
3989
+ console.log(` ${dim(symbols.teeEnd)} ${green(shown(r.dropSql))}`);
3559
3990
  newline();
3560
3991
  }
3561
3992
  }
@@ -3570,7 +4001,7 @@ function renderDoctorAudit(audit, minScans, snapshot) {
3570
4001
  renderUnusedCaveats(minScans, snapshot);
3571
4002
  for (const a of audit) {
3572
4003
  const tag = a.ambiguous ? red(' [ambiguous: truncated name collides with another column set]') : '';
3573
- console.log(` ${yellow(symbols.warning)} ${bold(cyan(a.table))} ${dim(`(${a.columns.join(', ') || '?'})`)} ${gray(`${a.indexName} · ${a.idxScan} scans · ${formatBytes(a.sizeBytes)}`)}${tag}`);
4004
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(a.table)))} ${dim(`(${shown(a.columns.join(', ') || '?')})`)} ${gray(`${shown(a.indexName)} · ${a.idxScan} scans · ${formatBytes(a.sizeBytes)}`)}${tag}`);
3574
4005
  if (a.ambiguous) {
3575
4006
  console.log(` ${dim(symbols.tee)} ${dim('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
3576
4007
  }
@@ -3579,7 +4010,7 @@ function renderDoctorAudit(audit, minScans, snapshot) {
3579
4010
  console.log(` ${dim('reappear as a missing-index finding in this same report.')}`);
3580
4011
  }
3581
4012
  else if (a.dropSql) {
3582
- console.log(` ${dim(symbols.teeEnd)} ${green(a.dropSql)}`);
4013
+ console.log(` ${dim(symbols.teeEnd)} ${green(shown(a.dropSql))}`);
3583
4014
  }
3584
4015
  newline();
3585
4016
  }
@@ -3633,12 +4064,12 @@ function divergenceAmplificationLines(d) {
3633
4064
  const amp = divInt(d.worstCaseAmplification ?? 0);
3634
4065
  const corr = d.orderColumnCorrelation;
3635
4066
  const corrLabel = corr === null || corr === undefined
3636
- ? `no pg_stats correlation available for "${d.orderColumn}"`
3637
- : `correlation ${corr.toFixed(5)} on "${d.orderColumn}"`;
4067
+ ? `no pg_stats correlation available for "${shown(d.orderColumn)}"`
4068
+ : `correlation ${corr.toFixed(5)} on "${shown(d.orderColumn)}"`;
3638
4069
  if (d.heapNearlyOrdered === true) {
3639
4070
  return [
3640
4071
  `The size of that flip turns on the heap's physical order, and THIS heap is in near-exact`,
3641
- `"${d.orderColumn}" order (${corrLabel}), so consecutive index entries hit the`,
4072
+ `"${shown(d.orderColumn)}" order (${corrLabel}), so consecutive index entries hit the`,
3642
4073
  `same pinned page: measured ~1x, not the ~${amp}x an unordered heap reads. Most likely this`,
3643
4074
  `one is not costing you anything today. It is also one sampled statistic away from the`,
3644
4075
  `much worse reading, so measure rather than assume in either direction.`,
@@ -3647,7 +4078,7 @@ function divergenceAmplificationLines(d) {
3647
4078
  return [
3648
4079
  `That costs ~${amp}x the buffers of the seq scan, because each index entry is its own heap`,
3649
4080
  `fetch (${corrLabel}). The one shape that reads ~1x instead is a heap`,
3650
- `in near-exact "${d.orderColumn}" order; two pages of local disorder already reads ~41x.`,
4081
+ `in near-exact "${shown(d.orderColumn)}" order; two pages of local disorder already reads ~41x.`,
3651
4082
  ];
3652
4083
  }
3653
4084
  function renderDivergenceEvidence(d) {
@@ -3655,7 +4086,7 @@ function renderDivergenceEvidence(d) {
3655
4086
  console.log(` ${dim(symbols.tee)} ${yellow('cached-plan risk:')} this unindexed filter column can also flip a cached plan.`);
3656
4087
  console.log(` ${dim(`${divInt(d.rows)} rows in ${divInt(d.pages)} pages, rarest value ~${divInt(d.rarestBucket)} rows, below the assumed LIMIT ${d.assumedLimit}.`)}`);
3657
4088
  console.log(` ${dim(`Without the index the good plan is a seq scan (${divInt(d.pages)} pages); a promoted generic`)}`);
3658
- console.log(` ${dim(`plan cannot see the value is rare, keeps the ordered "${d.orderColumn}" walk, and reads`)}`);
4089
+ console.log(` ${dim(`plan cannot see the value is rare, keeps the ordered "${shown(d.orderColumn)}" walk, and reads`)}`);
3659
4090
  console.log(` ${dim(`up to ~${tuples} tuples before it fills the LIMIT.`)}`);
3660
4091
  console.log(` ${dim('Postgres promotes this shape exactly when the workload keeps asking for the rare')}`);
3661
4092
  console.log(` ${dim('value: that is the case where the custom plan is expensive enough for the generic')}`);
@@ -3664,7 +4095,7 @@ function renderDivergenceEvidence(d) {
3664
4095
  console.log(` ${dim(line)}`);
3665
4096
  console.log(` ${dim('Adding the index above is the fix. Confirm first if you want to:')}`);
3666
4097
  for (const line of d.diagnosticSql.split('\n')) {
3667
- console.log(` ${green(line)}`);
4098
+ console.log(` ${green(shown(line))}`);
3668
4099
  }
3669
4100
  console.log(` ${dim('After adding this index, re-run doctor. This column is expected to reappear as a')}`);
3670
4101
  console.log(` ${dim('sparse-value finding in the cached-plan section. That later finding is exposure, not')}`);
@@ -3718,9 +4149,9 @@ function renderFinding(f, opts) {
3718
4149
  const rows = f.score.metrics.rows;
3719
4150
  const rowsLabel = rows !== null ? `~${rows.toLocaleString()} rows` : 'row count unknown';
3720
4151
  const sizeLabel = f.score.metrics.sizeBytes !== null ? `, ${formatBytes(f.score.metrics.sizeBytes)}` : '';
3721
- console.log(` ${yellow(symbols.warning)} ${bold(cyan(m.table))} ${dim(`(${m.columns.join(', ')})`)} ${gray(`${rowsLabel}${sizeLabel}`)}`);
4152
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(m.table)))} ${dim(`(${shown(m.columns.join(', '))})`)} ${gray(`${rowsLabel}${sizeLabel}`)}`);
3722
4153
  for (const p of m.probes) {
3723
- console.log(` ${dim(symbols.tee)} probed by ${p.from}.${blue(p.relation)} ${dim(`(${p.type})`)}`);
4154
+ console.log(` ${dim(symbols.tee)} probed by ${shown(p.from)}.${blue(shown(p.relation))} ${dim(`(${p.type})`)}`);
3724
4155
  }
3725
4156
  if (opts.withReasons) {
3726
4157
  for (const reason of f.score.reasons) {
@@ -3728,7 +4159,7 @@ function renderFinding(f, opts) {
3728
4159
  }
3729
4160
  }
3730
4161
  const last = opts.divergence ? symbols.tee : symbols.teeEnd;
3731
- console.log(` ${dim(last)} ${green(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
4162
+ console.log(` ${dim(last)} ${green(shown(doctorCreateSql(f, { concurrently: opts.concurrently })))}`);
3732
4163
  if (opts.divergence)
3733
4164
  renderDivergenceEvidence(opts.divergence);
3734
4165
  newline();
@@ -3741,8 +4172,8 @@ function renderInvalidIndexes(invalid) {
3741
4172
  console.log(` ${dim('IF NOT EXISTS skips an invalid index on rerun, so it never rebuilds. Drop it, then rerun:')}`);
3742
4173
  newline();
3743
4174
  for (const idx of invalid) {
3744
- console.log(` ${red(symbols.warning)} ${bold(cyan(idx.table))} ${dim(`(${idx.columns.join(', ') || '?'})`)} ${gray(idx.indexName)}`);
3745
- console.log(` ${dim(symbols.teeEnd)} ${green(idx.dropSql)}`);
4175
+ console.log(` ${red(symbols.warning)} ${bold(cyan(shown(idx.table)))} ${dim(`(${shown(idx.columns.join(', ') || '?')})`)} ${gray(shown(idx.indexName))}`);
4176
+ console.log(` ${dim(symbols.teeEnd)} ${green(shown(idx.dropSql))}`);
3746
4177
  newline();
3747
4178
  }
3748
4179
  }
@@ -3835,10 +4266,10 @@ function renderPlanDivergence(divergence, attached) {
3835
4266
  // on (an index that exists but cannot serve the equality: partial,
3836
4267
  // expression, or non-btree). The remedy is still an index, not a
3837
4268
  // plan-cache setting, so this entry never suggests forceCustomPlan.
3838
- console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('UNINDEXED-FILTER FLIP')}`);
4269
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${shown(f.table)}.${shown(f.column)}`))} ${gray('UNINDEXED-FILTER FLIP')}`);
3839
4270
  console.log(` ${dim(symbols.tee)} ${divInt(f.rows)} rows in ${divInt(f.pages)} pages, rarest value ~${bold(divInt(f.rarestBucket))} rows, below the assumed LIMIT ${f.assumedLimit}`);
3840
- console.log(` ${dim(symbols.tee)} no index serves ${f.column} = $1, so the good plan is a seq scan (${divInt(f.pages)} pages);`);
3841
- console.log(` ${dim(`a promoted generic plan keeps the ordered "${f.orderColumn}" walk and reads up to ~${divInt(f.tuplesWalked ?? f.rows)} tuples`)}`);
4271
+ console.log(` ${dim(symbols.tee)} no index serves ${shown(f.column)} = $1, so the good plan is a seq scan (${divInt(f.pages)} pages);`);
4272
+ console.log(` ${dim(`a promoted generic plan keeps the ordered "${shown(f.orderColumn)}" walk and reads up to ~${divInt(f.tuplesWalked ?? f.rows)} tuples`)}`);
3842
4273
  console.log(` ${dim('before it fills the LIMIT.')}`);
3843
4274
  for (const line of divergenceAmplificationLines(f))
3844
4275
  console.log(` ${dim(line)}`);
@@ -3853,13 +4284,13 @@ function renderPlanDivergence(divergence, attached) {
3853
4284
  newline();
3854
4285
  continue;
3855
4286
  }
3856
- console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('SPARSE-VALUE FLIP')}`);
4287
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${shown(f.table)}.${shown(f.column)}`))} ${gray('SPARSE-VALUE FLIP')}`);
3857
4288
  console.log(` ${dim(symbols.tee)} generic estimate ${bold(divInt(f.genericEstimate))} rows ${dim(`(${divInt(f.rows)} rows / ${divInt(f.distinctValues)} distinct values)`)}`);
3858
4289
  console.log(` ${dim(symbols.tee)} rarest value bucket ${bold(divInt(f.rarestBucket))} rows ${dim('(pg_stats most_common_freqs / residual bucket)')}`);
3859
4290
  console.log(` ${dim(symbols.tee)} crossover ${bold(divInt(f.crossoverRows ?? 0))} rows ${dim(`(sqrt(limit ${f.assumedLimit} x ${divInt(f.pages)} pages); ${divInt(f.crossoverRowsWide ?? 0)} at limit ${f.thresholds.wideLimit})`)}`);
3860
4291
  console.log(` ${dim(symbols.tee)} ${dim(`values below the crossover: ${divInt(f.valuesBelowCrossover ?? 0)} of ${divInt(f.distinctValues)}, filter-column correlation ${f.correlation.toFixed(2)}, ${analyzedLabel(f)}`)}`);
3861
4292
  console.log(` ${dim(symbols.tee)} for such a value the generic plan walks ~${bold(divInt(f.walkPages ?? 0))} of ${divInt(f.pages)} pages ${dim(`(${Math.round((f.walkFraction ?? 0) * 100)}% of the table)`)}`);
3862
- console.log(` ${dim(`for reads shaped WHERE ${f.column} = $1 ORDER BY ${f.orderColumn} LIMIT $n,`)}`);
4293
+ console.log(` ${dim(`for reads shaped WHERE ${shown(f.column)} = $1 ORDER BY ${shown(f.orderColumn)} LIMIT $n,`)}`);
3863
4294
  console.log(` ${dim("where the custom plan reads only that value's own rows.")}`);
3864
4295
  console.log(` ${dim(symbols.tee)} ${dim('No amplification figure is printed, deliberately. This models how many rows a')}`);
3865
4296
  console.log(` ${dim('value has, not WHERE they sit in the heap, and the second half can move the')}`);
@@ -3921,7 +4352,7 @@ function renderPlanDivergence(divergence, attached) {
3921
4352
  console.log(` ${dim('in one direction trades this finding for its mirror image. That applies equally to')}`);
3922
4353
  console.log(` ${dim("Turbine's client-level `planCacheMode` and to a SET or ALTER ROLE applied outside")}`);
3923
4354
  console.log(` ${dim('Turbine.')}`);
3924
- console.log(` 5. A composite index on (${first.column}, ${first.orderColumn}) makes the GOOD plan better. It`);
4355
+ console.log(` 5. A composite index on (${shown(first.column)}, ${shown(first.orderColumn)}) makes the GOOD plan better. It`);
3925
4356
  console.log(` ${dim('does NOT stop the generic plan from choosing the other one, and it can widen the gap.')}`);
3926
4357
  console.log(` ${dim('Add it for the custom-plan win, not as a fix for this finding.')}`);
3927
4358
  }
@@ -3946,7 +4377,7 @@ function renderPlanDivergence(divergence, attached) {
3946
4377
  if (notices.length > 0) {
3947
4378
  console.log(` ${dim('Not scored for cached-plan divergence (statistics missing):')}`);
3948
4379
  for (const n of notices) {
3949
- console.log(` ${dim(`- ${n.table}.${n.column}: ${n.reason}`)}`);
4380
+ console.log(` ${dim(`- ${shown(n.table)}.${shown(n.column)}: ${n.reason}`)}`);
3950
4381
  }
3951
4382
  newline();
3952
4383
  }
@@ -4044,7 +4475,7 @@ async function cmdStudio(args, config) {
4044
4475
  spinner.succeed(demo ? 'Demo Studio is running' : 'Studio is running');
4045
4476
  }
4046
4477
  catch (err) {
4047
- spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
4478
+ spinner.fail(`Failed to start Studio: ${errorText(err)}`);
4048
4479
  process.exit(1);
4049
4480
  }
4050
4481
  if (demo) {
@@ -4177,7 +4608,7 @@ async function cmdObserve(args) {
4177
4608
  spinner.succeed('Observe dashboard is running');
4178
4609
  }
4179
4610
  catch (err) {
4180
- spinner.fail(`Failed to start Observe: ${err instanceof Error ? err.message : String(err)}`);
4611
+ spinner.fail(`Failed to start Observe: ${errorText(err)}`);
4181
4612
  process.exit(1);
4182
4613
  }
4183
4614
  newline();
@@ -4285,10 +4716,12 @@ function showGenerateHelp() {
4285
4716
  console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema ${dim('(default: public)')}`);
4286
4717
  console.log(` ${cyan('--include')} ${dim('<tables>')} Comma-separated tables to include`);
4287
4718
  console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
4719
+ console.log(` ${cyan('--schema-file')} ${dim('<path>')} Your ${cyan('defineSchema()')} file, whose ${cyan('pii')} / ${cyan('updatedAt')} tags are applied ${dim('(default: ./turbine/schema.ts)')}`);
4288
4720
  console.log(` ${cyan('--zod')} Also emit ${cyan('zod.ts')} validation schemas ${dim('(needs the zod dep)')}`);
4289
4721
  console.log(` ${cyan('--include-views')} Include views + materialized views as read-only entities`);
4290
4722
  console.log(` ${cyan('--no-timestamp')} Omit the ${dim('Generated at:')} header line ${dim('(reproducible, diff-stable output)')}`);
4291
- console.log(` ${cyan('--import-ext')} ${dim('<mode>')} Sibling-import extension: ${cyan('js')} / ${cyan('none')} / ${cyan('auto')} ${dim('(default: auto)')}`);
4723
+ console.log(` ${cyan('--import-ext')} ${dim('<mode>')} Sibling-import extension: ${cyan('js')} / ${cyan('ts')} / ${cyan('none')} / ${cyan('auto')} ${dim('(default: auto)')}`);
4724
+ console.log(` ${dim('ts emits ./types.ts so')} ${cyan('node app.ts')} ${dim('can run the client with native type stripping')}`);
4292
4725
  console.log(` ${cyan('--keep-column-names')} Keep raw DB column names as field names ${dim('(snake_case, not camelCase)')}`);
4293
4726
  console.log(` ${cyan('--legacy-to-many-uniques')} Emit ${cyan('hasMany')} for unique-FK children ${dim('(pre-0.41 shape; default flips to hasOne)')}`);
4294
4727
  console.log(` ${cyan('--allow-empty')} Generate even when introspection matches 0 tables`);
@@ -4345,6 +4778,7 @@ function showPushHelp() {
4345
4778
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
4346
4779
  console.log(` ${cyan('--schema-file')} ${dim('<path>')} Your ${cyan('defineSchema()')} file ${dim('(default: ./turbine/schema.ts)')}`);
4347
4780
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
4781
+ console.log(` ${cyan('--exit-code')} With ${cyan('--dry-run')}: exit 2 when the database differs from the schema file, 0 when in sync ${dim('(CI)')}`);
4348
4782
  console.log(` ${cyan('--allow-destructive')} Skip the interactive confirmation for data-destroying statements ${dim('(CI)')}`);
4349
4783
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
4350
4784
  newline();
@@ -4371,7 +4805,11 @@ function showMigrateHelp() {
4371
4805
  console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a sanctioned migration pattern ${dim('(create only, e.g. backfill)')}`);
4372
4806
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
4373
4807
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
4808
+ console.log(` ${cyan('--exit-code')} Exit 2 when migrations are pending, modified, missing or misnamed ${dim('(status only, CI)')}`);
4809
+ console.log(` ${cyan('--wait')}${dim('[=<seconds>]')} Wait for a concurrent deploy to release the lock, default ${DEFAULT_LOCK_WAIT_SECONDS}s ${dim('(deploy only)')}`);
4810
+ console.log(` ${cyan('--allow-pending')} Let ${cyan('--auto')} / ${cyan('--from-diff')} run while earlier migrations are still pending ${dim('(create only)')}`);
4374
4811
  console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums, REWRITING history ${dim('(up / deploy / down)')}`);
4812
+ console.log(` ${cyan('--allow-pooler')} Run ${cyan('up')} / ${cyan('deploy')} / ${cyan('down')} through a transaction pooler ${dim('(refused: the migration lock is session-level)')}`);
4375
4813
  console.log(` ${cyan('--allow-destructive')} Run data-destroying migration statements without the interactive confirm`);
4376
4814
  console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema the migrations run in ${dim('(default: public)')}`);
4377
4815
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
@@ -4383,8 +4821,10 @@ function showMigrateHelp() {
4383
4821
  console.log(` ${dim('$')} npx turbine migrate create backfill_full_name --recipe backfill`);
4384
4822
  console.log(` ${dim('$')} npx turbine migrate up`);
4385
4823
  console.log(` ${dim('$')} npx turbine migrate deploy --dry-run`);
4824
+ console.log(` ${dim('$')} npx turbine migrate deploy --wait=120`);
4386
4825
  console.log(` ${dim('$')} npx turbine migrate down --step 2`);
4387
4826
  console.log(` ${dim('$')} npx turbine migrate status`);
4827
+ console.log(` ${dim('$')} npx turbine migrate status --exit-code`);
4388
4828
  newline();
4389
4829
  }
4390
4830
  function showSeedHelp() {
@@ -4608,8 +5048,12 @@ function showHelp() {
4608
5048
  console.log(` ${cyan('--from-diff')} Generate from schema diff, destructive statements flagged ${dim('(create)')}`);
4609
5049
  console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a named migration recipe, e.g. backfill ${dim('(create)')}`);
4610
5050
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
5051
+ console.log(` ${cyan('--exit-code')} Exit 2 on drift ${dim('(push --dry-run, migrate status)')}`);
5052
+ console.log(` ${cyan('--wait')}${dim('[=<seconds>]')} Wait for a concurrent deploy's lock instead of failing ${dim('(deploy)')}`);
5053
+ console.log(` ${cyan('--allow-pending')} Diff even while migrations are pending ${dim('(create --auto / --from-diff)')}`);
4611
5054
  console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting ${dim('(up/down/push)')}`);
4612
5055
  console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums on ${cyan('migrate up')} / ${cyan('deploy')} / ${cyan('down')} ${dim('(advanced)')}`);
5056
+ console.log(` ${cyan('--allow-pooler')} Let ${cyan('migrate up')} / ${cyan('deploy')} / ${cyan('down')} and ${cyan('doctor')} use a transaction pooler ${dim('(refused by default)')}`);
4613
5057
  newline();
4614
5058
  console.log(` ${bold('Init options:')}`);
4615
5059
  console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
@@ -4726,9 +5170,19 @@ function cmdSkill(args) {
4726
5170
  }
4727
5171
  const skillsDir = args.dir ?? join('.claude', 'skills');
4728
5172
  const target = resolve(process.cwd(), skillsDir, 'turbine-orm', 'SKILL.md');
4729
- const existed = existsSync(target);
4730
5173
  mkdirSync(dirname(target), { recursive: true });
4731
- writeFileSync(target, body);
5174
+ // Exclusive create first: only the wording depends on whether the file was
5175
+ // there, and asking separately would let the answer go stale before the write.
5176
+ let existed = false;
5177
+ try {
5178
+ writeFileSync(target, body, { flag: 'wx' });
5179
+ }
5180
+ catch (err) {
5181
+ if (err.code !== 'EEXIST')
5182
+ throw err;
5183
+ existed = true;
5184
+ writeFileSync(target, body);
5185
+ }
4732
5186
  newline();
4733
5187
  success(`${existed ? 'Updated' : 'Installed'} the Turbine query skill`);
4734
5188
  console.log(` ${dim(relative(process.cwd(), target))}`);
@@ -4791,7 +5245,7 @@ async function main() {
4791
5245
  // expected here; anything that does arrive is a bug in the parser rather
4792
5246
  // than in the caller's command line, and printing it beats swallowing it.
4793
5247
  errorLine();
4794
- error(err instanceof Error ? err.message : String(err));
5248
+ error(errorText(err));
4795
5249
  errorLine();
4796
5250
  process.exit(1);
4797
5251
  }
@@ -4924,6 +5378,9 @@ async function main() {
4924
5378
  }
4925
5379
  catch (err) {
4926
5380
  if (err instanceof Error) {
5381
+ // `errorText`, not `message`: a refused `localhost` is an
5382
+ // AggregateError with an empty message (see cli/error-text.ts).
5383
+ const message = errorText(err);
4927
5384
  // TYPE first, message second. These branches sniff for substrings, and
4928
5385
  // `ValidationError` beat them by accident: a refused `--schema` value says
4929
5386
  // "used as a connection parameter", which contains "connection", so a bad
@@ -4931,35 +5388,35 @@ async function main() {
4931
5388
  // firewall hints. A typed error already knows what it is.
4932
5389
  if (err instanceof ValidationError) {
4933
5390
  errorLine();
4934
- error(redactUrl(err.message));
5391
+ error(redactUrl(message));
4935
5392
  if (args.verbose && err.stack) {
4936
5393
  errorLine();
4937
5394
  console.error(dim(redactUrl(err.stack)));
4938
5395
  }
4939
5396
  }
4940
- else if (err.message.includes('ECONNREFUSED') || err.message.includes('connection')) {
5397
+ else if (message.includes('ECONNREFUSED') || message.includes('connection')) {
4941
5398
  errorLine();
4942
5399
  error(`Could not connect to database`);
4943
- errorLine(`${dim(redactUrl(err.message))}`);
5400
+ errorLine(`${dim(redactUrl(message))}`);
4944
5401
  errorLine();
4945
5402
  errorLine(`${dim('Check that:')}`);
4946
5403
  errorLine(` ${dim('1.')} Your database is running`);
4947
5404
  errorLine(` ${dim('2.')} The connection string is correct`);
4948
5405
  errorLine(` ${dim('3.')} Network/firewall allows the connection`);
4949
5406
  }
4950
- else if (err.message.includes('authentication')) {
5407
+ else if (message.includes('authentication')) {
4951
5408
  errorLine();
4952
5409
  error(`Authentication failed`);
4953
- errorLine(`${dim(redactUrl(err.message))}`);
5410
+ errorLine(`${dim(redactUrl(message))}`);
4954
5411
  }
4955
- else if (err.message.includes('does not exist')) {
5412
+ else if (message.includes('does not exist')) {
4956
5413
  errorLine();
4957
5414
  error(`Database or schema not found`);
4958
- errorLine(`${dim(redactUrl(err.message))}`);
5415
+ errorLine(`${dim(redactUrl(message))}`);
4959
5416
  }
4960
5417
  else {
4961
5418
  errorLine();
4962
- error(redactUrl(err.message));
5419
+ error(redactUrl(message));
4963
5420
  if (args.verbose && err.stack) {
4964
5421
  errorLine();
4965
5422
  console.error(dim(redactUrl(err.stack)));