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.
Files changed (82) hide show
  1. package/README.md +65 -21
  2. package/dist/cjs/cli/config.d.ts +40 -0
  3. package/dist/cjs/cli/config.js +74 -2
  4. package/dist/cjs/cli/index.d.ts +85 -1
  5. package/dist/cjs/cli/index.js +323 -24
  6. package/dist/cjs/cli/mcp.d.ts +8 -0
  7. package/dist/cjs/cli/mcp.js +448 -29
  8. package/dist/cjs/cli/pii-tags.d.ts +64 -9
  9. package/dist/cjs/cli/pii-tags.js +218 -39
  10. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  11. package/dist/cjs/cli/studio.d.ts +23 -0
  12. package/dist/cjs/cli/studio.js +126 -53
  13. package/dist/cjs/cli/ui.d.ts +15 -1
  14. package/dist/cjs/cli/ui.js +19 -5
  15. package/dist/cjs/client.js +186 -3
  16. package/dist/cjs/errors.d.ts +38 -1
  17. package/dist/cjs/errors.js +235 -24
  18. package/dist/cjs/index.d.ts +2 -2
  19. package/dist/cjs/index.js +7 -2
  20. package/dist/cjs/pipeline.js +15 -2
  21. package/dist/cjs/powql.d.ts +12 -0
  22. package/dist/cjs/powql.js +46 -21
  23. package/dist/cjs/prisma-compat.d.ts +15 -5
  24. package/dist/cjs/prisma-compat.js +273 -78
  25. package/dist/cjs/query/aggregates.d.ts +1 -1
  26. package/dist/cjs/query/aggregates.js +24 -10
  27. package/dist/cjs/query/batched-loader.d.ts +9 -4
  28. package/dist/cjs/query/batched-loader.js +4 -1
  29. package/dist/cjs/query/builder.d.ts +47 -0
  30. package/dist/cjs/query/builder.js +125 -21
  31. package/dist/cjs/query/index.d.ts +3 -1
  32. package/dist/cjs/query/index.js +7 -1
  33. package/dist/cjs/query/option-surface.d.ts +11 -0
  34. package/dist/cjs/query/option-surface.js +13 -0
  35. package/dist/cjs/query/relations.d.ts +8 -0
  36. package/dist/cjs/query/relations.js +21 -1
  37. package/dist/cjs/query/types.d.ts +152 -18
  38. package/dist/cjs/query/types.js +212 -1
  39. package/dist/cjs/query/where.d.ts +3 -3
  40. package/dist/cjs/query/where.js +8 -2
  41. package/dist/cjs/query/writes.js +10 -9
  42. package/dist/cli/config.d.ts +40 -0
  43. package/dist/cli/config.js +73 -2
  44. package/dist/cli/index.d.ts +85 -1
  45. package/dist/cli/index.js +321 -26
  46. package/dist/cli/mcp.d.ts +8 -0
  47. package/dist/cli/mcp.js +448 -29
  48. package/dist/cli/pii-tags.d.ts +64 -9
  49. package/dist/cli/pii-tags.js +217 -39
  50. package/dist/cli/studio-ui.generated.js +1 -1
  51. package/dist/cli/studio.d.ts +23 -0
  52. package/dist/cli/studio.js +125 -53
  53. package/dist/cli/ui.d.ts +15 -1
  54. package/dist/cli/ui.js +18 -4
  55. package/dist/client.js +187 -4
  56. package/dist/errors.d.ts +38 -1
  57. package/dist/errors.js +234 -23
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +5 -2
  60. package/dist/pipeline.js +15 -2
  61. package/dist/powql.d.ts +12 -0
  62. package/dist/powql.js +46 -21
  63. package/dist/prisma-compat.d.ts +15 -5
  64. package/dist/prisma-compat.js +274 -79
  65. package/dist/query/aggregates.d.ts +1 -1
  66. package/dist/query/aggregates.js +24 -10
  67. package/dist/query/batched-loader.d.ts +9 -4
  68. package/dist/query/batched-loader.js +4 -1
  69. package/dist/query/builder.d.ts +47 -0
  70. package/dist/query/builder.js +124 -21
  71. package/dist/query/index.d.ts +3 -1
  72. package/dist/query/index.js +2 -0
  73. package/dist/query/option-surface.d.ts +11 -0
  74. package/dist/query/option-surface.js +13 -0
  75. package/dist/query/relations.d.ts +8 -0
  76. package/dist/query/relations.js +21 -1
  77. package/dist/query/types.d.ts +152 -18
  78. package/dist/query/types.js +207 -2
  79. package/dist/query/where.d.ts +3 -3
  80. package/dist/query/where.js +8 -2
  81. package/dist/query/writes.js +10 -9
  82. 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
- console.error(`--import-ext requires one of: js, none, auto (got ${next ?? '(nothing)'})`);
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
- console.error('--recipe requires a name (e.g. --recipe backfill)');
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
- /** Probe database reachability with a short-lived connection. Never throws. */
641
- async function probeDatabase(url) {
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
- return `Create a starter schema file (${config.schemaFile})?`;
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
- state.dbReachable = await probeDatabase(url);
965
- if (state.dbReachable)
966
- probe.succeed('Database is reachable');
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' ? true : await promptYesNo(initPromptQuestion(step, config, seedFilePath), step.defaultYes);
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
- console.log(warn(`Studio is binding to ${yellow(host)}, this is NOT loopback. ` +
3092
- `Anyone on your network who can reach this port + guess the session token can read your database.`));
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
- console.log(warn('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
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
- console.log(warn('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
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
- console.log(warn('No PII-tagged columns found, so nothing will be redacted. Tags are declared in code ' +
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
- console.log(warn(`Observe is binding to ${yellow(host)}, this is NOT loopback. ` +
3237
- `Anyone on your network who can reach this port + guess the session token can read your metrics.`));
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
- function showSubcommandHelp(command) {
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>;