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/README.md +9 -3
- package/dist/cjs/cli/index.js +500 -130
- package/dist/cjs/cli/migrate.js +79 -0
- package/dist/cjs/client.js +1 -0
- package/dist/cjs/powql.js +7 -3
- package/dist/cjs/query/aggregates.js +11 -1
- package/dist/cjs/query/builder.js +35 -8
- package/dist/cjs/query/filters.js +33 -0
- package/dist/cjs/query/relations.js +7 -7
- package/dist/cli/index.d.ts +61 -1
- package/dist/cli/index.js +500 -131
- package/dist/cli/migrate.d.ts +33 -0
- package/dist/cli/migrate.js +78 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/powql.js +8 -4
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +12 -2
- package/dist/query/builder.d.ts +15 -3
- package/dist/query/builder.js +36 -9
- package/dist/query/deferred.d.ts +11 -0
- package/dist/query/filters.d.ts +17 -0
- package/dist/query/filters.js +32 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/relations.js +8 -8
- package/dist/query/types.d.ts +86 -4
- package/package.json +3 -3
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
mkdirSync(config.out, { recursive: true });
|
|
457
|
-
success(`Created ${cyan(`${config.out}/`)}`);
|
|
494
|
+
catch {
|
|
495
|
+
return false;
|
|
458
496
|
}
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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>')}
|
|
812
|
-
console.log(` ${cyan('create <name> --auto')}
|
|
813
|
-
console.log(` ${cyan('create <name> --
|
|
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
|
|
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(`
|
|
1686
|
-
console.log(`
|
|
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`);
|