turbine-orm 0.61.0 → 0.62.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 +65 -21
- package/dist/cjs/cli/config.d.ts +40 -0
- package/dist/cjs/cli/config.js +74 -2
- package/dist/cjs/cli/index.d.ts +85 -1
- package/dist/cjs/cli/index.js +323 -24
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +448 -29
- package/dist/cjs/cli/pii-tags.d.ts +64 -9
- package/dist/cjs/cli/pii-tags.js +218 -39
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +23 -0
- package/dist/cjs/cli/studio.js +126 -53
- package/dist/cjs/cli/ui.d.ts +15 -1
- package/dist/cjs/cli/ui.js +19 -5
- package/dist/cjs/client.js +186 -3
- package/dist/cjs/errors.d.ts +38 -1
- package/dist/cjs/errors.js +235 -24
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/pipeline.js +15 -2
- package/dist/cjs/powql.d.ts +12 -0
- package/dist/cjs/powql.js +46 -21
- package/dist/cjs/prisma-compat.d.ts +15 -5
- package/dist/cjs/prisma-compat.js +273 -78
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +24 -10
- package/dist/cjs/query/batched-loader.d.ts +9 -4
- package/dist/cjs/query/batched-loader.js +4 -1
- package/dist/cjs/query/builder.d.ts +47 -0
- package/dist/cjs/query/builder.js +125 -21
- package/dist/cjs/query/index.d.ts +3 -1
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/option-surface.d.ts +11 -0
- package/dist/cjs/query/option-surface.js +13 -0
- package/dist/cjs/query/relations.d.ts +8 -0
- package/dist/cjs/query/relations.js +21 -1
- package/dist/cjs/query/types.d.ts +152 -18
- package/dist/cjs/query/types.js +212 -1
- package/dist/cjs/query/where.d.ts +3 -3
- package/dist/cjs/query/where.js +8 -2
- package/dist/cjs/query/writes.js +10 -9
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.js +73 -2
- package/dist/cli/index.d.ts +85 -1
- package/dist/cli/index.js +321 -26
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +448 -29
- package/dist/cli/pii-tags.d.ts +64 -9
- package/dist/cli/pii-tags.js +217 -39
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +23 -0
- package/dist/cli/studio.js +125 -53
- package/dist/cli/ui.d.ts +15 -1
- package/dist/cli/ui.js +18 -4
- package/dist/client.js +187 -4
- package/dist/errors.d.ts +38 -1
- package/dist/errors.js +234 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/pipeline.js +15 -2
- package/dist/powql.d.ts +12 -0
- package/dist/powql.js +46 -21
- package/dist/prisma-compat.d.ts +15 -5
- package/dist/prisma-compat.js +274 -79
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +24 -10
- package/dist/query/batched-loader.d.ts +9 -4
- package/dist/query/batched-loader.js +4 -1
- package/dist/query/builder.d.ts +47 -0
- package/dist/query/builder.js +124 -21
- package/dist/query/index.d.ts +3 -1
- package/dist/query/index.js +2 -0
- package/dist/query/option-surface.d.ts +11 -0
- package/dist/query/option-surface.js +13 -0
- package/dist/query/relations.d.ts +8 -0
- package/dist/query/relations.js +21 -1
- package/dist/query/types.d.ts +152 -18
- package/dist/query/types.js +207 -2
- package/dist/query/where.d.ts +3 -3
- package/dist/query/where.js +8 -2
- package/dist/query/writes.js +10 -9
- package/package.json +13 -3
package/dist/cli/index.js
CHANGED
|
@@ -37,7 +37,7 @@ import { applyFlipVerdicts, emptyFlipProbeResult, needsFlipProbe, probePlanFlips
|
|
|
37
37
|
import { fingerprintPrismaSchema } from '../prisma-schema-fingerprint.js';
|
|
38
38
|
import { snakeToCamel } from '../schema.js';
|
|
39
39
|
import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
40
|
-
import { configTemplate, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
|
|
40
|
+
import { configTemplate, connectionStringHasPassword, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
|
|
41
41
|
import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
|
|
42
42
|
import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
|
|
43
43
|
import { runMcpServer } from './mcp.js';
|
|
@@ -48,6 +48,21 @@ import { DEFAULT_EXCLUDED_TABLES, resolvePrismaSchema } from './prisma-resolve.j
|
|
|
48
48
|
import { PrismaParseError, parsePrismaSchema, resolvePrismaDatasourceUrl, } from './prisma-schema.js';
|
|
49
49
|
import { startStudio } from './studio.js';
|
|
50
50
|
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';
|
|
51
|
+
/**
|
|
52
|
+
* Fail an argument-parse error the way every other CLI failure looks: banner,
|
|
53
|
+
* red error line, then indented hint lines. The flag validators used to call
|
|
54
|
+
* `console.error` directly, so a typo in `--recipe` printed one bare unstyled
|
|
55
|
+
* sentence with no banner and no hint, which reads like an internal crash
|
|
56
|
+
* rather than "you forgot the recipe name".
|
|
57
|
+
*/
|
|
58
|
+
function failArg(message, ...hints) {
|
|
59
|
+
banner();
|
|
60
|
+
error(message);
|
|
61
|
+
for (const hint of hints)
|
|
62
|
+
console.log(` ${dim(hint)}`);
|
|
63
|
+
newline();
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
51
66
|
export function parseArgs(argv = process.argv.slice(2)) {
|
|
52
67
|
const args = argv;
|
|
53
68
|
const result = {
|
|
@@ -108,6 +123,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
108
123
|
case '--skip-schema':
|
|
109
124
|
result.skipSchema = true;
|
|
110
125
|
break;
|
|
126
|
+
case '--with-schema':
|
|
127
|
+
result.withSchema = true;
|
|
128
|
+
break;
|
|
111
129
|
case '--skip-seed':
|
|
112
130
|
result.skipSeed = true;
|
|
113
131
|
break;
|
|
@@ -161,8 +179,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
161
179
|
case '--import-ext':
|
|
162
180
|
case '--import-extension':
|
|
163
181
|
if (next !== 'js' && next !== 'none' && next !== 'auto') {
|
|
164
|
-
|
|
165
|
-
process.exit(1);
|
|
182
|
+
failArg(`${cyan('--import-ext')} requires one of: js, none, auto ${dim(`(got ${next ?? 'nothing'})`)}`, 'Example: npx turbine generate --import-ext none');
|
|
166
183
|
}
|
|
167
184
|
result.importExtension = next;
|
|
168
185
|
i++;
|
|
@@ -178,8 +195,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
178
195
|
break;
|
|
179
196
|
case '--recipe':
|
|
180
197
|
if (next === undefined || next.startsWith('-')) {
|
|
181
|
-
|
|
182
|
-
process.exit(1);
|
|
198
|
+
failArg(`${cyan('--recipe')} requires a recipe name.`, `Known recipes: ${Object.keys(MIGRATION_RECIPES).join(', ') || '(none)'}`, 'Example: npx turbine migrate create backfill_full_name --recipe backfill');
|
|
183
199
|
}
|
|
184
200
|
result.recipe = next;
|
|
185
201
|
i++;
|
|
@@ -583,6 +599,23 @@ export function planInitSteps(state, flags) {
|
|
|
583
599
|
return { id, action: 'skip', defaultYes: true, skipReason: 'flag' };
|
|
584
600
|
if (exists)
|
|
585
601
|
return { id, action: 'skip', defaultYes: true, skipReason: 'exists' };
|
|
602
|
+
// An EMPTY code-first schema file written next to a database that ALREADY
|
|
603
|
+
// has tables is what makes the very next `turbine push` announce "Schema
|
|
604
|
+
// defines 0 tables" and list every real table under "Extra tables in
|
|
605
|
+
// database (not dropped automatically)", which reads like imminent data
|
|
606
|
+
// loss. For these projects the database is the source of truth
|
|
607
|
+
// (`turbine generate`), so the default flips to "don't scaffold it":
|
|
608
|
+
// prompted (default no) on a TTY, skipped with an explanation otherwise.
|
|
609
|
+
//
|
|
610
|
+
// `--with-schema` opts back in and returns this step to the normal path. A
|
|
611
|
+
// code-first project bootstrapping in CI against a populated database is a
|
|
612
|
+
// real case, and a default that no flag can override is a behavior change
|
|
613
|
+
// with no way out, not a safety rail.
|
|
614
|
+
if (id === 'schema' && state.dbHasTables === true && !flags.withSchema) {
|
|
615
|
+
return mode === 'prompt'
|
|
616
|
+
? { id, action: 'prompt', defaultYes: false }
|
|
617
|
+
: { id, action: 'skip', defaultYes: false, skipReason: 'db-has-tables' };
|
|
618
|
+
}
|
|
586
619
|
return { id, action: mode === 'prompt' ? 'prompt' : 'run', defaultYes: true };
|
|
587
620
|
};
|
|
588
621
|
steps.push(scaffold('schema', state.schemaExists, flags.skipSchema));
|
|
@@ -637,17 +670,33 @@ async function promptYesNo(question, defaultYes) {
|
|
|
637
670
|
rl.close();
|
|
638
671
|
}
|
|
639
672
|
}
|
|
640
|
-
/**
|
|
641
|
-
|
|
673
|
+
/**
|
|
674
|
+
* Probe database reachability (and how populated it is) with a short-lived
|
|
675
|
+
* connection. Never throws: an unreachable database is a normal init state.
|
|
676
|
+
*/
|
|
677
|
+
async function probeDatabase(url, schema) {
|
|
642
678
|
try {
|
|
643
679
|
const { default: pg } = await import('pg');
|
|
644
680
|
const client = new pg.Client({ connectionString: url });
|
|
645
681
|
await client.connect();
|
|
682
|
+
let tableCount = 0;
|
|
683
|
+
try {
|
|
684
|
+
const res = await client.query(`SELECT count(*)::text AS count
|
|
685
|
+
FROM information_schema.tables
|
|
686
|
+
WHERE table_schema = $1
|
|
687
|
+
AND table_type = 'BASE TABLE'
|
|
688
|
+
AND table_name NOT LIKE '\\_turbine\\_%'`, [schema]);
|
|
689
|
+
tableCount = Number.parseInt(res.rows[0]?.count ?? '0', 10) || 0;
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
// Reachable but the catalog read failed (permissions, odd engine). Treat
|
|
693
|
+
// it as "unknown", not "empty": tableCount 0 only suppresses a scaffold.
|
|
694
|
+
}
|
|
646
695
|
await client.end();
|
|
647
|
-
return true;
|
|
696
|
+
return { reachable: true, tableCount };
|
|
648
697
|
}
|
|
649
698
|
catch {
|
|
650
|
-
return false;
|
|
699
|
+
return { reachable: false, tableCount: 0 };
|
|
651
700
|
}
|
|
652
701
|
}
|
|
653
702
|
const INIT_SEED_TEMPLATE = `/**
|
|
@@ -688,6 +737,132 @@ export default defineSchema({
|
|
|
688
737
|
// },
|
|
689
738
|
});
|
|
690
739
|
`;
|
|
740
|
+
/**
|
|
741
|
+
* Does this `.gitignore` text already ignore `.env`?
|
|
742
|
+
*
|
|
743
|
+
* Line-based rather than a substring search: a `.gitignore` mentioning
|
|
744
|
+
* `.env.example` (a very common line, since the example file is the one you DO
|
|
745
|
+
* commit) contains the text `.env` while ignoring nothing of the kind, and
|
|
746
|
+
* treating that as covered is how the password would stay committable.
|
|
747
|
+
* Negations (`!.env`) are honored as a later line overriding an earlier one,
|
|
748
|
+
* exactly as git resolves them.
|
|
749
|
+
*
|
|
750
|
+
* @internal exported for tests.
|
|
751
|
+
*/
|
|
752
|
+
export function gitignoreIgnoresEnv(content) {
|
|
753
|
+
let ignored = false;
|
|
754
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
755
|
+
const line = raw.trim();
|
|
756
|
+
if (line === '' || line.startsWith('#'))
|
|
757
|
+
continue;
|
|
758
|
+
const negated = line.startsWith('!');
|
|
759
|
+
// A leading `/` anchors to the repo root, a trailing `/` means directory.
|
|
760
|
+
const pattern = (negated ? line.slice(1) : line).replace(/^\//, '').replace(/\/$/, '');
|
|
761
|
+
// The patterns that actually match a root-level `.env` file.
|
|
762
|
+
if (pattern === '.env' || pattern === '.env*' || pattern === '*.env' || pattern === '**/.env') {
|
|
763
|
+
ignored = !negated;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return ignored;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Decide what the `--url`-carries-a-password scaffold writes. Pure, so the whole
|
|
770
|
+
* matrix (fresh project, existing `.env`, existing `.gitignore`, re-run) is
|
|
771
|
+
* testable without a filesystem.
|
|
772
|
+
*
|
|
773
|
+
* An existing `DATABASE_URL` in `.env` is NEVER rewritten: it is the value the
|
|
774
|
+
* project already runs against, and silently repointing it at the `--url` from
|
|
775
|
+
* one command line is a worse failure than printing a notice.
|
|
776
|
+
*
|
|
777
|
+
* @internal exported for tests.
|
|
778
|
+
*/
|
|
779
|
+
export function planEnvScaffold(state) {
|
|
780
|
+
return {
|
|
781
|
+
env: !state.envExists ? 'created' : state.envHasDatabaseUrl ? 'unchanged' : 'appended',
|
|
782
|
+
envExample: state.envExampleExists ? 'unchanged' : 'created',
|
|
783
|
+
// No `.gitignore` at all means nothing is ignored, so the `.env` we just
|
|
784
|
+
// wrote is committable. Create one rather than warn about it.
|
|
785
|
+
gitignore: !state.gitignoreExists ? 'created' : state.gitignoreIgnoresEnv ? 'unchanged' : 'appended',
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
const ENV_EXAMPLE_TEMPLATE = `# Copy this file to .env and fill in your own values.
|
|
789
|
+
# This file is committed; .env is not.
|
|
790
|
+
DATABASE_URL=postgres://user:password@localhost:5432/database
|
|
791
|
+
`;
|
|
792
|
+
/**
|
|
793
|
+
* Move a password-bearing `--url` out of `turbine.config.ts` and into `.env`.
|
|
794
|
+
*
|
|
795
|
+
* `turbine init --url postgres://user:PASSWORD@host/db` is the documented
|
|
796
|
+
* one-liner, and it used to inline that string verbatim into a file projects
|
|
797
|
+
* commit, with no `.gitignore` written at all. The config template now refuses
|
|
798
|
+
* to inline a secret (see `configTemplate`), so the real value has to land
|
|
799
|
+
* somewhere the config can read it from: this writes `.env`, scaffolds the
|
|
800
|
+
* committable `.env.example` next to it, and makes sure `.gitignore` covers
|
|
801
|
+
* `.env` BEFORE the secret is on disk long enough to be staged.
|
|
802
|
+
*
|
|
803
|
+
* Paths are cwd-relative, exactly like the rest of the init scaffold.
|
|
804
|
+
*
|
|
805
|
+
* @internal exported for tests.
|
|
806
|
+
*/
|
|
807
|
+
export function scaffoldEnvForUrl(url) {
|
|
808
|
+
const envPath = '.env';
|
|
809
|
+
const envExamplePath = '.env.example';
|
|
810
|
+
const gitignorePath = '.gitignore';
|
|
811
|
+
const envExists = existsSync(envPath);
|
|
812
|
+
const plan = planEnvScaffold({
|
|
813
|
+
envExists,
|
|
814
|
+
envHasDatabaseUrl: envExists ? /^\s*(export\s+)?DATABASE_URL\s*=/m.test(readFileSync(envPath, 'utf-8')) : false,
|
|
815
|
+
envExampleExists: existsSync(envExamplePath),
|
|
816
|
+
gitignoreExists: existsSync(gitignorePath),
|
|
817
|
+
gitignoreIgnoresEnv: existsSync(gitignorePath) ? gitignoreIgnoresEnv(readFileSync(gitignorePath, 'utf-8')) : false,
|
|
818
|
+
});
|
|
819
|
+
// .gitignore FIRST: the ignore rule has to exist before the file holding the
|
|
820
|
+
// password does, or a `git add -A` in between commits it.
|
|
821
|
+
if (plan.gitignore === 'created')
|
|
822
|
+
writeFileSync(gitignorePath, '# Local environment (secrets)\n.env\n', 'utf-8');
|
|
823
|
+
else if (plan.gitignore === 'appended')
|
|
824
|
+
appendFileSync(gitignorePath, '\n# Local environment (secrets)\n.env\n');
|
|
825
|
+
if (plan.env === 'created') {
|
|
826
|
+
// 0600 on CREATION only. The whole point of this scaffold is moving a live
|
|
827
|
+
// password out of a committed file, and the default 0644 would leave it
|
|
828
|
+
// world-readable to every account on a shared box or CI runner. `mode` is
|
|
829
|
+
// applied by the OS only when the open() actually creates the file, so if
|
|
830
|
+
// one appeared since the existsSync above we write into it without widening
|
|
831
|
+
// or narrowing whatever mode its owner chose.
|
|
832
|
+
writeFileSync(envPath, `# Turbine database connection. Never commit this file.\nDATABASE_URL=${url}\n`, {
|
|
833
|
+
encoding: 'utf-8',
|
|
834
|
+
mode: 0o600,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
else if (plan.env === 'appended') {
|
|
838
|
+
appendFileSync(envPath, `\n# Added by turbine init\nDATABASE_URL=${url}\n`);
|
|
839
|
+
}
|
|
840
|
+
if (plan.envExample === 'created')
|
|
841
|
+
writeFileSync(envExamplePath, ENV_EXAMPLE_TEMPLATE, 'utf-8');
|
|
842
|
+
return plan;
|
|
843
|
+
}
|
|
844
|
+
/** Say exactly what was written where, so nobody has to guess where the password went. */
|
|
845
|
+
function reportEnvScaffold(plan, url) {
|
|
846
|
+
warn(`The connection string passed to ${cyan('--url')} contains a password.`);
|
|
847
|
+
console.log(` ${dim('It was NOT written into')} ${cyan('turbine.config.ts')}${dim(': that file reads')} ${cyan('process.env.DATABASE_URL')}${dim('.')}`);
|
|
848
|
+
if (plan.env === 'created')
|
|
849
|
+
success(`Wrote the connection string to ${cyan('.env')} ${dim(`(${redactUrl(url)})`)}`);
|
|
850
|
+
else if (plan.env === 'appended')
|
|
851
|
+
success(`Appended ${cyan('DATABASE_URL')} to your existing ${cyan('.env')} ${dim(`(${redactUrl(url)})`)}`);
|
|
852
|
+
else
|
|
853
|
+
info(`Your ${cyan('.env')} already sets ${cyan('DATABASE_URL')}: left untouched. The ${cyan('--url')} value was not saved anywhere.`);
|
|
854
|
+
if (plan.envExample === 'created')
|
|
855
|
+
success(`Created ${cyan('.env.example')} ${dim('(safe to commit)')}`);
|
|
856
|
+
if (plan.gitignore === 'created')
|
|
857
|
+
success(`Created ${cyan('.gitignore')} ignoring ${cyan('.env')}`);
|
|
858
|
+
else if (plan.gitignore === 'appended')
|
|
859
|
+
success(`Added ${cyan('.env')} to ${cyan('.gitignore')}`);
|
|
860
|
+
else
|
|
861
|
+
info(`${cyan('.gitignore')} already ignores ${cyan('.env')}`);
|
|
862
|
+
if (plan.env !== 'unchanged') {
|
|
863
|
+
console.log(` ${dim('The password is now in')} ${cyan('.env')}${dim('. If it was ever pushed to a shared history, rotate it.')}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
691
866
|
/** Create supporting directories + .gitignore entries (unconditional, unprompted). */
|
|
692
867
|
function ensureInitScaffoldDirs(config) {
|
|
693
868
|
const migrDir = config.migrationsDir;
|
|
@@ -842,15 +1017,27 @@ function reportInitSkip(step) {
|
|
|
842
1017
|
case 'default-no':
|
|
843
1018
|
console.log(` ${dim(`${symbols.dot} ${label} skipped (default no)`)}`);
|
|
844
1019
|
break;
|
|
1020
|
+
case 'db-has-tables':
|
|
1021
|
+
info(`${label} not created: your database already has tables ${dim('- skipped')}`);
|
|
1022
|
+
console.log(` ${dim('An empty')} ${cyan('defineSchema()')} ${dim('file would make')} ${cyan('turbine push')} ${dim('report every existing')}`);
|
|
1023
|
+
console.log(` ${dim('table as "extra". Your database is the source of truth here: run')} ${cyan('turbine generate')}`);
|
|
1024
|
+
console.log(` ${dim('to produce the typed client from it. For a code-first schema instead, re-run')}`);
|
|
1025
|
+
console.log(` ${dim('with')} ${cyan('--with-schema')} ${dim('to scaffold')} ${cyan('turbine/schema.ts')} ${dim('anyway, then use')} ${cyan('turbine push')}${dim('.')}`);
|
|
1026
|
+
break;
|
|
845
1027
|
default:
|
|
846
1028
|
console.log(` ${dim(`${symbols.dot} ${label} skipped`)}`);
|
|
847
1029
|
}
|
|
848
1030
|
}
|
|
849
1031
|
/** The interactive prompt question for a promptable step. */
|
|
850
|
-
function initPromptQuestion(step, config, seedFilePath) {
|
|
1032
|
+
function initPromptQuestion(step, config, seedFilePath, dbTableCount = 0) {
|
|
851
1033
|
switch (step.id) {
|
|
852
1034
|
case 'schema':
|
|
853
|
-
|
|
1035
|
+
// Ask the honest question when the database is already populated: the
|
|
1036
|
+
// file would be EMPTY, and `turbine push` reports every existing table as
|
|
1037
|
+
// extra until it is filled in by hand.
|
|
1038
|
+
return dbTableCount > 0
|
|
1039
|
+
? `Your database already has ${dbTableCount} table(s). Create an EMPTY code-first schema file anyway (${config.schemaFile})?`
|
|
1040
|
+
: `Create a starter schema file (${config.schemaFile})?`;
|
|
854
1041
|
case 'seed-file':
|
|
855
1042
|
return `Create a starter seed file (${seedFilePath})?`;
|
|
856
1043
|
case 'push':
|
|
@@ -959,11 +1146,18 @@ async function cmdInit(args, config) {
|
|
|
959
1146
|
// Probe the database once, up front, so the planner can decide the DB steps.
|
|
960
1147
|
// Skip the probe entirely when every DB step is already flag-skipped.
|
|
961
1148
|
const anyDbStepPossible = !(args.skipPush && args.skipGenerate && args.skipSeed);
|
|
1149
|
+
let dbTableCount = 0;
|
|
962
1150
|
if (hasUrl && anyDbStepPossible) {
|
|
963
1151
|
const probe = new Spinner('Checking database connection').start();
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1152
|
+
const probed = await probeDatabase(url, config.schema);
|
|
1153
|
+
state.dbReachable = probed.reachable;
|
|
1154
|
+
dbTableCount = probed.tableCount;
|
|
1155
|
+
state.dbHasTables = probed.tableCount > 0;
|
|
1156
|
+
if (state.dbReachable) {
|
|
1157
|
+
probe.succeed(dbTableCount > 0
|
|
1158
|
+
? `Database is reachable ${dim(`(${dbTableCount} table(s) in schema "${config.schema}")`)}`
|
|
1159
|
+
: 'Database is reachable');
|
|
1160
|
+
}
|
|
967
1161
|
else
|
|
968
1162
|
probe.info('Database not reachable: push / generate / seed steps will be skipped');
|
|
969
1163
|
}
|
|
@@ -972,6 +1166,7 @@ async function cmdInit(args, config) {
|
|
|
972
1166
|
force: args.force === true,
|
|
973
1167
|
interactive,
|
|
974
1168
|
skipSchema: args.skipSchema === true,
|
|
1169
|
+
withSchema: args.withSchema === true,
|
|
975
1170
|
skipSeed: args.skipSeed === true,
|
|
976
1171
|
skipPush: args.skipPush === true,
|
|
977
1172
|
skipGenerate: args.skipGenerate === true,
|
|
@@ -989,17 +1184,26 @@ async function cmdInit(args, config) {
|
|
|
989
1184
|
reportInitSkip(step);
|
|
990
1185
|
continue;
|
|
991
1186
|
}
|
|
992
|
-
const shouldRun = step.action === 'run'
|
|
1187
|
+
const shouldRun = step.action === 'run'
|
|
1188
|
+
? true
|
|
1189
|
+
: await promptYesNo(initPromptQuestion(step, config, seedFilePath, dbTableCount), step.defaultYes);
|
|
993
1190
|
if (!shouldRun) {
|
|
994
1191
|
info(`Skipped ${initStepLabel(step.id)}`);
|
|
995
1192
|
continue;
|
|
996
1193
|
}
|
|
997
1194
|
switch (step.id) {
|
|
998
|
-
case 'config':
|
|
1195
|
+
case 'config': {
|
|
999
1196
|
writeFileSync('turbine.config.ts', configTemplate(args.url ?? undefined), 'utf-8');
|
|
1000
1197
|
success(state.configExists ? `Overwrote ${cyan('turbine.config.ts')}` : `Created ${cyan('turbine.config.ts')}`);
|
|
1001
1198
|
tsFilesWritten.push('turbine.config.ts');
|
|
1199
|
+
// A `--url` with a password is never inlined by configTemplate, so the
|
|
1200
|
+
// real value has to be put somewhere the config can read it from.
|
|
1201
|
+
if (args.url && connectionStringHasPassword(args.url)) {
|
|
1202
|
+
newline();
|
|
1203
|
+
reportEnvScaffold(scaffoldEnvForUrl(args.url), args.url);
|
|
1204
|
+
}
|
|
1002
1205
|
break;
|
|
1206
|
+
}
|
|
1003
1207
|
case 'schema':
|
|
1004
1208
|
writeInitSchemaTemplate(config);
|
|
1005
1209
|
if (needsTsLoader(config.schemaFile))
|
|
@@ -3088,8 +3292,10 @@ async function cmdStudio(args, config) {
|
|
|
3088
3292
|
newline();
|
|
3089
3293
|
process.exit(1);
|
|
3090
3294
|
}
|
|
3091
|
-
|
|
3092
|
-
|
|
3295
|
+
// warn() prints; it returns void. Wrapping it in console.log() printed a
|
|
3296
|
+
// literal grey "undefined" line under every one of these SAFETY warnings.
|
|
3297
|
+
warn(`Studio is binding to ${yellow(host)}, this is NOT loopback. ` +
|
|
3298
|
+
`Anyone on your network who can reach this port + guess the session token can read your database.`);
|
|
3093
3299
|
}
|
|
3094
3300
|
const spinner = new Spinner(demo ? 'Seeding demo dataset' : 'Introspecting database').start();
|
|
3095
3301
|
let studio;
|
|
@@ -3135,12 +3341,12 @@ async function cmdStudio(args, config) {
|
|
|
3135
3341
|
// Loud startup warnings for the opt-in modes that widen Studio's surface.
|
|
3136
3342
|
if (args.write) {
|
|
3137
3343
|
newline();
|
|
3138
|
-
|
|
3139
|
-
`${redactUrl(url)}. Every change is committed directly to your database.`)
|
|
3344
|
+
warn('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
|
|
3345
|
+
`${redactUrl(url)}. Every change is committed directly to your database.`);
|
|
3140
3346
|
}
|
|
3141
3347
|
if (args.showPii) {
|
|
3142
3348
|
newline();
|
|
3143
|
-
|
|
3349
|
+
warn('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.');
|
|
3144
3350
|
}
|
|
3145
3351
|
// PII tags are a code-first declaration; introspection never infers them.
|
|
3146
3352
|
// Say plainly whether any reached this session, so nobody assumes a
|
|
@@ -3151,9 +3357,9 @@ async function cmdStudio(args, config) {
|
|
|
3151
3357
|
console.log(` ${dim('PII redaction:')} ${studio.piiTags.applied} tagged column(s) from ${dim(studio.piiTags.path)}`);
|
|
3152
3358
|
}
|
|
3153
3359
|
else {
|
|
3154
|
-
|
|
3360
|
+
warn('No PII-tagged columns found, so nothing will be redacted. Tags are declared in code ' +
|
|
3155
3361
|
`(defineSchema \`pii: true\`) and read from generated metadata in ${config.out}; ` +
|
|
3156
|
-
'introspection alone never infers them. Run `turbine generate` after tagging.')
|
|
3362
|
+
'introspection alone never infers them. Run `turbine generate` after tagging.');
|
|
3157
3363
|
}
|
|
3158
3364
|
}
|
|
3159
3365
|
newline();
|
|
@@ -3233,8 +3439,9 @@ async function cmdObserve(args) {
|
|
|
3233
3439
|
newline();
|
|
3234
3440
|
process.exit(1);
|
|
3235
3441
|
}
|
|
3236
|
-
|
|
3237
|
-
|
|
3442
|
+
// warn() prints and returns void; see the same guard in cmdStudio.
|
|
3443
|
+
warn(`Observe is binding to ${yellow(host)}, this is NOT loopback. ` +
|
|
3444
|
+
`Anyone on your network who can reach this port + guess the session token can read your metrics.`);
|
|
3238
3445
|
}
|
|
3239
3446
|
const spinner = new Spinner('Connecting to metrics database').start();
|
|
3240
3447
|
let handle;
|
|
@@ -3273,7 +3480,14 @@ async function cmdObserve(args) {
|
|
|
3273
3480
|
// ---------------------------------------------------------------------------
|
|
3274
3481
|
// Subcommand help
|
|
3275
3482
|
// ---------------------------------------------------------------------------
|
|
3276
|
-
|
|
3483
|
+
/**
|
|
3484
|
+
* Print `<command> --help` for a command that has real help, returning whether
|
|
3485
|
+
* one existed. Falling through to the GLOBAL help is the failure mode this map
|
|
3486
|
+
* guards against, and it is silent, so the coverage is asserted in tests.
|
|
3487
|
+
*
|
|
3488
|
+
* @internal exported for tests.
|
|
3489
|
+
*/
|
|
3490
|
+
export function showSubcommandHelp(command) {
|
|
3277
3491
|
const helpMap = {
|
|
3278
3492
|
init: showInitHelp,
|
|
3279
3493
|
generate: showGenerateHelp,
|
|
@@ -3284,6 +3498,8 @@ function showSubcommandHelp(command) {
|
|
|
3284
3498
|
migration: showMigrateHelp,
|
|
3285
3499
|
seed: showSeedHelp,
|
|
3286
3500
|
status: showStatusHelp,
|
|
3501
|
+
doctor: showDoctorHelp,
|
|
3502
|
+
studio: showStudioHelp,
|
|
3287
3503
|
mcp: showMcpHelp,
|
|
3288
3504
|
};
|
|
3289
3505
|
const fn = helpMap[command];
|
|
@@ -3313,6 +3529,7 @@ function showInitHelp() {
|
|
|
3313
3529
|
console.log(` ${cyan('--force, -f')} Overwrite existing config file`);
|
|
3314
3530
|
console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
|
|
3315
3531
|
console.log(` ${cyan('--skip-schema')} Don't create the starter schema file`);
|
|
3532
|
+
console.log(` ${cyan('--with-schema')} Create it even if the database already has tables`);
|
|
3316
3533
|
console.log(` ${cyan('--skip-seed')} Don't create the seed file or run the seed`);
|
|
3317
3534
|
console.log(` ${cyan('--skip-push')} Don't push the schema to the database`);
|
|
3318
3535
|
console.log(` ${cyan('--skip-generate')} Don't generate the typed client`);
|
|
@@ -3465,6 +3682,83 @@ function showStatusHelp() {
|
|
|
3465
3682
|
console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema ${dim('(default: public)')}`);
|
|
3466
3683
|
newline();
|
|
3467
3684
|
}
|
|
3685
|
+
function showDoctorHelp() {
|
|
3686
|
+
banner();
|
|
3687
|
+
console.log(` ${bold('turbine doctor')}, Index + cached-plan triage`);
|
|
3688
|
+
newline();
|
|
3689
|
+
console.log(` ${bold('Usage:')}`);
|
|
3690
|
+
console.log(` npx turbine doctor ${dim('[options]')}`);
|
|
3691
|
+
newline();
|
|
3692
|
+
console.log(` Introspects your schema and live statistics, then reports:`);
|
|
3693
|
+
console.log(` ${dim('•')} relation probes with no usable index ${dim('(ranked by estimated cost)')}`);
|
|
3694
|
+
console.log(` ${dim('•')} INVALID indexes ${dim('(left behind by a failed CREATE INDEX CONCURRENTLY)')}`);
|
|
3695
|
+
console.log(` ${dim('•')} columns whose value distribution can flip a cached plan`);
|
|
3696
|
+
console.log(` ${dim('•')} with ${cyan('--unused')}: never-scanned and redundant indexes, with DROP suggestions`);
|
|
3697
|
+
newline();
|
|
3698
|
+
console.log(` ${dim('Read-only: doctor never writes to your database. Only')} ${cyan('--fix')} ${dim('writes')}`);
|
|
3699
|
+
console.log(` ${dim('anything at all, and only a migration FILE you review and run yourself.')}`);
|
|
3700
|
+
newline();
|
|
3701
|
+
console.log(` ${bold('Options:')}`);
|
|
3702
|
+
console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
|
|
3703
|
+
console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema ${dim('(default: public)')}`);
|
|
3704
|
+
console.log(` ${cyan('--include')} ${dim('<tables>')} Comma-separated tables to include`);
|
|
3705
|
+
console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
|
|
3706
|
+
console.log(` ${cyan('--fix')} Write an add-index migration for the missing-index findings`);
|
|
3707
|
+
console.log(` ${cyan('--no-concurrently')} With ${cyan('--fix')}: emit plain CREATE INDEX ${dim('(default: CONCURRENTLY, no transaction)')}`);
|
|
3708
|
+
console.log(` ${cyan('--json')} Emit the versioned machine-readable report and nothing else`);
|
|
3709
|
+
console.log(` ${cyan('--unused')} Also report never-scanned / redundant indexes ${dim('(no --fix)')}`);
|
|
3710
|
+
console.log(` ${cyan('--audit')} Scope the unused report to doctor's own suggested index names`);
|
|
3711
|
+
console.log(` ${cyan('--min-scans')} ${dim('<n>')} idx_scan below this counts as never-scanned ${dim('(default: 1)')}`);
|
|
3712
|
+
console.log(` ${cyan('--metrics-url')} ${dim('<url>')} Read ${cyan('_turbine_metrics')} for the table-heat boost from a separate DB`);
|
|
3713
|
+
console.log(` ${cyan('--no-plan-divergence')} Skip the cached-plan divergence section ${dim('(and its pg_stats read)')}`);
|
|
3714
|
+
newline();
|
|
3715
|
+
console.log(` ${bold('Examples:')}`);
|
|
3716
|
+
console.log(` ${dim('$')} npx turbine doctor`);
|
|
3717
|
+
console.log(` ${dim('$')} npx turbine doctor --fix`);
|
|
3718
|
+
console.log(` ${dim('$')} npx turbine doctor --unused --min-scans 5`);
|
|
3719
|
+
console.log(` ${dim('$')} npx turbine doctor --json > doctor.json`);
|
|
3720
|
+
newline();
|
|
3721
|
+
}
|
|
3722
|
+
function showStudioHelp() {
|
|
3723
|
+
banner();
|
|
3724
|
+
console.log(` ${bold('turbine studio')}, Launch the local database UI`);
|
|
3725
|
+
newline();
|
|
3726
|
+
console.log(` ${bold('Usage:')}`);
|
|
3727
|
+
console.log(` npx turbine studio ${dim('[options]')}`);
|
|
3728
|
+
newline();
|
|
3729
|
+
console.log(` A local web UI with Query / Data / Schema tabs. There is no raw-SQL`);
|
|
3730
|
+
console.log(` surface: the Query tab is a visual ${cyan('findMany')} builder and every`);
|
|
3731
|
+
console.log(` identifier is validated against the introspected schema.`);
|
|
3732
|
+
newline();
|
|
3733
|
+
console.log(` ${bold('Defaults are the safe ones:')}`);
|
|
3734
|
+
console.log(` ${dim('•')} read-only ${dim('(reads run inside BEGIN READ ONLY)')}`);
|
|
3735
|
+
console.log(` ${dim('•')} PII-tagged column values redacted server-side`);
|
|
3736
|
+
console.log(` ${dim('•')} bound to 127.0.0.1, behind a random per-session token`);
|
|
3737
|
+
newline();
|
|
3738
|
+
console.log(` ${bold('Options:')}`);
|
|
3739
|
+
console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
|
|
3740
|
+
console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema ${dim('(default: public)')}`);
|
|
3741
|
+
console.log(` ${cyan('--out, -o')} ${dim('<dir>')} Generated-metadata dir, source of the PII tags`);
|
|
3742
|
+
console.log(` ${cyan('--include')} ${dim('<tables>')} Comma-separated tables to include`);
|
|
3743
|
+
console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
|
|
3744
|
+
console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983)')}`);
|
|
3745
|
+
console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
|
|
3746
|
+
console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
|
|
3747
|
+
console.log(` ${cyan('--allow-remote')} Allow a non-loopback ${cyan('--host')} ${dim('(refused without it)')}`);
|
|
3748
|
+
console.log(` ${cyan('--write')} Enable single-row update / insert / delete`);
|
|
3749
|
+
console.log(` ${cyan('--show-pii')} Show PII-tagged values unredacted`);
|
|
3750
|
+
console.log(` ${cyan('--demo')} Seeded in-memory sample database ${dim('(no DATABASE_URL)')}`);
|
|
3751
|
+
newline();
|
|
3752
|
+
console.log(` ${bold('Examples:')}`);
|
|
3753
|
+
console.log(` ${dim('$')} npx turbine studio`);
|
|
3754
|
+
console.log(` ${dim('$')} npx turbine studio --demo`);
|
|
3755
|
+
console.log(` ${dim('$')} npx turbine studio --write --port 5000`);
|
|
3756
|
+
newline();
|
|
3757
|
+
console.log(` ${dim('--write commits every change directly to the database, and')} ${cyan('--show-pii')}`);
|
|
3758
|
+
console.log(` ${dim('reveals values Studio otherwise never sends to the browser. Both print a')}`);
|
|
3759
|
+
console.log(` ${dim('startup warning and a persistent in-UI banner. Demo mode saves nothing.')}`);
|
|
3760
|
+
newline();
|
|
3761
|
+
}
|
|
3468
3762
|
function showMcpHelp() {
|
|
3469
3763
|
banner();
|
|
3470
3764
|
console.log(` ${bold('turbine mcp')}, Start read-only MCP server over stdio`);
|
|
@@ -3529,6 +3823,7 @@ function showHelp() {
|
|
|
3529
3823
|
console.log(` ${bold('Init options:')}`);
|
|
3530
3824
|
console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
|
|
3531
3825
|
console.log(` ${cyan('--skip-schema')} Don't scaffold the schema file`);
|
|
3826
|
+
console.log(` ${cyan('--with-schema')} Scaffold it even if the database already has tables`);
|
|
3532
3827
|
console.log(` ${cyan('--skip-seed')} Don't scaffold or run the seed file`);
|
|
3533
3828
|
console.log(` ${cyan('--skip-push')} Don't offer to push the schema to the database`);
|
|
3534
3829
|
console.log(` ${cyan('--skip-generate')} Don't offer to generate the typed client`);
|
package/dist/cli/mcp.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Readable, Writable } from 'node:stream';
|
|
2
|
+
import pg from 'pg';
|
|
2
3
|
import { type ColumnMetadata, type RelationDef } from '../schema.js';
|
|
3
4
|
export interface McpServerOptions {
|
|
4
5
|
url: string;
|
|
@@ -17,6 +18,13 @@ export interface McpServerOptions {
|
|
|
17
18
|
export interface McpTransport {
|
|
18
19
|
input?: Readable;
|
|
19
20
|
output?: Writable;
|
|
21
|
+
/**
|
|
22
|
+
* Pre-built pool, used ONLY by the perimeter tests so they can drive the real
|
|
23
|
+
* JSON-RPC line handler and the real tool handlers with no database (the same
|
|
24
|
+
* reason Studio exports `handleRequest`). Production never sets it: the
|
|
25
|
+
* server builds its own pool from `options.url`.
|
|
26
|
+
*/
|
|
27
|
+
pool?: pg.Pool;
|
|
20
28
|
}
|
|
21
29
|
export interface McpServerHandle {
|
|
22
30
|
dispose(): Promise<void>;
|