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.
@@ -0,0 +1,450 @@
1
+ "use strict";
2
+ /**
3
+ * Index statistics - cost-aware triage for the missing-index advisor.
4
+ *
5
+ * `index-advisor.ts` decides WHICH relation probes lack an index from pure
6
+ * schema topology. This module decides whether adding each one is worth it,
7
+ * reading live Postgres statistics (table size, write volume, existing indexes,
8
+ * HOT-update ratio, column null fraction) and scoring every finding into a tier:
9
+ *
10
+ * - take-freely large table, low write rate, few existing indexes;
11
+ * - take-deliberately real write rate, many existing indexes, or HOT at risk;
12
+ * - scrutinize tiny table, append-only log shape, or stats too young.
13
+ *
14
+ * DESIGN: the file splits into two halves.
15
+ * - The COLLECTOR half (`collectStatsSnapshot`) reads pg catalogs. It uses a
16
+ * single one-connection pool, sets a statement_timeout, and treats every
17
+ * catalog read as INDIVIDUALLY OPTIONAL: a read that fails (privileges,
18
+ * CockroachDB/YugabyteDB catalog gaps, missing view) degrades that one
19
+ * signal and records a notice rather than aborting the whole snapshot.
20
+ * - The PURE half (`scoreMissingIndex`, `findInvalidIndexes`, and the exported
21
+ * threshold constants) takes a typed {@link StatsSnapshot} and imports NO pg.
22
+ * It is fully unit-testable with hand-built snapshots and is what carries the
23
+ * thresholds the CLI prints alongside every verdict.
24
+ *
25
+ * All statistics features are Postgres-only. On a non-Postgres engine the
26
+ * collector returns an unavailable snapshot and the caller falls back to the
27
+ * topology-only report.
28
+ */
29
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ var desc = Object.getOwnPropertyDescriptor(m, k);
32
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
33
+ desc = { enumerable: true, get: function() { return m[k]; } };
34
+ }
35
+ Object.defineProperty(o, k2, desc);
36
+ }) : (function(o, m, k, k2) {
37
+ if (k2 === undefined) k2 = k;
38
+ o[k2] = m[k];
39
+ }));
40
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
41
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
42
+ }) : function(o, v) {
43
+ o["default"] = v;
44
+ });
45
+ var __importStar = (this && this.__importStar) || (function () {
46
+ var ownKeys = function(o) {
47
+ ownKeys = Object.getOwnPropertyNames || function (o) {
48
+ var ar = [];
49
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
50
+ return ar;
51
+ };
52
+ return ownKeys(o);
53
+ };
54
+ return function (mod) {
55
+ if (mod && mod.__esModule) return mod;
56
+ var result = {};
57
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
58
+ __setModuleDefault(result, mod);
59
+ return result;
60
+ };
61
+ })();
62
+ Object.defineProperty(exports, "__esModule", { value: true });
63
+ exports.STATS_THRESHOLDS = void 0;
64
+ exports.emptyStatsSnapshot = emptyStatsSnapshot;
65
+ exports.formatBytes = formatBytes;
66
+ exports.scoreMissingIndex = scoreMissingIndex;
67
+ exports.findInvalidIndexes = findInvalidIndexes;
68
+ exports.isSnapshotUsable = isSnapshotUsable;
69
+ exports.collectStatsSnapshot = collectStatsSnapshot;
70
+ const index_advisor_js_1 = require("./index-advisor.js");
71
+ // ---------------------------------------------------------------------------
72
+ // Thresholds (exported + printed so a user can see exactly why a tier was chosen)
73
+ // ---------------------------------------------------------------------------
74
+ /**
75
+ * Heuristic thresholds behind the tier decision. Exported and surfaced in the
76
+ * doctor output so the reasoning is never a black box: if a threshold is wrong
77
+ * for a workload, the user can see the exact number that drove the verdict.
78
+ */
79
+ exports.STATS_THRESHOLDS = {
80
+ /** reltuples below this = tiny table; a missing index there is noise → scrutinize. */
81
+ tinyTableRows: 1_000,
82
+ /**
83
+ * Writes/day (n_tup_ins+upd+del normalized by stats age) at or above this is a
84
+ * "real" write rate: the per-index write tax is worth a second look → take deliberately.
85
+ */
86
+ highWritesPerDay: 50_000,
87
+ /**
88
+ * Existing index count at or above this: every extra index compounds the write
89
+ * and storage tax, so a new one is no longer free → take deliberately.
90
+ */
91
+ manyIndexes: 4,
92
+ /**
93
+ * HOT ratio (n_tup_hot_upd / n_tup_upd) at or above this, WITH real update
94
+ * volume, means the table currently relies on heap-only-tuple updates. A new
95
+ * index can disqualify HOT and amplify write cost → warning + take deliberately.
96
+ */
97
+ hotRatioAtRisk: 0.3,
98
+ /** Minimum n_tup_upd for the HOT signal to be statistically meaningful. */
99
+ hotMinUpdates: 1_000,
100
+ /**
101
+ * null_frac at or above this on a probed FK column: the column is mostly NULL,
102
+ * so a partial `WHERE col IS NOT NULL` index covers every relation probe at a
103
+ * fraction of the size.
104
+ */
105
+ partialNullFrac: 0.9,
106
+ /**
107
+ * Stats younger than this (days since stats_reset) cannot normalize a write
108
+ * rate; the whole report degrades to topology-only output.
109
+ */
110
+ minStatsAgeDays: 1,
111
+ /** Append-heavy log shape: n_tup_ins at or above this ... */
112
+ appendHeavyMinInserts: 100_000,
113
+ /** ... with inserts making up at or above this fraction of all writes ... */
114
+ appendHeavyInsertRatio: 0.95,
115
+ /** ... and seq_scan at or below this ("near-zero probe reads") → scrutinize. */
116
+ appendHeavyMaxSeqScan: 5,
117
+ };
118
+ /** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
119
+ function emptyStatsSnapshot(notices = []) {
120
+ return {
121
+ available: false,
122
+ statsReset: null,
123
+ statsAgeDays: null,
124
+ tables: {},
125
+ indexes: [],
126
+ nullFrac: {},
127
+ notices,
128
+ };
129
+ }
130
+ /** Format a byte count as a short human string (KB/MB/GB). */
131
+ function formatBytes(bytes) {
132
+ if (bytes == null || !Number.isFinite(bytes))
133
+ return 'size unknown';
134
+ if (bytes < 1024)
135
+ return `${bytes} B`;
136
+ const units = ['KB', 'MB', 'GB', 'TB'];
137
+ let value = bytes / 1024;
138
+ let unit = 0;
139
+ while (value >= 1024 && unit < units.length - 1) {
140
+ value /= 1024;
141
+ unit++;
142
+ }
143
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
144
+ }
145
+ function formatInt(n) {
146
+ return Math.round(n).toLocaleString('en-US');
147
+ }
148
+ /**
149
+ * Score a single missing-index finding against a snapshot. Pure and total:
150
+ * every unknown degrades to a caveat rather than a fabricated number.
151
+ */
152
+ function scoreMissingIndex(missing, snapshot) {
153
+ const t = exports.STATS_THRESHOLDS;
154
+ const stats = snapshot.tables[missing.table];
155
+ const probingRelations = missing.probes.length;
156
+ // reltuples 0/-1 = never analyzed → unknown, NOT tiny.
157
+ const rows = stats && stats.reltuples > 0 ? stats.reltuples : null;
158
+ const sizeBytes = stats?.totalSizeBytes ?? null;
159
+ const existingIndexCount = stats?.existingIndexCount ?? null;
160
+ const statsAgeDays = snapshot.statsAgeDays;
161
+ // Highest null_frac across the probed columns (single-column probes are the
162
+ // partial-index candidates; a composite FK is never rewritten to partial).
163
+ let nullFrac = null;
164
+ for (const col of missing.columns) {
165
+ const nf = snapshot.nullFrac[`${missing.table}.${col}`];
166
+ if (nf !== undefined)
167
+ nullFrac = nullFrac === null ? nf : Math.max(nullFrac, nf);
168
+ }
169
+ const partialNotNull = missing.columns.length === 1 && nullFrac !== null && nullFrac >= t.partialNullFrac;
170
+ // Write volume, normalized by stats age.
171
+ let writesPerDay = null;
172
+ if (stats &&
173
+ stats.nTupIns !== undefined &&
174
+ stats.nTupUpd !== undefined &&
175
+ stats.nTupDel !== undefined &&
176
+ statsAgeDays !== null &&
177
+ statsAgeDays > 0) {
178
+ writesPerDay = (stats.nTupIns + stats.nTupUpd + stats.nTupDel) / statsAgeDays;
179
+ }
180
+ // HOT ratio.
181
+ const hotRatio = stats && stats.nTupUpd !== undefined && stats.nTupUpd > 0 && stats.nTupHotUpd !== undefined
182
+ ? stats.nTupHotUpd / stats.nTupUpd
183
+ : null;
184
+ const hotAtRisk = hotRatio !== null &&
185
+ stats?.nTupUpd !== undefined &&
186
+ hotRatio >= t.hotRatioAtRisk &&
187
+ stats.nTupUpd >= t.hotMinUpdates;
188
+ const hotWarning = hotAtRisk
189
+ ? `${Math.round((hotRatio ?? 0) * 100)}% of updates are HOT (${formatInt(stats?.nTupUpd ?? 0)} updates since stats reset): a new index on this table can disable heap-only-tuple updates and amplify write cost`
190
+ : null;
191
+ const metrics = {
192
+ rows,
193
+ sizeBytes,
194
+ writesPerDay,
195
+ existingIndexCount,
196
+ probingRelations,
197
+ hotRatio,
198
+ nullFrac,
199
+ statsAgeDays,
200
+ };
201
+ const reasons = [];
202
+ // --- Scrutinize conditions (they win over everything) --------------------
203
+ const noStats = !stats;
204
+ const rowsUnknown = rows === null;
205
+ const tiny = rows !== null && rows < t.tinyTableRows;
206
+ const appendHeavy = stats?.nTupIns !== undefined &&
207
+ stats.nTupIns >= t.appendHeavyMinInserts &&
208
+ writesPerDay !== null &&
209
+ stats.nTupIns / Math.max(1, (stats.nTupIns ?? 0) + (stats.nTupUpd ?? 0) + (stats.nTupDel ?? 0)) >=
210
+ t.appendHeavyInsertRatio &&
211
+ (stats.seqScan ?? Number.POSITIVE_INFINITY) <= t.appendHeavyMaxSeqScan;
212
+ const rateUntrustable = writesPerDay === null;
213
+ let tier;
214
+ if (noStats) {
215
+ tier = 'scrutinize';
216
+ reasons.push('no statistics available for this table: cannot assess cost');
217
+ }
218
+ else if (rowsUnknown) {
219
+ tier = 'scrutinize';
220
+ reasons.push('row count unknown (table never analyzed): run ANALYZE, then re-check');
221
+ }
222
+ else if (tiny) {
223
+ tier = 'scrutinize';
224
+ reasons.push(`only ${formatInt(rows ?? 0)} rows (< ${formatInt(t.tinyTableRows)}): a sequential scan here is already cheap`);
225
+ }
226
+ else if (appendHeavy) {
227
+ tier = 'scrutinize';
228
+ reasons.push(`append-heavy: ${formatInt(stats?.nTupIns ?? 0)} inserts, near-zero reads (seq_scan ${formatInt(stats?.seqScan ?? 0)}): log-shaped table rarely benefits from a read index`);
229
+ }
230
+ else if (rateUntrustable) {
231
+ // Large table but the write rate cannot be normalized (young/NULL stats).
232
+ // Cannot claim take-freely without confirming a low write rate.
233
+ tier = 'scrutinize';
234
+ reasons.push(statsAgeDays === null
235
+ ? 'stats reset time unknown: cannot normalize the write rate to confirm this is safe'
236
+ : 'write counters unavailable: cannot confirm a low write rate');
237
+ if (rows !== null)
238
+ reasons.push(`${formatInt(rows)} rows, ${formatBytes(sizeBytes)}`);
239
+ }
240
+ else {
241
+ // --- Deliberate vs free ------------------------------------------------
242
+ const realWriteRate = writesPerDay !== null && writesPerDay >= t.highWritesPerDay;
243
+ const manyIndexes = existingIndexCount !== null && existingIndexCount >= t.manyIndexes;
244
+ if (realWriteRate || manyIndexes || hotAtRisk) {
245
+ tier = 'take-deliberately';
246
+ }
247
+ else {
248
+ tier = 'take-freely';
249
+ }
250
+ reasons.push(`${formatInt(rows ?? 0)} rows, ${formatBytes(sizeBytes)}`);
251
+ if (writesPerDay !== null) {
252
+ reasons.push(`~${formatInt(writesPerDay)} writes/day since stats reset${statsAgeDays !== null ? ` (${formatStatsAge(statsAgeDays)} ago)` : ''}${realWriteRate ? ` - at/above the ${formatInt(t.highWritesPerDay)}/day threshold` : ''}`);
253
+ }
254
+ if (existingIndexCount !== null) {
255
+ reasons.push(`${existingIndexCount} existing index(es)${manyIndexes ? ` - at/above the ${t.manyIndexes}-index threshold, each new index compounds write cost` : ''}`);
256
+ }
257
+ reasons.push(`probed by ${probingRelations} relation(s)`);
258
+ }
259
+ if (hotWarning)
260
+ reasons.push(hotWarning);
261
+ if (partialNotNull) {
262
+ reasons.push(`column is ${Math.round((nullFrac ?? 0) * 100)}% NULL: suggesting a partial "WHERE ${missing.columns[0]} IS NOT NULL" index (caveat: a user-written where: { ${missing.columns[0]}: null } filter will NOT use it)`);
263
+ }
264
+ // Benefit sort key: bigger, more-probed tables first. Unknown rows sort last.
265
+ const benefitScore = (rows ?? 0) * Math.max(1, probingRelations);
266
+ return {
267
+ table: missing.table,
268
+ columns: missing.columns,
269
+ tier,
270
+ reasons,
271
+ metrics,
272
+ hotWarning,
273
+ partialNotNull,
274
+ benefitScore,
275
+ };
276
+ }
277
+ function formatStatsAge(days) {
278
+ if (days < 1) {
279
+ const hours = Math.round(days * 24);
280
+ return `${hours}h`;
281
+ }
282
+ return `${Math.round(days)}d`;
283
+ }
284
+ /**
285
+ * Indexes left INVALID (indisvalid = false) - the failure artifact of a
286
+ * `CREATE INDEX CONCURRENTLY` that errored. `IF NOT EXISTS` on a retry silently
287
+ * skips the corpse, so the fix is DROP INDEX CONCURRENTLY then rerun.
288
+ */
289
+ function findInvalidIndexes(snapshot) {
290
+ return snapshot.indexes
291
+ .filter((idx) => !idx.isValid)
292
+ .map((idx) => ({
293
+ table: idx.table,
294
+ indexName: idx.indexName,
295
+ columns: idx.columns,
296
+ dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
297
+ }))
298
+ .sort((a, b) => a.indexName.localeCompare(b.indexName));
299
+ }
300
+ /**
301
+ * Whether the snapshot is trustworthy enough to render tier verdicts. Empty,
302
+ * unavailable, or too-young stats degrade to the topology-only report.
303
+ */
304
+ function isSnapshotUsable(snapshot) {
305
+ if (!snapshot.available)
306
+ return false;
307
+ if (snapshot.statsAgeDays === null || snapshot.statsAgeDays < exports.STATS_THRESHOLDS.minStatsAgeDays)
308
+ return false;
309
+ // At least one table must have a known row count for the size sort to mean anything.
310
+ return Object.values(snapshot.tables).some((s) => s.reltuples > 0);
311
+ }
312
+ /**
313
+ * Read a live statistics snapshot from Postgres. Each catalog read is wrapped
314
+ * individually: a failure records a notice and leaves that signal absent, so a
315
+ * privilege gap or a CockroachDB/YugabyteDB catalog difference degrades one
316
+ * signal rather than the whole snapshot.
317
+ */
318
+ async function collectStatsSnapshot(options) {
319
+ const timeout = options.statementTimeoutMs ?? 5000;
320
+ const notices = [];
321
+ const snapshot = emptyStatsSnapshot(notices);
322
+ const { Pool } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
323
+ const pool = new Pool({ connectionString: options.connectionString, max: 1 });
324
+ const run = async (label, text, values) => {
325
+ try {
326
+ const res = await pool.query(text, values);
327
+ return res.rows;
328
+ }
329
+ catch (err) {
330
+ notices.push(`${label} unavailable (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`);
331
+ return null;
332
+ }
333
+ };
334
+ try {
335
+ // statement_timeout is best-effort; if it fails the reads still run.
336
+ await run('statement_timeout', `SET statement_timeout = ${Number(timeout)}`);
337
+ // --- stats_reset / age --------------------------------------------------
338
+ const resetRows = await run('pg_stat_database.stats_reset', `SELECT stats_reset FROM pg_stat_database WHERE datname = current_database()`);
339
+ if (resetRows && resetRows.length > 0) {
340
+ const reset = resetRows[0].stats_reset;
341
+ snapshot.statsReset = reset ? new Date(reset) : null;
342
+ if (snapshot.statsReset) {
343
+ const ageMs = Date.now() - snapshot.statsReset.getTime();
344
+ snapshot.statsAgeDays = ageMs > 0 ? ageMs / 86_400_000 : null;
345
+ }
346
+ else {
347
+ notices.push('pg_stat_database.stats_reset is NULL (statistics never reset): write rates cannot be normalized');
348
+ }
349
+ }
350
+ // --- table stats (pg_stat_user_tables) ---------------------------------
351
+ const statRows = await run('pg_stat_user_tables', `SELECT relname, n_tup_ins, n_tup_upd, n_tup_del, n_tup_hot_upd, seq_scan, seq_tup_read, n_live_tup
352
+ FROM pg_stat_user_tables
353
+ WHERE schemaname = $1 AND relname = ANY($2)`, [options.schema, options.tables]);
354
+ // --- class size + existing index count (pg_class) ----------------------
355
+ const classRows = await run('pg_class size', `SELECT c.relname,
356
+ c.reltuples::bigint::text AS reltuples,
357
+ pg_total_relation_size(c.oid)::text AS total_size,
358
+ pg_relation_size(c.oid)::text AS table_size,
359
+ (SELECT count(*) FROM pg_index i WHERE i.indrelid = c.oid)::text AS index_count
360
+ FROM pg_class c
361
+ JOIN pg_namespace n ON n.oid = c.relnamespace
362
+ WHERE n.nspname = $1 AND c.relname = ANY($2)`, [options.schema, options.tables]);
363
+ // Merge table-level signals. A table appears in the snapshot if EITHER read
364
+ // returned it, so a missing pg_stat row still yields size info and vice versa.
365
+ const tableMap = snapshot.tables;
366
+ const ensure = (name) => {
367
+ let s = tableMap[name];
368
+ if (!s) {
369
+ s = { table: name, reltuples: 0 };
370
+ tableMap[name] = s;
371
+ }
372
+ return s;
373
+ };
374
+ if (classRows) {
375
+ snapshot.available = true;
376
+ for (const row of classRows) {
377
+ const s = ensure(row.relname);
378
+ s.reltuples = Number(row.reltuples);
379
+ s.totalSizeBytes = Number(row.total_size);
380
+ s.tableSizeBytes = Number(row.table_size);
381
+ s.existingIndexCount = Number(row.index_count);
382
+ }
383
+ }
384
+ if (statRows) {
385
+ snapshot.available = true;
386
+ for (const row of statRows) {
387
+ const s = ensure(row.relname);
388
+ s.nTupIns = Number(row.n_tup_ins);
389
+ s.nTupUpd = Number(row.n_tup_upd);
390
+ s.nTupDel = Number(row.n_tup_del);
391
+ s.nTupHotUpd = Number(row.n_tup_hot_upd);
392
+ s.seqScan = Number(row.seq_scan);
393
+ s.seqTupRead = Number(row.seq_tup_read);
394
+ s.nLiveTup = Number(row.n_live_tup);
395
+ }
396
+ }
397
+ // --- invalid + all indexes (whole schema, for invalid detection) -------
398
+ const indexRows = await run('pg_index', `SELECT c.relname AS table_name,
399
+ ic.relname AS index_name,
400
+ i.indisvalid, i.indisunique, i.indisprimary, i.indisreplident,
401
+ s.idx_scan::text AS idx_scan,
402
+ (SELECT array_agg(a.attname ORDER BY k.ord)
403
+ FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
404
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
405
+ FROM pg_index i
406
+ JOIN pg_class c ON c.oid = i.indrelid
407
+ JOIN pg_class ic ON ic.oid = i.indexrelid
408
+ JOIN pg_namespace n ON n.oid = c.relnamespace
409
+ LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.indexrelid
410
+ WHERE n.nspname = $1`, [options.schema]);
411
+ if (indexRows) {
412
+ for (const row of indexRows) {
413
+ snapshot.indexes.push({
414
+ table: row.table_name,
415
+ indexName: row.index_name,
416
+ columns: row.columns ?? [],
417
+ idxScan: row.idx_scan == null ? undefined : Number(row.idx_scan),
418
+ isValid: row.indisvalid,
419
+ isUnique: row.indisunique,
420
+ isPrimary: row.indisprimary,
421
+ isReplicaIdent: row.indisreplident,
422
+ });
423
+ }
424
+ }
425
+ // --- null_frac for probed columns (pg_stats) ---------------------------
426
+ if (options.columns.length > 0) {
427
+ const tablesArg = options.columns.map((c) => c.table);
428
+ const colsArg = options.columns.map((c) => c.column);
429
+ const nullRows = await run('pg_stats.null_frac', `SELECT s.tablename, s.attname, s.null_frac
430
+ FROM pg_stats s
431
+ JOIN unnest($2::text[], $3::text[]) AS probe(t, c)
432
+ ON probe.t = s.tablename AND probe.c = s.attname
433
+ WHERE s.schemaname = $1`, [options.schema, tablesArg, colsArg]);
434
+ if (nullRows) {
435
+ for (const row of nullRows) {
436
+ snapshot.nullFrac[`${row.tablename}.${row.attname}`] = Number(row.null_frac);
437
+ }
438
+ }
439
+ }
440
+ }
441
+ finally {
442
+ try {
443
+ await pool.end();
444
+ }
445
+ catch {
446
+ /* pool teardown is best-effort */
447
+ }
448
+ }
449
+ return snapshot;
450
+ }
@@ -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 Check relations for missing FK indexes (--fix emits migration)
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)
@@ -47,6 +47,10 @@ export interface CliArgs {
47
47
  /** `migrate create --recipe <name>` scaffold selector. */
48
48
  recipe?: string;
49
49
  fix?: boolean;
50
+ /** `doctor --json`: emit a stable, versioned machine-readable report. */
51
+ json?: boolean;
52
+ /** `doctor --fix --no-concurrently`: emit plain CREATE INDEX instead of the CONCURRENTLY + no-transaction form. */
53
+ noConcurrently?: boolean;
50
54
  /** `init --yes`/`-y`: accept every step's default non-interactively. */
51
55
  yes?: boolean;
52
56
  /** `init --skip-schema`: don't scaffold the schema file. */