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/observe.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* turbine-orm
|
|
3
|
+
* turbine-orm: Observability module
|
|
4
4
|
*
|
|
5
5
|
* Buffers query metrics in memory (keyed by model:action per minute bucket),
|
|
6
|
-
* then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* then periodically flushes aggregates (count, avg, p50, p95, p99, errors) to a
|
|
7
|
+
* pluggable {@link ObserveSink}. The default sink writes to a dedicated
|
|
8
|
+
* `_turbine_metrics` Postgres table over its own 1-connection pool, so metrics
|
|
9
|
+
* writes never contend with the application pool; alternative sinks (for example
|
|
10
|
+
* {@link HttpJsonSink}) can forward the same aggregates elsewhere.
|
|
11
|
+
*
|
|
12
|
+
* The aggregation privacy posture is deliberate: a batch carries only the
|
|
13
|
+
* model/action identity, the counts, and the latency percentiles. It never
|
|
14
|
+
* carries SQL text or bound parameter values.
|
|
9
15
|
*/
|
|
10
16
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
17
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
18
|
};
|
|
13
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.ObserveEngine = void 0;
|
|
20
|
+
exports.ObserveEngine = exports.HttpJsonSink = exports.PgMetricsSink = void 0;
|
|
15
21
|
exports.floorToMinute = floorToMinute;
|
|
16
22
|
exports.percentile = percentile;
|
|
17
23
|
const pg_1 = __importDefault(require("pg"));
|
|
@@ -27,7 +33,7 @@ function percentile(sorted, p) {
|
|
|
27
33
|
return sorted[Math.max(0, idx)];
|
|
28
34
|
}
|
|
29
35
|
// ---------------------------------------------------------------------------
|
|
30
|
-
// Schema DDL
|
|
36
|
+
// Schema DDL + statements (default Postgres sink)
|
|
31
37
|
// ---------------------------------------------------------------------------
|
|
32
38
|
const SCHEMA_DDL = `
|
|
33
39
|
CREATE TABLE IF NOT EXISTS _turbine_metrics (
|
|
@@ -58,22 +64,104 @@ ON CONFLICT (bucket, model, action) DO UPDATE SET
|
|
|
58
64
|
error_count = _turbine_metrics.error_count + EXCLUDED.error_count
|
|
59
65
|
`;
|
|
60
66
|
const RETENTION_SQL = `DELETE FROM _turbine_metrics WHERE bucket < NOW() - INTERVAL '1 day' * $1`;
|
|
67
|
+
/**
|
|
68
|
+
* The default flush target: upserts each aggregate row into `_turbine_metrics`
|
|
69
|
+
* and prunes rows older than `retentionDays`. The SQL and per-row/retention
|
|
70
|
+
* ordering are byte-identical to the pre-sink `ObserveEngine.flush` writer.
|
|
71
|
+
*/
|
|
72
|
+
class PgMetricsSink {
|
|
73
|
+
pool;
|
|
74
|
+
retentionDays;
|
|
75
|
+
constructor(options) {
|
|
76
|
+
this.pool = new pg_1.default.Pool({ connectionString: options.connectionString, max: 1 });
|
|
77
|
+
this.retentionDays = options.retentionDays ?? 30;
|
|
78
|
+
}
|
|
79
|
+
async init() {
|
|
80
|
+
await this.pool.query(SCHEMA_DDL);
|
|
81
|
+
}
|
|
82
|
+
async flush(batch) {
|
|
83
|
+
if (batch.rows.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
for (const row of batch.rows) {
|
|
86
|
+
try {
|
|
87
|
+
await this.pool.query(UPSERT_SQL, [
|
|
88
|
+
row.bucket,
|
|
89
|
+
row.model,
|
|
90
|
+
row.action,
|
|
91
|
+
row.count,
|
|
92
|
+
row.avg,
|
|
93
|
+
row.p50,
|
|
94
|
+
row.p95,
|
|
95
|
+
row.p99,
|
|
96
|
+
row.errors,
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Fire-and-forget: never throw from flush
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await this.pool.query(RETENTION_SQL, [this.retentionDays]);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Best effort
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async stop() {
|
|
111
|
+
await this.pool.end();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
exports.PgMetricsSink = PgMetricsSink;
|
|
115
|
+
/**
|
|
116
|
+
* Forwards each aggregate batch to an HTTP endpoint as a JSON POST. Fire-and-
|
|
117
|
+
* forget: a failed request is swallowed and never throws, and there are no
|
|
118
|
+
* retries beyond the engine's next scheduled flush. Aggregates only: the body
|
|
119
|
+
* carries no SQL text and no parameter values.
|
|
120
|
+
*/
|
|
121
|
+
class HttpJsonSink {
|
|
122
|
+
url;
|
|
123
|
+
headers;
|
|
124
|
+
fetchFn;
|
|
125
|
+
constructor(options) {
|
|
126
|
+
this.url = options.url;
|
|
127
|
+
this.headers = options.headers ?? {};
|
|
128
|
+
this.fetchFn = options.fetchFunction ?? fetch;
|
|
129
|
+
}
|
|
130
|
+
async flush(batch) {
|
|
131
|
+
if (batch.rows.length === 0)
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
await this.fetchFn(this.url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'content-type': 'application/json', ...this.headers },
|
|
137
|
+
body: JSON.stringify(batch),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Fire-and-forget: a failing collector never affects the application.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.HttpJsonSink = HttpJsonSink;
|
|
61
146
|
// ---------------------------------------------------------------------------
|
|
62
147
|
// Observe engine
|
|
63
148
|
// ---------------------------------------------------------------------------
|
|
64
149
|
class ObserveEngine {
|
|
65
|
-
|
|
150
|
+
sink;
|
|
66
151
|
buffer = new Map();
|
|
67
152
|
currentBucket;
|
|
68
153
|
flushIntervalMs;
|
|
69
|
-
retentionDays;
|
|
70
154
|
timer;
|
|
71
155
|
listener;
|
|
72
156
|
stopped = false;
|
|
73
157
|
constructor(config) {
|
|
74
|
-
|
|
158
|
+
if (!config.sink && !config.connectionString) {
|
|
159
|
+
throw new Error('ObserveEngine requires either a connectionString or a sink');
|
|
160
|
+
}
|
|
161
|
+
this.sink =
|
|
162
|
+
config.sink ??
|
|
163
|
+
new PgMetricsSink({ connectionString: config.connectionString, retentionDays: config.retentionDays ?? 30 });
|
|
75
164
|
this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
|
|
76
|
-
this.retentionDays = config.retentionDays ?? 30;
|
|
77
165
|
this.currentBucket = floorToMinute(new Date());
|
|
78
166
|
this.listener = (event) => {
|
|
79
167
|
if (this.stopped)
|
|
@@ -97,7 +185,7 @@ class ObserveEngine {
|
|
|
97
185
|
return this.listener;
|
|
98
186
|
}
|
|
99
187
|
async init() {
|
|
100
|
-
await this.
|
|
188
|
+
await this.sink.init?.();
|
|
101
189
|
this.timer = setInterval(() => {
|
|
102
190
|
this.flush().catch(() => { });
|
|
103
191
|
}, this.flushIntervalMs);
|
|
@@ -112,34 +200,32 @@ class ObserveEngine {
|
|
|
112
200
|
const bucket = this.currentBucket;
|
|
113
201
|
const entries = new Map(this.buffer);
|
|
114
202
|
this.buffer.clear();
|
|
203
|
+
const rows = [];
|
|
115
204
|
for (const [key, entry] of entries) {
|
|
116
205
|
const [model, action] = key.split(':');
|
|
117
206
|
const sorted = entry.durations.slice().sort((a, b) => a - b);
|
|
118
207
|
const count = sorted.length;
|
|
119
208
|
const avg = sorted.reduce((s, v) => s + v, 0) / count;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
await this.pool.query(RETENTION_SQL, [this.retentionDays]);
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
// Best effort
|
|
209
|
+
rows.push({
|
|
210
|
+
bucket,
|
|
211
|
+
model: model ?? '',
|
|
212
|
+
action: action ?? '',
|
|
213
|
+
count,
|
|
214
|
+
avg,
|
|
215
|
+
p50: percentile(sorted, 0.5),
|
|
216
|
+
p95: percentile(sorted, 0.95),
|
|
217
|
+
p99: percentile(sorted, 0.99),
|
|
218
|
+
errors: entry.errors,
|
|
219
|
+
});
|
|
135
220
|
}
|
|
221
|
+
await this.sink.flush({ rows });
|
|
136
222
|
}
|
|
137
223
|
async stop() {
|
|
138
224
|
this.stopped = true;
|
|
139
225
|
if (this.timer)
|
|
140
226
|
clearInterval(this.timer);
|
|
141
227
|
await this.flush();
|
|
142
|
-
await this.
|
|
228
|
+
await this.sink.stop?.();
|
|
143
229
|
}
|
|
144
230
|
}
|
|
145
231
|
exports.ObserveEngine = ObserveEngine;
|
package/dist/cli/index.d.ts
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 - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
|
|
17
|
+
* turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
|
|
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)
|
|
@@ -51,6 +51,14 @@ export interface CliArgs {
|
|
|
51
51
|
json?: boolean;
|
|
52
52
|
/** `doctor --fix --no-concurrently`: emit plain CREATE INDEX instead of the CONCURRENTLY + no-transaction form. */
|
|
53
53
|
noConcurrently?: boolean;
|
|
54
|
+
/** `doctor --unused`: report-only never-scanned / redundant / invalid indexes with DROP suggestions. */
|
|
55
|
+
unused?: boolean;
|
|
56
|
+
/** `doctor --audit`: unused report scoped to doctor's own previously-suggested index names. */
|
|
57
|
+
audit?: boolean;
|
|
58
|
+
/** `doctor --min-scans <n>`: idx_scan below this counts as never-scanned (default 1 = idx_scan 0). */
|
|
59
|
+
minScans?: number;
|
|
60
|
+
/** `doctor --metrics-url <url>`: read _turbine_metrics for the table-heat boost from a separate DB. */
|
|
61
|
+
metricsUrl?: string;
|
|
54
62
|
/** `init --yes`/`-y`: accept every step's default non-interactively. */
|
|
55
63
|
yes?: boolean;
|
|
56
64
|
/** `init --skip-schema`: don't scaffold the schema file. */
|
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 - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
|
|
17
|
+
* turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
|
|
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,8 +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 { buildCreateIndexSql, buildDropIndexSql, findMissingRelationIndexes, } from '../index-advisor.js';
|
|
33
|
-
import { collectStatsSnapshot, findInvalidIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
|
|
32
|
+
import { buildCreateIndexSql, buildDropIndexSql, collectDoctorProbeIndexNames, findMissingRelationIndexes, } from '../index-advisor.js';
|
|
33
|
+
import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
|
|
34
34
|
import { introspect } from '../introspect.js';
|
|
35
35
|
import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
36
36
|
import { configTemplate, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
|
|
@@ -128,6 +128,20 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
128
128
|
case '--no-concurrently':
|
|
129
129
|
result.noConcurrently = true;
|
|
130
130
|
break;
|
|
131
|
+
case '--unused':
|
|
132
|
+
result.unused = true;
|
|
133
|
+
break;
|
|
134
|
+
case '--audit':
|
|
135
|
+
result.audit = true;
|
|
136
|
+
break;
|
|
137
|
+
case '--min-scans':
|
|
138
|
+
result.minScans = next ? Number.parseInt(next, 10) : undefined;
|
|
139
|
+
i++;
|
|
140
|
+
break;
|
|
141
|
+
case '--metrics-url':
|
|
142
|
+
result.metricsUrl = next;
|
|
143
|
+
i++;
|
|
144
|
+
break;
|
|
131
145
|
case '--zod':
|
|
132
146
|
result.zod = true;
|
|
133
147
|
break;
|
|
@@ -2095,20 +2109,64 @@ async function cmdDoctor(args, config) {
|
|
|
2095
2109
|
notices: [`statistics collection failed: ${err instanceof Error ? err.message : String(err)}`],
|
|
2096
2110
|
};
|
|
2097
2111
|
}
|
|
2112
|
+
// Table-heat boost: read _turbine_metrics (app DB or --metrics-url) and use
|
|
2113
|
+
// per-model heat as an extra benefit signal. Best-effort; a missing table just
|
|
2114
|
+
// means "heat boosting unavailable" and the triage continues without it.
|
|
2115
|
+
const heat = probedTables.length > 0
|
|
2116
|
+
? await collectTableHeatSafe(args.metricsUrl ?? url, probedTables)
|
|
2117
|
+
: { available: false, tables: {}, notice: null };
|
|
2098
2118
|
const invalid = findInvalidIndexes(snapshot);
|
|
2099
2119
|
const usable = isSnapshotUsable(snapshot);
|
|
2100
|
-
const findings = missing.map((m) => ({
|
|
2120
|
+
const findings = missing.map((m) => ({
|
|
2121
|
+
missing: m,
|
|
2122
|
+
score: scoreMissingIndex(m, snapshot, heat.tables[m.table]),
|
|
2123
|
+
}));
|
|
2124
|
+
// "doctor learns to subtract": report-only drop suggestions, never a migration.
|
|
2125
|
+
const unusedRan = args.unused === true;
|
|
2126
|
+
const auditRan = args.audit === true;
|
|
2127
|
+
const minScans = args.minScans;
|
|
2128
|
+
const unused = unusedRan ? findUnusedIndexes(snapshot, { minScans }) : [];
|
|
2129
|
+
const redundant = unusedRan ? findRedundantIndexes(snapshot) : [];
|
|
2130
|
+
const audit = auditRan ? auditDoctorIndexes(snapshot, collectDoctorProbeIndexNames(schema), { minScans }) : [];
|
|
2131
|
+
const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
|
|
2101
2132
|
if (jsonMode) {
|
|
2102
2133
|
spinner?.stop();
|
|
2103
|
-
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, args }), null, 2));
|
|
2134
|
+
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
|
|
2104
2135
|
return;
|
|
2105
2136
|
}
|
|
2106
|
-
await renderDoctorHuman({
|
|
2137
|
+
await renderDoctorHuman({
|
|
2138
|
+
spinner: spinner,
|
|
2139
|
+
schema,
|
|
2140
|
+
findings,
|
|
2141
|
+
invalid,
|
|
2142
|
+
snapshot,
|
|
2143
|
+
usable,
|
|
2144
|
+
heat,
|
|
2145
|
+
subtract,
|
|
2146
|
+
args,
|
|
2147
|
+
config,
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
/** Best-effort table-heat read: any failure degrades to unavailable, never throws. */
|
|
2151
|
+
async function collectTableHeatSafe(connectionString, models) {
|
|
2152
|
+
try {
|
|
2153
|
+
return await collectTableHeat({ connectionString, models });
|
|
2154
|
+
}
|
|
2155
|
+
catch (err) {
|
|
2156
|
+
return {
|
|
2157
|
+
available: false,
|
|
2158
|
+
tables: {},
|
|
2159
|
+
notice: `heat boosting is unavailable (reading _turbine_metrics failed: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}).`,
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2107
2162
|
}
|
|
2108
|
-
/**
|
|
2163
|
+
/**
|
|
2164
|
+
* The stable, versioned JSON contract (schemaVersion: 1). Fields are only ever
|
|
2165
|
+
* added, never removed or repurposed, so a parser never breaks on an upgrade.
|
|
2166
|
+
*/
|
|
2109
2167
|
function buildDoctorJson(ctx) {
|
|
2110
2168
|
const concurrently = ctx.args.noConcurrently !== true;
|
|
2111
|
-
|
|
2169
|
+
const out = {
|
|
2112
2170
|
schemaVersion: 1,
|
|
2113
2171
|
scannedTables: Object.keys(ctx.schema.tables).length,
|
|
2114
2172
|
stats: {
|
|
@@ -2118,6 +2176,10 @@ function buildDoctorJson(ctx) {
|
|
|
2118
2176
|
statsAgeDays: ctx.snapshot.statsAgeDays,
|
|
2119
2177
|
notices: ctx.snapshot.notices,
|
|
2120
2178
|
},
|
|
2179
|
+
heat: {
|
|
2180
|
+
available: ctx.heat.available,
|
|
2181
|
+
notice: ctx.heat.notice,
|
|
2182
|
+
},
|
|
2121
2183
|
thresholds: STATS_THRESHOLDS,
|
|
2122
2184
|
findings: ctx.findings.map((f) => ({
|
|
2123
2185
|
table: f.missing.table,
|
|
@@ -2128,18 +2190,37 @@ function buildDoctorJson(ctx) {
|
|
|
2128
2190
|
metrics: f.score.metrics,
|
|
2129
2191
|
hotWarning: f.score.hotWarning,
|
|
2130
2192
|
partialNotNull: f.score.partialNotNull,
|
|
2193
|
+
heatBoosted: f.score.heatBoosted,
|
|
2131
2194
|
probes: f.missing.probes,
|
|
2132
2195
|
createSql: doctorCreateSql(f, { concurrently }),
|
|
2133
2196
|
dropSql: buildDropIndexSql(f.missing.indexName, { concurrently }),
|
|
2134
2197
|
})),
|
|
2135
2198
|
invalidIndexes: ctx.invalid,
|
|
2136
2199
|
};
|
|
2200
|
+
// Additive: the drop-suggestion arrays appear only when --unused / --audit ran.
|
|
2201
|
+
if (ctx.subtract.unusedRan) {
|
|
2202
|
+
out.unused = ctx.subtract.unused;
|
|
2203
|
+
out.redundant = ctx.subtract.redundant;
|
|
2204
|
+
out.invalid = ctx.invalid;
|
|
2205
|
+
}
|
|
2206
|
+
if (ctx.subtract.auditRan) {
|
|
2207
|
+
out.audit = ctx.subtract.audit;
|
|
2208
|
+
}
|
|
2209
|
+
return out;
|
|
2137
2210
|
}
|
|
2138
2211
|
async function renderDoctorHuman(ctx) {
|
|
2139
|
-
const { spinner, schema, findings, invalid, snapshot, usable, args, config } = ctx;
|
|
2212
|
+
const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
|
|
2140
2213
|
spinner.succeed(`Scanned ${bold(String(Object.keys(schema.tables).length))} tables`);
|
|
2141
|
-
|
|
2142
|
-
|
|
2214
|
+
const subtractRan = subtract.unusedRan || subtract.auditRan;
|
|
2215
|
+
const nothingToAdd = findings.length === 0 && invalid.length === 0;
|
|
2216
|
+
const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
|
|
2217
|
+
if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
|
|
2218
|
+
if (subtractRan) {
|
|
2219
|
+
success('No never-scanned or redundant indexes found, and no invalid indexes were found');
|
|
2220
|
+
}
|
|
2221
|
+
else {
|
|
2222
|
+
success('Every relation probe is backed by an index, and no invalid indexes were found');
|
|
2223
|
+
}
|
|
2143
2224
|
newline();
|
|
2144
2225
|
return;
|
|
2145
2226
|
}
|
|
@@ -2155,8 +2236,20 @@ async function renderDoctorHuman(ctx) {
|
|
|
2155
2236
|
else {
|
|
2156
2237
|
renderTopologyFallback(findings, snapshot);
|
|
2157
2238
|
}
|
|
2239
|
+
// Heat honesty: one line when the workload-heat boost could not be sourced.
|
|
2240
|
+
if (!heat.available && heat.notice) {
|
|
2241
|
+
console.log(` ${dim(`Note: ${heat.notice}`)}`);
|
|
2242
|
+
newline();
|
|
2243
|
+
}
|
|
2158
2244
|
}
|
|
2159
2245
|
renderInvalidIndexes(invalid);
|
|
2246
|
+
if (subtract.unusedRan) {
|
|
2247
|
+
renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
|
|
2248
|
+
renderRedundantIndexes(subtract.redundant);
|
|
2249
|
+
}
|
|
2250
|
+
if (subtract.auditRan) {
|
|
2251
|
+
renderDoctorAudit(subtract.audit, subtract.minScans, snapshot);
|
|
2252
|
+
}
|
|
2160
2253
|
if (findings.length > 0) {
|
|
2161
2254
|
if (args.fix) {
|
|
2162
2255
|
renderFixMigration(findings, config, args);
|
|
@@ -2168,6 +2261,65 @@ async function renderDoctorHuman(ctx) {
|
|
|
2168
2261
|
}
|
|
2169
2262
|
}
|
|
2170
2263
|
}
|
|
2264
|
+
/** Shared caveat block: what an idx_scan of zero does and does not prove. */
|
|
2265
|
+
function renderUnusedCaveats(minScans, snapshot) {
|
|
2266
|
+
const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
|
|
2267
|
+
const threshold = minScans ?? STATS_THRESHOLDS.unusedMinScans;
|
|
2268
|
+
console.log(` ${dim(`Usage counters are since the last stats reset (${ageLabel} ago).`)}`);
|
|
2269
|
+
console.log(` ${dim(`Caveats: counters zero on a stats reset or crash; a read replica's index scans NEVER feed`)}`);
|
|
2270
|
+
console.log(` ${dim(`the primary's counters, so an index only a replica uses looks dead here. Threshold: idx_scan < ${threshold}.`)}`);
|
|
2271
|
+
console.log(` ${dim('Primary-key, unique, exclusion, and replica-identity indexes are excluded. Nothing here is auto-dropped.')}`);
|
|
2272
|
+
newline();
|
|
2273
|
+
}
|
|
2274
|
+
/** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
|
|
2275
|
+
function renderUnusedIndexes(unused, minScans, snapshot) {
|
|
2276
|
+
if (unused.length === 0)
|
|
2277
|
+
return;
|
|
2278
|
+
const total = unused.reduce((sum, u) => sum + (u.sizeBytes ?? 0), 0);
|
|
2279
|
+
warn(`Found ${bold(String(unused.length))} never-scanned index(es) (${formatBytes(total)} reclaimable).`);
|
|
2280
|
+
renderUnusedCaveats(minScans, snapshot);
|
|
2281
|
+
for (const u of unused) {
|
|
2282
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(u.table))} ${dim(`(${u.columns.join(', ') || '?'})`)} ${gray(`${u.indexName} · ${u.idxScan} scans · ${formatBytes(u.sizeBytes)}`)}`);
|
|
2283
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(u.dropSql)}`);
|
|
2284
|
+
newline();
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
/** doctor --unused: redundant leading-prefix indexes with DROP suggestions (report-only). */
|
|
2288
|
+
function renderRedundantIndexes(redundant) {
|
|
2289
|
+
if (redundant.length === 0)
|
|
2290
|
+
return;
|
|
2291
|
+
const total = redundant.reduce((sum, r) => sum + (r.sizeBytes ?? 0), 0);
|
|
2292
|
+
warn(`Found ${bold(String(redundant.length))} redundant index(es) (${formatBytes(total)} reclaimable).`);
|
|
2293
|
+
console.log(` ${dim('Each is a leading prefix of a wider index that already serves the same lookups. Report-only, never auto-dropped.')}`);
|
|
2294
|
+
newline();
|
|
2295
|
+
for (const r of redundant) {
|
|
2296
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(r.table))} ${dim(`(${r.columns.join(', ')})`)} ${gray(`${r.indexName} · ${formatBytes(r.sizeBytes)}`)}`);
|
|
2297
|
+
console.log(` ${dim(symbols.tee)} covered by ${blue(r.coveredBy)} ${dim(`(${r.coveredByColumns.join(', ')})`)}`);
|
|
2298
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(r.dropSql)}`);
|
|
2299
|
+
newline();
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
/** doctor --audit: doctor's own previously-suggested indexes now never scanned. */
|
|
2303
|
+
function renderDoctorAudit(audit, minScans, snapshot) {
|
|
2304
|
+
if (audit.length === 0) {
|
|
2305
|
+
success('No doctor-suggested index is going unused');
|
|
2306
|
+
newline();
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
warn(`doctor previously suggested these indexes; ${bold(String(audit.length))} have never been scanned since the stats reset.`);
|
|
2310
|
+
renderUnusedCaveats(minScans, snapshot);
|
|
2311
|
+
for (const a of audit) {
|
|
2312
|
+
const tag = a.ambiguous ? red(' [ambiguous: truncated name collides with another column set]') : '';
|
|
2313
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(a.table))} ${dim(`(${a.columns.join(', ') || '?'})`)} ${gray(`${a.indexName} · ${a.idxScan} scans · ${formatBytes(a.sizeBytes)}`)}${tag}`);
|
|
2314
|
+
if (a.ambiguous) {
|
|
2315
|
+
console.log(` ${dim(symbols.tee)} ${dim('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
|
|
2316
|
+
}
|
|
2317
|
+
console.log(` ${dim(symbols.teeEnd)} ${green(a.dropSql)}`);
|
|
2318
|
+
newline();
|
|
2319
|
+
}
|
|
2320
|
+
console.log(` ${dim('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
|
|
2321
|
+
newline();
|
|
2322
|
+
}
|
|
2171
2323
|
/** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
|
|
2172
2324
|
function renderTiers(findings, snapshot, _args) {
|
|
2173
2325
|
const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
|
|
@@ -2692,7 +2844,7 @@ function showHelp() {
|
|
|
2692
2844
|
console.log(` ${dim('status')} Show applied/pending migrations`);
|
|
2693
2845
|
console.log(` ${cyan('seed')} Run seed file`);
|
|
2694
2846
|
console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
|
|
2695
|
-
console.log(` ${cyan('doctor')} Cost-aware missing-FK-index triage ${dim('(--fix, --json, --
|
|
2847
|
+
console.log(` ${cyan('doctor')} Cost-aware missing-FK-index triage ${dim('(--fix, --json, --unused, --audit)')}`);
|
|
2696
2848
|
console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write for writes, --demo for a sample DB)')}`);
|
|
2697
2849
|
console.log(` ${cyan('mcp')} Start read-only MCP server over stdio`);
|
|
2698
2850
|
console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
|
package/dist/index-advisor.d.ts
CHANGED
|
@@ -79,6 +79,25 @@ export declare function buildDropIndexSql(indexName: string, options?: {
|
|
|
79
79
|
* counts as an index.
|
|
80
80
|
*/
|
|
81
81
|
export declare function isProbeIndexed(meta: TableMetadata, columns: string[]): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The deterministic index name `doctor --fix` uses for a relation-probe index:
|
|
84
|
+
* `idx_<table>_<cols>` truncated to Postgres's 63-byte identifier limit. `doctor
|
|
85
|
+
* --audit` recomputes this to recognize its own previously-suggested indexes.
|
|
86
|
+
*/
|
|
87
|
+
export declare function doctorIndexName(table: string, columns: string[]): string;
|
|
88
|
+
/** A (table, columns) pair whose probe maps to a given deterministic name. */
|
|
89
|
+
export interface DoctorProbeColumns {
|
|
90
|
+
table: string;
|
|
91
|
+
columns: string[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Map every deterministic doctor index name the schema's relation probes would
|
|
95
|
+
* generate to the distinct column sets that produce it. A name that maps to more
|
|
96
|
+
* than one distinct column set is a post-truncation collision (very long column
|
|
97
|
+
* names slicing to the same 63 bytes): `doctor --audit` reports those as
|
|
98
|
+
* ambiguous rather than issuing a confident drop verdict.
|
|
99
|
+
*/
|
|
100
|
+
export declare function collectDoctorProbeIndexNames(schema: SchemaMetadata): Map<string, DoctorProbeColumns[]>;
|
|
82
101
|
/**
|
|
83
102
|
* Scan every relation in the schema and return the probes with no index support,
|
|
84
103
|
* deduplicated by (table, column set) with all contributing relations attached.
|
package/dist/index-advisor.js
CHANGED
|
Binary file
|