turbine-orm 0.45.0 → 0.46.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/dist/cjs/cli/index.js +245 -52
- package/dist/cjs/cli/migrate.js +221 -16
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +450 -0
- package/dist/cli/index.d.ts +5 -1
- package/dist/cli/index.js +246 -53
- package/dist/cli/migrate.d.ts +41 -9
- package/dist/cli/migrate.js +220 -16
- package/dist/index-advisor.d.ts +30 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +208 -0
- package/dist/index-stats.js +408 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* turbine migrate status — Show migration status
|
|
15
15
|
* turbine seed — Run seed file
|
|
16
16
|
* turbine status — Show schema summary
|
|
17
|
-
* turbine doctor
|
|
17
|
+
* turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
|
|
18
18
|
* turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
|
|
19
19
|
* turbine mcp — Start read-only MCP server over JSON-RPC stdio
|
|
20
20
|
* turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
@@ -29,7 +29,8 @@ import { tmpdir } from 'node:os';
|
|
|
29
29
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
30
30
|
import { pathToFileURL } from 'node:url';
|
|
31
31
|
import { generate, generatePrismaMap } from '../generate.js';
|
|
32
|
-
import { findMissingRelationIndexes } from '../index-advisor.js';
|
|
32
|
+
import { buildCreateIndexSql, buildDropIndexSql, findMissingRelationIndexes, } from '../index-advisor.js';
|
|
33
|
+
import { collectStatsSnapshot, findInvalidIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
|
|
33
34
|
import { introspect } from '../introspect.js';
|
|
34
35
|
import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
35
36
|
import { configTemplate, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
|
|
@@ -121,6 +122,12 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
121
122
|
case '--fix':
|
|
122
123
|
result.fix = true;
|
|
123
124
|
break;
|
|
125
|
+
case '--json':
|
|
126
|
+
result.json = true;
|
|
127
|
+
break;
|
|
128
|
+
case '--no-concurrently':
|
|
129
|
+
result.noConcurrently = true;
|
|
130
|
+
break;
|
|
124
131
|
case '--zod':
|
|
125
132
|
result.zod = true;
|
|
126
133
|
break;
|
|
@@ -1528,12 +1535,24 @@ async function cmdMigrateUp(args, config) {
|
|
|
1528
1535
|
newline();
|
|
1529
1536
|
}
|
|
1530
1537
|
const spinner = new Spinner('Applying migrations').start();
|
|
1538
|
+
// A no-transaction migration (CREATE INDEX CONCURRENTLY) can wait a long time
|
|
1539
|
+
// on other transactions and otherwise looks hung. Stop the spinner and print
|
|
1540
|
+
// a loud notice the moment one is about to run.
|
|
1541
|
+
const onNoTransaction = (file) => {
|
|
1542
|
+
spinner.stop();
|
|
1543
|
+
warn(`Running ${bold(file.filename)} WITHOUT a transaction (-- turbine:no-transaction).`);
|
|
1544
|
+
console.log(` ${dim('Each statement runs on its own. A mid-file failure leaves earlier statements')}`);
|
|
1545
|
+
console.log(` ${dim('applied and the migration UNRECORDED, so every statement must be idempotent.')}`);
|
|
1546
|
+
console.log(` ${dim('CREATE INDEX CONCURRENTLY can wait on long-running transactions: not a hang.')}`);
|
|
1547
|
+
newline();
|
|
1548
|
+
};
|
|
1531
1549
|
let result;
|
|
1532
1550
|
try {
|
|
1533
1551
|
result = await migrateUp(url, config.migrationsDir, {
|
|
1534
1552
|
step: args.step,
|
|
1535
1553
|
allowDrift: args.allowDrift,
|
|
1536
1554
|
allowDestructive: args.allowDestructive,
|
|
1555
|
+
onNoTransaction,
|
|
1537
1556
|
});
|
|
1538
1557
|
}
|
|
1539
1558
|
catch (err) {
|
|
@@ -1550,6 +1569,7 @@ async function cmdMigrateUp(args, config) {
|
|
|
1550
1569
|
step: args.step,
|
|
1551
1570
|
allowDrift: args.allowDrift,
|
|
1552
1571
|
allowDestructive: true,
|
|
1572
|
+
onNoTransaction,
|
|
1553
1573
|
});
|
|
1554
1574
|
}
|
|
1555
1575
|
if (result.applied.length === 0 && result.errors.length === 0) {
|
|
@@ -1995,16 +2015,50 @@ async function cmdStatus(_args, config) {
|
|
|
1995
2015
|
newline();
|
|
1996
2016
|
}
|
|
1997
2017
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2018
|
+
/** Human labels + tier ordering for the three triage buckets. */
|
|
2019
|
+
const TIER_ORDER = ['take-freely', 'take-deliberately', 'scrutinize'];
|
|
2020
|
+
const TIER_LABEL = {
|
|
2021
|
+
'take-freely': 'TAKE FREELY',
|
|
2022
|
+
'take-deliberately': 'TAKE DELIBERATELY',
|
|
2023
|
+
scrutinize: 'SCRUTINIZE',
|
|
2024
|
+
};
|
|
2025
|
+
/** Build the `CREATE INDEX` for a finding, honoring the partial-null suggestion. */
|
|
2026
|
+
function doctorCreateSql(f, opts) {
|
|
2027
|
+
return buildCreateIndexSql(f.missing.table, f.missing.columns, f.missing.indexName, {
|
|
2028
|
+
concurrently: opts.concurrently,
|
|
2029
|
+
partialNotNull: f.score.partialNotNull,
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
/** The commented recipe prepended to a CONCURRENTLY fix migration's UP body. */
|
|
2033
|
+
const CONCURRENTLY_RECIPE_COMMENT = [
|
|
2034
|
+
'-- CREATE INDEX CONCURRENTLY builds without holding a write lock, but it cannot',
|
|
2035
|
+
'-- run inside a transaction (that is why this file carries the',
|
|
2036
|
+
'-- "-- turbine:no-transaction" directive above). Read before applying:',
|
|
2037
|
+
'--',
|
|
2038
|
+
'-- 1. Idempotency is required. This migration is recorded only after ALL',
|
|
2039
|
+
'-- statements succeed; a mid-file failure leaves earlier indexes built and',
|
|
2040
|
+
'-- the migration unrecorded, so a rerun must be safe. IF NOT EXISTS keeps',
|
|
2041
|
+
'-- each CREATE idempotent.',
|
|
2042
|
+
'-- 2. The INVALID-index trap. A CREATE INDEX CONCURRENTLY that fails partway',
|
|
2043
|
+
'-- leaves an INVALID index behind. On rerun, IF NOT EXISTS SKIPS that',
|
|
2044
|
+
'-- corpse (the name already exists), so the index is never actually built.',
|
|
2045
|
+
'-- Fix: DROP INDEX CONCURRENTLY the invalid index, then rerun. Run',
|
|
2046
|
+
'-- "turbine doctor" to list invalid indexes.',
|
|
2047
|
+
'-- 3. Locking. CREATE INDEX CONCURRENTLY waits for every transaction that can',
|
|
2048
|
+
'-- see the table to finish. A long-running transaction makes it wait and',
|
|
2049
|
+
'-- makes "migrate up" look hung. For bounded waits, SET lock_timeout /',
|
|
2050
|
+
'-- statement_timeout in a psql session.',
|
|
2051
|
+
].join('\n');
|
|
2001
2052
|
async function cmdDoctor(args, config) {
|
|
2002
|
-
|
|
2053
|
+
const jsonMode = args.json === true;
|
|
2003
2054
|
const url = requireUrl(config);
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2055
|
+
if (!jsonMode) {
|
|
2056
|
+
banner();
|
|
2057
|
+
label('Database', redactUrl(url));
|
|
2058
|
+
label('Schema', config.schema);
|
|
2059
|
+
newline();
|
|
2060
|
+
}
|
|
2061
|
+
const spinner = jsonMode ? null : new Spinner('Introspecting database').start();
|
|
2008
2062
|
const schema = await introspect({
|
|
2009
2063
|
connectionString: url,
|
|
2010
2064
|
schema: config.schema,
|
|
@@ -2012,62 +2066,201 @@ async function cmdDoctor(args, config) {
|
|
|
2012
2066
|
exclude: config.exclude.length ? config.exclude : undefined,
|
|
2013
2067
|
});
|
|
2014
2068
|
const missing = findMissingRelationIndexes(schema);
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2069
|
+
// Collect live statistics. The collector reads whole-schema indexes (for
|
|
2070
|
+
// invalid-index detection) plus per-table stats + probed-column null_frac.
|
|
2071
|
+
const probedTables = [...new Set(missing.map((m) => m.table))];
|
|
2072
|
+
const probedColumns = [];
|
|
2073
|
+
for (const m of missing) {
|
|
2074
|
+
if (m.columns.length === 1 && m.columns[0] !== undefined) {
|
|
2075
|
+
probedColumns.push({ table: m.table, column: m.columns[0] });
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
let snapshot;
|
|
2079
|
+
try {
|
|
2080
|
+
snapshot = await collectStatsSnapshot({
|
|
2081
|
+
connectionString: url,
|
|
2082
|
+
schema: config.schema,
|
|
2083
|
+
tables: probedTables,
|
|
2084
|
+
columns: probedColumns,
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
catch (err) {
|
|
2088
|
+
snapshot = {
|
|
2089
|
+
available: false,
|
|
2090
|
+
statsReset: null,
|
|
2091
|
+
statsAgeDays: null,
|
|
2092
|
+
tables: {},
|
|
2093
|
+
indexes: [],
|
|
2094
|
+
nullFrac: {},
|
|
2095
|
+
notices: [`statistics collection failed: ${err instanceof Error ? err.message : String(err)}`],
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
const invalid = findInvalidIndexes(snapshot);
|
|
2099
|
+
const usable = isSnapshotUsable(snapshot);
|
|
2100
|
+
const findings = missing.map((m) => ({ missing: m, score: scoreMissingIndex(m, snapshot) }));
|
|
2101
|
+
if (jsonMode) {
|
|
2102
|
+
spinner?.stop();
|
|
2103
|
+
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, args }), null, 2));
|
|
2018
2104
|
return;
|
|
2019
2105
|
}
|
|
2106
|
+
await renderDoctorHuman({ spinner: spinner, schema, findings, invalid, snapshot, usable, args, config });
|
|
2107
|
+
}
|
|
2108
|
+
/** The stable, versioned JSON contract (schemaVersion: 1). First external consumer: BataDB import. */
|
|
2109
|
+
function buildDoctorJson(ctx) {
|
|
2110
|
+
const concurrently = ctx.args.noConcurrently !== true;
|
|
2111
|
+
return {
|
|
2112
|
+
schemaVersion: 1,
|
|
2113
|
+
scannedTables: Object.keys(ctx.schema.tables).length,
|
|
2114
|
+
stats: {
|
|
2115
|
+
available: ctx.snapshot.available,
|
|
2116
|
+
usable: ctx.usable,
|
|
2117
|
+
statsReset: ctx.snapshot.statsReset ? ctx.snapshot.statsReset.toISOString() : null,
|
|
2118
|
+
statsAgeDays: ctx.snapshot.statsAgeDays,
|
|
2119
|
+
notices: ctx.snapshot.notices,
|
|
2120
|
+
},
|
|
2121
|
+
thresholds: STATS_THRESHOLDS,
|
|
2122
|
+
findings: ctx.findings.map((f) => ({
|
|
2123
|
+
table: f.missing.table,
|
|
2124
|
+
columns: f.missing.columns,
|
|
2125
|
+
indexName: f.missing.indexName,
|
|
2126
|
+
tier: f.score.tier,
|
|
2127
|
+
reasons: f.score.reasons,
|
|
2128
|
+
metrics: f.score.metrics,
|
|
2129
|
+
hotWarning: f.score.hotWarning,
|
|
2130
|
+
partialNotNull: f.score.partialNotNull,
|
|
2131
|
+
probes: f.missing.probes,
|
|
2132
|
+
createSql: doctorCreateSql(f, { concurrently }),
|
|
2133
|
+
dropSql: buildDropIndexSql(f.missing.indexName, { concurrently }),
|
|
2134
|
+
})),
|
|
2135
|
+
invalidIndexes: ctx.invalid,
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
async function renderDoctorHuman(ctx) {
|
|
2139
|
+
const { spinner, schema, findings, invalid, snapshot, usable, args, config } = ctx;
|
|
2020
2140
|
spinner.succeed(`Scanned ${bold(String(Object.keys(schema.tables).length))} tables`);
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
{
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
WHERE n.nspname = $1 AND c.relname = ANY($2)`, [config.schema, tables]);
|
|
2035
|
-
for (const row of res.rows)
|
|
2036
|
-
rowCounts.set(row.relname, Math.max(0, Number(row.reltuples)));
|
|
2141
|
+
if (findings.length === 0 && invalid.length === 0) {
|
|
2142
|
+
success('Every relation probe is backed by an index, and no invalid indexes were found');
|
|
2143
|
+
newline();
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (findings.length > 0) {
|
|
2147
|
+
warn(`Found ${bold(String(findings.length))} unindexed relation probe(s)`);
|
|
2148
|
+
newline();
|
|
2149
|
+
console.log(` ${dim('Turbine loads relations as correlated subqueries: the child table is probed')}`);
|
|
2150
|
+
console.log(` ${dim('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
|
|
2151
|
+
newline();
|
|
2152
|
+
if (usable) {
|
|
2153
|
+
renderTiers(findings, snapshot, args);
|
|
2037
2154
|
}
|
|
2038
|
-
|
|
2039
|
-
|
|
2155
|
+
else {
|
|
2156
|
+
renderTopologyFallback(findings, snapshot);
|
|
2040
2157
|
}
|
|
2041
2158
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
for (const m of missing) {
|
|
2047
|
-
const rows = rowCounts.get(m.table);
|
|
2048
|
-
const rowsLabel = rows !== undefined ? `~${rows.toLocaleString()} rows` : 'row count unknown';
|
|
2049
|
-
console.log(` ${yellow(symbols.warning)} ${bold(cyan(m.table))} ${dim(`(${m.columns.join(', ')})`)} ${gray(rowsLabel)}`);
|
|
2050
|
-
for (const p of m.probes) {
|
|
2051
|
-
console.log(` ${dim(symbols.tee)} probed by ${p.from}.${blue(p.relation)} ${dim(`(${p.type})`)}`);
|
|
2159
|
+
renderInvalidIndexes(invalid);
|
|
2160
|
+
if (findings.length > 0) {
|
|
2161
|
+
if (args.fix) {
|
|
2162
|
+
renderFixMigration(findings, config, args);
|
|
2052
2163
|
}
|
|
2053
|
-
|
|
2164
|
+
else {
|
|
2165
|
+
console.log(` ${dim('Generate a fix migration with:')} ${cyan('npx turbine doctor --fix')}`);
|
|
2166
|
+
console.log(` ${dim('Machine-readable report:')} ${cyan('npx turbine doctor --json')}`);
|
|
2167
|
+
newline();
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
/** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
|
|
2172
|
+
function renderTiers(findings, snapshot, _args) {
|
|
2173
|
+
const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
|
|
2174
|
+
console.log(` ${dim(`Cost triage based on live statistics (stats reset ${ageLabel} ago). Thresholds: tiny < ${STATS_THRESHOLDS.tinyTableRows.toLocaleString()} rows,`)}`);
|
|
2175
|
+
console.log(` ${dim(`"real" write rate >= ${STATS_THRESHOLDS.highWritesPerDay.toLocaleString()}/day, "many" indexes >= ${STATS_THRESHOLDS.manyIndexes}.`)}`);
|
|
2176
|
+
newline();
|
|
2177
|
+
const tierColor = {
|
|
2178
|
+
'take-freely': green,
|
|
2179
|
+
'take-deliberately': yellow,
|
|
2180
|
+
scrutinize: red,
|
|
2181
|
+
};
|
|
2182
|
+
for (const tier of TIER_ORDER) {
|
|
2183
|
+
const inTier = findings
|
|
2184
|
+
.filter((f) => f.score.tier === tier)
|
|
2185
|
+
.sort((a, b) => b.score.benefitScore - a.score.benefitScore);
|
|
2186
|
+
if (inTier.length === 0)
|
|
2187
|
+
continue;
|
|
2188
|
+
console.log(` ${bold(tierColor[tier](`${TIER_LABEL[tier]} (${inTier.length})`))}`);
|
|
2054
2189
|
newline();
|
|
2190
|
+
for (const f of inTier) {
|
|
2191
|
+
renderFinding(f, { concurrently: true, withReasons: true });
|
|
2192
|
+
}
|
|
2055
2193
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2194
|
+
}
|
|
2195
|
+
/** Degraded output: today's size-sorted topology report when stats are absent/young. */
|
|
2196
|
+
function renderTopologyFallback(findings, snapshot) {
|
|
2197
|
+
warn('Statistics unavailable or too young to score cost: showing size-sorted topology only.');
|
|
2198
|
+
for (const notice of snapshot.notices)
|
|
2199
|
+
console.log(` ${dim(`- ${notice}`)}`);
|
|
2200
|
+
if (snapshot.statsAgeDays !== null && snapshot.statsAgeDays < STATS_THRESHOLDS.minStatsAgeDays) {
|
|
2201
|
+
console.log(` ${dim(`- statistics were reset less than ${STATS_THRESHOLDS.minStatsAgeDays} day(s) ago; write rates are not yet meaningful.`)}`);
|
|
2202
|
+
}
|
|
2203
|
+
newline();
|
|
2204
|
+
const sorted = [...findings].sort((a, b) => (b.score.metrics.rows ?? 0) - (a.score.metrics.rows ?? 0));
|
|
2205
|
+
for (const f of sorted) {
|
|
2206
|
+
renderFinding(f, { concurrently: false, withReasons: false });
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
/** Render one finding: table + columns, probing relations, reasons, and the create SQL. */
|
|
2210
|
+
function renderFinding(f, opts) {
|
|
2211
|
+
const m = f.missing;
|
|
2212
|
+
const rows = f.score.metrics.rows;
|
|
2213
|
+
const rowsLabel = rows !== null ? `~${rows.toLocaleString()} rows` : 'row count unknown';
|
|
2214
|
+
const sizeLabel = f.score.metrics.sizeBytes !== null ? `, ${formatBytes(f.score.metrics.sizeBytes)}` : '';
|
|
2215
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(m.table))} ${dim(`(${m.columns.join(', ')})`)} ${gray(`${rowsLabel}${sizeLabel}`)}`);
|
|
2216
|
+
for (const p of m.probes) {
|
|
2217
|
+
console.log(` ${dim(symbols.tee)} probed by ${p.from}.${blue(p.relation)} ${dim(`(${p.type})`)}`);
|
|
2218
|
+
}
|
|
2219
|
+
if (opts.withReasons) {
|
|
2220
|
+
for (const reason of f.score.reasons) {
|
|
2221
|
+
console.log(` ${dim(symbols.tee)} ${dim(reason)}`);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
|
|
2225
|
+
newline();
|
|
2226
|
+
}
|
|
2227
|
+
/** The invalid-index report section (a failed CONCURRENTLY build leaves these behind). */
|
|
2228
|
+
function renderInvalidIndexes(invalid) {
|
|
2229
|
+
if (invalid.length === 0)
|
|
2230
|
+
return;
|
|
2231
|
+
warn(`Found ${bold(String(invalid.length))} INVALID index(es) (a failed CREATE INDEX CONCURRENTLY leaves these).`);
|
|
2232
|
+
console.log(` ${dim('IF NOT EXISTS skips an invalid index on rerun, so it never rebuilds. Drop it, then rerun:')}`);
|
|
2233
|
+
newline();
|
|
2234
|
+
for (const idx of invalid) {
|
|
2235
|
+
console.log(` ${red(symbols.warning)} ${bold(cyan(idx.table))} ${dim(`(${idx.columns.join(', ') || '?'})`)} ${gray(idx.indexName)}`);
|
|
2236
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(idx.dropSql)}`);
|
|
2061
2237
|
newline();
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
/** Write the --fix migration (CONCURRENTLY + directive by default; plain with --no-concurrently). */
|
|
2241
|
+
function renderFixMigration(findings, config, args) {
|
|
2242
|
+
const concurrently = args.noConcurrently !== true;
|
|
2243
|
+
const up = findings.map((f) => doctorCreateSql(f, { concurrently })).join('\n');
|
|
2244
|
+
const down = findings.map((f) => buildDropIndexSql(f.missing.indexName, { concurrently })).join('\n');
|
|
2245
|
+
let file;
|
|
2246
|
+
if (concurrently) {
|
|
2247
|
+
file = createMigration(config.migrationsDir, 'add_relation_fk_indexes', { up: `${CONCURRENTLY_RECIPE_COMMENT}\n\n${up}`, down }, { header: '-- turbine:no-transaction' });
|
|
2248
|
+
}
|
|
2249
|
+
else {
|
|
2250
|
+
file = createMigration(config.migrationsDir, 'add_relation_fk_indexes', { up, down });
|
|
2251
|
+
}
|
|
2252
|
+
success(`Created migration: ${bold(file.filename)}`);
|
|
2253
|
+
newline();
|
|
2254
|
+
if (concurrently) {
|
|
2255
|
+
console.log(` ${dim('This is a')} ${cyan('no-transaction')} ${dim('migration (CREATE INDEX CONCURRENTLY).')}`);
|
|
2062
2256
|
console.log(` ${dim('Review it, then apply with:')} ${cyan('npx turbine migrate up')}`);
|
|
2063
|
-
console.log(` ${dim('
|
|
2064
|
-
console.log(` ${dim('(cannot run inside a transaction, so it is not emitted in the migration).')}`);
|
|
2065
|
-
newline();
|
|
2257
|
+
console.log(` ${dim('For a plain in-transaction migration instead, use')} ${cyan('doctor --fix --no-concurrently')}`);
|
|
2066
2258
|
}
|
|
2067
2259
|
else {
|
|
2068
|
-
console.log(` ${dim('
|
|
2069
|
-
|
|
2260
|
+
console.log(` ${dim('Review it, then apply with:')} ${cyan('npx turbine migrate up')}`);
|
|
2261
|
+
console.log(` ${dim('For large, hot tables prefer the CONCURRENTLY form (drop')} ${cyan('--no-concurrently')}${dim(').')}`);
|
|
2070
2262
|
}
|
|
2263
|
+
newline();
|
|
2071
2264
|
}
|
|
2072
2265
|
// ---------------------------------------------------------------------------
|
|
2073
2266
|
// Loopback host gate (Studio / Observe)
|
|
@@ -2499,7 +2692,7 @@ function showHelp() {
|
|
|
2499
2692
|
console.log(` ${dim('status')} Show applied/pending migrations`);
|
|
2500
2693
|
console.log(` ${cyan('seed')} Run seed file`);
|
|
2501
2694
|
console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
|
|
2502
|
-
console.log(` ${cyan('doctor')}
|
|
2695
|
+
console.log(` ${cyan('doctor')} Cost-aware missing-FK-index triage ${dim('(--fix, --json, --no-concurrently)')}`);
|
|
2503
2696
|
console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write for writes, --demo for a sample DB)')}`);
|
|
2504
2697
|
console.log(` ${cyan('mcp')} Start read-only MCP server over stdio`);
|
|
2505
2698
|
console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -68,6 +68,13 @@ export interface MigrationRunResult {
|
|
|
68
68
|
destructive: DestructiveOffender[];
|
|
69
69
|
/** Migrations applied with a timestamp older than an already-applied one. */
|
|
70
70
|
outOfOrder: OutOfOrderApply[];
|
|
71
|
+
/**
|
|
72
|
+
* Migrations applied WITHOUT a transaction (they carried the
|
|
73
|
+
* `-- turbine:no-transaction` directive). The CLI prints a loud notice for
|
|
74
|
+
* each: a mid-file failure leaves earlier statements applied and the migration
|
|
75
|
+
* unrecorded, so every statement in one of these must be idempotent.
|
|
76
|
+
*/
|
|
77
|
+
noTransaction: MigrationFile[];
|
|
71
78
|
}
|
|
72
79
|
/** Extract the YYYYMMDDHHMMSS timestamp prefix from a migration name, or null. */
|
|
73
80
|
export declare function migrationTimestamp(name: string): string | null;
|
|
@@ -95,21 +102,36 @@ export declare function getPendingMigrations(migrationsDir: string, applied: str
|
|
|
95
102
|
* List all migration files in the migrations directory, sorted by name.
|
|
96
103
|
*/
|
|
97
104
|
export declare function listMigrationFiles(migrationsDir: string): MigrationFile[];
|
|
105
|
+
/** The parsed sections of a migration file plus its execution directives. */
|
|
106
|
+
export interface ParsedMigration {
|
|
107
|
+
up: string;
|
|
108
|
+
down: string;
|
|
109
|
+
/** True when the `-- turbine:no-transaction` directive is present in the header. */
|
|
110
|
+
noTransaction: boolean;
|
|
111
|
+
}
|
|
98
112
|
/**
|
|
99
|
-
* Parse migration content string into UP and DOWN sections.
|
|
113
|
+
* Parse migration content string into UP and DOWN sections plus directives.
|
|
100
114
|
* Exported for unit testing.
|
|
101
115
|
*/
|
|
102
|
-
export declare function parseMigrationContent(content: string):
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
export declare function parseMigrationContent(content: string): ParsedMigration;
|
|
117
|
+
/**
|
|
118
|
+
* Split a SQL script into individual statements on top-level semicolons.
|
|
119
|
+
*
|
|
120
|
+
* A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
|
|
121
|
+
* string, a double-quoted identifier, a dollar-quoted body, a line comment
|
|
122
|
+
* (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
|
|
123
|
+
* split. This is the one production-destroying failure mode of no-transaction
|
|
124
|
+
* migrations (a partial statement executed against production), so the behavior
|
|
125
|
+
* is pinned by exhaustive unit tests.
|
|
126
|
+
*
|
|
127
|
+
* Comment-only fragments are dropped; every returned statement is trimmed and
|
|
128
|
+
* carries no trailing semicolon.
|
|
129
|
+
*/
|
|
130
|
+
export declare function splitSqlStatements(sql: string): string[];
|
|
106
131
|
/**
|
|
107
132
|
* Parse a migration file into UP and DOWN sections.
|
|
108
133
|
*/
|
|
109
|
-
export declare function parseMigrationSQL(filePath: string):
|
|
110
|
-
up: string;
|
|
111
|
-
down: string;
|
|
112
|
-
};
|
|
134
|
+
export declare function parseMigrationSQL(filePath: string): ParsedMigration;
|
|
113
135
|
/**
|
|
114
136
|
* A named migration scaffold. `build()` returns the commented-SQL UP/DOWN body
|
|
115
137
|
* for the recipe; `createMigration({ recipe })` wraps it in the file header.
|
|
@@ -166,12 +188,16 @@ export declare function buildDiffMigrationBody(diff: {
|
|
|
166
188
|
* - `autoContent`: pre-populate UP/DOWN from a schema diff.
|
|
167
189
|
* - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
|
|
168
190
|
* Mutually exclusive with `autoContent`; an unknown recipe throws.
|
|
191
|
+
* - `options.header`: extra header line(s) injected BEFORE `-- UP` (the only
|
|
192
|
+
* place a `-- turbine:no-transaction` directive is honored). Used with
|
|
193
|
+
* `autoContent`.
|
|
169
194
|
*/
|
|
170
195
|
export declare function createMigration(migrationsDir: string, name: string, autoContent?: {
|
|
171
196
|
up: string;
|
|
172
197
|
down: string;
|
|
173
198
|
}, options?: {
|
|
174
199
|
recipe?: string;
|
|
200
|
+
header?: string;
|
|
175
201
|
}): MigrationFile;
|
|
176
202
|
/**
|
|
177
203
|
* Derive a Postgres advisory lock ID (positive int4) from the database name.
|
|
@@ -235,6 +261,12 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
|
|
|
235
261
|
allowDestructive?: boolean;
|
|
236
262
|
adapter?: DatabaseAdapter;
|
|
237
263
|
dialect?: Dialect;
|
|
264
|
+
/**
|
|
265
|
+
* Called right before a `-- turbine:no-transaction` migration runs, so the
|
|
266
|
+
* CLI can print its loud pre-run notice (a concurrent index build can wait a
|
|
267
|
+
* long time on other transactions and otherwise looks hung).
|
|
268
|
+
*/
|
|
269
|
+
onNoTransaction?: (file: MigrationFile) => void;
|
|
238
270
|
}): Promise<MigrationRunResult>;
|
|
239
271
|
/**
|
|
240
272
|
* Production migration apply. This intentionally applies files as written and
|