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.
@@ -7,7 +7,7 @@
7
7
  * turbine init — Initialize a Turbine project
8
8
  * turbine generate | pull — Introspect database and generate TypeScript types
9
9
  * turbine push - Apply schema-builder definitions to database (destructive ops gated)
10
- * turbine migrate create <name> - Create a new SQL migration file (--auto | --recipe <name>)
10
+ * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
11
11
  * turbine migrate up — Apply pending migrations
12
12
  * turbine migrate deploy — Apply pending migrations without prompts
13
13
  * turbine migrate down — Rollback last migration
@@ -62,6 +62,7 @@ exports.parseArgs = parseArgs;
62
62
  exports.loadDotEnvForCli = loadDotEnvForCli;
63
63
  exports.dotEnvUrlConflictWarning = dotEnvUrlConflictWarning;
64
64
  exports.detectConsumerModuleType = detectConsumerModuleType;
65
+ exports.planInitSteps = planInitSteps;
65
66
  exports.buildMigrateDeployOptions = buildMigrateDeployOptions;
66
67
  exports.getSeedExecutionPlan = getSeedExecutionPlan;
67
68
  exports.isLoopbackHost = isLoopbackHost;
@@ -129,6 +130,25 @@ function parseArgs(argv = process.argv.slice(2)) {
129
130
  case '--auto':
130
131
  result.auto = true;
131
132
  break;
133
+ case '--from-diff':
134
+ result.fromDiff = true;
135
+ break;
136
+ case '--yes':
137
+ case '-y':
138
+ result.yes = true;
139
+ break;
140
+ case '--skip-schema':
141
+ result.skipSchema = true;
142
+ break;
143
+ case '--skip-seed':
144
+ result.skipSeed = true;
145
+ break;
146
+ case '--skip-push':
147
+ result.skipPush = true;
148
+ break;
149
+ case '--skip-generate':
150
+ result.skipGenerate = true;
151
+ break;
132
152
  case '--allow-drift':
133
153
  result.allowDrift = true;
134
154
  break;
@@ -422,90 +442,103 @@ function detectConsumerModuleType(cwd = process.cwd()) {
422
442
  return 'none';
423
443
  }
424
444
  }
425
- // ---------------------------------------------------------------------------
426
- // Command: init
427
- // ---------------------------------------------------------------------------
428
- async function cmdInit(args, config) {
429
- (0, ui_js_1.banner)();
430
- (0, ui_js_1.header)('Initializing Turbine project');
431
- // Detect environment. main() has already auto-loaded a local `.env` into
432
- // process.env (when DATABASE_URL was not otherwise set), so these messages
433
- // describe the real, post-load state, no more "if set" hand-waving.
434
- const envUrl = process.env.DATABASE_URL;
435
- const hasEnvFile = (0, node_fs_1.existsSync)('.env');
436
- const hasEnvLocal = (0, node_fs_1.existsSync)('.env.local');
437
- // On Node < 20.12 (no process.loadEnvFile) main() could not auto-load .env, so
438
- // we cannot claim it "has no DATABASE_URL"; we simply could not read it.
439
- const canAutoLoadEnv = typeof process.loadEnvFile === 'function';
440
- if (envUrl) {
441
- (0, ui_js_1.success)(`Detected ${(0, ui_js_1.cyan)('DATABASE_URL')} in the environment`);
442
- }
443
- else if (hasEnvFile && !canAutoLoadEnv) {
444
- (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('(this Node version cannot auto-load it. Upgrade to Node 20.12+ or export')} ${(0, ui_js_1.cyan)('DATABASE_URL')}${(0, ui_js_1.dim)(')')}`);
445
- }
446
- else if (hasEnvFile) {
447
- // .env exists but did not provide DATABASE_URL; if it had, the auto-load
448
- // in main() would have populated envUrl above.
449
- (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('(no')} ${(0, ui_js_1.cyan)('DATABASE_URL')} ${(0, ui_js_1.dim)('set in it yet)')}`);
450
- }
451
- else if (hasEnvLocal) {
452
- (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env.local')} ${(0, ui_js_1.dim)('(note: Turbine only auto-loads')} ${(0, ui_js_1.cyan)('.env')}${(0, ui_js_1.dim)(')')}`);
453
- }
454
- else {
455
- (0, ui_js_1.info)(`No ${(0, ui_js_1.cyan)('DATABASE_URL')} found in environment`);
456
- }
457
- (0, ui_js_1.newline)();
458
- // Heads-up (not an edit) about the consumer's module system. A CommonJS
459
- // project (`npm init -y` default, or no "type" field) works fine now that the
460
- // config loader unwraps the CJS-interop double-wrapped default, but ESM is the
461
- // smoother path for a TypeScript config file.
462
- const moduleType = detectConsumerModuleType();
463
- if (moduleType === 'commonjs') {
464
- (0, ui_js_1.info)(`Your ${(0, ui_js_1.cyan)('package.json')} is a CommonJS project ${(0, ui_js_1.dim)('(no')} ${(0, ui_js_1.cyan)('"type": "module"')}${(0, ui_js_1.dim)(').')}`);
465
- console.log(` ${(0, ui_js_1.dim)('Turbine works either way. For the smoothest TypeScript config experience, consider adding')} ${(0, ui_js_1.cyan)('"type": "module"')}${(0, ui_js_1.dim)('.')}`);
466
- (0, ui_js_1.newline)();
467
- }
468
- const configPath = (0, config_js_1.findConfigFile)();
469
- // Create config file
470
- if (configPath && !args.force) {
471
- (0, ui_js_1.warn)(`Config file already exists: ${(0, ui_js_1.dim)(configPath)}`);
472
- console.log(` ${(0, ui_js_1.dim)('Run with')} ${(0, ui_js_1.cyan)('--force')} ${(0, ui_js_1.dim)('to overwrite')}`);
473
- }
474
- else {
475
- const urlForConfig = args.url ?? undefined;
476
- const configContent = (0, config_js_1.configTemplate)(urlForConfig);
477
- (0, node_fs_1.writeFileSync)('turbine.config.ts', configContent, 'utf-8');
478
- if (configPath) {
479
- (0, ui_js_1.success)(`Overwrote ${(0, ui_js_1.cyan)('turbine.config.ts')}`);
480
- }
481
- else {
482
- (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)('turbine.config.ts')}`);
483
- }
445
+ /**
446
+ * Pure step planner for `turbine init`. Given the detected project state and the
447
+ * effective flags, decide for each step whether to run it, prompt for it, or
448
+ * skip it (and why). No IO: every input is precomputed by the caller: so the
449
+ * whole decision matrix is unit-testable without a TTY or a database.
450
+ *
451
+ * Three modes:
452
+ * - `prompt` (interactive TTY, no `--yes`): scaffold + DB steps are prompted.
453
+ * - `auto-yes` (`--yes`): accept each step's default; the yes-defaults run.
454
+ * - `auto-legacy` (non-TTY, no `--yes`): reproduce the pre-existing init
455
+ * behavior. Scaffold files + generate run; push + seed-run do not.
456
+ *
457
+ * Steps that create files (config, schema, seed) are skipped when the file
458
+ * already exists, so re-runs are safe. DB steps (push, generate, seed-run) are
459
+ * skipped when there is no URL or the database is unreachable.
460
+ */
461
+ function planInitSteps(state, flags) {
462
+ const mode = flags.interactive && !flags.yes ? 'prompt' : flags.yes ? 'auto-yes' : 'auto-legacy';
463
+ const steps = [];
464
+ // config: core scaffold, never prompted.
465
+ steps.push(state.configExists && !flags.force
466
+ ? { id: 'config', action: 'skip', defaultYes: true, skipReason: 'exists' }
467
+ : { id: 'config', action: 'run', defaultYes: true });
468
+ // Scaffold files (schema, seed): created by default; prompted interactively.
469
+ const scaffold = (id, exists, skipFlag) => {
470
+ if (skipFlag)
471
+ return { id, action: 'skip', defaultYes: true, skipReason: 'flag' };
472
+ if (exists)
473
+ return { id, action: 'skip', defaultYes: true, skipReason: 'exists' };
474
+ return { id, action: mode === 'prompt' ? 'prompt' : 'run', defaultYes: true };
475
+ };
476
+ steps.push(scaffold('schema', state.schemaExists, flags.skipSchema));
477
+ steps.push(scaffold('seed-file', state.seedFileExists, flags.skipSeed));
478
+ // seed-run needs a seed file: either one that already exists, or one this run
479
+ // is about to create.
480
+ const seedWillExist = state.seedFileExists || steps.find((s) => s.id === 'seed-file')?.action !== 'skip';
481
+ // DB steps: need a reachable database.
482
+ const dbStep = (id, skipFlag, opts) => {
483
+ const { defaultYes } = opts;
484
+ if (skipFlag)
485
+ return { id, action: 'skip', defaultYes, skipReason: 'flag' };
486
+ if (!state.hasUrl)
487
+ return { id, action: 'skip', defaultYes, skipReason: 'no-url' };
488
+ if (!state.dbReachable)
489
+ return { id, action: 'skip', defaultYes, skipReason: 'unreachable' };
490
+ if (opts.extraSkip)
491
+ return { id, action: 'skip', defaultYes, skipReason: opts.extraSkip };
492
+ if (mode === 'prompt')
493
+ return { id, action: 'prompt', defaultYes };
494
+ if (mode === 'auto-yes') {
495
+ return defaultYes
496
+ ? { id, action: 'run', defaultYes }
497
+ : { id, action: 'skip', defaultYes, skipReason: 'default-no' };
498
+ }
499
+ // auto-legacy
500
+ return opts.legacyRun
501
+ ? { id, action: 'run', defaultYes }
502
+ : { id, action: 'skip', defaultYes, skipReason: 'non-interactive' };
503
+ };
504
+ steps.push(dbStep('push', flags.skipPush, { legacyRun: false, defaultYes: true }));
505
+ steps.push(dbStep('generate', flags.skipGenerate, { legacyRun: true, defaultYes: true }));
506
+ steps.push(dbStep('seed-run', flags.skipSeed, {
507
+ legacyRun: false,
508
+ defaultYes: false,
509
+ extraSkip: seedWillExist ? undefined : 'no-seed-file',
510
+ }));
511
+ return steps;
512
+ }
513
+ /** Prompt for a yes/no answer on an interactive TTY. */
514
+ async function promptYesNo(question, defaultYes) {
515
+ const { createInterface } = await Promise.resolve().then(() => __importStar(require('node:readline/promises')));
516
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
517
+ try {
518
+ const suffix = defaultYes ? '[Y/n]' : '[y/N]';
519
+ const raw = (await rl.question(` ${question} ${(0, ui_js_1.dim)(suffix)} `)).trim().toLowerCase();
520
+ if (raw === '')
521
+ return defaultYes;
522
+ return raw === 'y' || raw === 'yes';
484
523
  }
485
- // Create migrations directory
486
- const migrDir = config.migrationsDir;
487
- if (!(0, node_fs_1.existsSync)(migrDir)) {
488
- (0, node_fs_1.mkdirSync)(migrDir, { recursive: true });
489
- // Create .gitkeep
490
- (0, node_fs_1.writeFileSync)(`${migrDir}/.gitkeep`, '', 'utf-8');
491
- (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(`${migrDir}/`)}`);
524
+ finally {
525
+ rl.close();
492
526
  }
493
- else {
494
- (0, ui_js_1.info)(`Migrations dir already exists: ${(0, ui_js_1.dim)(migrDir)}`);
527
+ }
528
+ /** Probe database reachability with a short-lived connection. Never throws. */
529
+ async function probeDatabase(url) {
530
+ try {
531
+ const { default: pg } = await Promise.resolve().then(() => __importStar(require('pg')));
532
+ const client = new pg.Client({ connectionString: url });
533
+ await client.connect();
534
+ await client.end();
535
+ return true;
495
536
  }
496
- // Create output directory
497
- if (!(0, node_fs_1.existsSync)(config.out)) {
498
- (0, node_fs_1.mkdirSync)(config.out, { recursive: true });
499
- (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(`${config.out}/`)}`);
537
+ catch {
538
+ return false;
500
539
  }
501
- // Create seed file template
502
- const initSeedFile = config.seedFile ?? './seed.ts';
503
- const seedDir = (0, node_path_1.dirname)(initSeedFile);
504
- if (!(0, node_fs_1.existsSync)(initSeedFile)) {
505
- if (!(0, node_fs_1.existsSync)(seedDir)) {
506
- (0, node_fs_1.mkdirSync)(seedDir, { recursive: true });
507
- }
508
- (0, node_fs_1.writeFileSync)(initSeedFile, `/**
540
+ }
541
+ const INIT_SEED_TEMPLATE = `/**
509
542
  * Turbine seed file
510
543
  *
511
544
  * Run with: npx turbine seed
@@ -521,16 +554,8 @@ export default defineSeed(async (db) => {
521
554
 
522
555
  console.log('Done!');
523
556
  });
524
- `, 'utf-8');
525
- (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(initSeedFile)}`);
526
- }
527
- // Create schema builder template
528
- if (!(0, node_fs_1.existsSync)(config.schemaFile)) {
529
- const schemaDir = config.schemaFile.substring(0, config.schemaFile.lastIndexOf('/'));
530
- if (!(0, node_fs_1.existsSync)(schemaDir)) {
531
- (0, node_fs_1.mkdirSync)(schemaDir, { recursive: true });
532
- }
533
- (0, node_fs_1.writeFileSync)(config.schemaFile, `/**
557
+ `;
558
+ const INIT_SCHEMA_TEMPLATE = `/**
534
559
  * Turbine schema definition
535
560
  *
536
561
  * Define your database schema in TypeScript.
@@ -550,54 +575,298 @@ export default defineSchema({
550
575
  // created_at: { type: 'timestamp', default: 'NOW()' },
551
576
  // },
552
577
  });
553
- `, 'utf-8');
554
- (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(config.schemaFile)}`);
578
+ `;
579
+ /** Create supporting directories + .gitignore entries (unconditional, unprompted). */
580
+ function ensureInitScaffoldDirs(config) {
581
+ const migrDir = config.migrationsDir;
582
+ if (!(0, node_fs_1.existsSync)(migrDir)) {
583
+ (0, node_fs_1.mkdirSync)(migrDir, { recursive: true });
584
+ (0, node_fs_1.writeFileSync)(`${migrDir}/.gitkeep`, '', 'utf-8');
585
+ (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(`${migrDir}/`)}`);
586
+ }
587
+ if (!(0, node_fs_1.existsSync)(config.out)) {
588
+ (0, node_fs_1.mkdirSync)(config.out, { recursive: true });
589
+ (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(`${config.out}/`)}`);
555
590
  }
556
- // Add .gitignore entries for generated output and config (may contain connection strings)
557
591
  const gitignorePath = '.gitignore';
558
592
  if ((0, node_fs_1.existsSync)(gitignorePath)) {
559
593
  const gitignoreContent = (0, node_fs_1.readFileSync)(gitignorePath, 'utf-8');
560
594
  const additions = [];
561
- if (!gitignoreContent.includes('generated/turbine')) {
595
+ if (!gitignoreContent.includes('generated/turbine'))
562
596
  additions.push('generated/turbine/');
563
- }
564
- if (!gitignoreContent.includes('turbine.config.ts')) {
597
+ if (!gitignoreContent.includes('turbine.config.ts'))
565
598
  additions.push('turbine.config.ts');
566
- }
567
599
  if (additions.length > 0) {
568
600
  (0, node_fs_1.appendFileSync)(gitignorePath, `\n# Turbine generated client & config\n${additions.join('\n')}\n`);
569
601
  (0, ui_js_1.success)(`Added ${(0, ui_js_1.cyan)(additions.join(', '))} to ${(0, ui_js_1.cyan)('.gitignore')}`);
570
602
  }
571
603
  }
572
- // If we have a URL, run initial generate
573
- const url = args.url ?? envUrl ?? config.url;
574
- if (url) {
575
- (0, ui_js_1.newline)();
576
- (0, ui_js_1.divider)();
604
+ }
605
+ function writeInitSchemaTemplate(config) {
606
+ const schemaDir = (0, node_path_1.dirname)(config.schemaFile);
607
+ if (schemaDir && !(0, node_fs_1.existsSync)(schemaDir))
608
+ (0, node_fs_1.mkdirSync)(schemaDir, { recursive: true });
609
+ (0, node_fs_1.writeFileSync)(config.schemaFile, INIT_SCHEMA_TEMPLATE, 'utf-8');
610
+ (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(config.schemaFile)}`);
611
+ }
612
+ function writeInitSeedTemplate(seedFilePath) {
613
+ const seedDir = (0, node_path_1.dirname)(seedFilePath);
614
+ if (seedDir && !(0, node_fs_1.existsSync)(seedDir))
615
+ (0, node_fs_1.mkdirSync)(seedDir, { recursive: true });
616
+ (0, node_fs_1.writeFileSync)(seedFilePath, INIT_SEED_TEMPLATE, 'utf-8');
617
+ (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)(seedFilePath)}`);
618
+ }
619
+ /** Push the code-first schema to the database, preserving the destructive typed confirm. */
620
+ async function runInitPush(config, url) {
621
+ if (!(0, node_fs_1.existsSync)(config.schemaFile)) {
622
+ (0, ui_js_1.warn)(`No schema file at ${(0, ui_js_1.cyan)(config.schemaFile)}: skipping push.`);
623
+ return;
624
+ }
625
+ const schemaDef = await loadSchemaFile(config.schemaFile);
626
+ const spinner = new ui_js_1.Spinner('Computing schema diff').start();
627
+ const diff = await (0, schema_sql_js_1.schemaDiff)(schemaDef, url);
628
+ if (diff.statements.length === 0) {
629
+ spinner.succeed('Database already in sync: nothing to push');
630
+ return;
631
+ }
632
+ spinner.succeed(`Found ${(0, ui_js_1.bold)(String(diff.statements.length))} change(s) to apply`);
633
+ const pushSpinner = new ui_js_1.Spinner('Applying schema').start();
634
+ try {
635
+ const result = await (0, schema_sql_js_1.schemaPush)(schemaDef, url, { precomputedDiff: diff });
636
+ pushSpinner.succeed(`Applied ${(0, ui_js_1.bold)(String(result.statementsExecuted))} statement(s)`);
637
+ }
638
+ catch (err) {
639
+ if (!(err instanceof schema_sql_js_1.DestructivePushRefusal))
640
+ throw err;
641
+ pushSpinner.stop();
642
+ if (!(await confirmDestructive(err.message))) {
643
+ (0, ui_js_1.warn)('Push aborted: no schema changes were applied.');
644
+ return;
645
+ }
646
+ const result = await (0, schema_sql_js_1.schemaPush)(schemaDef, url, { allowDestructive: true, precomputedDiff: diff });
647
+ (0, ui_js_1.success)(`Applied ${(0, ui_js_1.bold)(String(result.statementsExecuted))} statement(s)`);
648
+ }
649
+ }
650
+ /** Introspect the database and generate the typed client. */
651
+ async function runInitGenerate(config, url) {
652
+ const spinner = new ui_js_1.Spinner('Introspecting database').start();
653
+ try {
654
+ const schema = await (0, introspect_js_1.introspect)({
655
+ connectionString: url,
656
+ schema: config.schema,
657
+ include: config.include.length ? config.include : undefined,
658
+ exclude: config.exclude.length ? config.exclude : undefined,
659
+ });
660
+ spinner.succeed(`Found ${(0, ui_js_1.bold)(String(Object.keys(schema.tables).length))} tables`);
661
+ const genSpinner = new ui_js_1.Spinner('Generating TypeScript client').start();
662
+ const result = (0, generate_js_1.generate)({ schema, outDir: config.out, connectionString: url });
663
+ genSpinner.succeed(`Generated ${(0, ui_js_1.bold)(String(result.files.length))} files to ${(0, ui_js_1.cyan)(`${config.out}/`)}`);
664
+ }
665
+ catch (err) {
666
+ spinner.fail('Could not generate client');
667
+ if (err instanceof Error)
668
+ console.log(` ${(0, ui_js_1.dim)((0, ui_js_1.redactUrl)(err.message))}`);
669
+ (0, ui_js_1.info)(`Run generation later with: ${(0, ui_js_1.cyan)('npx turbine generate')}`);
670
+ }
671
+ }
672
+ /** Run the seed file. */
673
+ async function runInitSeed(config) {
674
+ const seedFile = (0, config_js_1.resolveSeedFile)(config);
675
+ if (!seedFile || !(0, node_fs_1.existsSync)(seedFile)) {
676
+ (0, ui_js_1.warn)('No seed file found: skipping seed run.');
677
+ return;
678
+ }
679
+ const spinner = new ui_js_1.Spinner('Running seed file').start();
680
+ try {
681
+ await runSeedPlan(getSeedExecutionPlan(seedFile), config);
682
+ spinner.succeed('Seed completed');
683
+ }
684
+ catch (err) {
685
+ spinner.fail('Seed failed');
686
+ if (err instanceof Error)
687
+ console.log(` ${(0, ui_js_1.dim)((0, ui_js_1.redactUrl)(err.message))}`);
688
+ }
689
+ }
690
+ /** Human label for a step, used in prompts and skip messages. */
691
+ function initStepLabel(id) {
692
+ switch (id) {
693
+ case 'config':
694
+ return 'config file';
695
+ case 'schema':
696
+ return 'schema file';
697
+ case 'seed-file':
698
+ return 'seed file';
699
+ case 'push':
700
+ return 'schema push';
701
+ case 'generate':
702
+ return 'client generation';
703
+ case 'seed-run':
704
+ return 'seed run';
705
+ }
706
+ }
707
+ /** Report a skipped step with its reason. Existence skips are the quiet common case. */
708
+ function reportInitSkip(step) {
709
+ const label = initStepLabel(step.id);
710
+ switch (step.skipReason) {
711
+ case 'exists':
712
+ (0, ui_js_1.info)(`${label} already exists ${(0, ui_js_1.dim)('- skipped')}`);
713
+ break;
714
+ case 'flag':
715
+ console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped (flag)`)}`);
716
+ break;
717
+ case 'no-url':
718
+ console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped (no database URL)`)}`);
719
+ break;
720
+ case 'unreachable':
721
+ console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped (database unreachable)`)}`);
722
+ break;
723
+ case 'no-seed-file':
724
+ console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped (no seed file)`)}`);
725
+ break;
726
+ case 'non-interactive':
727
+ console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped (non-interactive: pass --yes to run it)`)}`);
728
+ break;
729
+ case 'default-no':
730
+ console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped (default no)`)}`);
731
+ break;
732
+ default:
733
+ console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped`)}`);
734
+ }
735
+ }
736
+ /** The interactive prompt question for a promptable step. */
737
+ function initPromptQuestion(step, config, seedFilePath) {
738
+ switch (step.id) {
739
+ case 'schema':
740
+ return `Create a starter schema file (${config.schemaFile})?`;
741
+ case 'seed-file':
742
+ return `Create a starter seed file (${seedFilePath})?`;
743
+ case 'push':
744
+ return 'Push your schema to the database now?';
745
+ case 'generate':
746
+ return 'Generate the typed client from the database now?';
747
+ case 'seed-run':
748
+ return 'Run the seed file now?';
749
+ default:
750
+ return `Run ${initStepLabel(step.id)}?`;
751
+ }
752
+ }
753
+ async function cmdInit(args, config) {
754
+ (0, ui_js_1.banner)();
755
+ (0, ui_js_1.header)('Initializing Turbine project');
756
+ // Detect environment. main() has already auto-loaded a local `.env` into
757
+ // process.env (when DATABASE_URL was not otherwise set), so these messages
758
+ // describe the real, post-load state, no more "if set" hand-waving.
759
+ const envUrl = process.env.DATABASE_URL;
760
+ const hasEnvFile = (0, node_fs_1.existsSync)('.env');
761
+ const hasEnvLocal = (0, node_fs_1.existsSync)('.env.local');
762
+ // On Node < 20.12 (no process.loadEnvFile) main() could not auto-load .env, so
763
+ // we cannot claim it "has no DATABASE_URL"; we simply could not read it.
764
+ const canAutoLoadEnv = typeof process.loadEnvFile === 'function';
765
+ if (envUrl) {
766
+ (0, ui_js_1.success)(`Detected ${(0, ui_js_1.cyan)('DATABASE_URL')} in the environment`);
767
+ }
768
+ else if (hasEnvFile && !canAutoLoadEnv) {
769
+ (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('(this Node version cannot auto-load it. Upgrade to Node 20.12+ or export')} ${(0, ui_js_1.cyan)('DATABASE_URL')}${(0, ui_js_1.dim)(')')}`);
770
+ }
771
+ else if (hasEnvFile) {
772
+ // .env exists but did not provide DATABASE_URL; if it had, the auto-load
773
+ // in main() would have populated envUrl above.
774
+ (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('(no')} ${(0, ui_js_1.cyan)('DATABASE_URL')} ${(0, ui_js_1.dim)('set in it yet)')}`);
775
+ }
776
+ else if (hasEnvLocal) {
777
+ (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env.local')} ${(0, ui_js_1.dim)('(note: Turbine only auto-loads')} ${(0, ui_js_1.cyan)('.env')}${(0, ui_js_1.dim)(')')}`);
778
+ }
779
+ else {
780
+ (0, ui_js_1.info)(`No ${(0, ui_js_1.cyan)('DATABASE_URL')} found in environment`);
781
+ }
782
+ (0, ui_js_1.newline)();
783
+ // Heads-up (not an edit) about the consumer's module system. A CommonJS
784
+ // project (`npm init -y` default, or no "type" field) works fine now that the
785
+ // config loader unwraps the CJS-interop double-wrapped default, but ESM is the
786
+ // smoother path for a TypeScript config file.
787
+ const moduleType = detectConsumerModuleType();
788
+ if (moduleType === 'commonjs') {
789
+ (0, ui_js_1.info)(`Your ${(0, ui_js_1.cyan)('package.json')} is a CommonJS project ${(0, ui_js_1.dim)('(no')} ${(0, ui_js_1.cyan)('"type": "module"')}${(0, ui_js_1.dim)(').')}`);
790
+ console.log(` ${(0, ui_js_1.dim)('Turbine works either way. For the smoothest TypeScript config experience, consider adding')} ${(0, ui_js_1.cyan)('"type": "module"')}${(0, ui_js_1.dim)('.')}`);
577
791
  (0, ui_js_1.newline)();
578
- const spinner = new ui_js_1.Spinner('Introspecting database').start();
579
- try {
580
- const schema = await (0, introspect_js_1.introspect)({
581
- connectionString: url,
582
- schema: config.schema,
583
- include: config.include.length ? config.include : undefined,
584
- exclude: config.exclude.length ? config.exclude : undefined,
585
- });
586
- const tableCount = Object.keys(schema.tables).length;
587
- spinner.succeed(`Found ${(0, ui_js_1.bold)(String(tableCount))} tables`);
588
- const genSpinner = new ui_js_1.Spinner('Generating TypeScript client').start();
589
- const result = (0, generate_js_1.generate)({ schema, outDir: config.out, connectionString: url });
590
- genSpinner.succeed(`Generated ${(0, ui_js_1.bold)(String(result.files.length))} files to ${(0, ui_js_1.cyan)(`${config.out}/`)}`);
591
- }
592
- catch (err) {
593
- spinner.fail('Could not connect to database');
594
- if (err instanceof Error) {
595
- console.log(` ${(0, ui_js_1.dim)((0, ui_js_1.redactUrl)(err.message))}`);
596
- }
597
- (0, ui_js_1.newline)();
598
- (0, ui_js_1.info)(`You can run generation later with: ${(0, ui_js_1.cyan)('npx turbine generate')}`);
792
+ }
793
+ // Resolve the effective URL and detect current project state.
794
+ const url = args.url ?? envUrl ?? config.url;
795
+ const hasUrl = Boolean(url);
796
+ const interactive = Boolean(process.stdin.isTTY);
797
+ const yes = args.yes === true;
798
+ const seedFilePath = config.seedFile ?? './seed.ts';
799
+ const configPath = (0, config_js_1.findConfigFile)();
800
+ const state = {
801
+ configExists: Boolean(configPath),
802
+ schemaExists: (0, node_fs_1.existsSync)(config.schemaFile),
803
+ seedFileExists: (0, node_fs_1.existsSync)(seedFilePath),
804
+ hasUrl,
805
+ dbReachable: false,
806
+ };
807
+ // Probe the database once, up front, so the planner can decide the DB steps.
808
+ // Skip the probe entirely when every DB step is already flag-skipped.
809
+ const anyDbStepPossible = !(args.skipPush && args.skipGenerate && args.skipSeed);
810
+ if (hasUrl && anyDbStepPossible) {
811
+ const probe = new ui_js_1.Spinner('Checking database connection').start();
812
+ state.dbReachable = await probeDatabase(url);
813
+ if (state.dbReachable)
814
+ probe.succeed('Database is reachable');
815
+ else
816
+ probe.info('Database not reachable: push / generate / seed steps will be skipped');
817
+ }
818
+ const flags = {
819
+ yes,
820
+ force: args.force === true,
821
+ interactive,
822
+ skipSchema: args.skipSchema === true,
823
+ skipSeed: args.skipSeed === true,
824
+ skipPush: args.skipPush === true,
825
+ skipGenerate: args.skipGenerate === true,
826
+ };
827
+ const plan = planInitSteps(state, flags);
828
+ const degraded = !interactive && !yes;
829
+ // Supporting dirs + .gitignore, regardless of prompts (unchanged behavior).
830
+ ensureInitScaffoldDirs(config);
831
+ (0, ui_js_1.newline)();
832
+ // Execute the plan in order. `run` proceeds; `prompt` asks; `skip` reports.
833
+ for (const step of plan) {
834
+ if (step.action === 'skip') {
835
+ reportInitSkip(step);
836
+ continue;
837
+ }
838
+ const shouldRun = step.action === 'run' ? true : await promptYesNo(initPromptQuestion(step, config, seedFilePath), step.defaultYes);
839
+ if (!shouldRun) {
840
+ (0, ui_js_1.info)(`Skipped ${initStepLabel(step.id)}`);
841
+ continue;
842
+ }
843
+ switch (step.id) {
844
+ case 'config':
845
+ (0, node_fs_1.writeFileSync)('turbine.config.ts', (0, config_js_1.configTemplate)(args.url ?? undefined), 'utf-8');
846
+ (0, ui_js_1.success)(state.configExists ? `Overwrote ${(0, ui_js_1.cyan)('turbine.config.ts')}` : `Created ${(0, ui_js_1.cyan)('turbine.config.ts')}`);
847
+ break;
848
+ case 'schema':
849
+ writeInitSchemaTemplate(config);
850
+ break;
851
+ case 'seed-file':
852
+ writeInitSeedTemplate(seedFilePath);
853
+ break;
854
+ case 'push':
855
+ await runInitPush(config, url);
856
+ break;
857
+ case 'generate':
858
+ await runInitGenerate(config, url);
859
+ break;
860
+ case 'seed-run':
861
+ await runInitSeed(config);
862
+ break;
599
863
  }
600
864
  }
865
+ if (degraded) {
866
+ (0, ui_js_1.newline)();
867
+ (0, ui_js_1.info)('Non-interactive shell: interactive prompts were skipped.');
868
+ console.log(` ${(0, ui_js_1.dim)('Re-run in a terminal, pass')} ${(0, ui_js_1.cyan)('--yes')} ${(0, ui_js_1.dim)('to accept defaults, or use')} ${(0, ui_js_1.cyan)('--skip-*')} ${(0, ui_js_1.dim)('flags to control each step.')}`);
869
+ }
601
870
  // Next steps
602
871
  (0, ui_js_1.newline)();
603
872
  (0, ui_js_1.divider)();
@@ -850,9 +1119,10 @@ async function cmdMigrate(args, config) {
850
1119
  console.log(` ${(0, ui_js_1.bold)('turbine migrate')} ${(0, ui_js_1.dim)('— SQL-first migration system')}`);
851
1120
  (0, ui_js_1.newline)();
852
1121
  console.log(` ${(0, ui_js_1.bold)('Commands:')}`);
853
- console.log(` ${(0, ui_js_1.cyan)('create <name>')} Create a new migration file`);
854
- console.log(` ${(0, ui_js_1.cyan)('create <name> --auto')} Auto-generate from schema diff`);
855
- console.log(` ${(0, ui_js_1.cyan)('create <name> --recipe')} Scaffold a named recipe (e.g. backfill)`);
1122
+ console.log(` ${(0, ui_js_1.cyan)('create <name>')} Create a new migration file`);
1123
+ console.log(` ${(0, ui_js_1.cyan)('create <name> --auto')} Auto-generate from schema diff`);
1124
+ console.log(` ${(0, ui_js_1.cyan)('create <name> --from-diff')} Generate from schema diff, destructive statements flagged`);
1125
+ console.log(` ${(0, ui_js_1.cyan)('create <name> --recipe')} Scaffold a named recipe (e.g. backfill)`);
856
1126
  console.log(` ${(0, ui_js_1.cyan)('up')} Apply pending migrations`);
857
1127
  console.log(` ${(0, ui_js_1.cyan)('deploy')} Apply pending migrations without prompts`);
858
1128
  console.log(` ${(0, ui_js_1.cyan)('down')} Rollback last migration`);
@@ -860,6 +1130,7 @@ async function cmdMigrate(args, config) {
860
1130
  (0, ui_js_1.newline)();
861
1131
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
862
1132
  console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
1133
+ console.log(` ${(0, ui_js_1.cyan)('--from-diff')} Like --auto, but flags destructive statements in the file`);
863
1134
  console.log(` ${(0, ui_js_1.cyan)('--recipe <name>')} Scaffold a sanctioned migration pattern`);
864
1135
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} Number of migrations to apply/rollback`);
865
1136
  console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
@@ -873,6 +1144,7 @@ async function cmdMigrate(args, config) {
873
1144
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
874
1145
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create add_users_table')}`);
875
1146
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create add_email_index --auto')}`);
1147
+ console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create sync_schema --from-diff')}`);
876
1148
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create backfill_full_name --recipe backfill')}`);
877
1149
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate up')}`);
878
1150
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate deploy --dry-run')}`);
@@ -915,6 +1187,20 @@ async function cmdMigrateCreate(args, config) {
915
1187
  (0, ui_js_1.newline)();
916
1188
  process.exit(1);
917
1189
  }
1190
+ // The scaffold strategies each own the file body, so combining them is
1191
+ // ambiguous. Refuse up front (before any of the strategy blocks run).
1192
+ if (args.fromDiff && args.recipe) {
1193
+ (0, ui_js_1.error)('--from-diff cannot be combined with --recipe.');
1194
+ console.log(` ${(0, ui_js_1.dim)('Pick one: --from-diff generates from the schema diff, --recipe scaffolds a pattern.')}`);
1195
+ (0, ui_js_1.newline)();
1196
+ process.exit(1);
1197
+ }
1198
+ if (args.fromDiff && args.auto) {
1199
+ (0, ui_js_1.error)('--from-diff cannot be combined with --auto.');
1200
+ console.log(` ${(0, ui_js_1.dim)('Both generate from the schema diff; --from-diff also flags destructive statements.')}`);
1201
+ (0, ui_js_1.newline)();
1202
+ process.exit(1);
1203
+ }
918
1204
  if (args.auto) {
919
1205
  // Auto-generate migration from schema diff
920
1206
  const url = requireUrl(config);
@@ -925,7 +1211,7 @@ async function cmdMigrateCreate(args, config) {
925
1211
  const diffSpinner = new ui_js_1.Spinner('Computing schema diff').start();
926
1212
  const diff = await (0, schema_sql_js_1.schemaDiff)(schemaDef, url);
927
1213
  if (diff.statements.length === 0) {
928
- diffSpinner.succeed('Database is already in sync — nothing to migrate');
1214
+ diffSpinner.succeed('Database is already in sync: nothing to migrate');
929
1215
  (0, ui_js_1.newline)();
930
1216
  return;
931
1217
  }
@@ -966,6 +1252,78 @@ async function cmdMigrateCreate(args, config) {
966
1252
  (0, ui_js_1.newline)();
967
1253
  return;
968
1254
  }
1255
+ if (args.fromDiff) {
1256
+ // Load the code-first schema (same file `push` uses). loadSchemaFile exits
1257
+ // with a clear message when the project has no schema-builder file.
1258
+ const url = requireUrl(config);
1259
+ (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
1260
+ (0, ui_js_1.label)('Schema file', config.schemaFile);
1261
+ (0, ui_js_1.newline)();
1262
+ const schemaDef = await loadSchemaFile(config.schemaFile);
1263
+ const diffSpinner = new ui_js_1.Spinner('Computing schema diff').start();
1264
+ const diff = await (0, schema_sql_js_1.schemaDiff)(schemaDef, url);
1265
+ if (diff.statements.length === 0) {
1266
+ diffSpinner.succeed('Database is already in sync: nothing to migrate');
1267
+ (0, ui_js_1.newline)();
1268
+ return;
1269
+ }
1270
+ diffSpinner.succeed(`Found ${(0, ui_js_1.bold)(String(diff.statements.length))} change(s)`);
1271
+ (0, ui_js_1.newline)();
1272
+ const body = (0, migrate_js_1.buildDiffMigrationBody)(diff);
1273
+ const file = (0, migrate_js_1.createMigration)(config.migrationsDir, name, { up: body.up, down: body.down });
1274
+ const relPath = (0, node_path_1.relative)(process.cwd(), file.path);
1275
+ (0, ui_js_1.success)(`Created diff migration: ${(0, ui_js_1.bold)(file.filename)}`);
1276
+ (0, ui_js_1.newline)();
1277
+ console.log(` ${(0, ui_js_1.dim)('File:')} ${(0, ui_js_1.cyan)(relPath)}`);
1278
+ (0, ui_js_1.newline)();
1279
+ // Summarize the changes, mirroring --auto's output.
1280
+ if (diff.create.length > 0) {
1281
+ console.log(` ${(0, ui_js_1.green)('+ Create')} ${diff.create.length} table(s): ${diff.create.map((t) => t.name).join(', ')}`);
1282
+ }
1283
+ if (diff.alter.length > 0) {
1284
+ console.log(` ${(0, ui_js_1.yellow)('~ Alter')} ${diff.alter.length} table(s):`);
1285
+ for (const a of diff.alter) {
1286
+ for (const col of a.columns) {
1287
+ const actionLabel = col.action === 'add'
1288
+ ? (0, ui_js_1.green)('+ add')
1289
+ : col.action === 'drop'
1290
+ ? (0, ui_js_1.red)('- drop')
1291
+ : col.action === 'add_unique'
1292
+ ? (0, ui_js_1.green)('+ unique')
1293
+ : col.action === 'drop_unique'
1294
+ ? (0, ui_js_1.red)('- unique')
1295
+ : (0, ui_js_1.yellow)(`~ ${col.action.replace(/_/g, ' ')}`);
1296
+ console.log(` ${actionLabel} ${a.table}.${col.column}`);
1297
+ }
1298
+ }
1299
+ }
1300
+ (0, ui_js_1.newline)();
1301
+ // Loudly flag destructive statements written into the file. They stay in the
1302
+ // migration (so `migrate up`'s gate still refuses them by default) but the
1303
+ // operator must know they are there before running.
1304
+ const destructiveCount = body.destructiveUp.length + body.destructiveDown.length;
1305
+ if (destructiveCount > 0) {
1306
+ (0, ui_js_1.warn)(`This migration contains ${(0, ui_js_1.bold)(String(destructiveCount))} DESTRUCTIVE statement(s), flagged in the file.`);
1307
+ for (const h of body.destructiveUp) {
1308
+ console.log(` ${(0, ui_js_1.red)(ui_js_1.symbols.warning)} ${(0, ui_js_1.dim)('UP')} [${h.kind}] ${h.target}`);
1309
+ }
1310
+ for (const h of body.destructiveDown) {
1311
+ console.log(` ${(0, ui_js_1.red)(ui_js_1.symbols.warning)} ${(0, ui_js_1.dim)('DOWN')} [${h.kind}] ${h.target}`);
1312
+ }
1313
+ (0, ui_js_1.newline)();
1314
+ console.log(` ${(0, ui_js_1.dim)('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${(0, ui_js_1.cyan)('--allow-destructive')}${(0, ui_js_1.dim)('.')}`);
1315
+ (0, ui_js_1.newline)();
1316
+ }
1317
+ if (diff.warnings && diff.warnings.length > 0) {
1318
+ for (const w of diff.warnings)
1319
+ (0, ui_js_1.warn)(w);
1320
+ (0, ui_js_1.newline)();
1321
+ }
1322
+ console.log(` ${(0, ui_js_1.dim)('Review the migration, then run:')}`);
1323
+ console.log(` ${(0, ui_js_1.cyan)('npx turbine migrate up')}`);
1324
+ (0, ui_js_1.newline)();
1325
+ return;
1326
+ }
969
1327
  if (args.recipe) {
970
1328
  if (!migrate_js_1.MIGRATION_RECIPES[args.recipe]) {
971
1329
  (0, ui_js_1.error)(`Unknown migration recipe: ${args.recipe}`);
@@ -1724,12 +2082,22 @@ function showInitHelp() {
1724
2082
  console.log(` ${(0, ui_js_1.bold)('Usage:')}`);
1725
2083
  console.log(` npx turbine init ${(0, ui_js_1.dim)('[options]')}`);
1726
2084
  (0, ui_js_1.newline)();
1727
- console.log(` Creates ${(0, ui_js_1.cyan)('turbine.config.ts')}, migrations directory, seed file template,`);
1728
- console.log(` and schema file template.`);
2085
+ console.log(` Sequenced, interactive bootstrap. Detects project state and runs only`);
2086
+ console.log(` the needed steps: writes ${(0, ui_js_1.cyan)('turbine.config.ts')}, offers a starter schema +`);
2087
+ console.log(` seed file, and (when a reachable database is configured) offers to push`);
2088
+ console.log(` the schema, generate the typed client, and run the seed file.`);
2089
+ (0, ui_js_1.newline)();
2090
+ console.log(` ${(0, ui_js_1.dim)('Re-runs skip completed steps. A non-interactive shell runs the safe')}`);
2091
+ console.log(` ${(0, ui_js_1.dim)('scaffold + generate steps only; pass --yes to accept every default.')}`);
1729
2092
  (0, ui_js_1.newline)();
1730
2093
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1731
2094
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string to embed in config`);
1732
2095
  console.log(` ${(0, ui_js_1.cyan)('--force, -f')} Overwrite existing config file`);
2096
+ console.log(` ${(0, ui_js_1.cyan)('--yes, -y')} Accept every step's default (non-interactive)`);
2097
+ console.log(` ${(0, ui_js_1.cyan)('--skip-schema')} Don't create the starter schema file`);
2098
+ console.log(` ${(0, ui_js_1.cyan)('--skip-seed')} Don't create the seed file or run the seed`);
2099
+ console.log(` ${(0, ui_js_1.cyan)('--skip-push')} Don't push the schema to the database`);
2100
+ console.log(` ${(0, ui_js_1.cyan)('--skip-generate')} Don't generate the typed client`);
1733
2101
  (0, ui_js_1.newline)();
1734
2102
  }
1735
2103
  function showGenerateHelp() {
@@ -1791,6 +2159,7 @@ function showMigrateHelp() {
1791
2159
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1792
2160
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
1793
2161
  console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create only)')}`);
2162
+ console.log(` ${(0, ui_js_1.cyan)('--from-diff')} Generate from schema diff, flagging destructive statements ${(0, ui_js_1.dim)('(create only)')}`);
1794
2163
  console.log(` ${(0, ui_js_1.cyan)('--recipe')} ${(0, ui_js_1.dim)('<name>')} Scaffold a sanctioned migration pattern ${(0, ui_js_1.dim)('(create only, e.g. backfill)')}`);
1795
2164
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1796
2165
  console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
@@ -1801,6 +2170,7 @@ function showMigrateHelp() {
1801
2170
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
1802
2171
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_users_table`);
1803
2172
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_email_index --auto`);
2173
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create sync_schema --from-diff`);
1804
2174
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create backfill_full_name --recipe backfill`);
1805
2175
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate up`);
1806
2176
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate deploy --dry-run`);