turbine-orm 0.77.1 → 0.78.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 (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
package/dist/cli/index.js CHANGED
@@ -29,7 +29,8 @@ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFi
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';
32
- import { detectPooler, poolerRefusalMessage } from '../connection-url.js';
32
+ import { detectPooler, parseSearchPathValue, poolerRefusalMessage } from '../connection-url.js';
33
+ import { ValidationError } from '../errors.js';
33
34
  import { generate, generatePrismaMap, resolveImportExtension } from '../generate.js';
34
35
  import { buildCreateIndexSql, buildCreateIndexStatements, buildDropIndexSql, collectDoctorProbeIndexNames, collectRelationProbeColumns, findMissingRelationIndexes, } from '../index-advisor.js';
35
36
  import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
@@ -44,7 +45,7 @@ import { configTemplate, connectionStringHasPassword, DEFAULT_INIT_SEED_FILE, fi
44
45
  import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
45
46
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
46
47
  import { runMcpServer } from './mcp.js';
47
- import { buildDiffMigrationBody, collectUpDestructive, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, parseMigrationContent, } from './migrate.js';
48
+ import { assertNoEmbeddedTransactions, assertPinnableSchema, assertSchemaExists, assertUpHasStatements, buildDiffMigrationBody, collectUpDestructive, connectionStringForSchema, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listIgnoredSqlFiles, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, parseMigrationContent, planMigrationRollback, } from './migrate.js';
48
49
  import { startObserve } from './observe.js';
49
50
  import { formatPrismaReport, summaryLines } from './prisma-report.js';
50
51
  import { DEFAULT_EXCLUDED_TABLES, resolvePrismaSchema } from './prisma-resolve.js';
@@ -299,6 +300,8 @@ export function parseArgs(argv = process.argv.slice(2)) {
299
300
  // command instead, which is the actual problem.
300
301
  const accepted = acceptedFlagTokens(result.command);
301
302
  const unknownFlags = [];
303
+ /** The raw `--step` operand, judged after the loop. Absent means unset. */
304
+ let stepRaw;
302
305
  for (; i < args.length; i++) {
303
306
  const arg = args[i];
304
307
  const next = args[i + 1];
@@ -337,10 +340,17 @@ export function parseArgs(argv = process.argv.slice(2)) {
337
340
  i++;
338
341
  break;
339
342
  case '--step':
340
- case '-n':
341
- result.step = next ? parseInt(next, 10) : undefined;
343
+ case '-n': {
344
+ // The VALUE is captured here and JUDGED after the loop, see
345
+ // `stepRaw` below. `--step -1` reached `applied.reverse().slice(0, -1)`,
346
+ // which is "every migration except the oldest" and began tearing down
347
+ // the history newest-first; `--step 0` and `--step abc` (NaN) were
348
+ // silent no-ops that look like a successful rollback. A count of things
349
+ // to do is a positive integer or it is a mistake.
350
+ stepRaw = next ?? '';
342
351
  i++;
343
352
  break;
353
+ }
344
354
  case '--dry-run':
345
355
  result.dryRun = true;
346
356
  break;
@@ -503,6 +513,21 @@ export function parseArgs(argv = process.argv.slice(2)) {
503
513
  // are two lines of one error rather than two runs of the CLI.
504
514
  if (unknownFlags.length > 0)
505
515
  failUnknownFlags(result.command, unknownFlags);
516
+ // Flag VALUES are judged last, and only for a command that exists. Both halves
517
+ // are the fix for one report: `turbine genrate --step 0` used to complain
518
+ // about `--step` and never mention the misspelled command, because the check
519
+ // ran mid-loop and threw past `main()`'s dispatch. And it threw a bare
520
+ // `ValidationError` rather than calling `failArg`, so it printed one unstyled
521
+ // sentence with no banner and no hint, which reads like an internal crash.
522
+ // `accepted === undefined` means the command is unknown; main() reports that,
523
+ // which is the actual problem.
524
+ if (stepRaw !== undefined && accepted !== undefined) {
525
+ const parsed = /^\d+$/.test(stepRaw.trim()) ? Number.parseInt(stepRaw, 10) : Number.NaN;
526
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
527
+ failArg(`${cyan('--step, -n')} expects a positive whole number of migrations, got ${bold(stepRaw === '' ? '(nothing)' : `"${stepRaw}"`)}.`, 'It is a COUNT of migrations to apply or roll back, so the smallest useful value is 1.', 'Example: npx turbine migrate down --step 2');
528
+ }
529
+ result.step = parsed;
530
+ }
506
531
  return result;
507
532
  }
508
533
  // ---------------------------------------------------------------------------
@@ -1285,6 +1310,7 @@ async function runInitGenerate(config, url) {
1285
1310
  const spinner = new Spinner('Introspecting database').start();
1286
1311
  try {
1287
1312
  const schema = await introspect({
1313
+ keepColumnNames: config.keepColumnNames,
1288
1314
  connectionString: url,
1289
1315
  schema: config.schema,
1290
1316
  include: config.include.length ? config.include : undefined,
@@ -1549,7 +1575,7 @@ async function cmdInit(args, config) {
1549
1575
  }
1550
1576
  switch (step.id) {
1551
1577
  case 'config': {
1552
- writeFileSync('turbine.config.ts', configTemplate(args.url ?? undefined), 'utf-8');
1578
+ writeFileSync('turbine.config.ts', configTemplate(args.url ?? undefined, config.schema), 'utf-8');
1553
1579
  success(state.configExists ? `Overwrote ${cyan('turbine.config.ts')}` : `Created ${cyan('turbine.config.ts')}`);
1554
1580
  tsFilesWritten.push('turbine.config.ts');
1555
1581
  // A `--url` with a password is never inlined by configTemplate, so the
@@ -1712,6 +1738,7 @@ async function cmdGenerate(args, config) {
1712
1738
  const spinner = new Spinner('Introspecting database schema').start();
1713
1739
  const skippedInternalTables = [];
1714
1740
  const schema = await introspect({
1741
+ keepColumnNames: config.keepColumnNames,
1715
1742
  connectionString: url,
1716
1743
  schema: config.schema,
1717
1744
  include: config.include.length ? config.include : undefined,
@@ -1912,6 +1939,7 @@ async function cmdMigrateFromPrisma(args, config) {
1912
1939
  label('Database', redactUrl(url));
1913
1940
  const spinner = new Spinner('Introspecting database schema').start();
1914
1941
  schemaMeta = await introspect({
1942
+ keepColumnNames: config.keepColumnNames,
1915
1943
  connectionString: url,
1916
1944
  // The Postgres NAMESPACE is fixed to `public` here (`--schema` names the
1917
1945
  // Prisma file, not the namespace).
@@ -2161,7 +2189,7 @@ async function cmdMigrate(args, config) {
2161
2189
  console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
2162
2190
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
2163
2191
  console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting`);
2164
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('up')} / ${cyan('deploy')} ${dim('(advanced)')}`);
2192
+ console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums on ${cyan('up')} / ${cyan('deploy')} / ${cyan('down')} ${dim('(advanced)')}`);
2165
2193
  newline();
2166
2194
  console.log(` ${bold('Recipes')} ${dim('(--recipe):')}`);
2167
2195
  for (const [key, recipe] of Object.entries(MIGRATION_RECIPES)) {
@@ -2452,6 +2480,7 @@ async function cmdMigrateUp(args, config) {
2452
2480
  label('Database', redactUrl(url));
2453
2481
  label('Migrations', config.migrationsDir);
2454
2482
  newline();
2483
+ warnIgnoredMigrationFiles(config.migrationsDir);
2455
2484
  const allFiles = listMigrationFiles(config.migrationsDir);
2456
2485
  if (allFiles.length === 0) {
2457
2486
  warn('No migration files found.');
@@ -2460,19 +2489,29 @@ async function cmdMigrateUp(args, config) {
2460
2489
  return;
2461
2490
  }
2462
2491
  if (args.dryRun) {
2463
- const status = await migrateStatus(url, config.migrationsDir);
2492
+ const status = await migrateStatus(url, config.migrationsDir, { schema: config.schema });
2464
2493
  let pending = status.filter((st) => !st.applied).map((st) => st.file);
2465
2494
  if (args.step != null && args.step > 0)
2466
2495
  pending = pending.slice(0, args.step);
2496
+ // The same pre-flight the real run does, by calling the same two functions
2497
+ // rather than re-deriving their rules. They live inside `migrateUp`, which a
2498
+ // dry run never reaches, so `--dry-run` used to green-light a batch the real
2499
+ // command refuses outright: an empty-UP migration previewed as "would apply
2500
+ // 1 migration" and exit 0, and the deploy that followed on merge failed.
2501
+ // A preview that is quieter than the run it previews is not a preview.
2502
+ assertNoEmbeddedTransactions(pending, 'up');
2503
+ assertUpHasStatements(pending);
2467
2504
  printMigrationDryRun(pending, 'up');
2468
2505
  return;
2469
2506
  }
2470
2507
  // Big, loud warning when bypassing drift detection, this is a deliberately
2471
2508
  // dangerous operation and the user should see it on every invocation.
2472
2509
  if (args.allowDrift) {
2473
- warn('--allow-drift is set, checksum validation is DISABLED for this run.');
2474
- console.log(` ${dim('Applied migrations may have been modified or deleted on disk.')}`);
2475
- console.log(` ${dim('Proceed only if you are intentionally rewriting migration history.')}`);
2510
+ warn('--allow-drift is set: a drifted migration will be RE-BASELINED, not skipped.');
2511
+ console.log(` ${dim('Checksum validation still runs, and its result is written back to the history')}`);
2512
+ console.log(` ${dim("table: each modified file's stored checksum becomes its current content.")}`);
2513
+ console.log(` ${dim('The SQL that already ran is NOT re-run. Proceed only if you are')}`);
2514
+ console.log(` ${dim('intentionally rewriting migration history.')}`);
2476
2515
  newline();
2477
2516
  }
2478
2517
  if (args.allowDestructive) {
@@ -2498,6 +2537,7 @@ async function cmdMigrateUp(args, config) {
2498
2537
  allowDrift: args.allowDrift,
2499
2538
  allowDestructive: args.allowDestructive,
2500
2539
  onNoTransaction,
2540
+ schema: config.schema,
2501
2541
  });
2502
2542
  }
2503
2543
  catch (err) {
@@ -2515,19 +2555,26 @@ async function cmdMigrateUp(args, config) {
2515
2555
  allowDrift: args.allowDrift,
2516
2556
  allowDestructive: true,
2517
2557
  onNoTransaction,
2558
+ schema: config.schema,
2518
2559
  });
2519
2560
  }
2520
- if (result.applied.length === 0 && result.errors.length === 0) {
2521
- spinner.succeed('All migrations are up to date');
2522
- newline();
2523
- return;
2524
- }
2525
2561
  if (result.applied.length > 0) {
2526
2562
  spinner.succeed(`Applied ${bold(String(result.applied.length))} migration(s)`);
2527
2563
  for (const file of result.applied) {
2528
2564
  console.log(` ${green(symbols.check)} ${file.filename}`);
2529
2565
  }
2530
2566
  }
2567
+ else if (result.errors.length === 0) {
2568
+ spinner.succeed('All migrations are up to date');
2569
+ }
2570
+ // Reported on EVERY path, including the one where nothing was applied. That is
2571
+ // `--allow-drift`'s most common case by far (you edited an already-applied
2572
+ // file, so nothing is pending), and it used to sit below an early return: the
2573
+ // stored checksum was rewritten and the terminal said everything was up to
2574
+ // date. A write to migration history announced as a no-op is worse than no
2575
+ // report at all, because the reader now believes the file and the database
2576
+ // still disagree.
2577
+ reportRebaselined(result.rebaselined);
2531
2578
  warnOutOfOrder(result.outOfOrder);
2532
2579
  if (result.errors.length > 0) {
2533
2580
  spinner.fail('Migration failed');
@@ -2540,6 +2587,40 @@ async function cmdMigrateUp(args, config) {
2540
2587
  }
2541
2588
  newline();
2542
2589
  }
2590
+ /**
2591
+ * Report each applied migration whose stored checksum was re-baselined to the
2592
+ * file's current content, naming both hashes so the change is auditable from
2593
+ * the terminal output alone.
2594
+ */
2595
+ function reportRebaselined(rebaselined) {
2596
+ if (rebaselined.length === 0)
2597
+ return;
2598
+ newline();
2599
+ warn(`Re-baselined ${bold(String(rebaselined.length))} migration checksum(s) to the current file content:`);
2600
+ for (const r of rebaselined) {
2601
+ console.log(` ${yellow(symbols.arrowRight)} ${r.name}.sql ${dim(`${r.from.slice(0, 12)} -> ${r.to.slice(0, 12)}`)}`);
2602
+ }
2603
+ console.log(` ${dim('The history table now records what is on disk, so future runs need no --allow-drift.')}`);
2604
+ newline();
2605
+ }
2606
+ /**
2607
+ * Warn about `.sql` files in the migrations directory that the runner will
2608
+ * never see, because their name is not `YYYYMMDDHHMMSS_<name>.sql`.
2609
+ *
2610
+ * They were skipped in silence by `status`, `up` and `deploy` alike, so a
2611
+ * hand-named migration simply never ran and nothing said so.
2612
+ */
2613
+ function warnIgnoredMigrationFiles(migrationsDir) {
2614
+ const ignored = listIgnoredSqlFiles(migrationsDir);
2615
+ if (ignored.length === 0)
2616
+ return;
2617
+ warn(`${bold(String(ignored.length))} .sql file(s) in ${migrationsDir} are NOT migrations and will never run:`);
2618
+ for (const name of ignored)
2619
+ console.log(` ${yellow(symbols.dot)} ${name}`);
2620
+ console.log(` ${dim('A migration filename must be')} ${cyan('YYYYMMDDHHMMSS_name.sql')}${dim('.')}`);
2621
+ console.log(` ${dim('Rename the file(s), or create them with')} ${cyan('npx turbine migrate create <name>')}${dim('.')}`);
2622
+ newline();
2623
+ }
2543
2624
  /** Print a one-line warning for each migration applied out of timestamp order. */
2544
2625
  function warnOutOfOrder(outOfOrder) {
2545
2626
  if (outOfOrder.length === 0)
@@ -2577,8 +2658,9 @@ async function cmdMigrateDeploy(args, config) {
2577
2658
  label('Database', redactUrl(url));
2578
2659
  label('Migrations', config.migrationsDir);
2579
2660
  newline();
2661
+ warnIgnoredMigrationFiles(config.migrationsDir);
2580
2662
  const spinner = new Spinner('Checking pending migrations').start();
2581
- const plan = await inspectMigrationDeploy(url, config.migrationsDir);
2663
+ const plan = await inspectMigrationDeploy(url, config.migrationsDir, { schema: config.schema });
2582
2664
  spinner.stop();
2583
2665
  // Drift handling: honor --allow-drift exactly like `up`. Without it, block;
2584
2666
  // with it, warn loudly and proceed.
@@ -2592,8 +2674,10 @@ async function cmdMigrateDeploy(args, config) {
2592
2674
  errorLine();
2593
2675
  process.exit(1);
2594
2676
  }
2595
- warn('--allow-drift is set: checksum validation is DISABLED for this deploy.');
2596
- console.log(` ${dim('Applied migrations may have been modified or deleted on disk.')}`);
2677
+ warn('--allow-drift is set: a drifted migration will be RE-BASELINED, not skipped.');
2678
+ console.log(` ${dim('Checksum validation still runs, and its result is written back to the history')}`);
2679
+ console.log(` ${dim("table: each modified file's stored checksum becomes its current content.")}`);
2680
+ console.log(` ${dim('The SQL that already ran is NOT re-run.')}`);
2597
2681
  newline();
2598
2682
  }
2599
2683
  if (args.dryRun) {
@@ -2602,10 +2686,21 @@ async function cmdMigrateDeploy(args, config) {
2602
2686
  newline();
2603
2687
  return;
2604
2688
  }
2689
+ // The same pre-flight the real run does, by calling the same two functions
2690
+ // rather than re-deriving their rules. They live inside `migrateUp`, which a
2691
+ // dry run never reaches, so `--dry-run` used to green-light a batch the real
2692
+ // command refuses outright: an empty-UP migration previewed as "would apply
2693
+ // 1 migration" and exit 0, and the deploy that followed on merge failed.
2694
+ // A preview that is quieter than the run it previews is not a preview.
2695
+ assertNoEmbeddedTransactions(plan.pending, 'up');
2696
+ assertUpHasStatements(plan.pending);
2605
2697
  info(`${bold(String(plan.pending.length))} pending migration(s)`);
2606
2698
  for (const file of plan.pending) {
2607
2699
  console.log(` ${yellow(symbols.dot)} ${file.filename}`);
2608
2700
  }
2701
+ // The real deploy warns about out-of-order applies; a preview that is
2702
+ // quieter than the run it previews is not a preview.
2703
+ warnOutOfOrder(plan.outOfOrder);
2609
2704
  // Surface destructive statements even in a dry run so CI can see them.
2610
2705
  const destructive = collectUpDestructive(plan.pending);
2611
2706
  if (destructive.length > 0) {
@@ -2620,18 +2715,24 @@ async function cmdMigrateDeploy(args, config) {
2620
2715
  if (destructive.length > 0)
2621
2716
  printDestructiveNotice(destructive);
2622
2717
  const runSpinner = new Spinner('Deploying migrations').start();
2623
- const result = await migrateDeploy(url, config.migrationsDir, { allowDrift: args.allowDrift });
2624
- if (result.applied.length === 0 && result.errors.length === 0) {
2625
- runSpinner.succeed('0 applied, all migrations are up to date');
2626
- newline();
2627
- return;
2628
- }
2718
+ const result = await migrateDeploy(url, config.migrationsDir, { allowDrift: args.allowDrift, schema: config.schema });
2629
2719
  if (result.applied.length > 0) {
2630
2720
  runSpinner.succeed(`${bold(String(result.applied.length))} applied`);
2631
2721
  for (const file of result.applied) {
2632
2722
  console.log(` ${green(symbols.check)} ${file.filename}`);
2633
2723
  }
2634
2724
  }
2725
+ else if (result.errors.length === 0) {
2726
+ runSpinner.succeed('0 applied, all migrations are up to date');
2727
+ }
2728
+ // Reported on EVERY path, including the one where nothing was applied. That is
2729
+ // `--allow-drift`'s most common case by far (you edited an already-applied
2730
+ // file, so nothing is pending), and it used to sit below an early return: the
2731
+ // stored checksum was rewritten and the terminal said everything was up to
2732
+ // date. A write to migration history announced as a no-op is worse than no
2733
+ // report at all, because the reader now believes the file and the database
2734
+ // still disagree.
2735
+ reportRebaselined(result.rebaselined);
2635
2736
  warnOutOfOrder(result.outOfOrder);
2636
2737
  if (result.errors.length > 0) {
2637
2738
  runSpinner.fail('Deploy failed');
@@ -2689,11 +2790,24 @@ async function cmdMigrateDown(args, config) {
2689
2790
  label('Migrations', config.migrationsDir);
2690
2791
  newline();
2691
2792
  if (args.dryRun) {
2692
- const status = await migrateStatus(url, config.migrationsDir);
2693
- // Rollback order is newest-applied first, and a migration whose file is
2694
- // missing has no DOWN section to show.
2695
- const applied = status.filter((st) => st.applied && !st.missingFile).map((st) => st.file);
2696
- printMigrationDryRun(applied.reverse().slice(0, args.step ?? 1), 'down');
2793
+ // Computed by the RUNNER'S rule (planMigrationRollback), not re-derived
2794
+ // from `status`. The old derivation sorted by filename and dropped applied
2795
+ // migrations whose file is missing, so with a deleted file it named the
2796
+ // migration AFTER the gap while the real run stopped at the gap.
2797
+ const plan = await planMigrationRollback(url, config.migrationsDir, {
2798
+ step: args.step ?? 1,
2799
+ schema: config.schema,
2800
+ // The real rollback refuses on drift unless this is set, so the preview
2801
+ // has to be told too: without it a deleted migration file previewed as
2802
+ // "would roll back 1 migration" while `migrate down` refused with E006.
2803
+ allowDrift: args.allowDrift,
2804
+ });
2805
+ printMigrationDryRun(plan.toRollback, 'down');
2806
+ if (plan.stoppedAt) {
2807
+ warn(`Rollback would STOP at ${bold(`${plan.stoppedAt.name}.sql`)}: ${plan.stoppedAt.error}`);
2808
+ console.log(` ${dim('A rollback batch is strictly LIFO with no gaps, so nothing after it would run.')}`);
2809
+ newline();
2810
+ }
2697
2811
  return;
2698
2812
  }
2699
2813
  const spinner = new Spinner('Rolling back migration(s)').start();
@@ -2702,6 +2816,8 @@ async function cmdMigrateDown(args, config) {
2702
2816
  result = await migrateDown(url, config.migrationsDir, {
2703
2817
  step: args.step ?? 1,
2704
2818
  allowDestructive: args.allowDestructive,
2819
+ allowDrift: args.allowDrift,
2820
+ schema: config.schema,
2705
2821
  });
2706
2822
  }
2707
2823
  catch (err) {
@@ -2717,19 +2833,27 @@ async function cmdMigrateDown(args, config) {
2717
2833
  result = await migrateDown(url, config.migrationsDir, {
2718
2834
  step: args.step ?? 1,
2719
2835
  allowDestructive: true,
2836
+ allowDrift: args.allowDrift,
2837
+ schema: config.schema,
2720
2838
  });
2721
2839
  }
2722
- if (result.rolledBack.length === 0 && result.errors.length === 0) {
2723
- spinner.succeed('No migrations to roll back');
2724
- newline();
2725
- return;
2726
- }
2727
2840
  if (result.rolledBack.length > 0) {
2728
2841
  spinner.succeed(`Rolled back ${bold(String(result.rolledBack.length))} migration(s)`);
2729
2842
  for (const file of result.rolledBack) {
2730
2843
  console.log(` ${yellow(symbols.arrowRight)} ${file.filename}`);
2731
2844
  }
2732
2845
  }
2846
+ else if (result.errors.length === 0) {
2847
+ spinner.succeed('No migrations to roll back');
2848
+ }
2849
+ // Reported on EVERY path, including the one where nothing was applied. That is
2850
+ // `--allow-drift`'s most common case by far (you edited an already-applied
2851
+ // file, so nothing is pending), and it used to sit below an early return: the
2852
+ // stored checksum was rewritten and the terminal said everything was up to
2853
+ // date. A write to migration history announced as a no-op is worse than no
2854
+ // report at all, because the reader now believes the file and the database
2855
+ // still disagree.
2856
+ reportRebaselined(result.rebaselined);
2733
2857
  if (result.errors.length > 0) {
2734
2858
  spinner.fail('Rollback failed');
2735
2859
  for (const { file, error: msg } of result.errors) {
@@ -2754,7 +2878,8 @@ async function cmdMigrateStatus(_args, config) {
2754
2878
  newline();
2755
2879
  return;
2756
2880
  }
2757
- const statuses = await migrateStatus(url, config.migrationsDir);
2881
+ warnIgnoredMigrationFiles(config.migrationsDir);
2882
+ const statuses = await migrateStatus(url, config.migrationsDir, { schema: config.schema });
2758
2883
  const appliedCount = statuses.filter((s) => s.applied).length;
2759
2884
  const pendingCount = statuses.filter((s) => !s.applied).length;
2760
2885
  info(`${bold(String(appliedCount))} applied, ${pendingCount > 0 ? yellow(bold(String(pendingCount))) : bold(String(pendingCount))} pending`);
@@ -2822,10 +2947,61 @@ export function getSeedExecutionPlan(seedFile) {
2822
2947
  }
2823
2948
  throw new Error(`Unsupported seed file extension: ${ext || '(none)'}. Use seed.ts, seed.js, or seed.sql.`);
2824
2949
  }
2950
+ /**
2951
+ * The seed's pinned connection string, refusing a configured schema that does
2952
+ * not exist before any seed kind runs.
2953
+ *
2954
+ * `migrate` gained that guard and `seed` did not, because the seed reached
2955
+ * `connectionStringForSchema` directly instead of going through the migration
2956
+ * runner's connect helper. So seeding a project whose schema was missing (a
2957
+ * typo, or a fresh database nobody had created it in) failed with a bare
2958
+ * `relation "..." does not exist`: no error code, no schema name, no mention of
2959
+ * search_path or turbine.config.ts. That is verbatim the failure the guard
2960
+ * exists to prevent.
2961
+ *
2962
+ * Checked ONCE here rather than in each of the three seed kinds, because that
2963
+ * is the only place all three still share: the `.sql` kind opens its own client
2964
+ * below, while the `.ts` and `.js` kinds connect somewhere this process cannot
2965
+ * reach (a `tsx` CHILD PROCESS, and the user's own `defineSeed` callback), both
2966
+ * of them through the `DATABASE_URL` this function returns.
2967
+ *
2968
+ * The probe also reads the connection's own `search_path`, so the pin EXTENDS
2969
+ * it rather than replacing it, exactly as `migrate` does. Without that, seeding
2970
+ * into a pinned schema stops resolving extension types and functions that the
2971
+ * unpinned connection could see.
2972
+ */
2973
+ async function seedConnectionString(config) {
2974
+ const schema = config.schema;
2975
+ if (schema === undefined || schema === '')
2976
+ return config.url;
2977
+ // Before the connection: an unpinnable NAME is not a missing schema.
2978
+ assertPinnableSchema(schema);
2979
+ const { default: pg } = await import('pg');
2980
+ const probe = new pg.Client({ connectionString: config.url });
2981
+ await probe.connect();
2982
+ let inherited;
2983
+ try {
2984
+ await assertSchemaExists(probe, schema);
2985
+ const shown = await probe.query('SHOW search_path');
2986
+ inherited = parseSearchPathValue(shown.rows[0]?.search_path ?? '');
2987
+ }
2988
+ finally {
2989
+ await probe.end();
2990
+ }
2991
+ return connectionStringForSchema(config.url, schema, inherited);
2992
+ }
2825
2993
  async function runSeedPlan(plan, config) {
2826
2994
  const oldDatabaseUrl = process.env.DATABASE_URL;
2827
- if (config.url)
2828
- process.env.DATABASE_URL = config.url;
2995
+ // The seed connects through DATABASE_URL, whichever way it runs: `defineSeed`
2996
+ // reads it in the tsx child and in the in-process `import()`, and the `.sql`
2997
+ // path opens a client on it below. So the configured schema is applied HERE,
2998
+ // once, as the `options=-c search_path` connection parameter (never a `SET`),
2999
+ // and every seed kind inherits it. Without this the seed ran against the
3000
+ // role's default path while `push` and the generated client used
3001
+ // `config.schema`, so the two halves of one project wrote to two namespaces.
3002
+ const seedUrl = config.url ? await seedConnectionString(config) : undefined;
3003
+ if (seedUrl)
3004
+ process.env.DATABASE_URL = seedUrl;
2829
3005
  try {
2830
3006
  if (plan.kind === 'tsx') {
2831
3007
  if (!canResolveTsx()) {
@@ -2844,7 +3020,7 @@ async function runSeedPlan(plan, config) {
2844
3020
  stdio: 'inherit',
2845
3021
  env: {
2846
3022
  ...process.env,
2847
- DATABASE_URL: config.url || process.env.DATABASE_URL,
3023
+ DATABASE_URL: seedUrl || process.env.DATABASE_URL,
2848
3024
  TURBINE_SEED_SENTINEL: sentinel,
2849
3025
  },
2850
3026
  });
@@ -2867,7 +3043,10 @@ async function runSeedPlan(plan, config) {
2867
3043
  await mod.default();
2868
3044
  return;
2869
3045
  }
2870
- const url = requireUrl(config);
3046
+ // `seedUrl` is already pinned and already schema-checked; `requireUrl` is
3047
+ // only reached when no url was configured at all, in which case there is no
3048
+ // schema to pin either.
3049
+ const url = seedUrl ?? requireUrl(config);
2871
3050
  const { default: pg } = await import('pg');
2872
3051
  const client = new pg.Client({ connectionString: url });
2873
3052
  await client.connect();
@@ -2927,6 +3106,7 @@ async function cmdStatus(_args, config) {
2927
3106
  newline();
2928
3107
  const spinner = new Spinner('Introspecting database').start();
2929
3108
  const schema = await introspect({
3109
+ keepColumnNames: config.keepColumnNames,
2930
3110
  connectionString: url,
2931
3111
  schema: config.schema,
2932
3112
  include: config.include.length ? config.include : undefined,
@@ -3072,6 +3252,7 @@ async function cmdDoctor(args, config) {
3072
3252
  }
3073
3253
  const spinner = jsonMode ? null : new Spinner('Introspecting database').start();
3074
3254
  const schema = await introspect({
3255
+ keepColumnNames: config.keepColumnNames,
3075
3256
  connectionString: url,
3076
3257
  schema: config.schema,
3077
3258
  include: config.include.length ? config.include : undefined,
@@ -4078,6 +4259,7 @@ function showInitHelp() {
4078
4259
  console.log(` ${cyan('--skip-seed')} Don't create the seed file or run the seed`);
4079
4260
  console.log(` ${cyan('--skip-push')} Don't push the schema to the database`);
4080
4261
  console.log(` ${cyan('--skip-generate')} Don't generate the typed client`);
4262
+ console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema to write into config ${dim('(default: public)')}`);
4081
4263
  newline();
4082
4264
  }
4083
4265
  function showGenerateHelp() {
@@ -4185,8 +4367,9 @@ function showMigrateHelp() {
4185
4367
  console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a sanctioned migration pattern ${dim('(create only, e.g. backfill)')}`);
4186
4368
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
4187
4369
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
4188
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only, advanced)')}`);
4370
+ console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums, REWRITING history ${dim('(up / deploy / down)')}`);
4189
4371
  console.log(` ${cyan('--allow-destructive')} Run data-destroying migration statements without the interactive confirm`);
4372
+ console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema the migrations run in ${dim('(default: public)')}`);
4190
4373
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
4191
4374
  newline();
4192
4375
  console.log(` ${bold('Examples:')}`);
@@ -4213,7 +4396,8 @@ function showSeedHelp() {
4213
4396
  console.log(` ${dim('TypeScript seeds run with')} ${cyan('npx tsx')} ${dim('and can export')} ${cyan('defineSeed(fn)')}${dim('.')}`);
4214
4397
  newline();
4215
4398
  console.log(` ${bold('Options:')}`);
4216
- console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
4399
+ console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
4400
+ console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema the seed writes into ${dim('(default: public)')}`);
4217
4401
  newline();
4218
4402
  }
4219
4403
  function showStatusHelp() {
@@ -4421,7 +4605,7 @@ function showHelp() {
4421
4605
  console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a named migration recipe, e.g. backfill ${dim('(create)')}`);
4422
4606
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
4423
4607
  console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting ${dim('(up/down/push)')}`);
4424
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} / ${cyan('deploy')} ${dim('(advanced)')}`);
4608
+ console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums on ${cyan('migrate up')} / ${cyan('deploy')} / ${cyan('down')} ${dim('(advanced)')}`);
4425
4609
  newline();
4426
4610
  console.log(` ${bold('Init options:')}`);
4427
4611
  console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
@@ -4593,7 +4777,20 @@ function showVersion() {
4593
4777
  // Main
4594
4778
  // ---------------------------------------------------------------------------
4595
4779
  async function main() {
4596
- const args = parseArgs();
4780
+ let args;
4781
+ try {
4782
+ args = parseArgs();
4783
+ }
4784
+ catch (err) {
4785
+ // A last-resort net. Every flag refusal parseArgs makes goes through
4786
+ // `failArg` (banner, red line, hints) and exits there, so nothing is
4787
+ // expected here; anything that does arrive is a bug in the parser rather
4788
+ // than in the caller's command line, and printing it beats swallowing it.
4789
+ errorLine();
4790
+ error(err instanceof Error ? err.message : String(err));
4791
+ errorLine();
4792
+ process.exit(1);
4793
+ }
4597
4794
  // Quick exits that don't need config
4598
4795
  if (args.command === 'help' || args.command === '--help' || args.command === '-h') {
4599
4796
  showHelp();
@@ -4723,7 +4920,20 @@ async function main() {
4723
4920
  }
4724
4921
  catch (err) {
4725
4922
  if (err instanceof Error) {
4726
- if (err.message.includes('ECONNREFUSED') || err.message.includes('connection')) {
4923
+ // TYPE first, message second. These branches sniff for substrings, and
4924
+ // `ValidationError` beat them by accident: a refused `--schema` value says
4925
+ // "used as a connection parameter", which contains "connection", so a bad
4926
+ // flag value was reported under "Could not connect to database" with three
4927
+ // firewall hints. A typed error already knows what it is.
4928
+ if (err instanceof ValidationError) {
4929
+ errorLine();
4930
+ error(redactUrl(err.message));
4931
+ if (args.verbose && err.stack) {
4932
+ errorLine();
4933
+ console.error(dim(redactUrl(err.stack)));
4934
+ }
4935
+ }
4936
+ else if (err.message.includes('ECONNREFUSED') || err.message.includes('connection')) {
4727
4937
  errorLine();
4728
4938
  error(`Could not connect to database`);
4729
4939
  errorLine(`${dim(redactUrl(err.message))}`);
package/dist/cli/mcp.d.ts CHANGED
@@ -26,9 +26,32 @@ export interface McpTransport {
26
26
  */
27
27
  pool?: PgCompatPool;
28
28
  }
29
+ /**
30
+ * Why a session ended: an explicit {@link McpServerHandle.dispose} (a signal,
31
+ * stdin closing), or the server closing it ITSELF because the peer's framing
32
+ * was lost. `runMcpServer` exits non-zero on the latter so a supervisor
33
+ * restarts the server rather than assuming a clean end.
34
+ */
35
+ export type McpCloseReason = 'disposed' | 'framing-lost';
29
36
  export interface McpServerHandle {
30
37
  dispose(): Promise<void>;
38
+ /**
39
+ * Resolves once the session has ended and the pool is closed, by either
40
+ * path. After the unframed-buffer refusal the server used to detach its
41
+ * reader and then neither exit nor answer: a live process that would never
42
+ * speak again, which a supervisor cannot tell from a healthy idle one.
43
+ */
44
+ closed: Promise<McpCloseReason>;
31
45
  }
46
+ /**
47
+ * The most bytes the stdio reader will hold WITHOUT seeing a newline.
48
+ *
49
+ * 8 MiB, which is far above any real request: the largest thing a client sends
50
+ * here is a `compile_query` args object, and the tool schemas cap what can
51
+ * meaningfully be in one. It is a liveness bound, not a policy: see the check
52
+ * itself for why an over-long line ends the session instead of being truncated.
53
+ */
54
+ export declare const MAX_STDIO_BUFFER_BYTES: number;
32
55
  export declare function startMcpServer(options: McpServerOptions, transport?: McpTransport): McpServerHandle;
33
56
  /**
34
57
  * Every SHORTEST relation chain from `from` to `to`, in deterministic order.