turbine-orm 0.46.0 → 0.47.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 +162 -10
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +219 -3
- package/dist/cjs/index.js +6 -2
- package/dist/cjs/observe.js +113 -27
- package/dist/cli/index.d.ts +9 -1
- package/dist/cli/index.js +164 -12
- package/dist/index-advisor.d.ts +19 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +127 -2
- package/dist/index-stats.js +215 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -0
- package/dist/observe.d.ts +88 -7
- package/dist/observe.js +110 -26
- package/package.json +1 -1
package/dist/cjs/cli/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* turbine migrate status — Show migration status
|
|
16
16
|
* turbine seed — Run seed file
|
|
17
17
|
* turbine status — Show schema summary
|
|
18
|
-
* turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
|
|
18
|
+
* turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
|
|
19
19
|
* turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
|
|
20
20
|
* turbine mcp — Start read-only MCP server over JSON-RPC stdio
|
|
21
21
|
* turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
@@ -171,6 +171,20 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
171
171
|
case '--no-concurrently':
|
|
172
172
|
result.noConcurrently = true;
|
|
173
173
|
break;
|
|
174
|
+
case '--unused':
|
|
175
|
+
result.unused = true;
|
|
176
|
+
break;
|
|
177
|
+
case '--audit':
|
|
178
|
+
result.audit = true;
|
|
179
|
+
break;
|
|
180
|
+
case '--min-scans':
|
|
181
|
+
result.minScans = next ? Number.parseInt(next, 10) : undefined;
|
|
182
|
+
i++;
|
|
183
|
+
break;
|
|
184
|
+
case '--metrics-url':
|
|
185
|
+
result.metricsUrl = next;
|
|
186
|
+
i++;
|
|
187
|
+
break;
|
|
174
188
|
case '--zod':
|
|
175
189
|
result.zod = true;
|
|
176
190
|
break;
|
|
@@ -2138,20 +2152,64 @@ async function cmdDoctor(args, config) {
|
|
|
2138
2152
|
notices: [`statistics collection failed: ${err instanceof Error ? err.message : String(err)}`],
|
|
2139
2153
|
};
|
|
2140
2154
|
}
|
|
2155
|
+
// Table-heat boost: read _turbine_metrics (app DB or --metrics-url) and use
|
|
2156
|
+
// per-model heat as an extra benefit signal. Best-effort; a missing table just
|
|
2157
|
+
// means "heat boosting unavailable" and the triage continues without it.
|
|
2158
|
+
const heat = probedTables.length > 0
|
|
2159
|
+
? await collectTableHeatSafe(args.metricsUrl ?? url, probedTables)
|
|
2160
|
+
: { available: false, tables: {}, notice: null };
|
|
2141
2161
|
const invalid = (0, index_stats_js_1.findInvalidIndexes)(snapshot);
|
|
2142
2162
|
const usable = (0, index_stats_js_1.isSnapshotUsable)(snapshot);
|
|
2143
|
-
const findings = missing.map((m) => ({
|
|
2163
|
+
const findings = missing.map((m) => ({
|
|
2164
|
+
missing: m,
|
|
2165
|
+
score: (0, index_stats_js_1.scoreMissingIndex)(m, snapshot, heat.tables[m.table]),
|
|
2166
|
+
}));
|
|
2167
|
+
// "doctor learns to subtract": report-only drop suggestions, never a migration.
|
|
2168
|
+
const unusedRan = args.unused === true;
|
|
2169
|
+
const auditRan = args.audit === true;
|
|
2170
|
+
const minScans = args.minScans;
|
|
2171
|
+
const unused = unusedRan ? (0, index_stats_js_1.findUnusedIndexes)(snapshot, { minScans }) : [];
|
|
2172
|
+
const redundant = unusedRan ? (0, index_stats_js_1.findRedundantIndexes)(snapshot) : [];
|
|
2173
|
+
const audit = auditRan ? (0, index_stats_js_1.auditDoctorIndexes)(snapshot, (0, index_advisor_js_1.collectDoctorProbeIndexNames)(schema), { minScans }) : [];
|
|
2174
|
+
const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
|
|
2144
2175
|
if (jsonMode) {
|
|
2145
2176
|
spinner?.stop();
|
|
2146
|
-
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, args }), null, 2));
|
|
2177
|
+
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
|
|
2147
2178
|
return;
|
|
2148
2179
|
}
|
|
2149
|
-
await renderDoctorHuman({
|
|
2180
|
+
await renderDoctorHuman({
|
|
2181
|
+
spinner: spinner,
|
|
2182
|
+
schema,
|
|
2183
|
+
findings,
|
|
2184
|
+
invalid,
|
|
2185
|
+
snapshot,
|
|
2186
|
+
usable,
|
|
2187
|
+
heat,
|
|
2188
|
+
subtract,
|
|
2189
|
+
args,
|
|
2190
|
+
config,
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
/** Best-effort table-heat read: any failure degrades to unavailable, never throws. */
|
|
2194
|
+
async function collectTableHeatSafe(connectionString, models) {
|
|
2195
|
+
try {
|
|
2196
|
+
return await (0, index_stats_js_1.collectTableHeat)({ connectionString, models });
|
|
2197
|
+
}
|
|
2198
|
+
catch (err) {
|
|
2199
|
+
return {
|
|
2200
|
+
available: false,
|
|
2201
|
+
tables: {},
|
|
2202
|
+
notice: `heat boosting is unavailable (reading _turbine_metrics failed: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}).`,
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2150
2205
|
}
|
|
2151
|
-
/**
|
|
2206
|
+
/**
|
|
2207
|
+
* The stable, versioned JSON contract (schemaVersion: 1). Fields are only ever
|
|
2208
|
+
* added, never removed or repurposed, so a parser never breaks on an upgrade.
|
|
2209
|
+
*/
|
|
2152
2210
|
function buildDoctorJson(ctx) {
|
|
2153
2211
|
const concurrently = ctx.args.noConcurrently !== true;
|
|
2154
|
-
|
|
2212
|
+
const out = {
|
|
2155
2213
|
schemaVersion: 1,
|
|
2156
2214
|
scannedTables: Object.keys(ctx.schema.tables).length,
|
|
2157
2215
|
stats: {
|
|
@@ -2161,6 +2219,10 @@ function buildDoctorJson(ctx) {
|
|
|
2161
2219
|
statsAgeDays: ctx.snapshot.statsAgeDays,
|
|
2162
2220
|
notices: ctx.snapshot.notices,
|
|
2163
2221
|
},
|
|
2222
|
+
heat: {
|
|
2223
|
+
available: ctx.heat.available,
|
|
2224
|
+
notice: ctx.heat.notice,
|
|
2225
|
+
},
|
|
2164
2226
|
thresholds: index_stats_js_1.STATS_THRESHOLDS,
|
|
2165
2227
|
findings: ctx.findings.map((f) => ({
|
|
2166
2228
|
table: f.missing.table,
|
|
@@ -2171,18 +2233,37 @@ function buildDoctorJson(ctx) {
|
|
|
2171
2233
|
metrics: f.score.metrics,
|
|
2172
2234
|
hotWarning: f.score.hotWarning,
|
|
2173
2235
|
partialNotNull: f.score.partialNotNull,
|
|
2236
|
+
heatBoosted: f.score.heatBoosted,
|
|
2174
2237
|
probes: f.missing.probes,
|
|
2175
2238
|
createSql: doctorCreateSql(f, { concurrently }),
|
|
2176
2239
|
dropSql: (0, index_advisor_js_1.buildDropIndexSql)(f.missing.indexName, { concurrently }),
|
|
2177
2240
|
})),
|
|
2178
2241
|
invalidIndexes: ctx.invalid,
|
|
2179
2242
|
};
|
|
2243
|
+
// Additive: the drop-suggestion arrays appear only when --unused / --audit ran.
|
|
2244
|
+
if (ctx.subtract.unusedRan) {
|
|
2245
|
+
out.unused = ctx.subtract.unused;
|
|
2246
|
+
out.redundant = ctx.subtract.redundant;
|
|
2247
|
+
out.invalid = ctx.invalid;
|
|
2248
|
+
}
|
|
2249
|
+
if (ctx.subtract.auditRan) {
|
|
2250
|
+
out.audit = ctx.subtract.audit;
|
|
2251
|
+
}
|
|
2252
|
+
return out;
|
|
2180
2253
|
}
|
|
2181
2254
|
async function renderDoctorHuman(ctx) {
|
|
2182
|
-
const { spinner, schema, findings, invalid, snapshot, usable, args, config } = ctx;
|
|
2255
|
+
const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
|
|
2183
2256
|
spinner.succeed(`Scanned ${(0, ui_js_1.bold)(String(Object.keys(schema.tables).length))} tables`);
|
|
2184
|
-
|
|
2185
|
-
|
|
2257
|
+
const subtractRan = subtract.unusedRan || subtract.auditRan;
|
|
2258
|
+
const nothingToAdd = findings.length === 0 && invalid.length === 0;
|
|
2259
|
+
const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
|
|
2260
|
+
if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
|
|
2261
|
+
if (subtractRan) {
|
|
2262
|
+
(0, ui_js_1.success)('No never-scanned or redundant indexes found, and no invalid indexes were found');
|
|
2263
|
+
}
|
|
2264
|
+
else {
|
|
2265
|
+
(0, ui_js_1.success)('Every relation probe is backed by an index, and no invalid indexes were found');
|
|
2266
|
+
}
|
|
2186
2267
|
(0, ui_js_1.newline)();
|
|
2187
2268
|
return;
|
|
2188
2269
|
}
|
|
@@ -2198,8 +2279,20 @@ async function renderDoctorHuman(ctx) {
|
|
|
2198
2279
|
else {
|
|
2199
2280
|
renderTopologyFallback(findings, snapshot);
|
|
2200
2281
|
}
|
|
2282
|
+
// Heat honesty: one line when the workload-heat boost could not be sourced.
|
|
2283
|
+
if (!heat.available && heat.notice) {
|
|
2284
|
+
console.log(` ${(0, ui_js_1.dim)(`Note: ${heat.notice}`)}`);
|
|
2285
|
+
(0, ui_js_1.newline)();
|
|
2286
|
+
}
|
|
2201
2287
|
}
|
|
2202
2288
|
renderInvalidIndexes(invalid);
|
|
2289
|
+
if (subtract.unusedRan) {
|
|
2290
|
+
renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
|
|
2291
|
+
renderRedundantIndexes(subtract.redundant);
|
|
2292
|
+
}
|
|
2293
|
+
if (subtract.auditRan) {
|
|
2294
|
+
renderDoctorAudit(subtract.audit, subtract.minScans, snapshot);
|
|
2295
|
+
}
|
|
2203
2296
|
if (findings.length > 0) {
|
|
2204
2297
|
if (args.fix) {
|
|
2205
2298
|
renderFixMigration(findings, config, args);
|
|
@@ -2211,6 +2304,65 @@ async function renderDoctorHuman(ctx) {
|
|
|
2211
2304
|
}
|
|
2212
2305
|
}
|
|
2213
2306
|
}
|
|
2307
|
+
/** Shared caveat block: what an idx_scan of zero does and does not prove. */
|
|
2308
|
+
function renderUnusedCaveats(minScans, snapshot) {
|
|
2309
|
+
const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
|
|
2310
|
+
const threshold = minScans ?? index_stats_js_1.STATS_THRESHOLDS.unusedMinScans;
|
|
2311
|
+
console.log(` ${(0, ui_js_1.dim)(`Usage counters are since the last stats reset (${ageLabel} ago).`)}`);
|
|
2312
|
+
console.log(` ${(0, ui_js_1.dim)(`Caveats: counters zero on a stats reset or crash; a read replica's index scans NEVER feed`)}`);
|
|
2313
|
+
console.log(` ${(0, ui_js_1.dim)(`the primary's counters, so an index only a replica uses looks dead here. Threshold: idx_scan < ${threshold}.`)}`);
|
|
2314
|
+
console.log(` ${(0, ui_js_1.dim)('Primary-key, unique, exclusion, and replica-identity indexes are excluded. Nothing here is auto-dropped.')}`);
|
|
2315
|
+
(0, ui_js_1.newline)();
|
|
2316
|
+
}
|
|
2317
|
+
/** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
|
|
2318
|
+
function renderUnusedIndexes(unused, minScans, snapshot) {
|
|
2319
|
+
if (unused.length === 0)
|
|
2320
|
+
return;
|
|
2321
|
+
const total = unused.reduce((sum, u) => sum + (u.sizeBytes ?? 0), 0);
|
|
2322
|
+
(0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(unused.length))} never-scanned index(es) (${(0, index_stats_js_1.formatBytes)(total)} reclaimable).`);
|
|
2323
|
+
renderUnusedCaveats(minScans, snapshot);
|
|
2324
|
+
for (const u of unused) {
|
|
2325
|
+
console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(u.table))} ${(0, ui_js_1.dim)(`(${u.columns.join(', ') || '?'})`)} ${(0, ui_js_1.gray)(`${u.indexName} · ${u.idxScan} scans · ${(0, index_stats_js_1.formatBytes)(u.sizeBytes)}`)}`);
|
|
2326
|
+
console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(u.dropSql)}`);
|
|
2327
|
+
(0, ui_js_1.newline)();
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
/** doctor --unused: redundant leading-prefix indexes with DROP suggestions (report-only). */
|
|
2331
|
+
function renderRedundantIndexes(redundant) {
|
|
2332
|
+
if (redundant.length === 0)
|
|
2333
|
+
return;
|
|
2334
|
+
const total = redundant.reduce((sum, r) => sum + (r.sizeBytes ?? 0), 0);
|
|
2335
|
+
(0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(redundant.length))} redundant index(es) (${(0, index_stats_js_1.formatBytes)(total)} reclaimable).`);
|
|
2336
|
+
console.log(` ${(0, ui_js_1.dim)('Each is a leading prefix of a wider index that already serves the same lookups. Report-only, never auto-dropped.')}`);
|
|
2337
|
+
(0, ui_js_1.newline)();
|
|
2338
|
+
for (const r of redundant) {
|
|
2339
|
+
console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(r.table))} ${(0, ui_js_1.dim)(`(${r.columns.join(', ')})`)} ${(0, ui_js_1.gray)(`${r.indexName} · ${(0, index_stats_js_1.formatBytes)(r.sizeBytes)}`)}`);
|
|
2340
|
+
console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} covered by ${(0, ui_js_1.blue)(r.coveredBy)} ${(0, ui_js_1.dim)(`(${r.coveredByColumns.join(', ')})`)}`);
|
|
2341
|
+
console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(r.dropSql)}`);
|
|
2342
|
+
(0, ui_js_1.newline)();
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
/** doctor --audit: doctor's own previously-suggested indexes now never scanned. */
|
|
2346
|
+
function renderDoctorAudit(audit, minScans, snapshot) {
|
|
2347
|
+
if (audit.length === 0) {
|
|
2348
|
+
(0, ui_js_1.success)('No doctor-suggested index is going unused');
|
|
2349
|
+
(0, ui_js_1.newline)();
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
(0, ui_js_1.warn)(`doctor previously suggested these indexes; ${(0, ui_js_1.bold)(String(audit.length))} have never been scanned since the stats reset.`);
|
|
2353
|
+
renderUnusedCaveats(minScans, snapshot);
|
|
2354
|
+
for (const a of audit) {
|
|
2355
|
+
const tag = a.ambiguous ? (0, ui_js_1.red)(' [ambiguous: truncated name collides with another column set]') : '';
|
|
2356
|
+
console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(a.table))} ${(0, ui_js_1.dim)(`(${a.columns.join(', ') || '?'})`)} ${(0, ui_js_1.gray)(`${a.indexName} · ${a.idxScan} scans · ${(0, index_stats_js_1.formatBytes)(a.sizeBytes)}`)}${tag}`);
|
|
2357
|
+
if (a.ambiguous) {
|
|
2358
|
+
console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
|
|
2359
|
+
}
|
|
2360
|
+
console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(a.dropSql)}`);
|
|
2361
|
+
(0, ui_js_1.newline)();
|
|
2362
|
+
}
|
|
2363
|
+
console.log(` ${(0, ui_js_1.dim)('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
|
|
2364
|
+
(0, ui_js_1.newline)();
|
|
2365
|
+
}
|
|
2214
2366
|
/** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
|
|
2215
2367
|
function renderTiers(findings, snapshot, _args) {
|
|
2216
2368
|
const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
|
|
@@ -2735,7 +2887,7 @@ function showHelp() {
|
|
|
2735
2887
|
console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
|
|
2736
2888
|
console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
|
|
2737
2889
|
console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
|
|
2738
|
-
console.log(` ${(0, ui_js_1.cyan)('doctor')} Cost-aware missing-FK-index triage ${(0, ui_js_1.dim)('(--fix, --json, --
|
|
2890
|
+
console.log(` ${(0, ui_js_1.cyan)('doctor')} Cost-aware missing-FK-index triage ${(0, ui_js_1.dim)('(--fix, --json, --unused, --audit)')}`);
|
|
2739
2891
|
console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI ${(0, ui_js_1.dim)('(--write for writes, --demo for a sample DB)')}`);
|
|
2740
2892
|
console.log(` ${(0, ui_js_1.cyan)('mcp')} Start read-only MCP server over stdio`);
|
|
2741
2893
|
console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
|
|
Binary file
|
package/dist/cjs/index-stats.js
CHANGED
|
@@ -65,8 +65,12 @@ exports.emptyStatsSnapshot = emptyStatsSnapshot;
|
|
|
65
65
|
exports.formatBytes = formatBytes;
|
|
66
66
|
exports.scoreMissingIndex = scoreMissingIndex;
|
|
67
67
|
exports.findInvalidIndexes = findInvalidIndexes;
|
|
68
|
+
exports.findUnusedIndexes = findUnusedIndexes;
|
|
69
|
+
exports.findRedundantIndexes = findRedundantIndexes;
|
|
70
|
+
exports.auditDoctorIndexes = auditDoctorIndexes;
|
|
68
71
|
exports.isSnapshotUsable = isSnapshotUsable;
|
|
69
72
|
exports.collectStatsSnapshot = collectStatsSnapshot;
|
|
73
|
+
exports.collectTableHeat = collectTableHeat;
|
|
70
74
|
const index_advisor_js_1 = require("./index-advisor.js");
|
|
71
75
|
// ---------------------------------------------------------------------------
|
|
72
76
|
// Thresholds (exported + printed so a user can see exactly why a tier was chosen)
|
|
@@ -114,6 +118,18 @@ exports.STATS_THRESHOLDS = {
|
|
|
114
118
|
appendHeavyInsertRatio: 0.95,
|
|
115
119
|
/** ... and seq_scan at or below this ("near-zero probe reads") → scrutinize. */
|
|
116
120
|
appendHeavyMaxSeqScan: 5,
|
|
121
|
+
/**
|
|
122
|
+
* Default `--min-scans` for the unused-index report: an index with fewer than
|
|
123
|
+
* this many scans since stats_reset is flagged never-scanned. Default 1 makes
|
|
124
|
+
* the bare report mean exactly "idx_scan = 0".
|
|
125
|
+
*/
|
|
126
|
+
unusedMinScans: 1,
|
|
127
|
+
/**
|
|
128
|
+
* Queries/min (from _turbine_metrics) at or above this makes a table "hot in
|
|
129
|
+
* your workload": a benefit signal that boosts the finding's priority and
|
|
130
|
+
* annotates it. It never downgrades a cost tier (write cost is a separate axis).
|
|
131
|
+
*/
|
|
132
|
+
heatMinQueriesPerMin: 1,
|
|
117
133
|
};
|
|
118
134
|
/** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
|
|
119
135
|
function emptyStatsSnapshot(notices = []) {
|
|
@@ -149,7 +165,7 @@ function formatInt(n) {
|
|
|
149
165
|
* Score a single missing-index finding against a snapshot. Pure and total:
|
|
150
166
|
* every unknown degrades to a caveat rather than a fabricated number.
|
|
151
167
|
*/
|
|
152
|
-
function scoreMissingIndex(missing, snapshot) {
|
|
168
|
+
function scoreMissingIndex(missing, snapshot, heat) {
|
|
153
169
|
const t = exports.STATS_THRESHOLDS;
|
|
154
170
|
const stats = snapshot.tables[missing.table];
|
|
155
171
|
const probingRelations = missing.probes.length;
|
|
@@ -261,8 +277,17 @@ function scoreMissingIndex(missing, snapshot) {
|
|
|
261
277
|
if (partialNotNull) {
|
|
262
278
|
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
279
|
}
|
|
280
|
+
// Workload heat is a BENEFIT signal, not a cost one: a table your app hits hard
|
|
281
|
+
// is a table where a missing index hurts most. It never downgrades a cost tier
|
|
282
|
+
// (write cost is decided above); it re-prioritizes and annotates.
|
|
283
|
+
const heatBoosted = heat !== undefined && heat.queriesPerMin >= t.heatMinQueriesPerMin;
|
|
284
|
+
if (heatBoosted && heat !== undefined) {
|
|
285
|
+
reasons.push(`hot in your workload: ${formatInt(heat.queriesPerMin)} queries/min, p95 ${heat.p95Ms >= 10 ? Math.round(heat.p95Ms) : heat.p95Ms.toFixed(1)} ms`);
|
|
286
|
+
}
|
|
264
287
|
// Benefit sort key: bigger, more-probed tables first. Unknown rows sort last.
|
|
265
|
-
|
|
288
|
+
// A heat-boosted finding is lifted above every non-hot finding in its tier.
|
|
289
|
+
const base = (rows ?? 0) * Math.max(1, probingRelations);
|
|
290
|
+
const benefitScore = heatBoosted && heat !== undefined ? base + (heat.queriesPerMin + 1) * 1e12 : base;
|
|
266
291
|
return {
|
|
267
292
|
table: missing.table,
|
|
268
293
|
columns: missing.columns,
|
|
@@ -271,6 +296,7 @@ function scoreMissingIndex(missing, snapshot) {
|
|
|
271
296
|
metrics,
|
|
272
297
|
hotWarning,
|
|
273
298
|
partialNotNull,
|
|
299
|
+
heatBoosted,
|
|
274
300
|
benefitScore,
|
|
275
301
|
};
|
|
276
302
|
}
|
|
@@ -297,6 +323,123 @@ function findInvalidIndexes(snapshot) {
|
|
|
297
323
|
}))
|
|
298
324
|
.sort((a, b) => a.indexName.localeCompare(b.indexName));
|
|
299
325
|
}
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Unused-index detection (pure): doctor learns to subtract
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
/**
|
|
330
|
+
* An index constraint-backing indexes are NEVER candidates for a drop suggestion:
|
|
331
|
+
* a primary key, a unique constraint, or an exclusion constraint owns its index,
|
|
332
|
+
* and a replica-identity index is load-bearing for logical replication. Dropping
|
|
333
|
+
* any of these changes semantics, not just performance.
|
|
334
|
+
*/
|
|
335
|
+
function isConstraintBacking(idx) {
|
|
336
|
+
return idx.isPrimary || idx.isUnique || idx.isExclusion === true || idx.isReplicaIdent;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Indexes never (or barely) scanned since the last stats reset. Report-only:
|
|
340
|
+
* counters reset on a crash/reset and REPLICA READS NEVER FEED PRIMARY COUNTERS,
|
|
341
|
+
* so an index only a read replica uses looks dead here. Constraint-backing and
|
|
342
|
+
* replica-identity indexes are excluded by construction. An index whose idx_scan
|
|
343
|
+
* could not be read (no pg_stat row) is skipped rather than guessed.
|
|
344
|
+
*/
|
|
345
|
+
function findUnusedIndexes(snapshot, options = {}) {
|
|
346
|
+
const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
|
|
347
|
+
return snapshot.indexes
|
|
348
|
+
.filter((idx) => idx.isValid && !isConstraintBacking(idx))
|
|
349
|
+
.filter((idx) => idx.idxScan !== undefined && idx.idxScan < minScans)
|
|
350
|
+
.map((idx) => ({
|
|
351
|
+
table: idx.table,
|
|
352
|
+
indexName: idx.indexName,
|
|
353
|
+
columns: idx.columns,
|
|
354
|
+
idxScan: idx.idxScan ?? 0,
|
|
355
|
+
sizeBytes: idx.sizeBytes ?? null,
|
|
356
|
+
dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
|
|
357
|
+
}))
|
|
358
|
+
.sort((a, b) => (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0) || a.indexName.localeCompare(b.indexName));
|
|
359
|
+
}
|
|
360
|
+
/** Whether `prefix` is a strict leading prefix of `columns`. */
|
|
361
|
+
function isLeadingPrefix(prefix, columns) {
|
|
362
|
+
if (prefix.length === 0 || prefix.length >= columns.length)
|
|
363
|
+
return false;
|
|
364
|
+
return prefix.every((c, i) => columns[i] === c);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Non-unique indexes whose column list is a leading prefix of a WIDER index on
|
|
368
|
+
* the same table. A btree serves any leading-prefix lookup, so the narrow index
|
|
369
|
+
* is redundant. Pure metadata (no idx_scan needed).
|
|
370
|
+
*
|
|
371
|
+
* Uniqueness compatibility: only a NON-unique index is ever reported. A unique
|
|
372
|
+
* or primary-key prefix is load-bearing (it enforces a constraint), so it is
|
|
373
|
+
* never called redundant even when a wider index shares its leading columns.
|
|
374
|
+
*/
|
|
375
|
+
function findRedundantIndexes(snapshot) {
|
|
376
|
+
const byTable = new Map();
|
|
377
|
+
for (const idx of snapshot.indexes) {
|
|
378
|
+
if (!idx.isValid)
|
|
379
|
+
continue;
|
|
380
|
+
let list = byTable.get(idx.table);
|
|
381
|
+
if (!list) {
|
|
382
|
+
list = [];
|
|
383
|
+
byTable.set(idx.table, list);
|
|
384
|
+
}
|
|
385
|
+
list.push(idx);
|
|
386
|
+
}
|
|
387
|
+
const out = [];
|
|
388
|
+
for (const list of byTable.values()) {
|
|
389
|
+
for (const narrow of list) {
|
|
390
|
+
// Candidate must be a plain, non-constraint index: dropping it must not
|
|
391
|
+
// remove a uniqueness/PK/exclusion guarantee or a replica identity.
|
|
392
|
+
if (isConstraintBacking(narrow))
|
|
393
|
+
continue;
|
|
394
|
+
if (narrow.columns.length === 0)
|
|
395
|
+
continue;
|
|
396
|
+
const wider = list.find((w) => w.indexName !== narrow.indexName && isLeadingPrefix(narrow.columns, w.columns));
|
|
397
|
+
if (!wider)
|
|
398
|
+
continue;
|
|
399
|
+
out.push({
|
|
400
|
+
table: narrow.table,
|
|
401
|
+
indexName: narrow.indexName,
|
|
402
|
+
columns: narrow.columns,
|
|
403
|
+
coveredBy: wider.indexName,
|
|
404
|
+
coveredByColumns: wider.columns,
|
|
405
|
+
sizeBytes: narrow.sizeBytes ?? null,
|
|
406
|
+
dropSql: (0, index_advisor_js_1.buildDropIndexSql)(narrow.indexName, { concurrently: true }),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return out.sort((a, b) => a.table.localeCompare(b.table) || a.indexName.localeCompare(b.indexName));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The unused-index machinery scoped to doctor's OWN previously-suggested indexes:
|
|
414
|
+
* existing indexes whose name matches the `idx_<table>_<cols>` (63-byte truncated)
|
|
415
|
+
* shape doctor emits, never scanned since the stats reset. `doctorNames` maps each
|
|
416
|
+
* deterministic name to the distinct column sets that truncate to it (see
|
|
417
|
+
* `collectDoctorProbeIndexNames`); a name with more than one column set is a
|
|
418
|
+
* post-truncation collision, reported as ambiguous.
|
|
419
|
+
*/
|
|
420
|
+
function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
|
|
421
|
+
const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
|
|
422
|
+
const out = [];
|
|
423
|
+
for (const idx of snapshot.indexes) {
|
|
424
|
+
if (!idx.isValid || isConstraintBacking(idx))
|
|
425
|
+
continue;
|
|
426
|
+
const candidates = doctorNames.get(idx.indexName);
|
|
427
|
+
if (!candidates || candidates.length === 0)
|
|
428
|
+
continue;
|
|
429
|
+
if (idx.idxScan === undefined || idx.idxScan >= minScans)
|
|
430
|
+
continue;
|
|
431
|
+
out.push({
|
|
432
|
+
table: idx.table,
|
|
433
|
+
indexName: idx.indexName,
|
|
434
|
+
columns: idx.columns,
|
|
435
|
+
idxScan: idx.idxScan,
|
|
436
|
+
sizeBytes: idx.sizeBytes ?? null,
|
|
437
|
+
dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
|
|
438
|
+
ambiguous: candidates.length > 1,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return out.sort((a, b) => a.indexName.localeCompare(b.indexName));
|
|
442
|
+
}
|
|
300
443
|
/**
|
|
301
444
|
* Whether the snapshot is trustworthy enough to render tier verdicts. Empty,
|
|
302
445
|
* unavailable, or too-young stats degrade to the topology-only report.
|
|
@@ -398,8 +541,11 @@ async function collectStatsSnapshot(options) {
|
|
|
398
541
|
const indexRows = await run('pg_index', `SELECT c.relname AS table_name,
|
|
399
542
|
ic.relname AS index_name,
|
|
400
543
|
i.indisvalid, i.indisunique, i.indisprimary, i.indisreplident,
|
|
544
|
+
EXISTS (SELECT 1 FROM pg_constraint con
|
|
545
|
+
WHERE con.conindid = i.indexrelid AND con.contype = 'x') AS is_exclusion,
|
|
401
546
|
s.idx_scan::text AS idx_scan,
|
|
402
|
-
(
|
|
547
|
+
pg_relation_size(i.indexrelid)::text AS index_size,
|
|
548
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
403
549
|
FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
|
404
550
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
|
|
405
551
|
FROM pg_index i
|
|
@@ -419,6 +565,8 @@ async function collectStatsSnapshot(options) {
|
|
|
419
565
|
isUnique: row.indisunique,
|
|
420
566
|
isPrimary: row.indisprimary,
|
|
421
567
|
isReplicaIdent: row.indisreplident,
|
|
568
|
+
isExclusion: row.is_exclusion,
|
|
569
|
+
sizeBytes: row.index_size == null ? undefined : Number(row.index_size),
|
|
422
570
|
});
|
|
423
571
|
}
|
|
424
572
|
}
|
|
@@ -448,3 +596,71 @@ async function collectStatsSnapshot(options) {
|
|
|
448
596
|
}
|
|
449
597
|
return snapshot;
|
|
450
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Read per-model workload heat from a `_turbine_metrics` table (written by
|
|
601
|
+
* `db.$observe()` with the default Postgres sink). When the table is absent, the
|
|
602
|
+
* result is `available: false` with a notice: this is the expected state when
|
|
603
|
+
* observe uses a non-Postgres sink, or when no metrics have been collected yet.
|
|
604
|
+
* Every read is best-effort; a failure degrades to unavailable, never throws.
|
|
605
|
+
*/
|
|
606
|
+
async function collectTableHeat(options) {
|
|
607
|
+
const windowMinutes = options.windowMinutes ?? 60;
|
|
608
|
+
const timeout = options.statementTimeoutMs ?? 5000;
|
|
609
|
+
const result = { available: false, tables: {}, notice: null };
|
|
610
|
+
if (options.models.length === 0) {
|
|
611
|
+
result.notice = 'no probed tables to correlate against workload heat.';
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
614
|
+
const { Pool } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
|
|
615
|
+
const pool = new Pool({ connectionString: options.connectionString, max: 1 });
|
|
616
|
+
try {
|
|
617
|
+
try {
|
|
618
|
+
await pool.query(`SET statement_timeout = ${Number(timeout)}`);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
/* best-effort */
|
|
622
|
+
}
|
|
623
|
+
const exists = await pool
|
|
624
|
+
.query(`SELECT to_regclass('_turbine_metrics')::text AS reg`)
|
|
625
|
+
.then((r) => r.rows[0]?.reg ?? null)
|
|
626
|
+
.catch(() => null);
|
|
627
|
+
if (!exists) {
|
|
628
|
+
result.notice =
|
|
629
|
+
'the _turbine_metrics table was not found: heat boosting is unavailable (observe may be using a non-Postgres sink, or no metrics have been collected yet).';
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
const rows = await pool
|
|
633
|
+
.query(`SELECT model,
|
|
634
|
+
sum(count)::float8::text AS total_count,
|
|
635
|
+
max(p95_ms)::text AS p95,
|
|
636
|
+
count(*)::text AS buckets
|
|
637
|
+
FROM _turbine_metrics
|
|
638
|
+
WHERE bucket >= NOW() - (INTERVAL '1 minute' * $1) AND model = ANY($2)
|
|
639
|
+
GROUP BY model`, [windowMinutes, options.models])
|
|
640
|
+
.then((r) => r.rows)
|
|
641
|
+
.catch(() => null);
|
|
642
|
+
if (rows === null) {
|
|
643
|
+
result.notice = 'reading _turbine_metrics failed: heat boosting is unavailable.';
|
|
644
|
+
return result;
|
|
645
|
+
}
|
|
646
|
+
result.available = true;
|
|
647
|
+
for (const row of rows) {
|
|
648
|
+
const total = Number(row.total_count);
|
|
649
|
+
if (!Number.isFinite(total))
|
|
650
|
+
continue;
|
|
651
|
+
result.tables[row.model] = {
|
|
652
|
+
queriesPerMin: total / Math.max(1, windowMinutes),
|
|
653
|
+
p95Ms: row.p95 == null ? 0 : Number(row.p95),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
try {
|
|
659
|
+
await pool.end();
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
/* best-effort */
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.
|
|
38
|
-
exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = void 0;
|
|
37
|
+
exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.PgMetricsSink = exports.HttpJsonSink = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
|
|
38
|
+
exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = exports.withDbFieldNames = exports.snakeToPascal = void 0;
|
|
39
39
|
var index_js_1 = require("./adapters/index.js");
|
|
40
40
|
Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
|
|
41
41
|
Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
|
|
@@ -85,6 +85,10 @@ var nested_write_js_1 = require("./nested-write.js");
|
|
|
85
85
|
Object.defineProperty(exports, "executeNestedCreate", { enumerable: true, get: function () { return nested_write_js_1.executeNestedCreate; } });
|
|
86
86
|
Object.defineProperty(exports, "executeNestedUpdate", { enumerable: true, get: function () { return nested_write_js_1.executeNestedUpdate; } });
|
|
87
87
|
Object.defineProperty(exports, "hasRelationFields", { enumerable: true, get: function () { return nested_write_js_1.hasRelationFields; } });
|
|
88
|
+
// Observability
|
|
89
|
+
var observe_js_1 = require("./observe.js");
|
|
90
|
+
Object.defineProperty(exports, "HttpJsonSink", { enumerable: true, get: function () { return observe_js_1.HttpJsonSink; } });
|
|
91
|
+
Object.defineProperty(exports, "PgMetricsSink", { enumerable: true, get: function () { return observe_js_1.PgMetricsSink; } });
|
|
88
92
|
// Pipeline
|
|
89
93
|
var pipeline_js_1 = require("./pipeline.js");
|
|
90
94
|
Object.defineProperty(exports, "executePipeline", { enumerable: true, get: function () { return pipeline_js_1.executePipeline; } });
|