turbine-orm 0.60.1 → 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 (84) hide show
  1. package/README.md +71 -27
  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 +374 -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 +248 -11
  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-submittable.js +26 -3
  21. package/dist/cjs/pipeline.js +15 -2
  22. package/dist/cjs/powql.d.ts +12 -0
  23. package/dist/cjs/powql.js +46 -21
  24. package/dist/cjs/prisma-compat.d.ts +15 -5
  25. package/dist/cjs/prisma-compat.js +273 -78
  26. package/dist/cjs/query/aggregates.d.ts +1 -1
  27. package/dist/cjs/query/aggregates.js +24 -10
  28. package/dist/cjs/query/batched-loader.d.ts +9 -4
  29. package/dist/cjs/query/batched-loader.js +4 -1
  30. package/dist/cjs/query/builder.d.ts +47 -0
  31. package/dist/cjs/query/builder.js +149 -21
  32. package/dist/cjs/query/index.d.ts +3 -1
  33. package/dist/cjs/query/index.js +7 -1
  34. package/dist/cjs/query/option-surface.d.ts +11 -0
  35. package/dist/cjs/query/option-surface.js +13 -0
  36. package/dist/cjs/query/relations.d.ts +8 -0
  37. package/dist/cjs/query/relations.js +21 -1
  38. package/dist/cjs/query/types.d.ts +152 -18
  39. package/dist/cjs/query/types.js +212 -1
  40. package/dist/cjs/query/where.d.ts +3 -3
  41. package/dist/cjs/query/where.js +8 -2
  42. package/dist/cjs/query/writes.js +10 -9
  43. package/dist/cli/config.d.ts +40 -0
  44. package/dist/cli/config.js +73 -2
  45. package/dist/cli/index.d.ts +85 -1
  46. package/dist/cli/index.js +373 -27
  47. package/dist/cli/mcp.d.ts +8 -0
  48. package/dist/cli/mcp.js +448 -29
  49. package/dist/cli/pii-tags.d.ts +64 -9
  50. package/dist/cli/pii-tags.js +217 -39
  51. package/dist/cli/studio-ui.generated.js +1 -1
  52. package/dist/cli/studio.d.ts +23 -0
  53. package/dist/cli/studio.js +125 -53
  54. package/dist/cli/ui.d.ts +15 -1
  55. package/dist/cli/ui.js +18 -4
  56. package/dist/client.js +250 -13
  57. package/dist/errors.d.ts +38 -1
  58. package/dist/errors.js +234 -23
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +5 -2
  61. package/dist/pipeline-submittable.js +26 -3
  62. package/dist/pipeline.js +15 -2
  63. package/dist/powql.d.ts +12 -0
  64. package/dist/powql.js +46 -21
  65. package/dist/prisma-compat.d.ts +15 -5
  66. package/dist/prisma-compat.js +274 -79
  67. package/dist/query/aggregates.d.ts +1 -1
  68. package/dist/query/aggregates.js +24 -10
  69. package/dist/query/batched-loader.d.ts +9 -4
  70. package/dist/query/batched-loader.js +4 -1
  71. package/dist/query/builder.d.ts +47 -0
  72. package/dist/query/builder.js +148 -21
  73. package/dist/query/index.d.ts +3 -1
  74. package/dist/query/index.js +2 -0
  75. package/dist/query/option-surface.d.ts +11 -0
  76. package/dist/query/option-surface.js +13 -0
  77. package/dist/query/relations.d.ts +8 -0
  78. package/dist/query/relations.js +21 -1
  79. package/dist/query/types.d.ts +152 -18
  80. package/dist/query/types.js +207 -2
  81. package/dist/query/where.d.ts +3 -3
  82. package/dist/query/where.js +8 -2
  83. package/dist/query/writes.js +10 -9
  84. package/package.json +13 -3
@@ -69,11 +69,15 @@ exports.tsxInstallCommand = tsxInstallCommand;
69
69
  exports.tsxRequiredNotice = tsxRequiredNotice;
70
70
  exports.detectConsumerModuleType = detectConsumerModuleType;
71
71
  exports.planInitSteps = planInitSteps;
72
+ exports.gitignoreIgnoresEnv = gitignoreIgnoresEnv;
73
+ exports.planEnvScaffold = planEnvScaffold;
74
+ exports.scaffoldEnvForUrl = scaffoldEnvForUrl;
72
75
  exports.initEnvNotice = initEnvNotice;
73
76
  exports.resolveMigrateFromPrismaUrl = resolveMigrateFromPrismaUrl;
74
77
  exports.buildMigrateDeployOptions = buildMigrateDeployOptions;
75
78
  exports.getSeedExecutionPlan = getSeedExecutionPlan;
76
79
  exports.isLoopbackHost = isLoopbackHost;
80
+ exports.showSubcommandHelp = showSubcommandHelp;
77
81
  const node_fs_1 = require("node:fs");
78
82
  const node_os_1 = require("node:os");
79
83
  const node_path_1 = require("node:path");
@@ -98,6 +102,21 @@ const prisma_resolve_js_1 = require("./prisma-resolve.js");
98
102
  const prisma_schema_js_1 = require("./prisma-schema.js");
99
103
  const studio_js_1 = require("./studio.js");
100
104
  const ui_js_1 = require("./ui.js");
105
+ /**
106
+ * Fail an argument-parse error the way every other CLI failure looks: banner,
107
+ * red error line, then indented hint lines. The flag validators used to call
108
+ * `console.error` directly, so a typo in `--recipe` printed one bare unstyled
109
+ * sentence with no banner and no hint, which reads like an internal crash
110
+ * rather than "you forgot the recipe name".
111
+ */
112
+ function failArg(message, ...hints) {
113
+ (0, ui_js_1.banner)();
114
+ (0, ui_js_1.error)(message);
115
+ for (const hint of hints)
116
+ console.log(` ${(0, ui_js_1.dim)(hint)}`);
117
+ (0, ui_js_1.newline)();
118
+ process.exit(1);
119
+ }
101
120
  function parseArgs(argv = process.argv.slice(2)) {
102
121
  const args = argv;
103
122
  const result = {
@@ -158,6 +177,9 @@ function parseArgs(argv = process.argv.slice(2)) {
158
177
  case '--skip-schema':
159
178
  result.skipSchema = true;
160
179
  break;
180
+ case '--with-schema':
181
+ result.withSchema = true;
182
+ break;
161
183
  case '--skip-seed':
162
184
  result.skipSeed = true;
163
185
  break;
@@ -211,8 +233,7 @@ function parseArgs(argv = process.argv.slice(2)) {
211
233
  case '--import-ext':
212
234
  case '--import-extension':
213
235
  if (next !== 'js' && next !== 'none' && next !== 'auto') {
214
- console.error(`--import-ext requires one of: js, none, auto (got ${next ?? '(nothing)'})`);
215
- process.exit(1);
236
+ failArg(`${(0, ui_js_1.cyan)('--import-ext')} requires one of: js, none, auto ${(0, ui_js_1.dim)(`(got ${next ?? 'nothing'})`)}`, 'Example: npx turbine generate --import-ext none');
216
237
  }
217
238
  result.importExtension = next;
218
239
  i++;
@@ -228,8 +249,7 @@ function parseArgs(argv = process.argv.slice(2)) {
228
249
  break;
229
250
  case '--recipe':
230
251
  if (next === undefined || next.startsWith('-')) {
231
- console.error('--recipe requires a name (e.g. --recipe backfill)');
232
- process.exit(1);
252
+ failArg(`${(0, ui_js_1.cyan)('--recipe')} requires a recipe name.`, `Known recipes: ${Object.keys(migrate_js_1.MIGRATION_RECIPES).join(', ') || '(none)'}`, 'Example: npx turbine migrate create backfill_full_name --recipe backfill');
233
253
  }
234
254
  result.recipe = next;
235
255
  i++;
@@ -633,6 +653,23 @@ function planInitSteps(state, flags) {
633
653
  return { id, action: 'skip', defaultYes: true, skipReason: 'flag' };
634
654
  if (exists)
635
655
  return { id, action: 'skip', defaultYes: true, skipReason: 'exists' };
656
+ // An EMPTY code-first schema file written next to a database that ALREADY
657
+ // has tables is what makes the very next `turbine push` announce "Schema
658
+ // defines 0 tables" and list every real table under "Extra tables in
659
+ // database (not dropped automatically)", which reads like imminent data
660
+ // loss. For these projects the database is the source of truth
661
+ // (`turbine generate`), so the default flips to "don't scaffold it":
662
+ // prompted (default no) on a TTY, skipped with an explanation otherwise.
663
+ //
664
+ // `--with-schema` opts back in and returns this step to the normal path. A
665
+ // code-first project bootstrapping in CI against a populated database is a
666
+ // real case, and a default that no flag can override is a behavior change
667
+ // with no way out, not a safety rail.
668
+ if (id === 'schema' && state.dbHasTables === true && !flags.withSchema) {
669
+ return mode === 'prompt'
670
+ ? { id, action: 'prompt', defaultYes: false }
671
+ : { id, action: 'skip', defaultYes: false, skipReason: 'db-has-tables' };
672
+ }
636
673
  return { id, action: mode === 'prompt' ? 'prompt' : 'run', defaultYes: true };
637
674
  };
638
675
  steps.push(scaffold('schema', state.schemaExists, flags.skipSchema));
@@ -687,17 +724,33 @@ async function promptYesNo(question, defaultYes) {
687
724
  rl.close();
688
725
  }
689
726
  }
690
- /** Probe database reachability with a short-lived connection. Never throws. */
691
- async function probeDatabase(url) {
727
+ /**
728
+ * Probe database reachability (and how populated it is) with a short-lived
729
+ * connection. Never throws: an unreachable database is a normal init state.
730
+ */
731
+ async function probeDatabase(url, schema) {
692
732
  try {
693
733
  const { default: pg } = await Promise.resolve().then(() => __importStar(require('pg')));
694
734
  const client = new pg.Client({ connectionString: url });
695
735
  await client.connect();
736
+ let tableCount = 0;
737
+ try {
738
+ const res = await client.query(`SELECT count(*)::text AS count
739
+ FROM information_schema.tables
740
+ WHERE table_schema = $1
741
+ AND table_type = 'BASE TABLE'
742
+ AND table_name NOT LIKE '\\_turbine\\_%'`, [schema]);
743
+ tableCount = Number.parseInt(res.rows[0]?.count ?? '0', 10) || 0;
744
+ }
745
+ catch {
746
+ // Reachable but the catalog read failed (permissions, odd engine). Treat
747
+ // it as "unknown", not "empty": tableCount 0 only suppresses a scaffold.
748
+ }
696
749
  await client.end();
697
- return true;
750
+ return { reachable: true, tableCount };
698
751
  }
699
752
  catch {
700
- return false;
753
+ return { reachable: false, tableCount: 0 };
701
754
  }
702
755
  }
703
756
  const INIT_SEED_TEMPLATE = `/**
@@ -738,6 +791,132 @@ export default defineSchema({
738
791
  // },
739
792
  });
740
793
  `;
794
+ /**
795
+ * Does this `.gitignore` text already ignore `.env`?
796
+ *
797
+ * Line-based rather than a substring search: a `.gitignore` mentioning
798
+ * `.env.example` (a very common line, since the example file is the one you DO
799
+ * commit) contains the text `.env` while ignoring nothing of the kind, and
800
+ * treating that as covered is how the password would stay committable.
801
+ * Negations (`!.env`) are honored as a later line overriding an earlier one,
802
+ * exactly as git resolves them.
803
+ *
804
+ * @internal exported for tests.
805
+ */
806
+ function gitignoreIgnoresEnv(content) {
807
+ let ignored = false;
808
+ for (const raw of content.split(/\r?\n/)) {
809
+ const line = raw.trim();
810
+ if (line === '' || line.startsWith('#'))
811
+ continue;
812
+ const negated = line.startsWith('!');
813
+ // A leading `/` anchors to the repo root, a trailing `/` means directory.
814
+ const pattern = (negated ? line.slice(1) : line).replace(/^\//, '').replace(/\/$/, '');
815
+ // The patterns that actually match a root-level `.env` file.
816
+ if (pattern === '.env' || pattern === '.env*' || pattern === '*.env' || pattern === '**/.env') {
817
+ ignored = !negated;
818
+ }
819
+ }
820
+ return ignored;
821
+ }
822
+ /**
823
+ * Decide what the `--url`-carries-a-password scaffold writes. Pure, so the whole
824
+ * matrix (fresh project, existing `.env`, existing `.gitignore`, re-run) is
825
+ * testable without a filesystem.
826
+ *
827
+ * An existing `DATABASE_URL` in `.env` is NEVER rewritten: it is the value the
828
+ * project already runs against, and silently repointing it at the `--url` from
829
+ * one command line is a worse failure than printing a notice.
830
+ *
831
+ * @internal exported for tests.
832
+ */
833
+ function planEnvScaffold(state) {
834
+ return {
835
+ env: !state.envExists ? 'created' : state.envHasDatabaseUrl ? 'unchanged' : 'appended',
836
+ envExample: state.envExampleExists ? 'unchanged' : 'created',
837
+ // No `.gitignore` at all means nothing is ignored, so the `.env` we just
838
+ // wrote is committable. Create one rather than warn about it.
839
+ gitignore: !state.gitignoreExists ? 'created' : state.gitignoreIgnoresEnv ? 'unchanged' : 'appended',
840
+ };
841
+ }
842
+ const ENV_EXAMPLE_TEMPLATE = `# Copy this file to .env and fill in your own values.
843
+ # This file is committed; .env is not.
844
+ DATABASE_URL=postgres://user:password@localhost:5432/database
845
+ `;
846
+ /**
847
+ * Move a password-bearing `--url` out of `turbine.config.ts` and into `.env`.
848
+ *
849
+ * `turbine init --url postgres://user:PASSWORD@host/db` is the documented
850
+ * one-liner, and it used to inline that string verbatim into a file projects
851
+ * commit, with no `.gitignore` written at all. The config template now refuses
852
+ * to inline a secret (see `configTemplate`), so the real value has to land
853
+ * somewhere the config can read it from: this writes `.env`, scaffolds the
854
+ * committable `.env.example` next to it, and makes sure `.gitignore` covers
855
+ * `.env` BEFORE the secret is on disk long enough to be staged.
856
+ *
857
+ * Paths are cwd-relative, exactly like the rest of the init scaffold.
858
+ *
859
+ * @internal exported for tests.
860
+ */
861
+ function scaffoldEnvForUrl(url) {
862
+ const envPath = '.env';
863
+ const envExamplePath = '.env.example';
864
+ const gitignorePath = '.gitignore';
865
+ const envExists = (0, node_fs_1.existsSync)(envPath);
866
+ const plan = planEnvScaffold({
867
+ envExists,
868
+ envHasDatabaseUrl: envExists ? /^\s*(export\s+)?DATABASE_URL\s*=/m.test((0, node_fs_1.readFileSync)(envPath, 'utf-8')) : false,
869
+ envExampleExists: (0, node_fs_1.existsSync)(envExamplePath),
870
+ gitignoreExists: (0, node_fs_1.existsSync)(gitignorePath),
871
+ gitignoreIgnoresEnv: (0, node_fs_1.existsSync)(gitignorePath) ? gitignoreIgnoresEnv((0, node_fs_1.readFileSync)(gitignorePath, 'utf-8')) : false,
872
+ });
873
+ // .gitignore FIRST: the ignore rule has to exist before the file holding the
874
+ // password does, or a `git add -A` in between commits it.
875
+ if (plan.gitignore === 'created')
876
+ (0, node_fs_1.writeFileSync)(gitignorePath, '# Local environment (secrets)\n.env\n', 'utf-8');
877
+ else if (plan.gitignore === 'appended')
878
+ (0, node_fs_1.appendFileSync)(gitignorePath, '\n# Local environment (secrets)\n.env\n');
879
+ if (plan.env === 'created') {
880
+ // 0600 on CREATION only. The whole point of this scaffold is moving a live
881
+ // password out of a committed file, and the default 0644 would leave it
882
+ // world-readable to every account on a shared box or CI runner. `mode` is
883
+ // applied by the OS only when the open() actually creates the file, so if
884
+ // one appeared since the existsSync above we write into it without widening
885
+ // or narrowing whatever mode its owner chose.
886
+ (0, node_fs_1.writeFileSync)(envPath, `# Turbine database connection. Never commit this file.\nDATABASE_URL=${url}\n`, {
887
+ encoding: 'utf-8',
888
+ mode: 0o600,
889
+ });
890
+ }
891
+ else if (plan.env === 'appended') {
892
+ (0, node_fs_1.appendFileSync)(envPath, `\n# Added by turbine init\nDATABASE_URL=${url}\n`);
893
+ }
894
+ if (plan.envExample === 'created')
895
+ (0, node_fs_1.writeFileSync)(envExamplePath, ENV_EXAMPLE_TEMPLATE, 'utf-8');
896
+ return plan;
897
+ }
898
+ /** Say exactly what was written where, so nobody has to guess where the password went. */
899
+ function reportEnvScaffold(plan, url) {
900
+ (0, ui_js_1.warn)(`The connection string passed to ${(0, ui_js_1.cyan)('--url')} contains a password.`);
901
+ console.log(` ${(0, ui_js_1.dim)('It was NOT written into')} ${(0, ui_js_1.cyan)('turbine.config.ts')}${(0, ui_js_1.dim)(': that file reads')} ${(0, ui_js_1.cyan)('process.env.DATABASE_URL')}${(0, ui_js_1.dim)('.')}`);
902
+ if (plan.env === 'created')
903
+ (0, ui_js_1.success)(`Wrote the connection string to ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)(`(${(0, ui_js_1.redactUrl)(url)})`)}`);
904
+ else if (plan.env === 'appended')
905
+ (0, ui_js_1.success)(`Appended ${(0, ui_js_1.cyan)('DATABASE_URL')} to your existing ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)(`(${(0, ui_js_1.redactUrl)(url)})`)}`);
906
+ else
907
+ (0, ui_js_1.info)(`Your ${(0, ui_js_1.cyan)('.env')} already sets ${(0, ui_js_1.cyan)('DATABASE_URL')}: left untouched. The ${(0, ui_js_1.cyan)('--url')} value was not saved anywhere.`);
908
+ if (plan.envExample === 'created')
909
+ (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)('.env.example')} ${(0, ui_js_1.dim)('(safe to commit)')}`);
910
+ if (plan.gitignore === 'created')
911
+ (0, ui_js_1.success)(`Created ${(0, ui_js_1.cyan)('.gitignore')} ignoring ${(0, ui_js_1.cyan)('.env')}`);
912
+ else if (plan.gitignore === 'appended')
913
+ (0, ui_js_1.success)(`Added ${(0, ui_js_1.cyan)('.env')} to ${(0, ui_js_1.cyan)('.gitignore')}`);
914
+ else
915
+ (0, ui_js_1.info)(`${(0, ui_js_1.cyan)('.gitignore')} already ignores ${(0, ui_js_1.cyan)('.env')}`);
916
+ if (plan.env !== 'unchanged') {
917
+ console.log(` ${(0, ui_js_1.dim)('The password is now in')} ${(0, ui_js_1.cyan)('.env')}${(0, ui_js_1.dim)('. If it was ever pushed to a shared history, rotate it.')}`);
918
+ }
919
+ }
741
920
  /** Create supporting directories + .gitignore entries (unconditional, unprompted). */
742
921
  function ensureInitScaffoldDirs(config) {
743
922
  const migrDir = config.migrationsDir;
@@ -892,15 +1071,27 @@ function reportInitSkip(step) {
892
1071
  case 'default-no':
893
1072
  console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped (default no)`)}`);
894
1073
  break;
1074
+ case 'db-has-tables':
1075
+ (0, ui_js_1.info)(`${label} not created: your database already has tables ${(0, ui_js_1.dim)('- skipped')}`);
1076
+ console.log(` ${(0, ui_js_1.dim)('An empty')} ${(0, ui_js_1.cyan)('defineSchema()')} ${(0, ui_js_1.dim)('file would make')} ${(0, ui_js_1.cyan)('turbine push')} ${(0, ui_js_1.dim)('report every existing')}`);
1077
+ console.log(` ${(0, ui_js_1.dim)('table as "extra". Your database is the source of truth here: run')} ${(0, ui_js_1.cyan)('turbine generate')}`);
1078
+ console.log(` ${(0, ui_js_1.dim)('to produce the typed client from it. For a code-first schema instead, re-run')}`);
1079
+ console.log(` ${(0, ui_js_1.dim)('with')} ${(0, ui_js_1.cyan)('--with-schema')} ${(0, ui_js_1.dim)('to scaffold')} ${(0, ui_js_1.cyan)('turbine/schema.ts')} ${(0, ui_js_1.dim)('anyway, then use')} ${(0, ui_js_1.cyan)('turbine push')}${(0, ui_js_1.dim)('.')}`);
1080
+ break;
895
1081
  default:
896
1082
  console.log(` ${(0, ui_js_1.dim)(`${ui_js_1.symbols.dot} ${label} skipped`)}`);
897
1083
  }
898
1084
  }
899
1085
  /** The interactive prompt question for a promptable step. */
900
- function initPromptQuestion(step, config, seedFilePath) {
1086
+ function initPromptQuestion(step, config, seedFilePath, dbTableCount = 0) {
901
1087
  switch (step.id) {
902
1088
  case 'schema':
903
- return `Create a starter schema file (${config.schemaFile})?`;
1089
+ // Ask the honest question when the database is already populated: the
1090
+ // file would be EMPTY, and `turbine push` reports every existing table as
1091
+ // extra until it is filled in by hand.
1092
+ return dbTableCount > 0
1093
+ ? `Your database already has ${dbTableCount} table(s). Create an EMPTY code-first schema file anyway (${config.schemaFile})?`
1094
+ : `Create a starter schema file (${config.schemaFile})?`;
904
1095
  case 'seed-file':
905
1096
  return `Create a starter seed file (${seedFilePath})?`;
906
1097
  case 'push':
@@ -1009,11 +1200,18 @@ async function cmdInit(args, config) {
1009
1200
  // Probe the database once, up front, so the planner can decide the DB steps.
1010
1201
  // Skip the probe entirely when every DB step is already flag-skipped.
1011
1202
  const anyDbStepPossible = !(args.skipPush && args.skipGenerate && args.skipSeed);
1203
+ let dbTableCount = 0;
1012
1204
  if (hasUrl && anyDbStepPossible) {
1013
1205
  const probe = new ui_js_1.Spinner('Checking database connection').start();
1014
- state.dbReachable = await probeDatabase(url);
1015
- if (state.dbReachable)
1016
- probe.succeed('Database is reachable');
1206
+ const probed = await probeDatabase(url, config.schema);
1207
+ state.dbReachable = probed.reachable;
1208
+ dbTableCount = probed.tableCount;
1209
+ state.dbHasTables = probed.tableCount > 0;
1210
+ if (state.dbReachable) {
1211
+ probe.succeed(dbTableCount > 0
1212
+ ? `Database is reachable ${(0, ui_js_1.dim)(`(${dbTableCount} table(s) in schema "${config.schema}")`)}`
1213
+ : 'Database is reachable');
1214
+ }
1017
1215
  else
1018
1216
  probe.info('Database not reachable: push / generate / seed steps will be skipped');
1019
1217
  }
@@ -1022,6 +1220,7 @@ async function cmdInit(args, config) {
1022
1220
  force: args.force === true,
1023
1221
  interactive,
1024
1222
  skipSchema: args.skipSchema === true,
1223
+ withSchema: args.withSchema === true,
1025
1224
  skipSeed: args.skipSeed === true,
1026
1225
  skipPush: args.skipPush === true,
1027
1226
  skipGenerate: args.skipGenerate === true,
@@ -1039,17 +1238,26 @@ async function cmdInit(args, config) {
1039
1238
  reportInitSkip(step);
1040
1239
  continue;
1041
1240
  }
1042
- const shouldRun = step.action === 'run' ? true : await promptYesNo(initPromptQuestion(step, config, seedFilePath), step.defaultYes);
1241
+ const shouldRun = step.action === 'run'
1242
+ ? true
1243
+ : await promptYesNo(initPromptQuestion(step, config, seedFilePath, dbTableCount), step.defaultYes);
1043
1244
  if (!shouldRun) {
1044
1245
  (0, ui_js_1.info)(`Skipped ${initStepLabel(step.id)}`);
1045
1246
  continue;
1046
1247
  }
1047
1248
  switch (step.id) {
1048
- case 'config':
1249
+ case 'config': {
1049
1250
  (0, node_fs_1.writeFileSync)('turbine.config.ts', (0, config_js_1.configTemplate)(args.url ?? undefined), 'utf-8');
1050
1251
  (0, ui_js_1.success)(state.configExists ? `Overwrote ${(0, ui_js_1.cyan)('turbine.config.ts')}` : `Created ${(0, ui_js_1.cyan)('turbine.config.ts')}`);
1051
1252
  tsFilesWritten.push('turbine.config.ts');
1253
+ // A `--url` with a password is never inlined by configTemplate, so the
1254
+ // real value has to be put somewhere the config can read it from.
1255
+ if (args.url && (0, config_js_1.connectionStringHasPassword)(args.url)) {
1256
+ (0, ui_js_1.newline)();
1257
+ reportEnvScaffold(scaffoldEnvForUrl(args.url), args.url);
1258
+ }
1052
1259
  break;
1260
+ }
1053
1261
  case 'schema':
1054
1262
  writeInitSchemaTemplate(config);
1055
1263
  if ((0, loader_js_1.needsTsLoader)(config.schemaFile))
@@ -1777,6 +1985,41 @@ async function cmdMigrateCreate(args, config) {
1777
1985
  console.log(` ${(0, ui_js_1.cyan)('npx turbine migrate up')}`);
1778
1986
  (0, ui_js_1.newline)();
1779
1987
  }
1988
+ /**
1989
+ * Print the SQL a `migrate up` / `migrate down` WOULD run, and execute nothing.
1990
+ *
1991
+ * `--dry-run` was parsed and then read by `push` and `deploy` only, so on
1992
+ * `migrate up` it was silently inert: the flag was accepted, no warning was
1993
+ * printed, and the migrations were applied. A flag whose whole purpose is
1994
+ * "show me what this would do to the database" that instead does it, against
1995
+ * whatever `--url` was passed, is the most dangerous shape a CLI bug can take.
1996
+ *
1997
+ * Reads through the same `parseMigrationContent` the executor uses, so what is
1998
+ * printed is what would run, rather than a second rendering that can drift.
1999
+ */
2000
+ function printMigrationDryRun(files, section) {
2001
+ const verb = section === 'up' ? 'apply' : 'roll back';
2002
+ if (files.length === 0) {
2003
+ (0, ui_js_1.info)(`Dry run: nothing to ${verb}.`);
2004
+ (0, ui_js_1.newline)();
2005
+ return;
2006
+ }
2007
+ (0, ui_js_1.info)(`Dry run: would ${verb} ${(0, ui_js_1.bold)(String(files.length))} migration(s). Nothing was executed.`);
2008
+ (0, ui_js_1.newline)();
2009
+ for (const file of files) {
2010
+ const parsed = (0, migrate_js_1.parseMigrationContent)((0, node_fs_1.readFileSync)(file.path, 'utf-8'));
2011
+ const sql = section === 'up' ? parsed.up : parsed.down;
2012
+ console.log(` ${(0, ui_js_1.cyan)(file.filename)}${parsed.noTransaction ? (0, ui_js_1.dim)(' (no transaction)') : ''}`);
2013
+ if (sql.trim().length === 0) {
2014
+ console.log(` ${(0, ui_js_1.dim)(`(empty ${section.toUpperCase()} section)`)}`);
2015
+ }
2016
+ else {
2017
+ for (const line of sql.split('\n'))
2018
+ console.log(` ${(0, ui_js_1.dim)(line)}`);
2019
+ }
2020
+ (0, ui_js_1.newline)();
2021
+ }
2022
+ }
1780
2023
  async function cmdMigrateUp(args, config) {
1781
2024
  (0, ui_js_1.banner)();
1782
2025
  const url = requireUrl(config);
@@ -1790,6 +2033,14 @@ async function cmdMigrateUp(args, config) {
1790
2033
  (0, ui_js_1.newline)();
1791
2034
  return;
1792
2035
  }
2036
+ if (args.dryRun) {
2037
+ const status = await (0, migrate_js_1.migrateStatus)(url, config.migrationsDir);
2038
+ let pending = status.filter((st) => !st.applied).map((st) => st.file);
2039
+ if (args.step != null && args.step > 0)
2040
+ pending = pending.slice(0, args.step);
2041
+ printMigrationDryRun(pending, 'up');
2042
+ return;
2043
+ }
1793
2044
  // Big, loud warning when bypassing drift detection, this is a deliberately
1794
2045
  // dangerous operation and the user should see it on every invocation.
1795
2046
  if (args.allowDrift) {
@@ -2011,6 +2262,14 @@ async function cmdMigrateDown(args, config) {
2011
2262
  (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
2012
2263
  (0, ui_js_1.label)('Migrations', config.migrationsDir);
2013
2264
  (0, ui_js_1.newline)();
2265
+ if (args.dryRun) {
2266
+ const status = await (0, migrate_js_1.migrateStatus)(url, config.migrationsDir);
2267
+ // Rollback order is newest-applied first, and a migration whose file is
2268
+ // missing has no DOWN section to show.
2269
+ const applied = status.filter((st) => st.applied && !st.missingFile).map((st) => st.file);
2270
+ printMigrationDryRun(applied.reverse().slice(0, args.step ?? 1), 'down');
2271
+ return;
2272
+ }
2014
2273
  const spinner = new ui_js_1.Spinner('Rolling back migration(s)').start();
2015
2274
  let result;
2016
2275
  try {
@@ -3087,8 +3346,10 @@ async function cmdStudio(args, config) {
3087
3346
  (0, ui_js_1.newline)();
3088
3347
  process.exit(1);
3089
3348
  }
3090
- console.log((0, ui_js_1.warn)(`Studio is binding to ${(0, ui_js_1.yellow)(host)}, this is NOT loopback. ` +
3091
- `Anyone on your network who can reach this port + guess the session token can read your database.`));
3349
+ // warn() prints; it returns void. Wrapping it in console.log() printed a
3350
+ // literal grey "undefined" line under every one of these SAFETY warnings.
3351
+ (0, ui_js_1.warn)(`Studio is binding to ${(0, ui_js_1.yellow)(host)}, this is NOT loopback. ` +
3352
+ `Anyone on your network who can reach this port + guess the session token can read your database.`);
3092
3353
  }
3093
3354
  const spinner = new ui_js_1.Spinner(demo ? 'Seeding demo dataset' : 'Introspecting database').start();
3094
3355
  let studio;
@@ -3134,12 +3395,12 @@ async function cmdStudio(args, config) {
3134
3395
  // Loud startup warnings for the opt-in modes that widen Studio's surface.
3135
3396
  if (args.write) {
3136
3397
  (0, ui_js_1.newline)();
3137
- console.log((0, ui_js_1.warn)('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
3138
- `${(0, ui_js_1.redactUrl)(url)}. Every change is committed directly to your database.`));
3398
+ (0, ui_js_1.warn)('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
3399
+ `${(0, ui_js_1.redactUrl)(url)}. Every change is committed directly to your database.`);
3139
3400
  }
3140
3401
  if (args.showPii) {
3141
3402
  (0, ui_js_1.newline)();
3142
- console.log((0, ui_js_1.warn)('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
3403
+ (0, ui_js_1.warn)('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.');
3143
3404
  }
3144
3405
  // PII tags are a code-first declaration; introspection never infers them.
3145
3406
  // Say plainly whether any reached this session, so nobody assumes a
@@ -3150,9 +3411,9 @@ async function cmdStudio(args, config) {
3150
3411
  console.log(` ${(0, ui_js_1.dim)('PII redaction:')} ${studio.piiTags.applied} tagged column(s) from ${(0, ui_js_1.dim)(studio.piiTags.path)}`);
3151
3412
  }
3152
3413
  else {
3153
- console.log((0, ui_js_1.warn)('No PII-tagged columns found, so nothing will be redacted. Tags are declared in code ' +
3414
+ (0, ui_js_1.warn)('No PII-tagged columns found, so nothing will be redacted. Tags are declared in code ' +
3154
3415
  `(defineSchema \`pii: true\`) and read from generated metadata in ${config.out}; ` +
3155
- 'introspection alone never infers them. Run `turbine generate` after tagging.'));
3416
+ 'introspection alone never infers them. Run `turbine generate` after tagging.');
3156
3417
  }
3157
3418
  }
3158
3419
  (0, ui_js_1.newline)();
@@ -3232,8 +3493,9 @@ async function cmdObserve(args) {
3232
3493
  (0, ui_js_1.newline)();
3233
3494
  process.exit(1);
3234
3495
  }
3235
- console.log((0, ui_js_1.warn)(`Observe is binding to ${(0, ui_js_1.yellow)(host)}, this is NOT loopback. ` +
3236
- `Anyone on your network who can reach this port + guess the session token can read your metrics.`));
3496
+ // warn() prints and returns void; see the same guard in cmdStudio.
3497
+ (0, ui_js_1.warn)(`Observe is binding to ${(0, ui_js_1.yellow)(host)}, this is NOT loopback. ` +
3498
+ `Anyone on your network who can reach this port + guess the session token can read your metrics.`);
3237
3499
  }
3238
3500
  const spinner = new ui_js_1.Spinner('Connecting to metrics database').start();
3239
3501
  let handle;
@@ -3272,6 +3534,13 @@ async function cmdObserve(args) {
3272
3534
  // ---------------------------------------------------------------------------
3273
3535
  // Subcommand help
3274
3536
  // ---------------------------------------------------------------------------
3537
+ /**
3538
+ * Print `<command> --help` for a command that has real help, returning whether
3539
+ * one existed. Falling through to the GLOBAL help is the failure mode this map
3540
+ * guards against, and it is silent, so the coverage is asserted in tests.
3541
+ *
3542
+ * @internal exported for tests.
3543
+ */
3275
3544
  function showSubcommandHelp(command) {
3276
3545
  const helpMap = {
3277
3546
  init: showInitHelp,
@@ -3283,6 +3552,8 @@ function showSubcommandHelp(command) {
3283
3552
  migration: showMigrateHelp,
3284
3553
  seed: showSeedHelp,
3285
3554
  status: showStatusHelp,
3555
+ doctor: showDoctorHelp,
3556
+ studio: showStudioHelp,
3286
3557
  mcp: showMcpHelp,
3287
3558
  };
3288
3559
  const fn = helpMap[command];
@@ -3312,6 +3583,7 @@ function showInitHelp() {
3312
3583
  console.log(` ${(0, ui_js_1.cyan)('--force, -f')} Overwrite existing config file`);
3313
3584
  console.log(` ${(0, ui_js_1.cyan)('--yes, -y')} Accept every step's default (non-interactive)`);
3314
3585
  console.log(` ${(0, ui_js_1.cyan)('--skip-schema')} Don't create the starter schema file`);
3586
+ console.log(` ${(0, ui_js_1.cyan)('--with-schema')} Create it even if the database already has tables`);
3315
3587
  console.log(` ${(0, ui_js_1.cyan)('--skip-seed')} Don't create the seed file or run the seed`);
3316
3588
  console.log(` ${(0, ui_js_1.cyan)('--skip-push')} Don't push the schema to the database`);
3317
3589
  console.log(` ${(0, ui_js_1.cyan)('--skip-generate')} Don't generate the typed client`);
@@ -3464,6 +3736,83 @@ function showStatusHelp() {
3464
3736
  console.log(` ${(0, ui_js_1.cyan)('--schema, -s')} ${(0, ui_js_1.dim)('<name>')} Postgres schema ${(0, ui_js_1.dim)('(default: public)')}`);
3465
3737
  (0, ui_js_1.newline)();
3466
3738
  }
3739
+ function showDoctorHelp() {
3740
+ (0, ui_js_1.banner)();
3741
+ console.log(` ${(0, ui_js_1.bold)('turbine doctor')}, Index + cached-plan triage`);
3742
+ (0, ui_js_1.newline)();
3743
+ console.log(` ${(0, ui_js_1.bold)('Usage:')}`);
3744
+ console.log(` npx turbine doctor ${(0, ui_js_1.dim)('[options]')}`);
3745
+ (0, ui_js_1.newline)();
3746
+ console.log(` Introspects your schema and live statistics, then reports:`);
3747
+ console.log(` ${(0, ui_js_1.dim)('•')} relation probes with no usable index ${(0, ui_js_1.dim)('(ranked by estimated cost)')}`);
3748
+ console.log(` ${(0, ui_js_1.dim)('•')} INVALID indexes ${(0, ui_js_1.dim)('(left behind by a failed CREATE INDEX CONCURRENTLY)')}`);
3749
+ console.log(` ${(0, ui_js_1.dim)('•')} columns whose value distribution can flip a cached plan`);
3750
+ console.log(` ${(0, ui_js_1.dim)('•')} with ${(0, ui_js_1.cyan)('--unused')}: never-scanned and redundant indexes, with DROP suggestions`);
3751
+ (0, ui_js_1.newline)();
3752
+ console.log(` ${(0, ui_js_1.dim)('Read-only: doctor never writes to your database. Only')} ${(0, ui_js_1.cyan)('--fix')} ${(0, ui_js_1.dim)('writes')}`);
3753
+ console.log(` ${(0, ui_js_1.dim)('anything at all, and only a migration FILE you review and run yourself.')}`);
3754
+ (0, ui_js_1.newline)();
3755
+ console.log(` ${(0, ui_js_1.bold)('Options:')}`);
3756
+ console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
3757
+ console.log(` ${(0, ui_js_1.cyan)('--schema, -s')} ${(0, ui_js_1.dim)('<name>')} Postgres schema ${(0, ui_js_1.dim)('(default: public)')}`);
3758
+ console.log(` ${(0, ui_js_1.cyan)('--include')} ${(0, ui_js_1.dim)('<tables>')} Comma-separated tables to include`);
3759
+ console.log(` ${(0, ui_js_1.cyan)('--exclude')} ${(0, ui_js_1.dim)('<tables>')} Comma-separated tables to exclude`);
3760
+ console.log(` ${(0, ui_js_1.cyan)('--fix')} Write an add-index migration for the missing-index findings`);
3761
+ console.log(` ${(0, ui_js_1.cyan)('--no-concurrently')} With ${(0, ui_js_1.cyan)('--fix')}: emit plain CREATE INDEX ${(0, ui_js_1.dim)('(default: CONCURRENTLY, no transaction)')}`);
3762
+ console.log(` ${(0, ui_js_1.cyan)('--json')} Emit the versioned machine-readable report and nothing else`);
3763
+ console.log(` ${(0, ui_js_1.cyan)('--unused')} Also report never-scanned / redundant indexes ${(0, ui_js_1.dim)('(no --fix)')}`);
3764
+ console.log(` ${(0, ui_js_1.cyan)('--audit')} Scope the unused report to doctor's own suggested index names`);
3765
+ console.log(` ${(0, ui_js_1.cyan)('--min-scans')} ${(0, ui_js_1.dim)('<n>')} idx_scan below this counts as never-scanned ${(0, ui_js_1.dim)('(default: 1)')}`);
3766
+ console.log(` ${(0, ui_js_1.cyan)('--metrics-url')} ${(0, ui_js_1.dim)('<url>')} Read ${(0, ui_js_1.cyan)('_turbine_metrics')} for the table-heat boost from a separate DB`);
3767
+ console.log(` ${(0, ui_js_1.cyan)('--no-plan-divergence')} Skip the cached-plan divergence section ${(0, ui_js_1.dim)('(and its pg_stats read)')}`);
3768
+ (0, ui_js_1.newline)();
3769
+ console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
3770
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine doctor`);
3771
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine doctor --fix`);
3772
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine doctor --unused --min-scans 5`);
3773
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine doctor --json > doctor.json`);
3774
+ (0, ui_js_1.newline)();
3775
+ }
3776
+ function showStudioHelp() {
3777
+ (0, ui_js_1.banner)();
3778
+ console.log(` ${(0, ui_js_1.bold)('turbine studio')}, Launch the local database UI`);
3779
+ (0, ui_js_1.newline)();
3780
+ console.log(` ${(0, ui_js_1.bold)('Usage:')}`);
3781
+ console.log(` npx turbine studio ${(0, ui_js_1.dim)('[options]')}`);
3782
+ (0, ui_js_1.newline)();
3783
+ console.log(` A local web UI with Query / Data / Schema tabs. There is no raw-SQL`);
3784
+ console.log(` surface: the Query tab is a visual ${(0, ui_js_1.cyan)('findMany')} builder and every`);
3785
+ console.log(` identifier is validated against the introspected schema.`);
3786
+ (0, ui_js_1.newline)();
3787
+ console.log(` ${(0, ui_js_1.bold)('Defaults are the safe ones:')}`);
3788
+ console.log(` ${(0, ui_js_1.dim)('•')} read-only ${(0, ui_js_1.dim)('(reads run inside BEGIN READ ONLY)')}`);
3789
+ console.log(` ${(0, ui_js_1.dim)('•')} PII-tagged column values redacted server-side`);
3790
+ console.log(` ${(0, ui_js_1.dim)('•')} bound to 127.0.0.1, behind a random per-session token`);
3791
+ (0, ui_js_1.newline)();
3792
+ console.log(` ${(0, ui_js_1.bold)('Options:')}`);
3793
+ console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
3794
+ console.log(` ${(0, ui_js_1.cyan)('--schema, -s')} ${(0, ui_js_1.dim)('<name>')} Postgres schema ${(0, ui_js_1.dim)('(default: public)')}`);
3795
+ console.log(` ${(0, ui_js_1.cyan)('--out, -o')} ${(0, ui_js_1.dim)('<dir>')} Generated-metadata dir, source of the PII tags`);
3796
+ console.log(` ${(0, ui_js_1.cyan)('--include')} ${(0, ui_js_1.dim)('<tables>')} Comma-separated tables to include`);
3797
+ console.log(` ${(0, ui_js_1.cyan)('--exclude')} ${(0, ui_js_1.dim)('<tables>')} Comma-separated tables to exclude`);
3798
+ console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983)')}`);
3799
+ console.log(` ${(0, ui_js_1.cyan)('--host')} ${(0, ui_js_1.dim)('<addr>')} Bind address ${(0, ui_js_1.dim)('(default: 127.0.0.1)')}`);
3800
+ console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
3801
+ console.log(` ${(0, ui_js_1.cyan)('--allow-remote')} Allow a non-loopback ${(0, ui_js_1.cyan)('--host')} ${(0, ui_js_1.dim)('(refused without it)')}`);
3802
+ console.log(` ${(0, ui_js_1.cyan)('--write')} Enable single-row update / insert / delete`);
3803
+ console.log(` ${(0, ui_js_1.cyan)('--show-pii')} Show PII-tagged values unredacted`);
3804
+ console.log(` ${(0, ui_js_1.cyan)('--demo')} Seeded in-memory sample database ${(0, ui_js_1.dim)('(no DATABASE_URL)')}`);
3805
+ (0, ui_js_1.newline)();
3806
+ console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
3807
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine studio`);
3808
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine studio --demo`);
3809
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine studio --write --port 5000`);
3810
+ (0, ui_js_1.newline)();
3811
+ console.log(` ${(0, ui_js_1.dim)('--write commits every change directly to the database, and')} ${(0, ui_js_1.cyan)('--show-pii')}`);
3812
+ console.log(` ${(0, ui_js_1.dim)('reveals values Studio otherwise never sends to the browser. Both print a')}`);
3813
+ console.log(` ${(0, ui_js_1.dim)('startup warning and a persistent in-UI banner. Demo mode saves nothing.')}`);
3814
+ (0, ui_js_1.newline)();
3815
+ }
3467
3816
  function showMcpHelp() {
3468
3817
  (0, ui_js_1.banner)();
3469
3818
  console.log(` ${(0, ui_js_1.bold)('turbine mcp')}, Start read-only MCP server over stdio`);
@@ -3528,6 +3877,7 @@ function showHelp() {
3528
3877
  console.log(` ${(0, ui_js_1.bold)('Init options:')}`);
3529
3878
  console.log(` ${(0, ui_js_1.cyan)('--yes, -y')} Accept every step's default (non-interactive)`);
3530
3879
  console.log(` ${(0, ui_js_1.cyan)('--skip-schema')} Don't scaffold the schema file`);
3880
+ console.log(` ${(0, ui_js_1.cyan)('--with-schema')} Scaffold it even if the database already has tables`);
3531
3881
  console.log(` ${(0, ui_js_1.cyan)('--skip-seed')} Don't scaffold or run the seed file`);
3532
3882
  console.log(` ${(0, ui_js_1.cyan)('--skip-push')} Don't offer to push the schema to the database`);
3533
3883
  console.log(` ${(0, ui_js_1.cyan)('--skip-generate')} Don't offer to generate the typed client`);
@@ -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>;