turbine-orm 0.39.0 → 0.40.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.
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * turbine init — Initialize a Turbine project
7
7
  * turbine generate | pull — Introspect database and generate TypeScript types
8
8
  * turbine push - Apply schema-builder definitions to database (destructive ops gated)
9
- * turbine migrate create <name> - Create a new SQL migration file (--auto | --recipe <name>)
9
+ * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
10
10
  * turbine migrate up — Apply pending migrations
11
11
  * turbine migrate deploy — Apply pending migrations without prompts
12
12
  * turbine migrate down — Rollback last migration
@@ -33,7 +33,7 @@ import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js
33
33
  import { configTemplate, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
34
34
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
35
35
  import { runMcpServer } from './mcp.js';
36
- import { createMigration, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
36
+ import { buildDiffMigrationBody, createMigration, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
37
37
  import { startObserve } from './observe.js';
38
38
  import { startStudio } from './studio.js';
39
39
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
@@ -87,6 +87,25 @@ export function parseArgs(argv = process.argv.slice(2)) {
87
87
  case '--auto':
88
88
  result.auto = true;
89
89
  break;
90
+ case '--from-diff':
91
+ result.fromDiff = true;
92
+ break;
93
+ case '--yes':
94
+ case '-y':
95
+ result.yes = true;
96
+ break;
97
+ case '--skip-schema':
98
+ result.skipSchema = true;
99
+ break;
100
+ case '--skip-seed':
101
+ result.skipSeed = true;
102
+ break;
103
+ case '--skip-push':
104
+ result.skipPush = true;
105
+ break;
106
+ case '--skip-generate':
107
+ result.skipGenerate = true;
108
+ break;
90
109
  case '--allow-drift':
91
110
  result.allowDrift = true;
92
111
  break;
@@ -380,90 +399,103 @@ export function detectConsumerModuleType(cwd = process.cwd()) {
380
399
  return 'none';
381
400
  }
382
401
  }
383
- // ---------------------------------------------------------------------------
384
- // Command: init
385
- // ---------------------------------------------------------------------------
386
- async function cmdInit(args, config) {
387
- banner();
388
- header('Initializing Turbine project');
389
- // Detect environment. main() has already auto-loaded a local `.env` into
390
- // process.env (when DATABASE_URL was not otherwise set), so these messages
391
- // describe the real, post-load state, no more "if set" hand-waving.
392
- const envUrl = process.env.DATABASE_URL;
393
- const hasEnvFile = existsSync('.env');
394
- const hasEnvLocal = existsSync('.env.local');
395
- // On Node < 20.12 (no process.loadEnvFile) main() could not auto-load .env, so
396
- // we cannot claim it "has no DATABASE_URL"; we simply could not read it.
397
- const canAutoLoadEnv = typeof process.loadEnvFile === 'function';
398
- if (envUrl) {
399
- success(`Detected ${cyan('DATABASE_URL')} in the environment`);
400
- }
401
- else if (hasEnvFile && !canAutoLoadEnv) {
402
- info(`Found ${cyan('.env')} ${dim('(this Node version cannot auto-load it. Upgrade to Node 20.12+ or export')} ${cyan('DATABASE_URL')}${dim(')')}`);
403
- }
404
- else if (hasEnvFile) {
405
- // .env exists but did not provide DATABASE_URL; if it had, the auto-load
406
- // in main() would have populated envUrl above.
407
- info(`Found ${cyan('.env')} ${dim('(no')} ${cyan('DATABASE_URL')} ${dim('set in it yet)')}`);
408
- }
409
- else if (hasEnvLocal) {
410
- info(`Found ${cyan('.env.local')} ${dim('(note: Turbine only auto-loads')} ${cyan('.env')}${dim(')')}`);
411
- }
412
- else {
413
- info(`No ${cyan('DATABASE_URL')} found in environment`);
414
- }
415
- newline();
416
- // Heads-up (not an edit) about the consumer's module system. A CommonJS
417
- // project (`npm init -y` default, or no "type" field) works fine now that the
418
- // config loader unwraps the CJS-interop double-wrapped default, but ESM is the
419
- // smoother path for a TypeScript config file.
420
- const moduleType = detectConsumerModuleType();
421
- if (moduleType === 'commonjs') {
422
- info(`Your ${cyan('package.json')} is a CommonJS project ${dim('(no')} ${cyan('"type": "module"')}${dim(').')}`);
423
- console.log(` ${dim('Turbine works either way. For the smoothest TypeScript config experience, consider adding')} ${cyan('"type": "module"')}${dim('.')}`);
424
- newline();
425
- }
426
- const configPath = findConfigFile();
427
- // Create config file
428
- if (configPath && !args.force) {
429
- warn(`Config file already exists: ${dim(configPath)}`);
430
- console.log(` ${dim('Run with')} ${cyan('--force')} ${dim('to overwrite')}`);
431
- }
432
- else {
433
- const urlForConfig = args.url ?? undefined;
434
- const configContent = configTemplate(urlForConfig);
435
- writeFileSync('turbine.config.ts', configContent, 'utf-8');
436
- if (configPath) {
437
- success(`Overwrote ${cyan('turbine.config.ts')}`);
438
- }
439
- else {
440
- success(`Created ${cyan('turbine.config.ts')}`);
441
- }
402
+ /**
403
+ * Pure step planner for `turbine init`. Given the detected project state and the
404
+ * effective flags, decide for each step whether to run it, prompt for it, or
405
+ * skip it (and why). No IO: every input is precomputed by the caller: so the
406
+ * whole decision matrix is unit-testable without a TTY or a database.
407
+ *
408
+ * Three modes:
409
+ * - `prompt` (interactive TTY, no `--yes`): scaffold + DB steps are prompted.
410
+ * - `auto-yes` (`--yes`): accept each step's default; the yes-defaults run.
411
+ * - `auto-legacy` (non-TTY, no `--yes`): reproduce the pre-existing init
412
+ * behavior. Scaffold files + generate run; push + seed-run do not.
413
+ *
414
+ * Steps that create files (config, schema, seed) are skipped when the file
415
+ * already exists, so re-runs are safe. DB steps (push, generate, seed-run) are
416
+ * skipped when there is no URL or the database is unreachable.
417
+ */
418
+ export function planInitSteps(state, flags) {
419
+ const mode = flags.interactive && !flags.yes ? 'prompt' : flags.yes ? 'auto-yes' : 'auto-legacy';
420
+ const steps = [];
421
+ // config: core scaffold, never prompted.
422
+ steps.push(state.configExists && !flags.force
423
+ ? { id: 'config', action: 'skip', defaultYes: true, skipReason: 'exists' }
424
+ : { id: 'config', action: 'run', defaultYes: true });
425
+ // Scaffold files (schema, seed): created by default; prompted interactively.
426
+ const scaffold = (id, exists, skipFlag) => {
427
+ if (skipFlag)
428
+ return { id, action: 'skip', defaultYes: true, skipReason: 'flag' };
429
+ if (exists)
430
+ return { id, action: 'skip', defaultYes: true, skipReason: 'exists' };
431
+ return { id, action: mode === 'prompt' ? 'prompt' : 'run', defaultYes: true };
432
+ };
433
+ steps.push(scaffold('schema', state.schemaExists, flags.skipSchema));
434
+ steps.push(scaffold('seed-file', state.seedFileExists, flags.skipSeed));
435
+ // seed-run needs a seed file: either one that already exists, or one this run
436
+ // is about to create.
437
+ const seedWillExist = state.seedFileExists || steps.find((s) => s.id === 'seed-file')?.action !== 'skip';
438
+ // DB steps: need a reachable database.
439
+ const dbStep = (id, skipFlag, opts) => {
440
+ const { defaultYes } = opts;
441
+ if (skipFlag)
442
+ return { id, action: 'skip', defaultYes, skipReason: 'flag' };
443
+ if (!state.hasUrl)
444
+ return { id, action: 'skip', defaultYes, skipReason: 'no-url' };
445
+ if (!state.dbReachable)
446
+ return { id, action: 'skip', defaultYes, skipReason: 'unreachable' };
447
+ if (opts.extraSkip)
448
+ return { id, action: 'skip', defaultYes, skipReason: opts.extraSkip };
449
+ if (mode === 'prompt')
450
+ return { id, action: 'prompt', defaultYes };
451
+ if (mode === 'auto-yes') {
452
+ return defaultYes
453
+ ? { id, action: 'run', defaultYes }
454
+ : { id, action: 'skip', defaultYes, skipReason: 'default-no' };
455
+ }
456
+ // auto-legacy
457
+ return opts.legacyRun
458
+ ? { id, action: 'run', defaultYes }
459
+ : { id, action: 'skip', defaultYes, skipReason: 'non-interactive' };
460
+ };
461
+ steps.push(dbStep('push', flags.skipPush, { legacyRun: false, defaultYes: true }));
462
+ steps.push(dbStep('generate', flags.skipGenerate, { legacyRun: true, defaultYes: true }));
463
+ steps.push(dbStep('seed-run', flags.skipSeed, {
464
+ legacyRun: false,
465
+ defaultYes: false,
466
+ extraSkip: seedWillExist ? undefined : 'no-seed-file',
467
+ }));
468
+ return steps;
469
+ }
470
+ /** Prompt for a yes/no answer on an interactive TTY. */
471
+ async function promptYesNo(question, defaultYes) {
472
+ const { createInterface } = await import('node:readline/promises');
473
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
474
+ try {
475
+ const suffix = defaultYes ? '[Y/n]' : '[y/N]';
476
+ const raw = (await rl.question(` ${question} ${dim(suffix)} `)).trim().toLowerCase();
477
+ if (raw === '')
478
+ return defaultYes;
479
+ return raw === 'y' || raw === 'yes';
442
480
  }
443
- // Create migrations directory
444
- const migrDir = config.migrationsDir;
445
- if (!existsSync(migrDir)) {
446
- mkdirSync(migrDir, { recursive: true });
447
- // Create .gitkeep
448
- writeFileSync(`${migrDir}/.gitkeep`, '', 'utf-8');
449
- success(`Created ${cyan(`${migrDir}/`)}`);
481
+ finally {
482
+ rl.close();
450
483
  }
451
- else {
452
- info(`Migrations dir already exists: ${dim(migrDir)}`);
484
+ }
485
+ /** Probe database reachability with a short-lived connection. Never throws. */
486
+ async function probeDatabase(url) {
487
+ try {
488
+ const { default: pg } = await import('pg');
489
+ const client = new pg.Client({ connectionString: url });
490
+ await client.connect();
491
+ await client.end();
492
+ return true;
453
493
  }
454
- // Create output directory
455
- if (!existsSync(config.out)) {
456
- mkdirSync(config.out, { recursive: true });
457
- success(`Created ${cyan(`${config.out}/`)}`);
494
+ catch {
495
+ return false;
458
496
  }
459
- // Create seed file template
460
- const initSeedFile = config.seedFile ?? './seed.ts';
461
- const seedDir = dirname(initSeedFile);
462
- if (!existsSync(initSeedFile)) {
463
- if (!existsSync(seedDir)) {
464
- mkdirSync(seedDir, { recursive: true });
465
- }
466
- writeFileSync(initSeedFile, `/**
497
+ }
498
+ const INIT_SEED_TEMPLATE = `/**
467
499
  * Turbine seed file
468
500
  *
469
501
  * Run with: npx turbine seed
@@ -479,16 +511,8 @@ export default defineSeed(async (db) => {
479
511
 
480
512
  console.log('Done!');
481
513
  });
482
- `, 'utf-8');
483
- success(`Created ${cyan(initSeedFile)}`);
484
- }
485
- // Create schema builder template
486
- if (!existsSync(config.schemaFile)) {
487
- const schemaDir = config.schemaFile.substring(0, config.schemaFile.lastIndexOf('/'));
488
- if (!existsSync(schemaDir)) {
489
- mkdirSync(schemaDir, { recursive: true });
490
- }
491
- writeFileSync(config.schemaFile, `/**
514
+ `;
515
+ const INIT_SCHEMA_TEMPLATE = `/**
492
516
  * Turbine schema definition
493
517
  *
494
518
  * Define your database schema in TypeScript.
@@ -508,54 +532,298 @@ export default defineSchema({
508
532
  // created_at: { type: 'timestamp', default: 'NOW()' },
509
533
  // },
510
534
  });
511
- `, 'utf-8');
512
- success(`Created ${cyan(config.schemaFile)}`);
535
+ `;
536
+ /** Create supporting directories + .gitignore entries (unconditional, unprompted). */
537
+ function ensureInitScaffoldDirs(config) {
538
+ const migrDir = config.migrationsDir;
539
+ if (!existsSync(migrDir)) {
540
+ mkdirSync(migrDir, { recursive: true });
541
+ writeFileSync(`${migrDir}/.gitkeep`, '', 'utf-8');
542
+ success(`Created ${cyan(`${migrDir}/`)}`);
543
+ }
544
+ if (!existsSync(config.out)) {
545
+ mkdirSync(config.out, { recursive: true });
546
+ success(`Created ${cyan(`${config.out}/`)}`);
513
547
  }
514
- // Add .gitignore entries for generated output and config (may contain connection strings)
515
548
  const gitignorePath = '.gitignore';
516
549
  if (existsSync(gitignorePath)) {
517
550
  const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
518
551
  const additions = [];
519
- if (!gitignoreContent.includes('generated/turbine')) {
552
+ if (!gitignoreContent.includes('generated/turbine'))
520
553
  additions.push('generated/turbine/');
521
- }
522
- if (!gitignoreContent.includes('turbine.config.ts')) {
554
+ if (!gitignoreContent.includes('turbine.config.ts'))
523
555
  additions.push('turbine.config.ts');
524
- }
525
556
  if (additions.length > 0) {
526
557
  appendFileSync(gitignorePath, `\n# Turbine generated client & config\n${additions.join('\n')}\n`);
527
558
  success(`Added ${cyan(additions.join(', '))} to ${cyan('.gitignore')}`);
528
559
  }
529
560
  }
530
- // If we have a URL, run initial generate
531
- const url = args.url ?? envUrl ?? config.url;
532
- if (url) {
533
- newline();
534
- divider();
561
+ }
562
+ function writeInitSchemaTemplate(config) {
563
+ const schemaDir = dirname(config.schemaFile);
564
+ if (schemaDir && !existsSync(schemaDir))
565
+ mkdirSync(schemaDir, { recursive: true });
566
+ writeFileSync(config.schemaFile, INIT_SCHEMA_TEMPLATE, 'utf-8');
567
+ success(`Created ${cyan(config.schemaFile)}`);
568
+ }
569
+ function writeInitSeedTemplate(seedFilePath) {
570
+ const seedDir = dirname(seedFilePath);
571
+ if (seedDir && !existsSync(seedDir))
572
+ mkdirSync(seedDir, { recursive: true });
573
+ writeFileSync(seedFilePath, INIT_SEED_TEMPLATE, 'utf-8');
574
+ success(`Created ${cyan(seedFilePath)}`);
575
+ }
576
+ /** Push the code-first schema to the database, preserving the destructive typed confirm. */
577
+ async function runInitPush(config, url) {
578
+ if (!existsSync(config.schemaFile)) {
579
+ warn(`No schema file at ${cyan(config.schemaFile)}: skipping push.`);
580
+ return;
581
+ }
582
+ const schemaDef = await loadSchemaFile(config.schemaFile);
583
+ const spinner = new Spinner('Computing schema diff').start();
584
+ const diff = await schemaDiff(schemaDef, url);
585
+ if (diff.statements.length === 0) {
586
+ spinner.succeed('Database already in sync: nothing to push');
587
+ return;
588
+ }
589
+ spinner.succeed(`Found ${bold(String(diff.statements.length))} change(s) to apply`);
590
+ const pushSpinner = new Spinner('Applying schema').start();
591
+ try {
592
+ const result = await schemaPush(schemaDef, url, { precomputedDiff: diff });
593
+ pushSpinner.succeed(`Applied ${bold(String(result.statementsExecuted))} statement(s)`);
594
+ }
595
+ catch (err) {
596
+ if (!(err instanceof DestructivePushRefusal))
597
+ throw err;
598
+ pushSpinner.stop();
599
+ if (!(await confirmDestructive(err.message))) {
600
+ warn('Push aborted: no schema changes were applied.');
601
+ return;
602
+ }
603
+ const result = await schemaPush(schemaDef, url, { allowDestructive: true, precomputedDiff: diff });
604
+ success(`Applied ${bold(String(result.statementsExecuted))} statement(s)`);
605
+ }
606
+ }
607
+ /** Introspect the database and generate the typed client. */
608
+ async function runInitGenerate(config, url) {
609
+ const spinner = new Spinner('Introspecting database').start();
610
+ try {
611
+ const schema = await introspect({
612
+ connectionString: url,
613
+ schema: config.schema,
614
+ include: config.include.length ? config.include : undefined,
615
+ exclude: config.exclude.length ? config.exclude : undefined,
616
+ });
617
+ spinner.succeed(`Found ${bold(String(Object.keys(schema.tables).length))} tables`);
618
+ const genSpinner = new Spinner('Generating TypeScript client').start();
619
+ const result = generate({ schema, outDir: config.out, connectionString: url });
620
+ genSpinner.succeed(`Generated ${bold(String(result.files.length))} files to ${cyan(`${config.out}/`)}`);
621
+ }
622
+ catch (err) {
623
+ spinner.fail('Could not generate client');
624
+ if (err instanceof Error)
625
+ console.log(` ${dim(redactUrl(err.message))}`);
626
+ info(`Run generation later with: ${cyan('npx turbine generate')}`);
627
+ }
628
+ }
629
+ /** Run the seed file. */
630
+ async function runInitSeed(config) {
631
+ const seedFile = resolveSeedFile(config);
632
+ if (!seedFile || !existsSync(seedFile)) {
633
+ warn('No seed file found: skipping seed run.');
634
+ return;
635
+ }
636
+ const spinner = new Spinner('Running seed file').start();
637
+ try {
638
+ await runSeedPlan(getSeedExecutionPlan(seedFile), config);
639
+ spinner.succeed('Seed completed');
640
+ }
641
+ catch (err) {
642
+ spinner.fail('Seed failed');
643
+ if (err instanceof Error)
644
+ console.log(` ${dim(redactUrl(err.message))}`);
645
+ }
646
+ }
647
+ /** Human label for a step, used in prompts and skip messages. */
648
+ function initStepLabel(id) {
649
+ switch (id) {
650
+ case 'config':
651
+ return 'config file';
652
+ case 'schema':
653
+ return 'schema file';
654
+ case 'seed-file':
655
+ return 'seed file';
656
+ case 'push':
657
+ return 'schema push';
658
+ case 'generate':
659
+ return 'client generation';
660
+ case 'seed-run':
661
+ return 'seed run';
662
+ }
663
+ }
664
+ /** Report a skipped step with its reason. Existence skips are the quiet common case. */
665
+ function reportInitSkip(step) {
666
+ const label = initStepLabel(step.id);
667
+ switch (step.skipReason) {
668
+ case 'exists':
669
+ info(`${label} already exists ${dim('- skipped')}`);
670
+ break;
671
+ case 'flag':
672
+ console.log(` ${dim(`${symbols.dot} ${label} skipped (flag)`)}`);
673
+ break;
674
+ case 'no-url':
675
+ console.log(` ${dim(`${symbols.dot} ${label} skipped (no database URL)`)}`);
676
+ break;
677
+ case 'unreachable':
678
+ console.log(` ${dim(`${symbols.dot} ${label} skipped (database unreachable)`)}`);
679
+ break;
680
+ case 'no-seed-file':
681
+ console.log(` ${dim(`${symbols.dot} ${label} skipped (no seed file)`)}`);
682
+ break;
683
+ case 'non-interactive':
684
+ console.log(` ${dim(`${symbols.dot} ${label} skipped (non-interactive: pass --yes to run it)`)}`);
685
+ break;
686
+ case 'default-no':
687
+ console.log(` ${dim(`${symbols.dot} ${label} skipped (default no)`)}`);
688
+ break;
689
+ default:
690
+ console.log(` ${dim(`${symbols.dot} ${label} skipped`)}`);
691
+ }
692
+ }
693
+ /** The interactive prompt question for a promptable step. */
694
+ function initPromptQuestion(step, config, seedFilePath) {
695
+ switch (step.id) {
696
+ case 'schema':
697
+ return `Create a starter schema file (${config.schemaFile})?`;
698
+ case 'seed-file':
699
+ return `Create a starter seed file (${seedFilePath})?`;
700
+ case 'push':
701
+ return 'Push your schema to the database now?';
702
+ case 'generate':
703
+ return 'Generate the typed client from the database now?';
704
+ case 'seed-run':
705
+ return 'Run the seed file now?';
706
+ default:
707
+ return `Run ${initStepLabel(step.id)}?`;
708
+ }
709
+ }
710
+ async function cmdInit(args, config) {
711
+ banner();
712
+ header('Initializing Turbine project');
713
+ // Detect environment. main() has already auto-loaded a local `.env` into
714
+ // process.env (when DATABASE_URL was not otherwise set), so these messages
715
+ // describe the real, post-load state, no more "if set" hand-waving.
716
+ const envUrl = process.env.DATABASE_URL;
717
+ const hasEnvFile = existsSync('.env');
718
+ const hasEnvLocal = existsSync('.env.local');
719
+ // On Node < 20.12 (no process.loadEnvFile) main() could not auto-load .env, so
720
+ // we cannot claim it "has no DATABASE_URL"; we simply could not read it.
721
+ const canAutoLoadEnv = typeof process.loadEnvFile === 'function';
722
+ if (envUrl) {
723
+ success(`Detected ${cyan('DATABASE_URL')} in the environment`);
724
+ }
725
+ else if (hasEnvFile && !canAutoLoadEnv) {
726
+ info(`Found ${cyan('.env')} ${dim('(this Node version cannot auto-load it. Upgrade to Node 20.12+ or export')} ${cyan('DATABASE_URL')}${dim(')')}`);
727
+ }
728
+ else if (hasEnvFile) {
729
+ // .env exists but did not provide DATABASE_URL; if it had, the auto-load
730
+ // in main() would have populated envUrl above.
731
+ info(`Found ${cyan('.env')} ${dim('(no')} ${cyan('DATABASE_URL')} ${dim('set in it yet)')}`);
732
+ }
733
+ else if (hasEnvLocal) {
734
+ info(`Found ${cyan('.env.local')} ${dim('(note: Turbine only auto-loads')} ${cyan('.env')}${dim(')')}`);
735
+ }
736
+ else {
737
+ info(`No ${cyan('DATABASE_URL')} found in environment`);
738
+ }
739
+ newline();
740
+ // Heads-up (not an edit) about the consumer's module system. A CommonJS
741
+ // project (`npm init -y` default, or no "type" field) works fine now that the
742
+ // config loader unwraps the CJS-interop double-wrapped default, but ESM is the
743
+ // smoother path for a TypeScript config file.
744
+ const moduleType = detectConsumerModuleType();
745
+ if (moduleType === 'commonjs') {
746
+ info(`Your ${cyan('package.json')} is a CommonJS project ${dim('(no')} ${cyan('"type": "module"')}${dim(').')}`);
747
+ console.log(` ${dim('Turbine works either way. For the smoothest TypeScript config experience, consider adding')} ${cyan('"type": "module"')}${dim('.')}`);
535
748
  newline();
536
- const spinner = new Spinner('Introspecting database').start();
537
- try {
538
- const schema = await introspect({
539
- connectionString: url,
540
- schema: config.schema,
541
- include: config.include.length ? config.include : undefined,
542
- exclude: config.exclude.length ? config.exclude : undefined,
543
- });
544
- const tableCount = Object.keys(schema.tables).length;
545
- spinner.succeed(`Found ${bold(String(tableCount))} tables`);
546
- const genSpinner = new Spinner('Generating TypeScript client').start();
547
- const result = generate({ schema, outDir: config.out, connectionString: url });
548
- genSpinner.succeed(`Generated ${bold(String(result.files.length))} files to ${cyan(`${config.out}/`)}`);
549
- }
550
- catch (err) {
551
- spinner.fail('Could not connect to database');
552
- if (err instanceof Error) {
553
- console.log(` ${dim(redactUrl(err.message))}`);
554
- }
555
- newline();
556
- info(`You can run generation later with: ${cyan('npx turbine generate')}`);
749
+ }
750
+ // Resolve the effective URL and detect current project state.
751
+ const url = args.url ?? envUrl ?? config.url;
752
+ const hasUrl = Boolean(url);
753
+ const interactive = Boolean(process.stdin.isTTY);
754
+ const yes = args.yes === true;
755
+ const seedFilePath = config.seedFile ?? './seed.ts';
756
+ const configPath = findConfigFile();
757
+ const state = {
758
+ configExists: Boolean(configPath),
759
+ schemaExists: existsSync(config.schemaFile),
760
+ seedFileExists: existsSync(seedFilePath),
761
+ hasUrl,
762
+ dbReachable: false,
763
+ };
764
+ // Probe the database once, up front, so the planner can decide the DB steps.
765
+ // Skip the probe entirely when every DB step is already flag-skipped.
766
+ const anyDbStepPossible = !(args.skipPush && args.skipGenerate && args.skipSeed);
767
+ if (hasUrl && anyDbStepPossible) {
768
+ const probe = new Spinner('Checking database connection').start();
769
+ state.dbReachable = await probeDatabase(url);
770
+ if (state.dbReachable)
771
+ probe.succeed('Database is reachable');
772
+ else
773
+ probe.info('Database not reachable: push / generate / seed steps will be skipped');
774
+ }
775
+ const flags = {
776
+ yes,
777
+ force: args.force === true,
778
+ interactive,
779
+ skipSchema: args.skipSchema === true,
780
+ skipSeed: args.skipSeed === true,
781
+ skipPush: args.skipPush === true,
782
+ skipGenerate: args.skipGenerate === true,
783
+ };
784
+ const plan = planInitSteps(state, flags);
785
+ const degraded = !interactive && !yes;
786
+ // Supporting dirs + .gitignore, regardless of prompts (unchanged behavior).
787
+ ensureInitScaffoldDirs(config);
788
+ newline();
789
+ // Execute the plan in order. `run` proceeds; `prompt` asks; `skip` reports.
790
+ for (const step of plan) {
791
+ if (step.action === 'skip') {
792
+ reportInitSkip(step);
793
+ continue;
794
+ }
795
+ const shouldRun = step.action === 'run' ? true : await promptYesNo(initPromptQuestion(step, config, seedFilePath), step.defaultYes);
796
+ if (!shouldRun) {
797
+ info(`Skipped ${initStepLabel(step.id)}`);
798
+ continue;
799
+ }
800
+ switch (step.id) {
801
+ case 'config':
802
+ writeFileSync('turbine.config.ts', configTemplate(args.url ?? undefined), 'utf-8');
803
+ success(state.configExists ? `Overwrote ${cyan('turbine.config.ts')}` : `Created ${cyan('turbine.config.ts')}`);
804
+ break;
805
+ case 'schema':
806
+ writeInitSchemaTemplate(config);
807
+ break;
808
+ case 'seed-file':
809
+ writeInitSeedTemplate(seedFilePath);
810
+ break;
811
+ case 'push':
812
+ await runInitPush(config, url);
813
+ break;
814
+ case 'generate':
815
+ await runInitGenerate(config, url);
816
+ break;
817
+ case 'seed-run':
818
+ await runInitSeed(config);
819
+ break;
557
820
  }
558
821
  }
822
+ if (degraded) {
823
+ newline();
824
+ info('Non-interactive shell: interactive prompts were skipped.');
825
+ console.log(` ${dim('Re-run in a terminal, pass')} ${cyan('--yes')} ${dim('to accept defaults, or use')} ${cyan('--skip-*')} ${dim('flags to control each step.')}`);
826
+ }
559
827
  // Next steps
560
828
  newline();
561
829
  divider();
@@ -808,9 +1076,10 @@ async function cmdMigrate(args, config) {
808
1076
  console.log(` ${bold('turbine migrate')} ${dim('— SQL-first migration system')}`);
809
1077
  newline();
810
1078
  console.log(` ${bold('Commands:')}`);
811
- console.log(` ${cyan('create <name>')} Create a new migration file`);
812
- console.log(` ${cyan('create <name> --auto')} Auto-generate from schema diff`);
813
- console.log(` ${cyan('create <name> --recipe')} Scaffold a named recipe (e.g. backfill)`);
1079
+ console.log(` ${cyan('create <name>')} Create a new migration file`);
1080
+ console.log(` ${cyan('create <name> --auto')} Auto-generate from schema diff`);
1081
+ console.log(` ${cyan('create <name> --from-diff')} Generate from schema diff, destructive statements flagged`);
1082
+ console.log(` ${cyan('create <name> --recipe')} Scaffold a named recipe (e.g. backfill)`);
814
1083
  console.log(` ${cyan('up')} Apply pending migrations`);
815
1084
  console.log(` ${cyan('deploy')} Apply pending migrations without prompts`);
816
1085
  console.log(` ${cyan('down')} Rollback last migration`);
@@ -818,6 +1087,7 @@ async function cmdMigrate(args, config) {
818
1087
  newline();
819
1088
  console.log(` ${bold('Options:')}`);
820
1089
  console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
1090
+ console.log(` ${cyan('--from-diff')} Like --auto, but flags destructive statements in the file`);
821
1091
  console.log(` ${cyan('--recipe <name>')} Scaffold a sanctioned migration pattern`);
822
1092
  console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
823
1093
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
@@ -831,6 +1101,7 @@ async function cmdMigrate(args, config) {
831
1101
  console.log(` ${bold('Examples:')}`);
832
1102
  console.log(` ${dim('npx turbine migrate create add_users_table')}`);
833
1103
  console.log(` ${dim('npx turbine migrate create add_email_index --auto')}`);
1104
+ console.log(` ${dim('npx turbine migrate create sync_schema --from-diff')}`);
834
1105
  console.log(` ${dim('npx turbine migrate create backfill_full_name --recipe backfill')}`);
835
1106
  console.log(` ${dim('npx turbine migrate up')}`);
836
1107
  console.log(` ${dim('npx turbine migrate deploy --dry-run')}`);
@@ -873,6 +1144,20 @@ async function cmdMigrateCreate(args, config) {
873
1144
  newline();
874
1145
  process.exit(1);
875
1146
  }
1147
+ // The scaffold strategies each own the file body, so combining them is
1148
+ // ambiguous. Refuse up front (before any of the strategy blocks run).
1149
+ if (args.fromDiff && args.recipe) {
1150
+ error('--from-diff cannot be combined with --recipe.');
1151
+ console.log(` ${dim('Pick one: --from-diff generates from the schema diff, --recipe scaffolds a pattern.')}`);
1152
+ newline();
1153
+ process.exit(1);
1154
+ }
1155
+ if (args.fromDiff && args.auto) {
1156
+ error('--from-diff cannot be combined with --auto.');
1157
+ console.log(` ${dim('Both generate from the schema diff; --from-diff also flags destructive statements.')}`);
1158
+ newline();
1159
+ process.exit(1);
1160
+ }
876
1161
  if (args.auto) {
877
1162
  // Auto-generate migration from schema diff
878
1163
  const url = requireUrl(config);
@@ -883,7 +1168,7 @@ async function cmdMigrateCreate(args, config) {
883
1168
  const diffSpinner = new Spinner('Computing schema diff').start();
884
1169
  const diff = await schemaDiff(schemaDef, url);
885
1170
  if (diff.statements.length === 0) {
886
- diffSpinner.succeed('Database is already in sync — nothing to migrate');
1171
+ diffSpinner.succeed('Database is already in sync: nothing to migrate');
887
1172
  newline();
888
1173
  return;
889
1174
  }
@@ -924,6 +1209,78 @@ async function cmdMigrateCreate(args, config) {
924
1209
  newline();
925
1210
  return;
926
1211
  }
1212
+ if (args.fromDiff) {
1213
+ // Load the code-first schema (same file `push` uses). loadSchemaFile exits
1214
+ // with a clear message when the project has no schema-builder file.
1215
+ const url = requireUrl(config);
1216
+ label('Database', redactUrl(url));
1217
+ label('Schema file', config.schemaFile);
1218
+ newline();
1219
+ const schemaDef = await loadSchemaFile(config.schemaFile);
1220
+ const diffSpinner = new Spinner('Computing schema diff').start();
1221
+ const diff = await schemaDiff(schemaDef, url);
1222
+ if (diff.statements.length === 0) {
1223
+ diffSpinner.succeed('Database is already in sync: nothing to migrate');
1224
+ newline();
1225
+ return;
1226
+ }
1227
+ diffSpinner.succeed(`Found ${bold(String(diff.statements.length))} change(s)`);
1228
+ newline();
1229
+ const body = buildDiffMigrationBody(diff);
1230
+ const file = createMigration(config.migrationsDir, name, { up: body.up, down: body.down });
1231
+ const relPath = relative(process.cwd(), file.path);
1232
+ success(`Created diff migration: ${bold(file.filename)}`);
1233
+ newline();
1234
+ console.log(` ${dim('File:')} ${cyan(relPath)}`);
1235
+ newline();
1236
+ // Summarize the changes, mirroring --auto's output.
1237
+ if (diff.create.length > 0) {
1238
+ console.log(` ${green('+ Create')} ${diff.create.length} table(s): ${diff.create.map((t) => t.name).join(', ')}`);
1239
+ }
1240
+ if (diff.alter.length > 0) {
1241
+ console.log(` ${yellow('~ Alter')} ${diff.alter.length} table(s):`);
1242
+ for (const a of diff.alter) {
1243
+ for (const col of a.columns) {
1244
+ const actionLabel = col.action === 'add'
1245
+ ? green('+ add')
1246
+ : col.action === 'drop'
1247
+ ? red('- drop')
1248
+ : col.action === 'add_unique'
1249
+ ? green('+ unique')
1250
+ : col.action === 'drop_unique'
1251
+ ? red('- unique')
1252
+ : yellow(`~ ${col.action.replace(/_/g, ' ')}`);
1253
+ console.log(` ${actionLabel} ${a.table}.${col.column}`);
1254
+ }
1255
+ }
1256
+ }
1257
+ newline();
1258
+ // Loudly flag destructive statements written into the file. They stay in the
1259
+ // migration (so `migrate up`'s gate still refuses them by default) but the
1260
+ // operator must know they are there before running.
1261
+ const destructiveCount = body.destructiveUp.length + body.destructiveDown.length;
1262
+ if (destructiveCount > 0) {
1263
+ warn(`This migration contains ${bold(String(destructiveCount))} DESTRUCTIVE statement(s), flagged in the file.`);
1264
+ for (const h of body.destructiveUp) {
1265
+ console.log(` ${red(symbols.warning)} ${dim('UP')} [${h.kind}] ${h.target}`);
1266
+ }
1267
+ for (const h of body.destructiveDown) {
1268
+ console.log(` ${red(symbols.warning)} ${dim('DOWN')} [${h.kind}] ${h.target}`);
1269
+ }
1270
+ newline();
1271
+ console.log(` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`);
1272
+ newline();
1273
+ }
1274
+ if (diff.warnings && diff.warnings.length > 0) {
1275
+ for (const w of diff.warnings)
1276
+ warn(w);
1277
+ newline();
1278
+ }
1279
+ console.log(` ${dim('Review the migration, then run:')}`);
1280
+ console.log(` ${cyan('npx turbine migrate up')}`);
1281
+ newline();
1282
+ return;
1283
+ }
927
1284
  if (args.recipe) {
928
1285
  if (!MIGRATION_RECIPES[args.recipe]) {
929
1286
  error(`Unknown migration recipe: ${args.recipe}`);
@@ -1682,12 +2039,22 @@ function showInitHelp() {
1682
2039
  console.log(` ${bold('Usage:')}`);
1683
2040
  console.log(` npx turbine init ${dim('[options]')}`);
1684
2041
  newline();
1685
- console.log(` Creates ${cyan('turbine.config.ts')}, migrations directory, seed file template,`);
1686
- console.log(` and schema file template.`);
2042
+ console.log(` Sequenced, interactive bootstrap. Detects project state and runs only`);
2043
+ console.log(` the needed steps: writes ${cyan('turbine.config.ts')}, offers a starter schema +`);
2044
+ console.log(` seed file, and (when a reachable database is configured) offers to push`);
2045
+ console.log(` the schema, generate the typed client, and run the seed file.`);
2046
+ newline();
2047
+ console.log(` ${dim('Re-runs skip completed steps. A non-interactive shell runs the safe')}`);
2048
+ console.log(` ${dim('scaffold + generate steps only; pass --yes to accept every default.')}`);
1687
2049
  newline();
1688
2050
  console.log(` ${bold('Options:')}`);
1689
2051
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string to embed in config`);
1690
2052
  console.log(` ${cyan('--force, -f')} Overwrite existing config file`);
2053
+ console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
2054
+ console.log(` ${cyan('--skip-schema')} Don't create the starter schema file`);
2055
+ console.log(` ${cyan('--skip-seed')} Don't create the seed file or run the seed`);
2056
+ console.log(` ${cyan('--skip-push')} Don't push the schema to the database`);
2057
+ console.log(` ${cyan('--skip-generate')} Don't generate the typed client`);
1691
2058
  newline();
1692
2059
  }
1693
2060
  function showGenerateHelp() {
@@ -1749,6 +2116,7 @@ function showMigrateHelp() {
1749
2116
  console.log(` ${bold('Options:')}`);
1750
2117
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
1751
2118
  console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create only)')}`);
2119
+ console.log(` ${cyan('--from-diff')} Generate from schema diff, flagging destructive statements ${dim('(create only)')}`);
1752
2120
  console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a sanctioned migration pattern ${dim('(create only, e.g. backfill)')}`);
1753
2121
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1754
2122
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
@@ -1759,6 +2127,7 @@ function showMigrateHelp() {
1759
2127
  console.log(` ${bold('Examples:')}`);
1760
2128
  console.log(` ${dim('$')} npx turbine migrate create add_users_table`);
1761
2129
  console.log(` ${dim('$')} npx turbine migrate create add_email_index --auto`);
2130
+ console.log(` ${dim('$')} npx turbine migrate create sync_schema --from-diff`);
1762
2131
  console.log(` ${dim('$')} npx turbine migrate create backfill_full_name --recipe backfill`);
1763
2132
  console.log(` ${dim('$')} npx turbine migrate up`);
1764
2133
  console.log(` ${dim('$')} npx turbine migrate deploy --dry-run`);