turbine-orm 0.81.0 → 0.90.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 +26 -25
- package/dist/checkout.d.ts +27 -2
- package/dist/checkout.js +75 -4
- package/dist/cjs/checkout.d.ts +27 -2
- package/dist/cjs/checkout.js +76 -3
- package/dist/cjs/cli/code-first-tags.d.ts +62 -0
- package/dist/cjs/cli/code-first-tags.js +124 -0
- package/dist/cjs/cli/compile-query.js +9 -0
- package/dist/cjs/cli/config.d.ts +35 -6
- package/dist/cjs/cli/config.js +17 -1
- package/dist/cjs/cli/destructive.d.ts +7 -4
- package/dist/cjs/cli/destructive.js +224 -20
- package/dist/cjs/cli/error-catalog.js +36 -0
- package/dist/cjs/cli/error-text.d.ts +15 -0
- package/dist/cjs/cli/error-text.js +40 -0
- package/dist/cjs/cli/index.d.ts +62 -4
- package/dist/cjs/cli/index.js +568 -108
- package/dist/cjs/cli/local-server-guard.d.ts +53 -0
- package/dist/cjs/cli/local-server-guard.js +119 -0
- package/dist/cjs/cli/local-server-output.d.ts +41 -0
- package/dist/cjs/cli/local-server-output.js +103 -0
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +121 -32
- package/dist/cjs/cli/migrate.d.ts +113 -5
- package/dist/cjs/cli/migrate.js +270 -21
- package/dist/cjs/cli/observe.d.ts +6 -1
- package/dist/cjs/cli/observe.js +85 -55
- package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cjs/cli/pii-predicate-guard.js +98 -37
- package/dist/cjs/cli/prisma-resolve.js +17 -13
- package/dist/cjs/cli/prisma-schema.js +12 -4
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +14 -2
- package/dist/cjs/cli/studio.js +244 -69
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +558 -85
- package/dist/cjs/client.js +1270 -395
- package/dist/cjs/connection-guard.d.ts +13 -0
- package/dist/cjs/connection-guard.js +28 -0
- package/dist/cjs/connection-url.d.ts +51 -16
- package/dist/cjs/connection-url.js +67 -34
- package/dist/cjs/diagnostics.d.ts +81 -0
- package/dist/cjs/diagnostics.js +147 -0
- package/dist/cjs/dialect.d.ts +449 -20
- package/dist/cjs/dialect.js +117 -76
- package/dist/cjs/engine-config.d.ts +7 -6
- package/dist/cjs/errors.d.ts +126 -6
- package/dist/cjs/errors.js +256 -13
- package/dist/cjs/generate.d.ts +57 -7
- package/dist/cjs/generate.js +635 -279
- package/dist/cjs/global-filters-config.d.ts +59 -0
- package/dist/cjs/global-filters-config.js +367 -0
- package/dist/cjs/index.d.ts +9 -4
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.d.ts +31 -6
- package/dist/cjs/introspect.js +108 -50
- package/dist/cjs/log-codes.d.ts +12 -0
- package/dist/cjs/log-codes.js +77 -0
- package/dist/cjs/logger.d.ts +87 -0
- package/dist/cjs/logger.js +98 -0
- package/dist/cjs/mssql.d.ts +8 -5
- package/dist/cjs/mssql.js +352 -65
- package/dist/cjs/mysql.d.ts +3 -5
- package/dist/cjs/mysql.js +420 -70
- package/dist/cjs/nested-write.d.ts +3 -0
- package/dist/cjs/nested-write.js +179 -114
- package/dist/cjs/pipeline-submittable.d.ts +12 -0
- package/dist/cjs/pipeline-submittable.js +118 -44
- package/dist/cjs/pipeline.js +21 -26
- package/dist/cjs/powdb-introspect.d.ts +5 -2
- package/dist/cjs/powdb-introspect.js +20 -15
- package/dist/cjs/powdb-shared.d.ts +36 -5
- package/dist/cjs/powdb-shared.js +67 -8
- package/dist/cjs/powdb.d.ts +4 -0
- package/dist/cjs/powdb.js +183 -51
- package/dist/cjs/powql.d.ts +394 -51
- package/dist/cjs/powql.js +1829 -284
- package/dist/cjs/prisma-compat.d.ts +23 -15
- package/dist/cjs/prisma-compat.js +507 -372
- package/dist/cjs/query/aggregates.d.ts +17 -1
- package/dist/cjs/query/aggregates.js +261 -29
- package/dist/cjs/query/batched-loader.d.ts +25 -6
- package/dist/cjs/query/batched-loader.js +103 -20
- package/dist/cjs/query/builder.d.ts +325 -184
- package/dist/cjs/query/builder.js +1452 -490
- package/dist/cjs/query/column-ref.d.ts +61 -0
- package/dist/cjs/query/column-ref.js +73 -0
- package/dist/cjs/query/compound-unique.d.ts +21 -0
- package/dist/cjs/query/compound-unique.js +98 -18
- package/dist/cjs/query/deferred.d.ts +53 -16
- package/dist/cjs/query/expr.d.ts +217 -0
- package/dist/cjs/query/expr.js +859 -0
- package/dist/cjs/query/filters.d.ts +155 -53
- package/dist/cjs/query/filters.js +613 -76
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/json-value.d.ts +43 -0
- package/dist/cjs/query/json-value.js +59 -0
- package/dist/cjs/query/normalize-args.d.ts +87 -0
- package/dist/cjs/query/normalize-args.js +527 -0
- package/dist/cjs/query/option-surface.d.ts +16 -4
- package/dist/cjs/query/option-surface.js +57 -14
- package/dist/cjs/query/relation-names.d.ts +14 -0
- package/dist/cjs/query/relation-names.js +77 -18
- package/dist/cjs/query/relations.d.ts +30 -3
- package/dist/cjs/query/relations.js +219 -63
- package/dist/cjs/query/row-lock.d.ts +49 -0
- package/dist/cjs/query/row-lock.js +138 -0
- package/dist/cjs/query/sql-comment.d.ts +161 -0
- package/dist/cjs/query/sql-comment.js +381 -0
- package/dist/cjs/query/stream-idle.d.ts +132 -0
- package/dist/cjs/query/stream-idle.js +212 -0
- package/dist/cjs/query/table-client.d.ts +101 -0
- package/dist/cjs/query/table-client.js +74 -0
- package/dist/cjs/query/types.d.ts +677 -298
- package/dist/cjs/query/utils.d.ts +105 -6
- package/dist/cjs/query/utils.js +426 -19
- package/dist/cjs/query/warn-registry.d.ts +18 -0
- package/dist/cjs/query/warn-registry.js +18 -0
- package/dist/cjs/query/where-compile.d.ts +138 -14
- package/dist/cjs/query/where-compile.js +513 -29
- package/dist/cjs/query/where.d.ts +159 -59
- package/dist/cjs/query/where.js +716 -246
- package/dist/cjs/query/writes.d.ts +112 -50
- package/dist/cjs/query/writes.js +1498 -200
- package/dist/cjs/query-events.d.ts +4 -2
- package/dist/cjs/query-events.js +3 -1
- package/dist/cjs/realtime.d.ts +7 -3
- package/dist/cjs/realtime.js +13 -2
- package/dist/cjs/schema-builder.d.ts +55 -4
- package/dist/cjs/schema-builder.js +382 -18
- package/dist/cjs/schema-metadata.d.ts +21 -1
- package/dist/cjs/schema-metadata.js +29 -7
- package/dist/cjs/schema-sql.d.ts +75 -5
- package/dist/cjs/schema-sql.js +497 -45
- package/dist/cjs/schema.d.ts +26 -5
- package/dist/cjs/schema.js +110 -13
- package/dist/cjs/sql-placeholders.d.ts +56 -0
- package/dist/cjs/sql-placeholders.js +204 -0
- package/dist/cjs/sqlite.d.ts +75 -20
- package/dist/cjs/sqlite.js +593 -95
- package/dist/cjs/supplied-pool-types.d.ts +48 -0
- package/dist/cjs/supplied-pool-types.js +256 -0
- package/dist/cjs/transaction-control.d.ts +51 -0
- package/dist/cjs/transaction-control.js +316 -0
- package/dist/cjs/transaction-state.d.ts +342 -0
- package/dist/cjs/transaction-state.js +707 -0
- package/dist/cjs/typed-sql.d.ts +37 -3
- package/dist/cjs/typed-sql.js +102 -8
- package/dist/cli/code-first-tags.d.ts +62 -0
- package/dist/cli/code-first-tags.js +120 -0
- package/dist/cli/compile-query.js +9 -0
- package/dist/cli/config.d.ts +35 -6
- package/dist/cli/config.js +16 -1
- package/dist/cli/destructive.d.ts +7 -4
- package/dist/cli/destructive.js +224 -20
- package/dist/cli/error-catalog.js +36 -0
- package/dist/cli/error-text.d.ts +15 -0
- package/dist/cli/error-text.js +37 -0
- package/dist/cli/index.d.ts +62 -4
- package/dist/cli/index.js +569 -112
- package/dist/cli/local-server-guard.d.ts +53 -0
- package/dist/cli/local-server-guard.js +113 -0
- package/dist/cli/local-server-output.d.ts +41 -0
- package/dist/cli/local-server-output.js +98 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +121 -34
- package/dist/cli/migrate.d.ts +113 -5
- package/dist/cli/migrate.js +261 -21
- package/dist/cli/observe.d.ts +6 -1
- package/dist/cli/observe.js +84 -54
- package/dist/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cli/pii-predicate-guard.js +100 -35
- package/dist/cli/prisma-resolve.js +15 -11
- package/dist/cli/prisma-schema.js +12 -4
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +14 -2
- package/dist/cli/studio.js +246 -71
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +558 -85
- package/dist/client.js +1275 -400
- package/dist/connection-guard.d.ts +13 -0
- package/dist/connection-guard.js +27 -0
- package/dist/connection-url.d.ts +51 -16
- package/dist/connection-url.js +63 -34
- package/dist/diagnostics.d.ts +81 -0
- package/dist/diagnostics.js +144 -0
- package/dist/dialect.d.ts +449 -20
- package/dist/dialect.js +115 -43
- package/dist/engine-config.d.ts +7 -6
- package/dist/errors.d.ts +126 -6
- package/dist/errors.js +252 -12
- package/dist/generate.d.ts +57 -7
- package/dist/generate.js +635 -281
- package/dist/global-filters-config.d.ts +59 -0
- package/dist/global-filters-config.js +361 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -2
- package/dist/introspect.d.ts +31 -6
- package/dist/introspect.js +106 -50
- package/dist/log-codes.d.ts +12 -0
- package/dist/log-codes.js +74 -0
- package/dist/logger.d.ts +87 -0
- package/dist/logger.js +93 -0
- package/dist/mssql.d.ts +8 -5
- package/dist/mssql.js +354 -67
- package/dist/mysql.d.ts +3 -5
- package/dist/mysql.js +423 -73
- package/dist/nested-write.d.ts +3 -0
- package/dist/nested-write.js +177 -112
- package/dist/pipeline-submittable.d.ts +12 -0
- package/dist/pipeline-submittable.js +117 -44
- package/dist/pipeline.js +21 -26
- package/dist/powdb-introspect.d.ts +5 -2
- package/dist/powdb-introspect.js +21 -16
- package/dist/powdb-shared.d.ts +36 -5
- package/dist/powdb-shared.js +65 -8
- package/dist/powdb.d.ts +4 -0
- package/dist/powdb.js +183 -51
- package/dist/powql.d.ts +394 -51
- package/dist/powql.js +1836 -291
- package/dist/prisma-compat.d.ts +23 -15
- package/dist/prisma-compat.js +508 -373
- package/dist/query/aggregates.d.ts +17 -1
- package/dist/query/aggregates.js +263 -32
- package/dist/query/batched-loader.d.ts +25 -6
- package/dist/query/batched-loader.js +101 -21
- package/dist/query/builder.d.ts +325 -184
- package/dist/query/builder.js +1458 -495
- package/dist/query/column-ref.d.ts +61 -0
- package/dist/query/column-ref.js +66 -0
- package/dist/query/compound-unique.d.ts +21 -0
- package/dist/query/compound-unique.js +97 -19
- package/dist/query/deferred.d.ts +53 -16
- package/dist/query/expr.d.ts +217 -0
- package/dist/query/expr.js +848 -0
- package/dist/query/filters.d.ts +155 -53
- package/dist/query/filters.js +597 -72
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +4 -0
- package/dist/query/json-value.d.ts +43 -0
- package/dist/query/json-value.js +54 -0
- package/dist/query/normalize-args.d.ts +87 -0
- package/dist/query/normalize-args.js +517 -0
- package/dist/query/option-surface.d.ts +16 -4
- package/dist/query/option-surface.js +56 -13
- package/dist/query/relation-names.d.ts +14 -0
- package/dist/query/relation-names.js +77 -19
- package/dist/query/relations.d.ts +30 -3
- package/dist/query/relations.js +220 -67
- package/dist/query/row-lock.d.ts +49 -0
- package/dist/query/row-lock.js +131 -0
- package/dist/query/sql-comment.d.ts +161 -0
- package/dist/query/sql-comment.js +374 -0
- package/dist/query/stream-idle.d.ts +132 -0
- package/dist/query/stream-idle.js +203 -0
- package/dist/query/table-client.d.ts +101 -0
- package/dist/query/table-client.js +70 -0
- package/dist/query/types.d.ts +677 -298
- package/dist/query/utils.d.ts +105 -6
- package/dist/query/utils.js +414 -18
- package/dist/query/warn-registry.d.ts +18 -0
- package/dist/query/warn-registry.js +18 -0
- package/dist/query/where-compile.d.ts +138 -14
- package/dist/query/where-compile.js +508 -31
- package/dist/query/where.d.ts +159 -59
- package/dist/query/where.js +711 -248
- package/dist/query/writes.d.ts +112 -50
- package/dist/query/writes.js +1496 -202
- package/dist/query-events.d.ts +4 -2
- package/dist/query-events.js +3 -1
- package/dist/realtime.d.ts +7 -3
- package/dist/realtime.js +13 -2
- package/dist/schema-builder.d.ts +55 -4
- package/dist/schema-builder.js +381 -18
- package/dist/schema-metadata.d.ts +21 -1
- package/dist/schema-metadata.js +28 -7
- package/dist/schema-sql.d.ts +75 -5
- package/dist/schema-sql.js +494 -44
- package/dist/schema.d.ts +26 -5
- package/dist/schema.js +109 -13
- package/dist/sql-placeholders.d.ts +56 -0
- package/dist/sql-placeholders.js +200 -0
- package/dist/sqlite.d.ts +75 -20
- package/dist/sqlite.js +597 -99
- package/dist/supplied-pool-types.d.ts +48 -0
- package/dist/supplied-pool-types.js +247 -0
- package/dist/transaction-control.d.ts +51 -0
- package/dist/transaction-control.js +311 -0
- package/dist/transaction-state.d.ts +342 -0
- package/dist/transaction-state.js +696 -0
- package/dist/typed-sql.d.ts +37 -3
- package/dist/typed-sql.js +100 -8
- package/package.json +7 -4
- package/skills/turbine-orm/SKILL.md +56 -6
package/dist/cli/index.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* npx turbine init --url postgres://...
|
|
26
26
|
* npx turbine migrate create add_users_table
|
|
27
27
|
*/
|
|
28
|
-
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync,
|
|
28
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
29
29
|
import { tmpdir } from 'node:os';
|
|
30
30
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
31
31
|
import { pathToFileURL } from 'node:url';
|
|
@@ -35,24 +35,28 @@ import { ValidationError } from '../errors.js';
|
|
|
35
35
|
import { generate, generatePrismaMap, resolveImportExtension } from '../generate.js';
|
|
36
36
|
import { buildCreateIndexSql, buildCreateIndexStatements, buildDropIndexSql, collectDoctorProbeIndexNames, collectRelationProbeColumns, findMissingRelationIndexes, } from '../index-advisor.js';
|
|
37
37
|
import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
|
|
38
|
-
import { introspect } from '../introspect.js';
|
|
38
|
+
import { applyTableFilters, introspect, printableCatalogName } from '../introspect.js';
|
|
39
39
|
import { collectDivergenceCandidateColumns, collectDivergenceOrderColumns, findPlanDivergence, PLAN_DIVERGENCE_THRESHOLDS, } from '../plan-divergence.js';
|
|
40
40
|
import { applyFlipVerdicts, emptyFlipProbeResult, needsFlipProbe, probePlanFlips } from '../plan-flip-probe.js';
|
|
41
41
|
import { fingerprintPrismaSchema } from '../prisma-schema-fingerprint.js';
|
|
42
42
|
import { closestName } from '../query/utils.js';
|
|
43
43
|
import { snakeToCamel } from '../schema.js';
|
|
44
|
-
import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
44
|
+
import { DestructivePushRefusal, driftWarnings, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
45
|
+
import { applyCodeFirstColumnTags, extractCodeFirstColumnTags } from './code-first-tags.js';
|
|
45
46
|
import { configTemplate, connectionStringHasPassword, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
|
|
46
47
|
import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
|
|
48
|
+
import { errorText } from './error-text.js';
|
|
47
49
|
import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
|
|
48
50
|
import { runMcpServer } from './mcp.js';
|
|
49
|
-
import { assertNoEmbeddedTransactions, assertPinnableSchema, assertSchemaExists, assertUpHasStatements, buildDiffMigrationBody, collectUpDestructive, connectionStringForSchema, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listIgnoredSqlFiles, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, parseMigrationContent, planMigrationRollback, } from './migrate.js';
|
|
51
|
+
import { assertNoEmbeddedTransactions, assertPinnableSchema, assertSchemaExists, assertUpHasStatements, buildDiffMigrationBody, collectUpDestructive, connectionStringForSchema, createMigration, formatChecksumMismatchError, formatPendingAutoRefusal, inspectMigrationDeploy, listIgnoredSqlFiles, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, parseMigrationContent, pendingMigrationFiles, planMigrationRollback, rollbacksWithoutSql, statusGateProblems, summarizeMigrationStatus, } from './migrate.js';
|
|
50
52
|
import { startObserve } from './observe.js';
|
|
51
53
|
import { formatPrismaReport, summaryLines } from './prisma-report.js';
|
|
52
54
|
import { DEFAULT_EXCLUDED_TABLES, resolvePrismaSchema } from './prisma-resolve.js';
|
|
53
55
|
import { PrismaParseError, parsePrismaSchema, resolvePrismaDatasourceUrl, } from './prisma-schema.js';
|
|
54
56
|
import { startStudio } from './studio.js';
|
|
55
57
|
import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, errorBanner, errorLine, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
|
|
58
|
+
/** How long a bare `migrate deploy --wait` waits for a concurrent deploy to finish. */
|
|
59
|
+
export const DEFAULT_LOCK_WAIT_SECONDS = 300;
|
|
56
60
|
/**
|
|
57
61
|
* Fail an argument-parse error the way every other CLI failure looks: banner,
|
|
58
62
|
* red error line, then indented hint lines. The flag validators used to call
|
|
@@ -107,6 +111,7 @@ const GLOBAL_FLAGS = [
|
|
|
107
111
|
const COMMAND_FLAGS = {
|
|
108
112
|
init: ['--yes', '--schema-file', '--skip-schema', '--with-schema', '--skip-seed', '--skip-push', '--skip-generate'],
|
|
109
113
|
generate: [
|
|
114
|
+
'--schema-file',
|
|
110
115
|
'--zod',
|
|
111
116
|
'--include-views',
|
|
112
117
|
'--no-timestamp',
|
|
@@ -116,7 +121,7 @@ const COMMAND_FLAGS = {
|
|
|
116
121
|
'--allow-empty',
|
|
117
122
|
],
|
|
118
123
|
'migrate-from-prisma': ['--no-db', '--if-db', '--allow-partial', '--no-timestamp'],
|
|
119
|
-
push: ['--schema-file', '--dry-run', '--allow-destructive'],
|
|
124
|
+
push: ['--schema-file', '--dry-run', '--exit-code', '--allow-destructive'],
|
|
120
125
|
migrate: [
|
|
121
126
|
'--schema-file',
|
|
122
127
|
'--auto',
|
|
@@ -124,8 +129,12 @@ const COMMAND_FLAGS = {
|
|
|
124
129
|
'--recipe',
|
|
125
130
|
'--step',
|
|
126
131
|
'--dry-run',
|
|
132
|
+
'--exit-code',
|
|
133
|
+
'--wait',
|
|
127
134
|
'--allow-drift',
|
|
128
135
|
'--allow-destructive',
|
|
136
|
+
'--allow-pending',
|
|
137
|
+
'--allow-pooler',
|
|
129
138
|
],
|
|
130
139
|
seed: [],
|
|
131
140
|
status: [],
|
|
@@ -303,6 +312,8 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
303
312
|
const unknownFlags = [];
|
|
304
313
|
/** The raw `--step` operand, judged after the loop. Absent means unset. */
|
|
305
314
|
let stepRaw;
|
|
315
|
+
/** The raw `--wait` operand, judged after the loop: undefined absent, null bare `--wait`. */
|
|
316
|
+
let waitRaw;
|
|
306
317
|
for (; i < args.length; i++) {
|
|
307
318
|
const arg = args[i];
|
|
308
319
|
const next = args[i + 1];
|
|
@@ -310,8 +321,15 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
310
321
|
// (`--step -1`, `--host -x`) is consumed by its own case and never reaches
|
|
311
322
|
// this test. A bare `-`-prefixed token that is not a flag reaches it and is
|
|
312
323
|
// rejected: no command takes a negative-number positional.
|
|
313
|
-
|
|
324
|
+
// `--wait=30` is the one flag whose value attaches with `=`, so it is
|
|
325
|
+
// matched by its name and handled here rather than by a `case` label.
|
|
326
|
+
const attachedWait = arg.startsWith('--wait=');
|
|
327
|
+
if (accepted && arg.startsWith('-') && !accepted.has(attachedWait ? '--wait' : arg))
|
|
314
328
|
unknownFlags.push(arg);
|
|
329
|
+
if (attachedWait) {
|
|
330
|
+
waitRaw = arg.slice('--wait='.length);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
315
333
|
switch (arg) {
|
|
316
334
|
case '--url':
|
|
317
335
|
case '-u':
|
|
@@ -355,6 +373,23 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
355
373
|
case '--dry-run':
|
|
356
374
|
result.dryRun = true;
|
|
357
375
|
break;
|
|
376
|
+
case '--exit-code':
|
|
377
|
+
result.exitCode = true;
|
|
378
|
+
break;
|
|
379
|
+
case '--allow-pending':
|
|
380
|
+
result.allowPending = true;
|
|
381
|
+
break;
|
|
382
|
+
case '--wait':
|
|
383
|
+
// `--wait 30` reads the next token only when it is all digits: `deploy`
|
|
384
|
+
// takes no positional, so a bare number after the flag is its value.
|
|
385
|
+
if (next !== undefined && /^\d+$/.test(next)) {
|
|
386
|
+
waitRaw = next;
|
|
387
|
+
i++;
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
waitRaw = null;
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
358
393
|
case '--auto':
|
|
359
394
|
result.auto = true;
|
|
360
395
|
break;
|
|
@@ -437,8 +472,8 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
437
472
|
break;
|
|
438
473
|
case '--import-ext':
|
|
439
474
|
case '--import-extension':
|
|
440
|
-
if (next !== 'js' && next !== 'none' && next !== 'auto') {
|
|
441
|
-
failArg(`${cyan('--import-ext')} requires one of: js, none, auto ${dim(`(got ${next ?? 'nothing'})`)}`, 'Example: npx turbine generate --import-ext
|
|
475
|
+
if (next !== 'js' && next !== 'ts' && next !== 'none' && next !== 'auto') {
|
|
476
|
+
failArg(`${cyan('--import-ext')} requires one of: js, ts, none, auto ${dim(`(got ${next ?? 'nothing'})`)}`, 'Example: npx turbine generate --import-ext ts');
|
|
442
477
|
}
|
|
443
478
|
result.importExtension = next;
|
|
444
479
|
i++;
|
|
@@ -529,6 +564,18 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
529
564
|
}
|
|
530
565
|
result.step = parsed;
|
|
531
566
|
}
|
|
567
|
+
if (waitRaw !== undefined && accepted !== undefined) {
|
|
568
|
+
if (waitRaw === null) {
|
|
569
|
+
result.wait = DEFAULT_LOCK_WAIT_SECONDS;
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
const parsed = /^\d+$/.test(waitRaw.trim()) ? Number.parseInt(waitRaw, 10) : Number.NaN;
|
|
573
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
574
|
+
failArg(`${cyan('--wait')} expects a positive whole number of seconds, got ${bold(waitRaw === '' ? '(nothing)' : `"${waitRaw}"`)}.`, `Bare ${'--wait'} waits ${DEFAULT_LOCK_WAIT_SECONDS} seconds. To not wait at all, leave the flag off.`, 'Example: npx turbine migrate deploy --wait=120');
|
|
575
|
+
}
|
|
576
|
+
result.wait = parsed;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
532
579
|
return result;
|
|
533
580
|
}
|
|
534
581
|
// ---------------------------------------------------------------------------
|
|
@@ -727,6 +774,11 @@ export function loadDotEnvForCli(deps = {}) {
|
|
|
727
774
|
if (!fileExists(envPath)) {
|
|
728
775
|
return { fileExists: false, loaded: false, unsupported: false, databaseUrlProvenance: shellOrNone };
|
|
729
776
|
}
|
|
777
|
+
// An exported-but-empty `DATABASE_URL=` means "not configured", and the loader
|
|
778
|
+
// never overrides a variable that is already set, so it would otherwise block
|
|
779
|
+
// the value in the `.env` for good.
|
|
780
|
+
if (env.DATABASE_URL === '')
|
|
781
|
+
delete env.DATABASE_URL;
|
|
730
782
|
const loader = deps.loadEnvFile !== undefined
|
|
731
783
|
? deps.loadEnvFile
|
|
732
784
|
: typeof process.loadEnvFile === 'function'
|
|
@@ -744,7 +796,7 @@ export function loadDotEnvForCli(deps = {}) {
|
|
|
744
796
|
loaded: false,
|
|
745
797
|
unsupported: false,
|
|
746
798
|
databaseUrlProvenance: shellOrNone,
|
|
747
|
-
loadError:
|
|
799
|
+
loadError: errorText(err),
|
|
748
800
|
};
|
|
749
801
|
}
|
|
750
802
|
// `.env`-sourced only if DATABASE_URL was absent before and present after.
|
|
@@ -1143,42 +1195,94 @@ DATABASE_URL=postgres://user:password@localhost:5432/database
|
|
|
1143
1195
|
* @internal exported for tests.
|
|
1144
1196
|
*/
|
|
1145
1197
|
export function scaffoldEnvForUrl(url) {
|
|
1198
|
+
// The plan is read from disk and the writes come after it, so a file can
|
|
1199
|
+
// appear in between. Creation is exclusive (see createFileExclusive); when it
|
|
1200
|
+
// loses, plan again from what is there now. The cap only ends the loop for a
|
|
1201
|
+
// name that cannot be read but cannot be created either, a dangling symlink.
|
|
1202
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1203
|
+
const plan = applyEnvScaffold(url);
|
|
1204
|
+
if (plan)
|
|
1205
|
+
return plan;
|
|
1206
|
+
}
|
|
1207
|
+
throw new ValidationError('Could not write .env, .env.example and .gitignore: one of those names is taken by something that cannot be read ' +
|
|
1208
|
+
'(a dangling symlink?). Remove it and run the command again.');
|
|
1209
|
+
}
|
|
1210
|
+
/** One planning pass plus its writes; undefined when a create found its name already taken. */
|
|
1211
|
+
function applyEnvScaffold(url) {
|
|
1146
1212
|
const envPath = '.env';
|
|
1147
1213
|
const envExamplePath = '.env.example';
|
|
1148
1214
|
const gitignorePath = '.gitignore';
|
|
1149
|
-
const
|
|
1215
|
+
const env = readIfPresent(envPath);
|
|
1216
|
+
const gitignore = readIfPresent(gitignorePath);
|
|
1150
1217
|
const plan = planEnvScaffold({
|
|
1151
|
-
envExists,
|
|
1152
|
-
envHasDatabaseUrl:
|
|
1153
|
-
envExampleExists:
|
|
1154
|
-
gitignoreExists:
|
|
1155
|
-
gitignoreIgnoresEnv:
|
|
1218
|
+
envExists: env !== undefined,
|
|
1219
|
+
envHasDatabaseUrl: env !== undefined && /^\s*(export\s+)?DATABASE_URL\s*=/m.test(env),
|
|
1220
|
+
envExampleExists: readIfPresent(envExamplePath) !== undefined,
|
|
1221
|
+
gitignoreExists: gitignore !== undefined,
|
|
1222
|
+
gitignoreIgnoresEnv: gitignore !== undefined && gitignoreIgnoresEnv(gitignore),
|
|
1156
1223
|
});
|
|
1157
1224
|
// .gitignore FIRST: the ignore rule has to exist before the file holding the
|
|
1158
1225
|
// password does, or a `git add -A` in between commits it.
|
|
1159
|
-
if (plan.gitignore === 'created')
|
|
1160
|
-
|
|
1161
|
-
|
|
1226
|
+
if (plan.gitignore === 'created') {
|
|
1227
|
+
if (!createFileExclusive(gitignorePath, '# Local environment (secrets)\n.env\n'))
|
|
1228
|
+
return undefined;
|
|
1229
|
+
}
|
|
1230
|
+
else if (plan.gitignore === 'appended') {
|
|
1162
1231
|
appendFileSync(gitignorePath, '\n# Local environment (secrets)\n.env\n');
|
|
1232
|
+
}
|
|
1233
|
+
// 0600 on CREATION only. The whole point of this scaffold is moving a live
|
|
1234
|
+
// password out of a committed file, and the default 0644 would leave it
|
|
1235
|
+
// world-readable to every account on a shared box or CI runner. `mode` is
|
|
1236
|
+
// applied by the OS only when the open() actually creates the file, so a
|
|
1237
|
+
// `.env` that exists keeps whatever mode its owner chose.
|
|
1163
1238
|
if (plan.env === 'created') {
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
// applied by the OS only when the open() actually creates the file, so if
|
|
1168
|
-
// one appeared since the existsSync above we write into it without widening
|
|
1169
|
-
// or narrowing whatever mode its owner chose.
|
|
1170
|
-
writeFileSync(envPath, `# Turbine database connection. Never commit this file.\nDATABASE_URL=${url}\n`, {
|
|
1171
|
-
encoding: 'utf-8',
|
|
1172
|
-
mode: 0o600,
|
|
1173
|
-
});
|
|
1239
|
+
const body = `# Turbine database connection. Never commit this file.\nDATABASE_URL=${url}\n`;
|
|
1240
|
+
if (!createFileExclusive(envPath, body, 0o600))
|
|
1241
|
+
return undefined;
|
|
1174
1242
|
}
|
|
1175
1243
|
else if (plan.env === 'appended') {
|
|
1176
|
-
appendFileSync(envPath, `\n# Added by turbine init\nDATABASE_URL=${url}\n
|
|
1244
|
+
appendFileSync(envPath, `\n# Added by turbine init\nDATABASE_URL=${url}\n`, { mode: 0o600 });
|
|
1177
1245
|
}
|
|
1178
|
-
if (plan.envExample === 'created')
|
|
1179
|
-
|
|
1246
|
+
if (plan.envExample === 'created' && !createFileExclusive(envExamplePath, ENV_EXAMPLE_TEMPLATE))
|
|
1247
|
+
return undefined;
|
|
1180
1248
|
return plan;
|
|
1181
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* A file's text, or undefined when there is no such file. One read in place of
|
|
1252
|
+
* an existence check followed by a read, so there is no window between the two.
|
|
1253
|
+
* A directory under that name counts as present: the write step then fails on
|
|
1254
|
+
* it by name, which is the error that was always raised.
|
|
1255
|
+
*/
|
|
1256
|
+
function readIfPresent(path) {
|
|
1257
|
+
try {
|
|
1258
|
+
return readFileSync(path, 'utf-8');
|
|
1259
|
+
}
|
|
1260
|
+
catch (err) {
|
|
1261
|
+
const code = err.code;
|
|
1262
|
+
if (code === 'ENOENT')
|
|
1263
|
+
return undefined;
|
|
1264
|
+
if (code === 'EISDIR')
|
|
1265
|
+
return '';
|
|
1266
|
+
throw err;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Create `path` only if nothing is there; false when the name is taken.
|
|
1271
|
+
* `wx` is O_CREAT | O_EXCL: the kernel refuses (EEXIST) any existing name,
|
|
1272
|
+
* a symlink included, so a file that appeared after the plan was made is never
|
|
1273
|
+
* truncated and a link planted at the name is never written through.
|
|
1274
|
+
*/
|
|
1275
|
+
function createFileExclusive(path, body, mode) {
|
|
1276
|
+
try {
|
|
1277
|
+
writeFileSync(path, body, { encoding: 'utf-8', flag: 'wx', ...(mode === undefined ? {} : { mode }) });
|
|
1278
|
+
return true;
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
if (err.code === 'EEXIST')
|
|
1282
|
+
return false;
|
|
1283
|
+
throw err;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1182
1286
|
/** Say exactly what was written where, so nobody has to guess where the password went. */
|
|
1183
1287
|
function reportEnvScaffold(plan, url) {
|
|
1184
1288
|
warn(`The connection string passed to ${cyan('--url')} contains a password.`);
|
|
@@ -1214,8 +1318,8 @@ function ensureInitScaffoldDirs(config) {
|
|
|
1214
1318
|
success(`Created ${cyan(`${config.out}/`)}`);
|
|
1215
1319
|
}
|
|
1216
1320
|
const gitignorePath = '.gitignore';
|
|
1217
|
-
|
|
1218
|
-
|
|
1321
|
+
const gitignoreContent = readIfPresent(gitignorePath);
|
|
1322
|
+
if (gitignoreContent !== undefined) {
|
|
1219
1323
|
const additions = [];
|
|
1220
1324
|
if (!gitignoreContent.includes('generated/turbine'))
|
|
1221
1325
|
additions.push('generated/turbine/');
|
|
@@ -1321,8 +1425,19 @@ async function runInitGenerate(config, url) {
|
|
|
1321
1425
|
});
|
|
1322
1426
|
const tableNames = Object.keys(schema.tables);
|
|
1323
1427
|
spinner.succeed(`Found ${bold(String(tableNames.length))} tables`);
|
|
1428
|
+
// The schema file init just pushed is where `pii` / `updatedAt` live.
|
|
1429
|
+
const appliedTags = await overlayCodeFirstTags(schema, config);
|
|
1430
|
+
if (appliedTags && describeAppliedTags(appliedTags)) {
|
|
1431
|
+
info(`Applied column tags from ${cyan(config.schemaFile)}: ${describeAppliedTags(appliedTags)}`);
|
|
1432
|
+
}
|
|
1324
1433
|
const genSpinner = new Spinner('Generating TypeScript client').start();
|
|
1325
|
-
const result = generate({
|
|
1434
|
+
const result = generate({
|
|
1435
|
+
schema,
|
|
1436
|
+
outDir: config.out,
|
|
1437
|
+
connectionString: url,
|
|
1438
|
+
importExtension: config.importExtension,
|
|
1439
|
+
columnTypes: await generatorColumnTypesFor(appliedTags, config, schema, url),
|
|
1440
|
+
});
|
|
1326
1441
|
genSpinner.succeed(`Generated ${bold(String(result.files.length))} files to ${cyan(`${config.out}/`)}`);
|
|
1327
1442
|
// The accessor name the generator emits is snakeToCamel of the table name
|
|
1328
1443
|
// (generate.ts, generateIndex), so this is the spelling that autocompletes.
|
|
@@ -1331,8 +1446,7 @@ async function runInitGenerate(config, url) {
|
|
|
1331
1446
|
}
|
|
1332
1447
|
catch (err) {
|
|
1333
1448
|
spinner.fail('Could not generate client');
|
|
1334
|
-
|
|
1335
|
-
errorLine(dim(redactUrl(err.message)));
|
|
1449
|
+
errorLine(dim(redactUrl(errorText(err))));
|
|
1336
1450
|
info(`Run generation later with: ${cyan('npx turbine generate')}`);
|
|
1337
1451
|
return { tableCount: null };
|
|
1338
1452
|
}
|
|
@@ -1346,13 +1460,14 @@ async function runInitSeed(config) {
|
|
|
1346
1460
|
}
|
|
1347
1461
|
const spinner = new Spinner('Running seed file').start();
|
|
1348
1462
|
try {
|
|
1349
|
-
await runSeedPlan(getSeedExecutionPlan(seedFile), config);
|
|
1463
|
+
const outcome = await runSeedPlan(getSeedExecutionPlan(seedFile), config);
|
|
1350
1464
|
spinner.succeed('Seed completed');
|
|
1465
|
+
if (!outcome.confirmed)
|
|
1466
|
+
warn(UNCONFIRMED_SEED_NOTICE);
|
|
1351
1467
|
}
|
|
1352
1468
|
catch (err) {
|
|
1353
1469
|
spinner.fail('Seed failed');
|
|
1354
|
-
|
|
1355
|
-
console.log(` ${dim(redactUrl(err.message))}`);
|
|
1470
|
+
console.log(` ${dim(redactUrl(errorText(err)))}`);
|
|
1356
1471
|
}
|
|
1357
1472
|
}
|
|
1358
1473
|
/** Human label for a step, used in prompts and skip messages. */
|
|
@@ -1663,7 +1778,7 @@ async function cmdInit(args, config) {
|
|
|
1663
1778
|
console.log(` ${dim('Nothing to connect to yet?')} ${cyan('npx turbine studio --demo')} ${dim('needs no database.')}`);
|
|
1664
1779
|
}
|
|
1665
1780
|
else {
|
|
1666
|
-
const accessor = generated.sampleAccessor ?? 'users';
|
|
1781
|
+
const accessor = shown(generated.sampleAccessor ?? 'users');
|
|
1667
1782
|
console.log(` ${dim('1.')} Import the generated client:`);
|
|
1668
1783
|
console.log(` ${cyan(generatedClientImport(config))}`);
|
|
1669
1784
|
newline();
|
|
@@ -1712,7 +1827,7 @@ export function refuseSchemaFilePath(config, options = {}) {
|
|
|
1712
1827
|
errorLine();
|
|
1713
1828
|
errorLine(`${dim('Or set it once in')} ${cyan('turbine.config.ts')}${dim(':')}`);
|
|
1714
1829
|
errorLine(` ${green('schema:')} ${cyan("'public'")}${dim(", // or omit, reads the 'public' schema")}`);
|
|
1715
|
-
errorLine(` ${green('schemaFile:')} ${cyan(`'${config.schema}'`)}${dim(', // your defineSchema() file (
|
|
1830
|
+
errorLine(` ${green('schemaFile:')} ${cyan(`'${config.schema}'`)}${dim(', // your defineSchema() file (read by push and generate)')}`);
|
|
1716
1831
|
if (options.escapeHatch) {
|
|
1717
1832
|
errorLine();
|
|
1718
1833
|
errorLine(`${dim('Re-run with')} ${cyan(options.escapeHatch)} ${dim('to use this literal schema name anyway.')}`);
|
|
@@ -1721,6 +1836,113 @@ export function refuseSchemaFilePath(config, options = {}) {
|
|
|
1721
1836
|
process.exit(1);
|
|
1722
1837
|
}
|
|
1723
1838
|
// ---------------------------------------------------------------------------
|
|
1839
|
+
// Code-first column tags
|
|
1840
|
+
// ---------------------------------------------------------------------------
|
|
1841
|
+
/**
|
|
1842
|
+
* Overlay the column tags the defineSchema() file declares (`pii`, `updatedAt`,
|
|
1843
|
+
* `maxLength`) onto an introspected schema, before it is generated.
|
|
1844
|
+
*
|
|
1845
|
+
* Introspection reads the database, and the database has no column for these
|
|
1846
|
+
* flags, so a client generated from introspection alone returns PII by default,
|
|
1847
|
+
* never stamps `updatedAt`, and leaves Studio and the MCP server with nothing to
|
|
1848
|
+
* redact. The schema file is the only place the tags exist.
|
|
1849
|
+
*
|
|
1850
|
+
* Loaded when the file was named (`--schema-file` / config `schemaFile`) or sits
|
|
1851
|
+
* at the default path. A named file that is missing, or any file that cannot be
|
|
1852
|
+
* loaded, fails the command: carrying on would generate the unredacted client
|
|
1853
|
+
* this exists to prevent. Only a missing DEFAULT file is skipped, which is a
|
|
1854
|
+
* database-first project with no schema file at all.
|
|
1855
|
+
*
|
|
1856
|
+
* Returns what was applied, or `null` when there was no schema file to read.
|
|
1857
|
+
*
|
|
1858
|
+
* @internal exported for tests.
|
|
1859
|
+
*/
|
|
1860
|
+
export async function overlayCodeFirstTags(schema, config) {
|
|
1861
|
+
if (config.schemaFileConfigured !== true && !existsSync(resolve(config.schemaFile)))
|
|
1862
|
+
return null;
|
|
1863
|
+
const def = await loadSchemaFile(config.schemaFile);
|
|
1864
|
+
const filters = {
|
|
1865
|
+
include: config.include.length ? config.include : undefined,
|
|
1866
|
+
exclude: config.exclude.length ? config.exclude : undefined,
|
|
1867
|
+
};
|
|
1868
|
+
return applyCodeFirstColumnTags(schema, extractCodeFirstColumnTags(def), {
|
|
1869
|
+
source: config.schemaFile,
|
|
1870
|
+
// A table the caller filtered out of the client has no client to tag.
|
|
1871
|
+
inScope: (table) => applyTableFilters([table], filters).length > 0,
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
/** "2 pii, 1 updatedAt" for the line that reports an {@link overlayCodeFirstTags} result. */
|
|
1875
|
+
function describeAppliedTags(applied) {
|
|
1876
|
+
const parts = [];
|
|
1877
|
+
if (applied.pii > 0)
|
|
1878
|
+
parts.push(`${applied.pii} pii`);
|
|
1879
|
+
if (applied.updatedAt > 0)
|
|
1880
|
+
parts.push(`${applied.updatedAt} updatedAt`);
|
|
1881
|
+
if (applied.maxLength > 0)
|
|
1882
|
+
parts.push(`${applied.maxLength} maxLength`);
|
|
1883
|
+
const tsTypes = Object.keys(applied.columnTypes).length;
|
|
1884
|
+
if (tsTypes > 0)
|
|
1885
|
+
parts.push(`${tsTypes} tsType`);
|
|
1886
|
+
return parts.join(', ');
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* The generator's `columnTypes`: the schema file's `tsType` declarations, then
|
|
1890
|
+
* the config file's `columnTypes`, which wins on the same key. A config key
|
|
1891
|
+
* whose table the run's include / exclude filters leave out is dropped, since
|
|
1892
|
+
* that table has no generated type to override; every other key goes to the
|
|
1893
|
+
* generator, which rejects one naming no table or column.
|
|
1894
|
+
*
|
|
1895
|
+
* An `include` list leaves out every table it does not name, so a misspelt
|
|
1896
|
+
* table looks exactly like a real table that was left out. `databaseTables`
|
|
1897
|
+
* (every table the schema holds, before filtering) tells them apart: only a
|
|
1898
|
+
* table the database has is dropped, and a name it does not have goes on to the
|
|
1899
|
+
* generator, which names the closest match. Without it every key outside the
|
|
1900
|
+
* filters is dropped.
|
|
1901
|
+
*
|
|
1902
|
+
* @internal exported for tests.
|
|
1903
|
+
*/
|
|
1904
|
+
export function generatorColumnTypes(applied, config, schema, databaseTables) {
|
|
1905
|
+
const filters = {
|
|
1906
|
+
include: config.include.length ? config.include : undefined,
|
|
1907
|
+
exclude: config.exclude.length ? config.exclude : undefined,
|
|
1908
|
+
};
|
|
1909
|
+
const merged = { ...applied?.columnTypes };
|
|
1910
|
+
for (const [key, type] of Object.entries(config.columnTypes ?? {})) {
|
|
1911
|
+
const table = key.slice(0, key.lastIndexOf('.'));
|
|
1912
|
+
const leftOut = !Object.hasOwn(schema.tables, table) && applyTableFilters([table], filters).length === 0;
|
|
1913
|
+
if (leftOut && (databaseTables === undefined || databaseTables.has(table)))
|
|
1914
|
+
continue;
|
|
1915
|
+
merged[key] = type;
|
|
1916
|
+
}
|
|
1917
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
1918
|
+
}
|
|
1919
|
+
/** The names of every table and view in `schema`, before any include / exclude filter. */
|
|
1920
|
+
async function listDatabaseTables(url, schema) {
|
|
1921
|
+
const { default: pg } = await import('pg');
|
|
1922
|
+
const client = new pg.Client({ connectionString: url });
|
|
1923
|
+
guardConnection(client);
|
|
1924
|
+
await client.connect();
|
|
1925
|
+
try {
|
|
1926
|
+
const res = await client.query('SELECT table_name FROM information_schema.tables WHERE table_schema = $1', [schema]);
|
|
1927
|
+
return new Set(res.rows.map((r) => r.table_name));
|
|
1928
|
+
}
|
|
1929
|
+
finally {
|
|
1930
|
+
await client.end();
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* {@link generatorColumnTypes} for a run against a database. The catalog is read
|
|
1935
|
+
* only when an `include` list is set and a config key names a table outside the
|
|
1936
|
+
* generated set, which is the one case where a typo and a table left out look
|
|
1937
|
+
* alike.
|
|
1938
|
+
*/
|
|
1939
|
+
async function generatorColumnTypesFor(applied, config, schema, url, namespace = config.schema) {
|
|
1940
|
+
const keys = Object.keys(config.columnTypes ?? {});
|
|
1941
|
+
const outsideInclude = config.include.length > 0 && keys.some((key) => !Object.hasOwn(schema.tables, key.slice(0, key.lastIndexOf('.'))));
|
|
1942
|
+
const databaseTables = outsideInclude && url ? await listDatabaseTables(url, namespace) : undefined;
|
|
1943
|
+
return generatorColumnTypes(applied, config, schema, databaseTables);
|
|
1944
|
+
}
|
|
1945
|
+
// ---------------------------------------------------------------------------
|
|
1724
1946
|
// Command: generate (pull)
|
|
1725
1947
|
// ---------------------------------------------------------------------------
|
|
1726
1948
|
async function cmdGenerate(args, config) {
|
|
@@ -1759,6 +1981,10 @@ async function cmdGenerate(args, config) {
|
|
|
1759
1981
|
for (const t of skippedInternalTables) {
|
|
1760
1982
|
console.log(` ${dim(`${symbols.teeEnd} skipped internal table ${t} (add it to include to keep it)`)}`);
|
|
1761
1983
|
}
|
|
1984
|
+
const appliedTags = await overlayCodeFirstTags(schema, config);
|
|
1985
|
+
if (appliedTags && describeAppliedTags(appliedTags)) {
|
|
1986
|
+
console.log(` ${dim(`${symbols.teeEnd} applied column tags from ${config.schemaFile}: ${describeAppliedTags(appliedTags)}`)}`);
|
|
1987
|
+
}
|
|
1762
1988
|
// Guard: zero tables means the generated client would be empty. That is almost
|
|
1763
1989
|
// always a misconfiguration (wrong `schema`, an include/exclude that filtered
|
|
1764
1990
|
// everything, or a database with no tables yet) rather than intent. Fail loudly
|
|
@@ -1783,12 +2009,12 @@ async function cmdGenerate(args, config) {
|
|
|
1783
2009
|
for (const tbl of Object.values(schema.tables)) {
|
|
1784
2010
|
const relCount = Object.keys(tbl.relations).length;
|
|
1785
2011
|
const pk = tbl.primaryKey.join(', ') || '(none)';
|
|
1786
|
-
console.log(` ${symbols.tee} ${bold(tbl.name)} ${dim(`${tbl.columns.length} cols, PK: ${pk}`)}${relCount > 0 ? dim(`, ${relCount} rels`) : ''}`);
|
|
2012
|
+
console.log(` ${symbols.tee} ${bold(shown(tbl.name))} ${dim(`${tbl.columns.length} cols, PK: ${shown(pk)}`)}${relCount > 0 ? dim(`, ${relCount} rels`) : ''}`);
|
|
1787
2013
|
}
|
|
1788
2014
|
newline();
|
|
1789
2015
|
}
|
|
1790
2016
|
if (Object.keys(schema.enums).length > 0) {
|
|
1791
|
-
info(`Enums: ${Object.keys(schema.enums).join(', ')}`);
|
|
2017
|
+
info(`Enums: ${Object.keys(schema.enums).map(shown).join(', ')}`);
|
|
1792
2018
|
}
|
|
1793
2019
|
// Generate
|
|
1794
2020
|
const genSpinner = new Spinner('Generating TypeScript client').start();
|
|
@@ -1800,6 +2026,7 @@ async function cmdGenerate(args, config) {
|
|
|
1800
2026
|
noTimestamp: args.noTimestamp,
|
|
1801
2027
|
importExtension: config.importExtension,
|
|
1802
2028
|
keepColumnNames: config.keepColumnNames,
|
|
2029
|
+
columnTypes: await generatorColumnTypesFor(appliedTags, config, schema, url),
|
|
1803
2030
|
});
|
|
1804
2031
|
genSpinner.succeed(`Generated ${bold(String(result.files.length))} files in ${elapsed(startTime)}`);
|
|
1805
2032
|
// List files
|
|
@@ -1816,7 +2043,12 @@ async function cmdGenerate(args, config) {
|
|
|
1816
2043
|
console.log(` ${dim('const db = turbine({ connectionString: process.env.DATABASE_URL });')}`);
|
|
1817
2044
|
// Name a table the client just emitted rather than a hardcoded `users`.
|
|
1818
2045
|
const sampleAccessor = snakeToCamel(tableNames[0] ?? 'users');
|
|
1819
|
-
console.log(` ${dim(`const row = await db.${sampleAccessor}.findFirst();`)}`);
|
|
2046
|
+
console.log(` ${dim(`const row = await db.${shown(sampleAccessor)}.findFirst();`)}`);
|
|
2047
|
+
if (config.importExtension === 'ts') {
|
|
2048
|
+
newline();
|
|
2049
|
+
console.log(` ${dim('Run it with Node type stripping:')} ${cyan('node --env-file=.env app.ts')} ${dim('(Node 22.18+ or 23.6+; earlier 22.x needs --experimental-strip-types)')}`);
|
|
2050
|
+
console.log(` ${dim('A type checker needs')} ${cyan('allowImportingTsExtensions')} ${dim('(or')} ${cyan('rewriteRelativeImportExtensions')}${dim(') in tsconfig.json.')}`);
|
|
2051
|
+
}
|
|
1820
2052
|
newline();
|
|
1821
2053
|
}
|
|
1822
2054
|
/**
|
|
@@ -1854,8 +2086,15 @@ export function resolveMigrateFromPrismaUrl(configUrl, ast, env) {
|
|
|
1854
2086
|
* concatenation, so each file is introduced by a comment naming it.
|
|
1855
2087
|
*/
|
|
1856
2088
|
export function readPrismaSchemaSource(path) {
|
|
1857
|
-
if
|
|
2089
|
+
// Read first, ask what it is only if that fails: a stat followed by a read
|
|
2090
|
+
// can disagree with itself when the path is replaced in between.
|
|
2091
|
+
try {
|
|
1858
2092
|
return readFileSync(path, 'utf-8');
|
|
2093
|
+
}
|
|
2094
|
+
catch (err) {
|
|
2095
|
+
if (err.code !== 'EISDIR')
|
|
2096
|
+
throw err;
|
|
2097
|
+
}
|
|
1859
2098
|
const files = readdirSync(path)
|
|
1860
2099
|
.filter((f) => f.endsWith('.prisma'))
|
|
1861
2100
|
.sort();
|
|
@@ -1890,7 +2129,7 @@ async function cmdMigrateFromPrisma(args, config) {
|
|
|
1890
2129
|
catch (err) {
|
|
1891
2130
|
newline();
|
|
1892
2131
|
error(`Could not read ${cyan(prismaPath)}`);
|
|
1893
|
-
errorLine(`${red(
|
|
2132
|
+
errorLine(`${red(errorText(err))}`);
|
|
1894
2133
|
errorLine();
|
|
1895
2134
|
process.exit(1);
|
|
1896
2135
|
}
|
|
@@ -2013,6 +2252,8 @@ async function cmdMigrateFromPrisma(args, config) {
|
|
|
2013
2252
|
noTimestamp: args.noTimestamp,
|
|
2014
2253
|
importExtension: config.importExtension,
|
|
2015
2254
|
keepColumnNames: config.keepColumnNames,
|
|
2255
|
+
// No schema file is read here, so only the config file's overrides apply.
|
|
2256
|
+
columnTypes: await generatorColumnTypesFor(null, config, schemaMeta, url, 'public'),
|
|
2016
2257
|
});
|
|
2017
2258
|
for (const file of gen.files) {
|
|
2018
2259
|
console.log(` ${dim(symbols.teeEnd)} ${cyan(join(outDir, file))} ${dim('(client)')}`);
|
|
@@ -2045,9 +2286,26 @@ async function cmdMigrateFromPrisma(args, config) {
|
|
|
2045
2286
|
// ---------------------------------------------------------------------------
|
|
2046
2287
|
// Command: push
|
|
2047
2288
|
// ---------------------------------------------------------------------------
|
|
2289
|
+
/**
|
|
2290
|
+
* Print what a diff did NOT compare, under an "in sync" style message.
|
|
2291
|
+
*
|
|
2292
|
+
* "Already in sync" only means the compared surface matches. The rest is listed
|
|
2293
|
+
* so the sentence is not read as a guarantee about the whole database.
|
|
2294
|
+
*/
|
|
2295
|
+
function printNotCompared(items) {
|
|
2296
|
+
if (!items || items.length === 0)
|
|
2297
|
+
return;
|
|
2298
|
+
console.log(` ${dim('Not compared, so this says nothing about:')}`);
|
|
2299
|
+
for (const item of items)
|
|
2300
|
+
console.log(` ${dim(symbols.dot)} ${dim(item)}`);
|
|
2301
|
+
newline();
|
|
2302
|
+
}
|
|
2048
2303
|
async function cmdPush(args, config) {
|
|
2049
2304
|
banner();
|
|
2050
2305
|
refuseSchemaFilePath(config);
|
|
2306
|
+
if (args.exitCode && !args.dryRun) {
|
|
2307
|
+
failArg(`${cyan('--exit-code')} needs ${cyan('--dry-run')} on push.`, 'It reports whether the database differs from the schema file (exit 2) without changing it.', 'Example: npx turbine push --dry-run --exit-code');
|
|
2308
|
+
}
|
|
2051
2309
|
const url = requireUrl(config);
|
|
2052
2310
|
label('Database', redactUrl(url));
|
|
2053
2311
|
label('Schema file', config.schemaFile);
|
|
@@ -2059,8 +2317,32 @@ async function cmdPush(args, config) {
|
|
|
2059
2317
|
const diffSpinner = new Spinner('Computing schema diff').start();
|
|
2060
2318
|
const diff = await schemaDiff(schemaDef, url, { schema: config.schema });
|
|
2061
2319
|
if (diff.statements.length === 0 && diff.drop.length === 0) {
|
|
2062
|
-
|
|
2320
|
+
// "In sync" is a claim about everything the diff looked at, so it is made
|
|
2321
|
+
// only when the diff also has nothing to say. A difference it cannot apply
|
|
2322
|
+
// (an undeclared index, a reordered enum) is reported as exactly that.
|
|
2323
|
+
const advisories = diff.warnings ?? [];
|
|
2324
|
+
if (advisories.length === 0) {
|
|
2325
|
+
diffSpinner.succeed('Database is already in sync');
|
|
2326
|
+
}
|
|
2327
|
+
else {
|
|
2328
|
+
diffSpinner.succeed('Nothing to apply, but the database is not fully in sync');
|
|
2329
|
+
}
|
|
2063
2330
|
newline();
|
|
2331
|
+
for (const w of advisories)
|
|
2332
|
+
warn(w);
|
|
2333
|
+
if (advisories.length > 0)
|
|
2334
|
+
newline();
|
|
2335
|
+
printNotCompared(diff.notCompared);
|
|
2336
|
+
// A warning is a difference the diff found and cannot apply (a unique index
|
|
2337
|
+
// that is not unique, a removed enum label), so a gate fails on it. What the
|
|
2338
|
+
// diff only discloses as "not compared" is not a difference it found, and a
|
|
2339
|
+
// CHECK expression that merely reads differently is a warning it cannot
|
|
2340
|
+
// confirm (driftWarnings leaves it out).
|
|
2341
|
+
if (args.exitCode && driftWarnings(diff).length > 0) {
|
|
2342
|
+
warn(`The database differs from the schema file ${dim('(--exit-code: exiting 2)')}.`);
|
|
2343
|
+
newline();
|
|
2344
|
+
process.exit(2);
|
|
2345
|
+
}
|
|
2064
2346
|
return;
|
|
2065
2347
|
}
|
|
2066
2348
|
diffSpinner.succeed('Found changes');
|
|
@@ -2117,6 +2399,11 @@ async function cmdPush(args, config) {
|
|
|
2117
2399
|
if (args.dryRun) {
|
|
2118
2400
|
info('Dry run, no changes applied.');
|
|
2119
2401
|
newline();
|
|
2402
|
+
if (args.exitCode) {
|
|
2403
|
+
warn(`The database differs from the schema file ${dim('(--exit-code: exiting 2)')}.`);
|
|
2404
|
+
newline();
|
|
2405
|
+
process.exit(2);
|
|
2406
|
+
}
|
|
2120
2407
|
return;
|
|
2121
2408
|
}
|
|
2122
2409
|
if (args.allowDestructive) {
|
|
@@ -2190,8 +2477,12 @@ async function cmdMigrate(args, config) {
|
|
|
2190
2477
|
console.log(` ${cyan('--recipe <name>')} Scaffold a sanctioned migration pattern`);
|
|
2191
2478
|
console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
|
|
2192
2479
|
console.log(` ${cyan('--dry-run')} Show SQL without executing`);
|
|
2480
|
+
console.log(` ${cyan('--exit-code')} Exit 2 when ${cyan('status')} finds pending, modified, missing or misnamed migrations`);
|
|
2481
|
+
console.log(` ${cyan('--wait')}${dim('[=<seconds>]')} Make ${cyan('deploy')} wait for a concurrent deploy's lock`);
|
|
2482
|
+
console.log(` ${cyan('--allow-pending')} Let ${cyan('--auto')} / ${cyan('--from-diff')} run with migrations pending`);
|
|
2193
2483
|
console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting`);
|
|
2194
2484
|
console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums on ${cyan('up')} / ${cyan('deploy')} / ${cyan('down')} ${dim('(advanced)')}`);
|
|
2485
|
+
console.log(` ${cyan('--allow-pooler')} Run ${cyan('up')} / ${cyan('deploy')} / ${cyan('down')} against a transaction pooler anyway ${dim('(refused by default: the migration lock is session-level and a pooler can release it on another backend)')}`);
|
|
2195
2486
|
newline();
|
|
2196
2487
|
console.log(` ${bold('Recipes')} ${dim('(--recipe):')}`);
|
|
2197
2488
|
for (const [key, recipe] of Object.entries(MIGRATION_RECIPES)) {
|
|
@@ -2209,6 +2500,23 @@ async function cmdMigrate(args, config) {
|
|
|
2209
2500
|
newline();
|
|
2210
2501
|
return;
|
|
2211
2502
|
}
|
|
2503
|
+
// Flags that change what a subcommand does are refused where they do nothing,
|
|
2504
|
+
// rather than accepted and ignored: a CI gate that silently never gates is
|
|
2505
|
+
// worse than one that will not start.
|
|
2506
|
+
if (args.exitCode && sub !== 'status' && sub !== 'list') {
|
|
2507
|
+
failArg(`${cyan('--exit-code')} only applies to ${cyan('migrate status')} (and ${cyan('push --dry-run')}).`, 'Example: npx turbine migrate status --exit-code');
|
|
2508
|
+
}
|
|
2509
|
+
if (args.wait !== undefined && sub !== 'deploy') {
|
|
2510
|
+
failArg(`${cyan('--wait')} only applies to ${cyan('migrate deploy')}.`, 'Example: npx turbine migrate deploy --wait=120');
|
|
2511
|
+
}
|
|
2512
|
+
// The commands that take the migration lock, before the banner and before any
|
|
2513
|
+
// connection. A dry run takes no lock, so it may use any endpoint.
|
|
2514
|
+
if ((sub === 'up' || sub === 'deploy' || sub === 'down') && args.dryRun !== true && config.url) {
|
|
2515
|
+
refusePoolerConnection(config.url, args, {
|
|
2516
|
+
command: `turbine migrate ${sub}`,
|
|
2517
|
+
explanation: MIGRATE_POOLER_EXPLANATION,
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2212
2520
|
switch (sub) {
|
|
2213
2521
|
case 'create':
|
|
2214
2522
|
await cmdMigrateCreate(args, config);
|
|
@@ -2232,6 +2540,58 @@ async function cmdMigrate(args, config) {
|
|
|
2232
2540
|
process.exit(1);
|
|
2233
2541
|
}
|
|
2234
2542
|
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Stop `migrate create --auto` / `--from-diff` while migrations are pending.
|
|
2545
|
+
*
|
|
2546
|
+
* Both diff the schema file against the live database, and a pending migration
|
|
2547
|
+
* is a change the file already describes and the database does not have yet. The
|
|
2548
|
+
* new migration would repeat it and fail on the first database that runs the
|
|
2549
|
+
* pending one first. See {@link formatPendingAutoRefusal}.
|
|
2550
|
+
*/
|
|
2551
|
+
async function refuseWhenMigrationsPending(args, config, url, flag) {
|
|
2552
|
+
if (listMigrationFiles(config.migrationsDir).length === 0)
|
|
2553
|
+
return;
|
|
2554
|
+
const pending = pendingMigrationFiles(await migrateStatus(url, config.migrationsDir, { schema: config.schema }));
|
|
2555
|
+
if (pending.length === 0)
|
|
2556
|
+
return;
|
|
2557
|
+
if (args.allowPending) {
|
|
2558
|
+
warn(`--allow-pending is set: ${bold(String(pending.length))} pending migration(s) are NOT reflected in the diff.`);
|
|
2559
|
+
newline();
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
const [first, ...rest] = formatPendingAutoRefusal(pending, flag).split('\n');
|
|
2563
|
+
error(first);
|
|
2564
|
+
for (const line of rest)
|
|
2565
|
+
errorLine(line);
|
|
2566
|
+
errorLine();
|
|
2567
|
+
process.exit(1);
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Explain an empty `--auto` / `--from-diff` diff without claiming more than it
|
|
2571
|
+
* knows: "in sync" only when nothing, extra tables and advisories included, was
|
|
2572
|
+
* left over.
|
|
2573
|
+
*/
|
|
2574
|
+
function reportNothingToMigrate(diff, spinner) {
|
|
2575
|
+
const advisories = diff.warnings ?? [];
|
|
2576
|
+
if (diff.drop.length === 0 && advisories.length === 0) {
|
|
2577
|
+
spinner.succeed('Database is already in sync: nothing to migrate');
|
|
2578
|
+
newline();
|
|
2579
|
+
printNotCompared(diff.notCompared);
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
spinner.succeed('Nothing to migrate: no statement to write, but the database is not fully in sync');
|
|
2583
|
+
newline();
|
|
2584
|
+
if (diff.drop.length > 0) {
|
|
2585
|
+
warn(`${bold(String(diff.drop.length))} table(s) exist in the database but not in the schema file ` +
|
|
2586
|
+
`(${diff.drop.join(', ')}). A migration never drops a table on its own: write that one by hand.`);
|
|
2587
|
+
newline();
|
|
2588
|
+
}
|
|
2589
|
+
for (const w of advisories)
|
|
2590
|
+
warn(w);
|
|
2591
|
+
if (advisories.length > 0)
|
|
2592
|
+
newline();
|
|
2593
|
+
printNotCompared(diff.notCompared);
|
|
2594
|
+
}
|
|
2235
2595
|
async function cmdMigrateCreate(args, config) {
|
|
2236
2596
|
banner();
|
|
2237
2597
|
// `--auto` / `--from-diff` diff the schema FILE against the namespace, so both
|
|
@@ -2261,18 +2621,21 @@ async function cmdMigrateCreate(args, config) {
|
|
|
2261
2621
|
errorLine();
|
|
2262
2622
|
process.exit(1);
|
|
2263
2623
|
}
|
|
2624
|
+
if (args.allowPending && !args.auto && !args.fromDiff) {
|
|
2625
|
+
failArg(`${cyan('--allow-pending')} only applies to ${cyan('--auto')} and ${cyan('--from-diff')}.`, 'It lets a diff-generated migration proceed while earlier migrations are still pending.');
|
|
2626
|
+
}
|
|
2264
2627
|
if (args.auto) {
|
|
2265
2628
|
// Auto-generate migration from schema diff
|
|
2266
2629
|
const url = requireUrl(config);
|
|
2267
2630
|
label('Database', redactUrl(url));
|
|
2268
2631
|
label('Schema file', config.schemaFile);
|
|
2269
2632
|
newline();
|
|
2633
|
+
await refuseWhenMigrationsPending(args, config, url, '--auto');
|
|
2270
2634
|
const schemaDef = await loadSchemaFile(config.schemaFile);
|
|
2271
2635
|
const diffSpinner = new Spinner('Computing schema diff').start();
|
|
2272
2636
|
const diff = await schemaDiff(schemaDef, url, { schema: config.schema });
|
|
2273
2637
|
if (diff.statements.length === 0) {
|
|
2274
|
-
|
|
2275
|
-
newline();
|
|
2638
|
+
reportNothingToMigrate(diff, diffSpinner);
|
|
2276
2639
|
return;
|
|
2277
2640
|
}
|
|
2278
2641
|
diffSpinner.succeed(`Found ${bold(String(diff.statements.length))} change(s)`);
|
|
@@ -2324,7 +2687,9 @@ async function cmdMigrateCreate(args, config) {
|
|
|
2324
2687
|
console.log(` ${red(symbols.warning)} ${dim('DOWN')} [${h.kind}] ${h.target}`);
|
|
2325
2688
|
}
|
|
2326
2689
|
newline();
|
|
2327
|
-
console.log(
|
|
2690
|
+
console.log(body.destructiveUp.length > 0
|
|
2691
|
+
? ` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`
|
|
2692
|
+
: ` ${dim('Only the DOWN section is destructive: `migrate up` runs this file without a prompt, `migrate down` asks first.')}`);
|
|
2328
2693
|
newline();
|
|
2329
2694
|
}
|
|
2330
2695
|
if (diff.warnings && diff.warnings.length > 0) {
|
|
@@ -2344,12 +2709,12 @@ async function cmdMigrateCreate(args, config) {
|
|
|
2344
2709
|
label('Database', redactUrl(url));
|
|
2345
2710
|
label('Schema file', config.schemaFile);
|
|
2346
2711
|
newline();
|
|
2712
|
+
await refuseWhenMigrationsPending(args, config, url, '--from-diff');
|
|
2347
2713
|
const schemaDef = await loadSchemaFile(config.schemaFile);
|
|
2348
2714
|
const diffSpinner = new Spinner('Computing schema diff').start();
|
|
2349
2715
|
const diff = await schemaDiff(schemaDef, url, { schema: config.schema });
|
|
2350
2716
|
if (diff.statements.length === 0) {
|
|
2351
|
-
|
|
2352
|
-
newline();
|
|
2717
|
+
reportNothingToMigrate(diff, diffSpinner);
|
|
2353
2718
|
return;
|
|
2354
2719
|
}
|
|
2355
2720
|
diffSpinner.succeed(`Found ${bold(String(diff.statements.length))} change(s)`);
|
|
@@ -2396,7 +2761,9 @@ async function cmdMigrateCreate(args, config) {
|
|
|
2396
2761
|
console.log(` ${red(symbols.warning)} ${dim('DOWN')} [${h.kind}] ${h.target}`);
|
|
2397
2762
|
}
|
|
2398
2763
|
newline();
|
|
2399
|
-
console.log(
|
|
2764
|
+
console.log(body.destructiveUp.length > 0
|
|
2765
|
+
? ` ${dim('`migrate up` refuses destructive statements by default: confirm interactively or pass')} ${cyan('--allow-destructive')}${dim('.')}`
|
|
2766
|
+
: ` ${dim('Only the DOWN section is destructive: `migrate up` runs this file without a prompt, `migrate down` asks first.')}`);
|
|
2400
2767
|
newline();
|
|
2401
2768
|
}
|
|
2402
2769
|
if (diff.warnings && diff.warnings.length > 0) {
|
|
@@ -2717,7 +3084,18 @@ async function cmdMigrateDeploy(args, config) {
|
|
|
2717
3084
|
if (destructive.length > 0)
|
|
2718
3085
|
printDestructiveNotice(destructive);
|
|
2719
3086
|
const runSpinner = new Spinner('Deploying migrations').start();
|
|
2720
|
-
const result = await migrateDeploy(url, config.migrationsDir, {
|
|
3087
|
+
const result = await migrateDeploy(url, config.migrationsDir, {
|
|
3088
|
+
allowDrift: args.allowDrift,
|
|
3089
|
+
schema: config.schema,
|
|
3090
|
+
lockWaitMs: args.wait !== undefined ? args.wait * 1000 : undefined,
|
|
3091
|
+
onLockWait: () => {
|
|
3092
|
+
runSpinner.stop();
|
|
3093
|
+
warn(`Another migration holds the lock: waiting up to ${bold(String(args.wait))}s for it to finish.`);
|
|
3094
|
+
console.log(` ${dim('Whatever it applies is then no longer pending, so this run will skip it.')}`);
|
|
3095
|
+
newline();
|
|
3096
|
+
runSpinner.start();
|
|
3097
|
+
},
|
|
3098
|
+
});
|
|
2721
3099
|
if (result.applied.length > 0) {
|
|
2722
3100
|
runSpinner.succeed(`${bold(String(result.applied.length))} applied`);
|
|
2723
3101
|
for (const file of result.applied) {
|
|
@@ -2844,6 +3222,15 @@ async function cmdMigrateDown(args, config) {
|
|
|
2844
3222
|
for (const file of result.rolledBack) {
|
|
2845
3223
|
console.log(` ${yellow(symbols.arrowRight)} ${file.filename}`);
|
|
2846
3224
|
}
|
|
3225
|
+
const noSql = rollbacksWithoutSql(result.rolledBack);
|
|
3226
|
+
if (noSql.length > 0) {
|
|
3227
|
+
newline();
|
|
3228
|
+
warn(`${bold(String(noSql.length))} rolled-back migration(s) have a DOWN section that runs no SQL:`);
|
|
3229
|
+
for (const file of noSql)
|
|
3230
|
+
console.log(` ${yellow(symbols.dot)} ${file.filename}`);
|
|
3231
|
+
console.log(` ${dim('Their tracking rows are removed, but nothing they changed was undone (a dropped column')}`);
|
|
3232
|
+
console.log(` ${dim('cannot be restored automatically). Restore that data from a backup.')}`);
|
|
3233
|
+
}
|
|
2847
3234
|
}
|
|
2848
3235
|
else if (result.errors.length === 0) {
|
|
2849
3236
|
spinner.succeed('No migrations to roll back');
|
|
@@ -2867,20 +3254,24 @@ async function cmdMigrateDown(args, config) {
|
|
|
2867
3254
|
}
|
|
2868
3255
|
newline();
|
|
2869
3256
|
}
|
|
2870
|
-
async function cmdMigrateStatus(
|
|
3257
|
+
async function cmdMigrateStatus(args, config) {
|
|
2871
3258
|
banner();
|
|
2872
3259
|
const url = requireUrl(config);
|
|
2873
3260
|
label('Database', redactUrl(url));
|
|
2874
3261
|
label('Migrations', config.migrationsDir);
|
|
2875
3262
|
newline();
|
|
2876
3263
|
const allFiles = listMigrationFiles(config.migrationsDir);
|
|
2877
|
-
|
|
3264
|
+
// A gate still has to ask the database when there are no files: applied
|
|
3265
|
+
// migrations whose files are gone are exactly the drift it exists to catch.
|
|
3266
|
+
if (allFiles.length === 0 && !args.exitCode) {
|
|
2878
3267
|
warn('No migration files found.');
|
|
2879
3268
|
console.log(` ${dim('Create one with:')} ${cyan('npx turbine migrate create <name>')}`);
|
|
2880
3269
|
newline();
|
|
2881
3270
|
return;
|
|
2882
3271
|
}
|
|
2883
3272
|
warnIgnoredMigrationFiles(config.migrationsDir);
|
|
3273
|
+
if (allFiles.length === 0)
|
|
3274
|
+
warn('No migration files found.');
|
|
2884
3275
|
const statuses = await migrateStatus(url, config.migrationsDir, { schema: config.schema });
|
|
2885
3276
|
const appliedCount = statuses.filter((s) => s.applied).length;
|
|
2886
3277
|
const pendingCount = statuses.filter((s) => !s.applied).length;
|
|
@@ -2935,6 +3326,14 @@ async function cmdMigrateStatus(_args, config) {
|
|
|
2935
3326
|
console.log(` ${dim('Run')} ${cyan('npx turbine migrate up')} ${dim('to apply pending migrations.')}`);
|
|
2936
3327
|
newline();
|
|
2937
3328
|
}
|
|
3329
|
+
if (args.exitCode) {
|
|
3330
|
+
const problems = statusGateProblems(summarizeMigrationStatus(statuses), listIgnoredSqlFiles(config.migrationsDir).map(printableCatalogName));
|
|
3331
|
+
if (problems.length > 0) {
|
|
3332
|
+
warn(`Migration history is not clean: ${problems.join(', ')} ${dim('(--exit-code: exiting 2)')}.`);
|
|
3333
|
+
newline();
|
|
3334
|
+
process.exit(2);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
2938
3337
|
}
|
|
2939
3338
|
export function getSeedExecutionPlan(seedFile) {
|
|
2940
3339
|
const ext = extname(seedFile).toLowerCase();
|
|
@@ -2993,6 +3392,9 @@ async function seedConnectionString(config) {
|
|
|
2993
3392
|
}
|
|
2994
3393
|
return connectionStringForSchema(config.url, schema, inherited);
|
|
2995
3394
|
}
|
|
3395
|
+
/** The notice for a {@link SeedRunOutcome} that could not be confirmed. */
|
|
3396
|
+
const UNCONFIRMED_SEED_NOTICE = 'The seed file ran to completion, but it does not call defineSeed(), so Turbine cannot tell whether it wrote anything. ' +
|
|
3397
|
+
'Wrap the work in defineSeed(async (db) => { ... }) to have it confirmed.';
|
|
2996
3398
|
async function runSeedPlan(plan, config) {
|
|
2997
3399
|
const oldDatabaseUrl = process.env.DATABASE_URL;
|
|
2998
3400
|
// The seed connects through DATABASE_URL, whichever way it runs: `defineSeed`
|
|
@@ -3012,9 +3414,11 @@ async function runSeedPlan(plan, config) {
|
|
|
3012
3414
|
}
|
|
3013
3415
|
// The seed runs in a child process, so we cannot observe its callback
|
|
3014
3416
|
// directly. Hand it a sentinel path: `defineSeed`'s runner writes the file
|
|
3015
|
-
// only after a callback executes to completion.
|
|
3016
|
-
//
|
|
3017
|
-
//
|
|
3417
|
+
// only after a callback executes to completion. A non-zero exit is a
|
|
3418
|
+
// failure. A clean exit with no sentinel is a script that did its work at
|
|
3419
|
+
// the top level (a typed-client seed), which succeeds and says so, unless
|
|
3420
|
+
// the file names `defineSeed`: then its callback was expected to run and
|
|
3421
|
+
// did not, which is a no-op to report as a failure.
|
|
3018
3422
|
const sentinelDir = mkdtempSync(join(tmpdir(), 'turbine-seed-'));
|
|
3019
3423
|
const sentinel = join(sentinelDir, 'ran');
|
|
3020
3424
|
const { execFileSync } = await import('node:child_process');
|
|
@@ -3027,15 +3431,18 @@ async function runSeedPlan(plan, config) {
|
|
|
3027
3431
|
TURBINE_SEED_SENTINEL: sentinel,
|
|
3028
3432
|
},
|
|
3029
3433
|
});
|
|
3030
|
-
if (
|
|
3434
|
+
if (existsSync(sentinel))
|
|
3435
|
+
return { confirmed: true };
|
|
3436
|
+
const seedFile = plan.args[plan.args.length - 1] ?? '';
|
|
3437
|
+
if (/\bdefineSeed\b/.test(readFileSync(seedFile, 'utf-8'))) {
|
|
3031
3438
|
throw new Error('The seed file completed without running a seed callback. ' +
|
|
3032
3439
|
'Make sure it calls defineSeed(async (db) => { ... }) at the top level.');
|
|
3033
3440
|
}
|
|
3441
|
+
return { confirmed: false };
|
|
3034
3442
|
}
|
|
3035
3443
|
finally {
|
|
3036
3444
|
rmSync(sentinelDir, { recursive: true, force: true });
|
|
3037
3445
|
}
|
|
3038
|
-
return;
|
|
3039
3446
|
}
|
|
3040
3447
|
if (plan.kind === 'js') {
|
|
3041
3448
|
const mod = await import(pathToFileURL(plan.file).href);
|
|
@@ -3044,7 +3451,7 @@ async function runSeedPlan(plan, config) {
|
|
|
3044
3451
|
'Export your seed with `export default defineSeed(async (db) => { ... })`.');
|
|
3045
3452
|
}
|
|
3046
3453
|
await mod.default();
|
|
3047
|
-
return;
|
|
3454
|
+
return { confirmed: true };
|
|
3048
3455
|
}
|
|
3049
3456
|
// `seedUrl` is already pinned and already schema-checked; `requireUrl` is
|
|
3050
3457
|
// only reached when no url was configured at all, in which case there is no
|
|
@@ -3060,6 +3467,7 @@ async function runSeedPlan(plan, config) {
|
|
|
3060
3467
|
finally {
|
|
3061
3468
|
await client.end();
|
|
3062
3469
|
}
|
|
3470
|
+
return { confirmed: true };
|
|
3063
3471
|
}
|
|
3064
3472
|
finally {
|
|
3065
3473
|
if (oldDatabaseUrl === undefined) {
|
|
@@ -3085,14 +3493,14 @@ async function cmdSeed(_args, config) {
|
|
|
3085
3493
|
}
|
|
3086
3494
|
const spinner = new Spinner('Running seed file').start();
|
|
3087
3495
|
try {
|
|
3088
|
-
await runSeedPlan(getSeedExecutionPlan(seedFile), config);
|
|
3496
|
+
const outcome = await runSeedPlan(getSeedExecutionPlan(seedFile), config);
|
|
3089
3497
|
spinner.succeed('Seed completed');
|
|
3498
|
+
if (!outcome.confirmed)
|
|
3499
|
+
warn(UNCONFIRMED_SEED_NOTICE);
|
|
3090
3500
|
}
|
|
3091
3501
|
catch (err) {
|
|
3092
3502
|
spinner.fail('Seed failed');
|
|
3093
|
-
|
|
3094
|
-
console.log(` ${dim(redactUrl(err.message))}`);
|
|
3095
|
-
}
|
|
3503
|
+
console.log(` ${dim(redactUrl(errorText(err)))}`);
|
|
3096
3504
|
newline();
|
|
3097
3505
|
process.exit(1);
|
|
3098
3506
|
}
|
|
@@ -3123,7 +3531,7 @@ async function cmdStatus(_args, config) {
|
|
|
3123
3531
|
for (const tbl of Object.values(schema.tables)) {
|
|
3124
3532
|
const relCount = Object.keys(tbl.relations).length;
|
|
3125
3533
|
const _pk = tbl.primaryKey.join(', ') || dim('(none)');
|
|
3126
|
-
console.log(` ${bold(cyan(tbl.name))}`);
|
|
3534
|
+
console.log(` ${bold(cyan(shown(tbl.name)))}`);
|
|
3127
3535
|
for (let i = 0; i < tbl.columns.length; i++) {
|
|
3128
3536
|
const col = tbl.columns[i];
|
|
3129
3537
|
const isLast = i === tbl.columns.length - 1 && relCount === 0;
|
|
@@ -3131,7 +3539,7 @@ async function cmdStatus(_args, config) {
|
|
|
3131
3539
|
const nullable = col.nullable ? dim('?') : '';
|
|
3132
3540
|
const def = col.hasDefault ? dim(' (default)') : '';
|
|
3133
3541
|
const pkLabel = tbl.primaryKey.includes(col.name) ? ` ${magenta('PK')}` : '';
|
|
3134
|
-
console.log(` ${dim(prefix)} ${col.field}${nullable}: ${green(col.tsType)}${pkLabel}${def} ${gray(`${symbols.arrow} ${col.pgType}`)}`);
|
|
3542
|
+
console.log(` ${dim(prefix)} ${shown(col.field)}${nullable}: ${green(shown(col.tsType))}${pkLabel}${def} ${gray(`${symbols.arrow} ${shown(col.pgType)}`)}`);
|
|
3135
3543
|
}
|
|
3136
3544
|
const rels = Object.entries(tbl.relations);
|
|
3137
3545
|
if (rels.length > 0) {
|
|
@@ -3141,7 +3549,7 @@ async function cmdStatus(_args, config) {
|
|
|
3141
3549
|
const prefix = isLast ? symbols.teeEnd : symbols.tee;
|
|
3142
3550
|
const relColor = rel.type === 'hasMany' ? blue : yellow;
|
|
3143
3551
|
const fkDisplay = Array.isArray(rel.foreignKey) ? rel.foreignKey.join(', ') : rel.foreignKey;
|
|
3144
|
-
console.log(` ${dim(prefix)} ${relColor(relName)} ${dim(symbols.arrow)} ${rel.to} ${dim(`(${rel.type}, FK: ${fkDisplay})`)}`);
|
|
3552
|
+
console.log(` ${dim(prefix)} ${relColor(shown(relName))} ${dim(symbols.arrow)} ${shown(rel.to)} ${dim(`(${rel.type}, FK: ${shown(fkDisplay)})`)}`);
|
|
3145
3553
|
}
|
|
3146
3554
|
}
|
|
3147
3555
|
newline();
|
|
@@ -3149,7 +3557,7 @@ async function cmdStatus(_args, config) {
|
|
|
3149
3557
|
if (Object.keys(schema.enums).length > 0) {
|
|
3150
3558
|
console.log(` ${bold('Enums:')}`);
|
|
3151
3559
|
for (const [enumName, labels] of Object.entries(schema.enums)) {
|
|
3152
|
-
console.log(` ${cyan(enumName)}: ${labels.map((l) => green(`'${l}'`)).join(dim(' | '))}`);
|
|
3560
|
+
console.log(` ${cyan(shown(enumName))}: ${labels.map((l) => green(`'${shown(l)}'`)).join(dim(' | '))}`);
|
|
3153
3561
|
}
|
|
3154
3562
|
newline();
|
|
3155
3563
|
}
|
|
@@ -3207,6 +3615,23 @@ const CONCURRENTLY_RECIPE_COMMENT = [
|
|
|
3207
3615
|
'-- makes "migrate up" look hung. For bounded waits, SET lock_timeout /',
|
|
3208
3616
|
'-- statement_timeout in a psql session.',
|
|
3209
3617
|
].join('\n');
|
|
3618
|
+
/**
|
|
3619
|
+
* Why `migrate up`, `deploy` and `down` refuse a pooled endpoint. The reason is
|
|
3620
|
+
* the migration lock, not the doctor's session-state argument.
|
|
3621
|
+
*/
|
|
3622
|
+
export const MIGRATE_POOLER_EXPLANATION = [
|
|
3623
|
+
'turbine migrate serializes runs with a session-level advisory lock (pg_try_advisory_lock).',
|
|
3624
|
+
'That lock belongs to one server backend and only means something while the same backend',
|
|
3625
|
+
'serves every statement of the run. A transaction pooler (PgBouncer, a Neon "-pooler"',
|
|
3626
|
+
"endpoint, Supabase's pooler) hands each transaction to whichever backend is free, so:",
|
|
3627
|
+
'',
|
|
3628
|
+
' 1. The lock is not tied to this run. It can be taken on one backend and released on',
|
|
3629
|
+
' another, which leaves it held on a shared backend (later runs are told another',
|
|
3630
|
+
' migration is in progress) and lets two runs apply the same file.',
|
|
3631
|
+
' 2. Session state a migration file sets (SET lock_timeout, SET search_path) is left on',
|
|
3632
|
+
' a shared backend for other clients, and state an earlier client left there applies',
|
|
3633
|
+
' to this run.',
|
|
3634
|
+
];
|
|
3210
3635
|
/**
|
|
3211
3636
|
* Refuse a `doctor` run whose connection string points at a transaction pooler.
|
|
3212
3637
|
*
|
|
@@ -3228,13 +3653,13 @@ const CONCURRENTLY_RECIPE_COMMENT = [
|
|
|
3228
3653
|
* Exported for the unit test only (like {@link isLoopbackHost}): a gate whose
|
|
3229
3654
|
* failure path is `process.exit` is untestable through the command itself.
|
|
3230
3655
|
*/
|
|
3231
|
-
export function refusePoolerConnection(url, args) {
|
|
3656
|
+
export function refusePoolerConnection(url, args, refusal = { command: 'turbine doctor' }) {
|
|
3232
3657
|
if (args.allowPooler === true)
|
|
3233
3658
|
return;
|
|
3234
3659
|
const detection = detectPooler(url);
|
|
3235
3660
|
if (!detection.pooled)
|
|
3236
3661
|
return;
|
|
3237
|
-
const lines = poolerRefusalMessage(detection, {
|
|
3662
|
+
const lines = poolerRefusalMessage(detection, { ...refusal, allowFlag: '--allow-pooler' });
|
|
3238
3663
|
console.error('');
|
|
3239
3664
|
console.error(` ${red(symbols.cross)} ${lines[0]}`);
|
|
3240
3665
|
for (const line of lines.slice(1))
|
|
@@ -3302,7 +3727,7 @@ async function cmdDoctor(args, config) {
|
|
|
3302
3727
|
tables: {},
|
|
3303
3728
|
indexes: [],
|
|
3304
3729
|
nullFrac: {},
|
|
3305
|
-
notices: [`statistics collection failed: ${
|
|
3730
|
+
notices: [`statistics collection failed: ${errorText(err)}`],
|
|
3306
3731
|
};
|
|
3307
3732
|
}
|
|
3308
3733
|
// Table-heat boost: read _turbine_metrics (app DB or --metrics-url) and use
|
|
@@ -3527,6 +3952,12 @@ function renderUnusedCaveats(minScans, snapshot) {
|
|
|
3527
3952
|
}
|
|
3528
3953
|
newline();
|
|
3529
3954
|
}
|
|
3955
|
+
/**
|
|
3956
|
+
* Catalog names go to the terminal through this. A database lets its creator
|
|
3957
|
+
* pick any characters for a table or index name, and a raw ESC or bidi control
|
|
3958
|
+
* in one rewrites the line it is printed on.
|
|
3959
|
+
*/
|
|
3960
|
+
const shown = printableCatalogName;
|
|
3530
3961
|
/** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
|
|
3531
3962
|
function renderUnusedIndexes(unused, minScans, snapshot) {
|
|
3532
3963
|
if (unused.length === 0)
|
|
@@ -3535,12 +3966,12 @@ function renderUnusedIndexes(unused, minScans, snapshot) {
|
|
|
3535
3966
|
warn(`Found ${bold(String(unused.length))} never-scanned index(es) (${formatBytes(total)} reclaimable).`);
|
|
3536
3967
|
renderUnusedCaveats(minScans, snapshot);
|
|
3537
3968
|
for (const u of unused) {
|
|
3538
|
-
console.log(` ${yellow(symbols.warning)} ${bold(cyan(u.table))} ${dim(`(${u.columns.join(', ') || '?'})`)} ${gray(`${u.indexName} · ${u.idxScan} scans · ${formatBytes(u.sizeBytes)}`)}`);
|
|
3969
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(u.table)))} ${dim(`(${shown(u.columns.join(', ') || '?')})`)} ${gray(`${shown(u.indexName)} · ${u.idxScan} scans · ${formatBytes(u.sizeBytes)}`)}`);
|
|
3539
3970
|
// A functional / partial / non-btree index is not a plain `(col)` rebuild:
|
|
3540
3971
|
// say what it actually is before anyone runs the DROP.
|
|
3541
3972
|
if (u.caveat)
|
|
3542
3973
|
console.log(` ${dim(symbols.tee)} ${yellow(u.caveat)}`);
|
|
3543
|
-
console.log(` ${dim(symbols.teeEnd)} ${green(u.dropSql)}`);
|
|
3974
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(shown(u.dropSql))}`);
|
|
3544
3975
|
newline();
|
|
3545
3976
|
}
|
|
3546
3977
|
}
|
|
@@ -3553,9 +3984,9 @@ function renderRedundantIndexes(redundant) {
|
|
|
3553
3984
|
console.log(` ${dim('Each is a leading prefix of a wider index that already serves the same lookups. Report-only, never auto-dropped.')}`);
|
|
3554
3985
|
newline();
|
|
3555
3986
|
for (const r of redundant) {
|
|
3556
|
-
console.log(` ${yellow(symbols.warning)} ${bold(cyan(r.table))} ${dim(`(${r.columns.join(', ')})`)} ${gray(`${r.indexName} · ${formatBytes(r.sizeBytes)}`)}`);
|
|
3557
|
-
console.log(` ${dim(symbols.tee)} covered by ${blue(r.coveredBy)} ${dim(`(${r.coveredByColumns.join(', ')})`)}`);
|
|
3558
|
-
console.log(` ${dim(symbols.teeEnd)} ${green(r.dropSql)}`);
|
|
3987
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(r.table)))} ${dim(`(${shown(r.columns.join(', '))})`)} ${gray(`${shown(r.indexName)} · ${formatBytes(r.sizeBytes)}`)}`);
|
|
3988
|
+
console.log(` ${dim(symbols.tee)} covered by ${blue(shown(r.coveredBy))} ${dim(`(${shown(r.coveredByColumns.join(', '))})`)}`);
|
|
3989
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(shown(r.dropSql))}`);
|
|
3559
3990
|
newline();
|
|
3560
3991
|
}
|
|
3561
3992
|
}
|
|
@@ -3570,7 +4001,7 @@ function renderDoctorAudit(audit, minScans, snapshot) {
|
|
|
3570
4001
|
renderUnusedCaveats(minScans, snapshot);
|
|
3571
4002
|
for (const a of audit) {
|
|
3572
4003
|
const tag = a.ambiguous ? red(' [ambiguous: truncated name collides with another column set]') : '';
|
|
3573
|
-
console.log(` ${yellow(symbols.warning)} ${bold(cyan(a.table))} ${dim(`(${a.columns.join(', ') || '?'})`)} ${gray(`${a.indexName} · ${a.idxScan} scans · ${formatBytes(a.sizeBytes)}`)}${tag}`);
|
|
4004
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(a.table)))} ${dim(`(${shown(a.columns.join(', ') || '?')})`)} ${gray(`${shown(a.indexName)} · ${a.idxScan} scans · ${formatBytes(a.sizeBytes)}`)}${tag}`);
|
|
3574
4005
|
if (a.ambiguous) {
|
|
3575
4006
|
console.log(` ${dim(symbols.tee)} ${dim('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
|
|
3576
4007
|
}
|
|
@@ -3579,7 +4010,7 @@ function renderDoctorAudit(audit, minScans, snapshot) {
|
|
|
3579
4010
|
console.log(` ${dim('reappear as a missing-index finding in this same report.')}`);
|
|
3580
4011
|
}
|
|
3581
4012
|
else if (a.dropSql) {
|
|
3582
|
-
console.log(` ${dim(symbols.teeEnd)} ${green(a.dropSql)}`);
|
|
4013
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(shown(a.dropSql))}`);
|
|
3583
4014
|
}
|
|
3584
4015
|
newline();
|
|
3585
4016
|
}
|
|
@@ -3633,12 +4064,12 @@ function divergenceAmplificationLines(d) {
|
|
|
3633
4064
|
const amp = divInt(d.worstCaseAmplification ?? 0);
|
|
3634
4065
|
const corr = d.orderColumnCorrelation;
|
|
3635
4066
|
const corrLabel = corr === null || corr === undefined
|
|
3636
|
-
? `no pg_stats correlation available for "${d.orderColumn}"`
|
|
3637
|
-
: `correlation ${corr.toFixed(5)} on "${d.orderColumn}"`;
|
|
4067
|
+
? `no pg_stats correlation available for "${shown(d.orderColumn)}"`
|
|
4068
|
+
: `correlation ${corr.toFixed(5)} on "${shown(d.orderColumn)}"`;
|
|
3638
4069
|
if (d.heapNearlyOrdered === true) {
|
|
3639
4070
|
return [
|
|
3640
4071
|
`The size of that flip turns on the heap's physical order, and THIS heap is in near-exact`,
|
|
3641
|
-
`"${d.orderColumn}" order (${corrLabel}), so consecutive index entries hit the`,
|
|
4072
|
+
`"${shown(d.orderColumn)}" order (${corrLabel}), so consecutive index entries hit the`,
|
|
3642
4073
|
`same pinned page: measured ~1x, not the ~${amp}x an unordered heap reads. Most likely this`,
|
|
3643
4074
|
`one is not costing you anything today. It is also one sampled statistic away from the`,
|
|
3644
4075
|
`much worse reading, so measure rather than assume in either direction.`,
|
|
@@ -3647,7 +4078,7 @@ function divergenceAmplificationLines(d) {
|
|
|
3647
4078
|
return [
|
|
3648
4079
|
`That costs ~${amp}x the buffers of the seq scan, because each index entry is its own heap`,
|
|
3649
4080
|
`fetch (${corrLabel}). The one shape that reads ~1x instead is a heap`,
|
|
3650
|
-
`in near-exact "${d.orderColumn}" order; two pages of local disorder already reads ~41x.`,
|
|
4081
|
+
`in near-exact "${shown(d.orderColumn)}" order; two pages of local disorder already reads ~41x.`,
|
|
3651
4082
|
];
|
|
3652
4083
|
}
|
|
3653
4084
|
function renderDivergenceEvidence(d) {
|
|
@@ -3655,7 +4086,7 @@ function renderDivergenceEvidence(d) {
|
|
|
3655
4086
|
console.log(` ${dim(symbols.tee)} ${yellow('cached-plan risk:')} this unindexed filter column can also flip a cached plan.`);
|
|
3656
4087
|
console.log(` ${dim(`${divInt(d.rows)} rows in ${divInt(d.pages)} pages, rarest value ~${divInt(d.rarestBucket)} rows, below the assumed LIMIT ${d.assumedLimit}.`)}`);
|
|
3657
4088
|
console.log(` ${dim(`Without the index the good plan is a seq scan (${divInt(d.pages)} pages); a promoted generic`)}`);
|
|
3658
|
-
console.log(` ${dim(`plan cannot see the value is rare, keeps the ordered "${d.orderColumn}" walk, and reads`)}`);
|
|
4089
|
+
console.log(` ${dim(`plan cannot see the value is rare, keeps the ordered "${shown(d.orderColumn)}" walk, and reads`)}`);
|
|
3659
4090
|
console.log(` ${dim(`up to ~${tuples} tuples before it fills the LIMIT.`)}`);
|
|
3660
4091
|
console.log(` ${dim('Postgres promotes this shape exactly when the workload keeps asking for the rare')}`);
|
|
3661
4092
|
console.log(` ${dim('value: that is the case where the custom plan is expensive enough for the generic')}`);
|
|
@@ -3664,7 +4095,7 @@ function renderDivergenceEvidence(d) {
|
|
|
3664
4095
|
console.log(` ${dim(line)}`);
|
|
3665
4096
|
console.log(` ${dim('Adding the index above is the fix. Confirm first if you want to:')}`);
|
|
3666
4097
|
for (const line of d.diagnosticSql.split('\n')) {
|
|
3667
|
-
console.log(` ${green(line)}`);
|
|
4098
|
+
console.log(` ${green(shown(line))}`);
|
|
3668
4099
|
}
|
|
3669
4100
|
console.log(` ${dim('After adding this index, re-run doctor. This column is expected to reappear as a')}`);
|
|
3670
4101
|
console.log(` ${dim('sparse-value finding in the cached-plan section. That later finding is exposure, not')}`);
|
|
@@ -3718,9 +4149,9 @@ function renderFinding(f, opts) {
|
|
|
3718
4149
|
const rows = f.score.metrics.rows;
|
|
3719
4150
|
const rowsLabel = rows !== null ? `~${rows.toLocaleString()} rows` : 'row count unknown';
|
|
3720
4151
|
const sizeLabel = f.score.metrics.sizeBytes !== null ? `, ${formatBytes(f.score.metrics.sizeBytes)}` : '';
|
|
3721
|
-
console.log(` ${yellow(symbols.warning)} ${bold(cyan(m.table))} ${dim(`(${m.columns.join(', ')})`)} ${gray(`${rowsLabel}${sizeLabel}`)}`);
|
|
4152
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(shown(m.table)))} ${dim(`(${shown(m.columns.join(', '))})`)} ${gray(`${rowsLabel}${sizeLabel}`)}`);
|
|
3722
4153
|
for (const p of m.probes) {
|
|
3723
|
-
console.log(` ${dim(symbols.tee)} probed by ${p.from}.${blue(p.relation)} ${dim(`(${p.type})`)}`);
|
|
4154
|
+
console.log(` ${dim(symbols.tee)} probed by ${shown(p.from)}.${blue(shown(p.relation))} ${dim(`(${p.type})`)}`);
|
|
3724
4155
|
}
|
|
3725
4156
|
if (opts.withReasons) {
|
|
3726
4157
|
for (const reason of f.score.reasons) {
|
|
@@ -3728,7 +4159,7 @@ function renderFinding(f, opts) {
|
|
|
3728
4159
|
}
|
|
3729
4160
|
}
|
|
3730
4161
|
const last = opts.divergence ? symbols.tee : symbols.teeEnd;
|
|
3731
|
-
console.log(` ${dim(last)} ${green(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
|
|
4162
|
+
console.log(` ${dim(last)} ${green(shown(doctorCreateSql(f, { concurrently: opts.concurrently })))}`);
|
|
3732
4163
|
if (opts.divergence)
|
|
3733
4164
|
renderDivergenceEvidence(opts.divergence);
|
|
3734
4165
|
newline();
|
|
@@ -3741,8 +4172,8 @@ function renderInvalidIndexes(invalid) {
|
|
|
3741
4172
|
console.log(` ${dim('IF NOT EXISTS skips an invalid index on rerun, so it never rebuilds. Drop it, then rerun:')}`);
|
|
3742
4173
|
newline();
|
|
3743
4174
|
for (const idx of invalid) {
|
|
3744
|
-
console.log(` ${red(symbols.warning)} ${bold(cyan(idx.table))} ${dim(`(${idx.columns.join(', ') || '?'})`)} ${gray(idx.indexName)}`);
|
|
3745
|
-
console.log(` ${dim(symbols.teeEnd)} ${green(idx.dropSql)}`);
|
|
4175
|
+
console.log(` ${red(symbols.warning)} ${bold(cyan(shown(idx.table)))} ${dim(`(${shown(idx.columns.join(', ') || '?')})`)} ${gray(shown(idx.indexName))}`);
|
|
4176
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(shown(idx.dropSql))}`);
|
|
3746
4177
|
newline();
|
|
3747
4178
|
}
|
|
3748
4179
|
}
|
|
@@ -3835,10 +4266,10 @@ function renderPlanDivergence(divergence, attached) {
|
|
|
3835
4266
|
// on (an index that exists but cannot serve the equality: partial,
|
|
3836
4267
|
// expression, or non-btree). The remedy is still an index, not a
|
|
3837
4268
|
// plan-cache setting, so this entry never suggests forceCustomPlan.
|
|
3838
|
-
console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('UNINDEXED-FILTER FLIP')}`);
|
|
4269
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${shown(f.table)}.${shown(f.column)}`))} ${gray('UNINDEXED-FILTER FLIP')}`);
|
|
3839
4270
|
console.log(` ${dim(symbols.tee)} ${divInt(f.rows)} rows in ${divInt(f.pages)} pages, rarest value ~${bold(divInt(f.rarestBucket))} rows, below the assumed LIMIT ${f.assumedLimit}`);
|
|
3840
|
-
console.log(` ${dim(symbols.tee)} no index serves ${f.column} = $1, so the good plan is a seq scan (${divInt(f.pages)} pages);`);
|
|
3841
|
-
console.log(` ${dim(`a promoted generic plan keeps the ordered "${f.orderColumn}" walk and reads up to ~${divInt(f.tuplesWalked ?? f.rows)} tuples`)}`);
|
|
4271
|
+
console.log(` ${dim(symbols.tee)} no index serves ${shown(f.column)} = $1, so the good plan is a seq scan (${divInt(f.pages)} pages);`);
|
|
4272
|
+
console.log(` ${dim(`a promoted generic plan keeps the ordered "${shown(f.orderColumn)}" walk and reads up to ~${divInt(f.tuplesWalked ?? f.rows)} tuples`)}`);
|
|
3842
4273
|
console.log(` ${dim('before it fills the LIMIT.')}`);
|
|
3843
4274
|
for (const line of divergenceAmplificationLines(f))
|
|
3844
4275
|
console.log(` ${dim(line)}`);
|
|
@@ -3853,13 +4284,13 @@ function renderPlanDivergence(divergence, attached) {
|
|
|
3853
4284
|
newline();
|
|
3854
4285
|
continue;
|
|
3855
4286
|
}
|
|
3856
|
-
console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('SPARSE-VALUE FLIP')}`);
|
|
4287
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${shown(f.table)}.${shown(f.column)}`))} ${gray('SPARSE-VALUE FLIP')}`);
|
|
3857
4288
|
console.log(` ${dim(symbols.tee)} generic estimate ${bold(divInt(f.genericEstimate))} rows ${dim(`(${divInt(f.rows)} rows / ${divInt(f.distinctValues)} distinct values)`)}`);
|
|
3858
4289
|
console.log(` ${dim(symbols.tee)} rarest value bucket ${bold(divInt(f.rarestBucket))} rows ${dim('(pg_stats most_common_freqs / residual bucket)')}`);
|
|
3859
4290
|
console.log(` ${dim(symbols.tee)} crossover ${bold(divInt(f.crossoverRows ?? 0))} rows ${dim(`(sqrt(limit ${f.assumedLimit} x ${divInt(f.pages)} pages); ${divInt(f.crossoverRowsWide ?? 0)} at limit ${f.thresholds.wideLimit})`)}`);
|
|
3860
4291
|
console.log(` ${dim(symbols.tee)} ${dim(`values below the crossover: ${divInt(f.valuesBelowCrossover ?? 0)} of ${divInt(f.distinctValues)}, filter-column correlation ${f.correlation.toFixed(2)}, ${analyzedLabel(f)}`)}`);
|
|
3861
4292
|
console.log(` ${dim(symbols.tee)} for such a value the generic plan walks ~${bold(divInt(f.walkPages ?? 0))} of ${divInt(f.pages)} pages ${dim(`(${Math.round((f.walkFraction ?? 0) * 100)}% of the table)`)}`);
|
|
3862
|
-
console.log(` ${dim(`for reads shaped WHERE ${f.column} = $1 ORDER BY ${f.orderColumn} LIMIT $n,`)}`);
|
|
4293
|
+
console.log(` ${dim(`for reads shaped WHERE ${shown(f.column)} = $1 ORDER BY ${shown(f.orderColumn)} LIMIT $n,`)}`);
|
|
3863
4294
|
console.log(` ${dim("where the custom plan reads only that value's own rows.")}`);
|
|
3864
4295
|
console.log(` ${dim(symbols.tee)} ${dim('No amplification figure is printed, deliberately. This models how many rows a')}`);
|
|
3865
4296
|
console.log(` ${dim('value has, not WHERE they sit in the heap, and the second half can move the')}`);
|
|
@@ -3921,7 +4352,7 @@ function renderPlanDivergence(divergence, attached) {
|
|
|
3921
4352
|
console.log(` ${dim('in one direction trades this finding for its mirror image. That applies equally to')}`);
|
|
3922
4353
|
console.log(` ${dim("Turbine's client-level `planCacheMode` and to a SET or ALTER ROLE applied outside")}`);
|
|
3923
4354
|
console.log(` ${dim('Turbine.')}`);
|
|
3924
|
-
console.log(` 5. A composite index on (${first.column}, ${first.orderColumn}) makes the GOOD plan better. It`);
|
|
4355
|
+
console.log(` 5. A composite index on (${shown(first.column)}, ${shown(first.orderColumn)}) makes the GOOD plan better. It`);
|
|
3925
4356
|
console.log(` ${dim('does NOT stop the generic plan from choosing the other one, and it can widen the gap.')}`);
|
|
3926
4357
|
console.log(` ${dim('Add it for the custom-plan win, not as a fix for this finding.')}`);
|
|
3927
4358
|
}
|
|
@@ -3946,7 +4377,7 @@ function renderPlanDivergence(divergence, attached) {
|
|
|
3946
4377
|
if (notices.length > 0) {
|
|
3947
4378
|
console.log(` ${dim('Not scored for cached-plan divergence (statistics missing):')}`);
|
|
3948
4379
|
for (const n of notices) {
|
|
3949
|
-
console.log(` ${dim(`- ${n.table}.${n.column}: ${n.reason}`)}`);
|
|
4380
|
+
console.log(` ${dim(`- ${shown(n.table)}.${shown(n.column)}: ${n.reason}`)}`);
|
|
3950
4381
|
}
|
|
3951
4382
|
newline();
|
|
3952
4383
|
}
|
|
@@ -4044,7 +4475,7 @@ async function cmdStudio(args, config) {
|
|
|
4044
4475
|
spinner.succeed(demo ? 'Demo Studio is running' : 'Studio is running');
|
|
4045
4476
|
}
|
|
4046
4477
|
catch (err) {
|
|
4047
|
-
spinner.fail(`Failed to start Studio: ${
|
|
4478
|
+
spinner.fail(`Failed to start Studio: ${errorText(err)}`);
|
|
4048
4479
|
process.exit(1);
|
|
4049
4480
|
}
|
|
4050
4481
|
if (demo) {
|
|
@@ -4177,7 +4608,7 @@ async function cmdObserve(args) {
|
|
|
4177
4608
|
spinner.succeed('Observe dashboard is running');
|
|
4178
4609
|
}
|
|
4179
4610
|
catch (err) {
|
|
4180
|
-
spinner.fail(`Failed to start Observe: ${
|
|
4611
|
+
spinner.fail(`Failed to start Observe: ${errorText(err)}`);
|
|
4181
4612
|
process.exit(1);
|
|
4182
4613
|
}
|
|
4183
4614
|
newline();
|
|
@@ -4285,10 +4716,12 @@ function showGenerateHelp() {
|
|
|
4285
4716
|
console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema ${dim('(default: public)')}`);
|
|
4286
4717
|
console.log(` ${cyan('--include')} ${dim('<tables>')} Comma-separated tables to include`);
|
|
4287
4718
|
console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
|
|
4719
|
+
console.log(` ${cyan('--schema-file')} ${dim('<path>')} Your ${cyan('defineSchema()')} file, whose ${cyan('pii')} / ${cyan('updatedAt')} tags are applied ${dim('(default: ./turbine/schema.ts)')}`);
|
|
4288
4720
|
console.log(` ${cyan('--zod')} Also emit ${cyan('zod.ts')} validation schemas ${dim('(needs the zod dep)')}`);
|
|
4289
4721
|
console.log(` ${cyan('--include-views')} Include views + materialized views as read-only entities`);
|
|
4290
4722
|
console.log(` ${cyan('--no-timestamp')} Omit the ${dim('Generated at:')} header line ${dim('(reproducible, diff-stable output)')}`);
|
|
4291
|
-
console.log(` ${cyan('--import-ext')} ${dim('<mode>')} Sibling-import extension: ${cyan('js')} / ${cyan('none')} / ${cyan('auto')} ${dim('(default: auto)')}`);
|
|
4723
|
+
console.log(` ${cyan('--import-ext')} ${dim('<mode>')} Sibling-import extension: ${cyan('js')} / ${cyan('ts')} / ${cyan('none')} / ${cyan('auto')} ${dim('(default: auto)')}`);
|
|
4724
|
+
console.log(` ${dim('ts emits ./types.ts so')} ${cyan('node app.ts')} ${dim('can run the client with native type stripping')}`);
|
|
4292
4725
|
console.log(` ${cyan('--keep-column-names')} Keep raw DB column names as field names ${dim('(snake_case, not camelCase)')}`);
|
|
4293
4726
|
console.log(` ${cyan('--legacy-to-many-uniques')} Emit ${cyan('hasMany')} for unique-FK children ${dim('(pre-0.41 shape; default flips to hasOne)')}`);
|
|
4294
4727
|
console.log(` ${cyan('--allow-empty')} Generate even when introspection matches 0 tables`);
|
|
@@ -4345,6 +4778,7 @@ function showPushHelp() {
|
|
|
4345
4778
|
console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
|
|
4346
4779
|
console.log(` ${cyan('--schema-file')} ${dim('<path>')} Your ${cyan('defineSchema()')} file ${dim('(default: ./turbine/schema.ts)')}`);
|
|
4347
4780
|
console.log(` ${cyan('--dry-run')} Show SQL without executing`);
|
|
4781
|
+
console.log(` ${cyan('--exit-code')} With ${cyan('--dry-run')}: exit 2 when the database differs from the schema file, 0 when in sync ${dim('(CI)')}`);
|
|
4348
4782
|
console.log(` ${cyan('--allow-destructive')} Skip the interactive confirmation for data-destroying statements ${dim('(CI)')}`);
|
|
4349
4783
|
console.log(` ${cyan('--verbose, -v')} Show detailed output`);
|
|
4350
4784
|
newline();
|
|
@@ -4371,7 +4805,11 @@ function showMigrateHelp() {
|
|
|
4371
4805
|
console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a sanctioned migration pattern ${dim('(create only, e.g. backfill)')}`);
|
|
4372
4806
|
console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
|
|
4373
4807
|
console.log(` ${cyan('--dry-run')} Show SQL without executing`);
|
|
4808
|
+
console.log(` ${cyan('--exit-code')} Exit 2 when migrations are pending, modified, missing or misnamed ${dim('(status only, CI)')}`);
|
|
4809
|
+
console.log(` ${cyan('--wait')}${dim('[=<seconds>]')} Wait for a concurrent deploy to release the lock, default ${DEFAULT_LOCK_WAIT_SECONDS}s ${dim('(deploy only)')}`);
|
|
4810
|
+
console.log(` ${cyan('--allow-pending')} Let ${cyan('--auto')} / ${cyan('--from-diff')} run while earlier migrations are still pending ${dim('(create only)')}`);
|
|
4374
4811
|
console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums, REWRITING history ${dim('(up / deploy / down)')}`);
|
|
4812
|
+
console.log(` ${cyan('--allow-pooler')} Run ${cyan('up')} / ${cyan('deploy')} / ${cyan('down')} through a transaction pooler ${dim('(refused: the migration lock is session-level)')}`);
|
|
4375
4813
|
console.log(` ${cyan('--allow-destructive')} Run data-destroying migration statements without the interactive confirm`);
|
|
4376
4814
|
console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema the migrations run in ${dim('(default: public)')}`);
|
|
4377
4815
|
console.log(` ${cyan('--verbose, -v')} Show detailed output`);
|
|
@@ -4383,8 +4821,10 @@ function showMigrateHelp() {
|
|
|
4383
4821
|
console.log(` ${dim('$')} npx turbine migrate create backfill_full_name --recipe backfill`);
|
|
4384
4822
|
console.log(` ${dim('$')} npx turbine migrate up`);
|
|
4385
4823
|
console.log(` ${dim('$')} npx turbine migrate deploy --dry-run`);
|
|
4824
|
+
console.log(` ${dim('$')} npx turbine migrate deploy --wait=120`);
|
|
4386
4825
|
console.log(` ${dim('$')} npx turbine migrate down --step 2`);
|
|
4387
4826
|
console.log(` ${dim('$')} npx turbine migrate status`);
|
|
4827
|
+
console.log(` ${dim('$')} npx turbine migrate status --exit-code`);
|
|
4388
4828
|
newline();
|
|
4389
4829
|
}
|
|
4390
4830
|
function showSeedHelp() {
|
|
@@ -4608,8 +5048,12 @@ function showHelp() {
|
|
|
4608
5048
|
console.log(` ${cyan('--from-diff')} Generate from schema diff, destructive statements flagged ${dim('(create)')}`);
|
|
4609
5049
|
console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a named migration recipe, e.g. backfill ${dim('(create)')}`);
|
|
4610
5050
|
console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
|
|
5051
|
+
console.log(` ${cyan('--exit-code')} Exit 2 on drift ${dim('(push --dry-run, migrate status)')}`);
|
|
5052
|
+
console.log(` ${cyan('--wait')}${dim('[=<seconds>]')} Wait for a concurrent deploy's lock instead of failing ${dim('(deploy)')}`);
|
|
5053
|
+
console.log(` ${cyan('--allow-pending')} Diff even while migrations are pending ${dim('(create --auto / --from-diff)')}`);
|
|
4611
5054
|
console.log(` ${cyan('--allow-destructive')} Run data-destroying statements without prompting ${dim('(up/down/push)')}`);
|
|
4612
5055
|
console.log(` ${cyan('--allow-drift')} Re-baseline drifted checksums on ${cyan('migrate up')} / ${cyan('deploy')} / ${cyan('down')} ${dim('(advanced)')}`);
|
|
5056
|
+
console.log(` ${cyan('--allow-pooler')} Let ${cyan('migrate up')} / ${cyan('deploy')} / ${cyan('down')} and ${cyan('doctor')} use a transaction pooler ${dim('(refused by default)')}`);
|
|
4613
5057
|
newline();
|
|
4614
5058
|
console.log(` ${bold('Init options:')}`);
|
|
4615
5059
|
console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
|
|
@@ -4726,9 +5170,19 @@ function cmdSkill(args) {
|
|
|
4726
5170
|
}
|
|
4727
5171
|
const skillsDir = args.dir ?? join('.claude', 'skills');
|
|
4728
5172
|
const target = resolve(process.cwd(), skillsDir, 'turbine-orm', 'SKILL.md');
|
|
4729
|
-
const existed = existsSync(target);
|
|
4730
5173
|
mkdirSync(dirname(target), { recursive: true });
|
|
4731
|
-
|
|
5174
|
+
// Exclusive create first: only the wording depends on whether the file was
|
|
5175
|
+
// there, and asking separately would let the answer go stale before the write.
|
|
5176
|
+
let existed = false;
|
|
5177
|
+
try {
|
|
5178
|
+
writeFileSync(target, body, { flag: 'wx' });
|
|
5179
|
+
}
|
|
5180
|
+
catch (err) {
|
|
5181
|
+
if (err.code !== 'EEXIST')
|
|
5182
|
+
throw err;
|
|
5183
|
+
existed = true;
|
|
5184
|
+
writeFileSync(target, body);
|
|
5185
|
+
}
|
|
4732
5186
|
newline();
|
|
4733
5187
|
success(`${existed ? 'Updated' : 'Installed'} the Turbine query skill`);
|
|
4734
5188
|
console.log(` ${dim(relative(process.cwd(), target))}`);
|
|
@@ -4791,7 +5245,7 @@ async function main() {
|
|
|
4791
5245
|
// expected here; anything that does arrive is a bug in the parser rather
|
|
4792
5246
|
// than in the caller's command line, and printing it beats swallowing it.
|
|
4793
5247
|
errorLine();
|
|
4794
|
-
error(
|
|
5248
|
+
error(errorText(err));
|
|
4795
5249
|
errorLine();
|
|
4796
5250
|
process.exit(1);
|
|
4797
5251
|
}
|
|
@@ -4924,6 +5378,9 @@ async function main() {
|
|
|
4924
5378
|
}
|
|
4925
5379
|
catch (err) {
|
|
4926
5380
|
if (err instanceof Error) {
|
|
5381
|
+
// `errorText`, not `message`: a refused `localhost` is an
|
|
5382
|
+
// AggregateError with an empty message (see cli/error-text.ts).
|
|
5383
|
+
const message = errorText(err);
|
|
4927
5384
|
// TYPE first, message second. These branches sniff for substrings, and
|
|
4928
5385
|
// `ValidationError` beat them by accident: a refused `--schema` value says
|
|
4929
5386
|
// "used as a connection parameter", which contains "connection", so a bad
|
|
@@ -4931,35 +5388,35 @@ async function main() {
|
|
|
4931
5388
|
// firewall hints. A typed error already knows what it is.
|
|
4932
5389
|
if (err instanceof ValidationError) {
|
|
4933
5390
|
errorLine();
|
|
4934
|
-
error(redactUrl(
|
|
5391
|
+
error(redactUrl(message));
|
|
4935
5392
|
if (args.verbose && err.stack) {
|
|
4936
5393
|
errorLine();
|
|
4937
5394
|
console.error(dim(redactUrl(err.stack)));
|
|
4938
5395
|
}
|
|
4939
5396
|
}
|
|
4940
|
-
else if (
|
|
5397
|
+
else if (message.includes('ECONNREFUSED') || message.includes('connection')) {
|
|
4941
5398
|
errorLine();
|
|
4942
5399
|
error(`Could not connect to database`);
|
|
4943
|
-
errorLine(`${dim(redactUrl(
|
|
5400
|
+
errorLine(`${dim(redactUrl(message))}`);
|
|
4944
5401
|
errorLine();
|
|
4945
5402
|
errorLine(`${dim('Check that:')}`);
|
|
4946
5403
|
errorLine(` ${dim('1.')} Your database is running`);
|
|
4947
5404
|
errorLine(` ${dim('2.')} The connection string is correct`);
|
|
4948
5405
|
errorLine(` ${dim('3.')} Network/firewall allows the connection`);
|
|
4949
5406
|
}
|
|
4950
|
-
else if (
|
|
5407
|
+
else if (message.includes('authentication')) {
|
|
4951
5408
|
errorLine();
|
|
4952
5409
|
error(`Authentication failed`);
|
|
4953
|
-
errorLine(`${dim(redactUrl(
|
|
5410
|
+
errorLine(`${dim(redactUrl(message))}`);
|
|
4954
5411
|
}
|
|
4955
|
-
else if (
|
|
5412
|
+
else if (message.includes('does not exist')) {
|
|
4956
5413
|
errorLine();
|
|
4957
5414
|
error(`Database or schema not found`);
|
|
4958
|
-
errorLine(`${dim(redactUrl(
|
|
5415
|
+
errorLine(`${dim(redactUrl(message))}`);
|
|
4959
5416
|
}
|
|
4960
5417
|
else {
|
|
4961
5418
|
errorLine();
|
|
4962
|
-
error(redactUrl(
|
|
5419
|
+
error(redactUrl(message));
|
|
4963
5420
|
if (args.verbose && err.stack) {
|
|
4964
5421
|
errorLine();
|
|
4965
5422
|
console.error(dim(redactUrl(err.stack)));
|