querydig 0.1.0 → 0.2.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,697 @@
1
+ import {
2
+ RULE_MAP,
3
+ sqlShape
4
+ } from "./chunk-YKUITQ6N.js";
5
+
6
+ // src/live/correlate.ts
7
+ import { createHash } from "crypto";
8
+
9
+ // src/live/match.ts
10
+ var norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
11
+ function singular(s) {
12
+ if (s.endsWith("ies") && s.length > 4) return s.slice(0, -3) + "y";
13
+ if (/(ses|xes|zes|ches|shes)$/.test(s)) return s.slice(0, -2);
14
+ if (s.endsWith("s") && !s.endsWith("ss") && s.length > 3) return s.slice(0, -1);
15
+ return s;
16
+ }
17
+ function resolveTable(name, tables) {
18
+ if (!name) return void 0;
19
+ const all = [...tables];
20
+ const n = norm(name);
21
+ const ns = singular(n);
22
+ const exact = all.find((t) => t === name);
23
+ if (exact) return exact;
24
+ const byNorm = all.find((t) => norm(t) === n);
25
+ if (byNorm) return byNorm;
26
+ const bySingular = all.find((t) => singular(norm(t)) === ns);
27
+ if (bySingular) return bySingular;
28
+ const byDjango = all.find((t) => t.includes("_") && singular(norm(t.split("_").pop())) === ns);
29
+ return byDjango;
30
+ }
31
+ function indexCovers(index, filterKeys, sortKeys) {
32
+ const keys = index.keys.map((k) => k.key);
33
+ const f = [...new Set(filterKeys)];
34
+ if (!f.length && !sortKeys.length) return false;
35
+ const prefix = keys.slice(0, f.length);
36
+ if (f.some((k) => !prefix.includes(k))) return false;
37
+ const rest = index.keys.slice(f.length, f.length + sortKeys.length);
38
+ if (rest.length < sortKeys.length) return !sortKeys.length;
39
+ const same = sortKeys.every((s, i) => rest[i].key === s.key && rest[i].dir === s.dir);
40
+ const reversed = sortKeys.every((s, i) => rest[i].key === s.key && rest[i].dir === -s.dir);
41
+ return same || reversed;
42
+ }
43
+ function suggestIndex(adapter, table, filterKeys, sortKeys) {
44
+ const keys = [...new Set(filterKeys)].map((key) => ({ key, dir: 1 }));
45
+ for (const s of sortKeys) if (!keys.some((k) => k.key === s.key)) keys.push(s);
46
+ if (!keys.length) return void 0;
47
+ if (adapter === "mongodb") return `db.${table}.createIndex({ ${keys.map((k) => `${/^[A-Za-z_$][\w$]*$/.test(k.key) ? k.key : `"${k.key}"`}: ${k.dir}`).join(", ")} })`;
48
+ return `CREATE INDEX CONCURRENTLY ON "${table}" (${keys.map((k) => `"${k.key}"${k.dir === -1 ? " DESC" : ""}`).join(", ")});`;
49
+ }
50
+ function matchSlowQuery(q, shapes, shapeTables) {
51
+ if (!q.table) return void 0;
52
+ const set = new Set(q.filterKeys);
53
+ let best;
54
+ for (const s of shapes) {
55
+ if (shapeTables.get(s.id) !== q.table) continue;
56
+ const overlap = s.filterKeys.filter((k) => set.has(k)).length;
57
+ const exact = overlap === set.size && overlap === new Set(s.filterKeys).size;
58
+ const sortMatch = q.sortKeys.length > 0 && q.sortKeys.every((k, i) => s.sortKeys[i]?.key === k.key);
59
+ const score = (exact ? 10 : overlap) + (sortMatch ? 3 : 0) + (s.inLoop ? 1 : 0);
60
+ if (score > 0 && (!best || score > best.score)) best = { s, score };
61
+ }
62
+ return best?.s;
63
+ }
64
+ function redactUri(uri) {
65
+ return uri.replace(/\/\/([^:@/]+):([^@/]*)@/, "//$1:****@");
66
+ }
67
+ function redactText(text, uri) {
68
+ const m = /\/\/([^:@/]+):([^@/]*)@/.exec(uri);
69
+ let out = text.split(uri).join(redactUri(uri));
70
+ if (m && m[2]) {
71
+ out = out.split(m[2]).join("****");
72
+ try {
73
+ out = out.split(decodeURIComponent(m[2])).join("****");
74
+ } catch {
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ // src/live/correlate.ts
81
+ var THRESHOLDS = { big: 1e5, huge: 1e6, scanMin: 1e4, tiny: 1e3, criticalMs: 1e3 };
82
+ var fmt = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k` : String(Math.round(n));
83
+ var unit = (adapter) => adapter === "mongodb" ? "docs" : "rows";
84
+ var fp = (...parts) => createHash("sha1").update(parts.join("|")).digest("hex").slice(0, 16);
85
+ function change(f, to, ev) {
86
+ if (to === f.severity) return { ...f, evidence: ev };
87
+ return { ...f, severity: to, evidence: { ...ev, severityChange: { from: f.severity, to } } };
88
+ }
89
+ function correlate(findings, shapes, data) {
90
+ const shapeById = new Map(shapes.map((s) => [s.id, s]));
91
+ const u = unit(data.adapter);
92
+ const rowsOf = (t) => t ? data.tables.get(t)?.rows : void 0;
93
+ const tableIndexes = (t) => data.indexes.filter((i) => i.table === t);
94
+ const planText = (p) => `${p.text}${p.docsExamined != null ? ` \xB7 ${fmt(p.docsExamined)} ${u} examined` : ""}${p.ms != null ? ` \xB7 ${p.ms}ms` : ""}`;
95
+ const handled = /* @__PURE__ */ new Set();
96
+ const out = findings.map((f) => {
97
+ const shape = f.shapeId ? shapeById.get(f.shapeId) : void 0;
98
+ if (!shape) return f;
99
+ const table = data.shapeTables.get(shape.id);
100
+ if (!table) return f;
101
+ const rows = rowsOf(table);
102
+ const plan = data.plans.get(shape.id);
103
+ const base = {
104
+ source: plan ? "explain" : "stats",
105
+ collection: table,
106
+ rows,
107
+ plan: plan ? planText(plan) : void 0,
108
+ index: plan?.indexUsed,
109
+ docsExamined: plan?.docsExamined,
110
+ ms: plan?.ms,
111
+ note: `${table}: ${rows != null ? `${fmt(rows)} ${u}` : "size unknown"}${plan ? ` \xB7 ${planText(plan)}` : ""}`
112
+ };
113
+ switch (f.ruleId) {
114
+ case "QD001":
115
+ case "QD002":
116
+ case "QD003": {
117
+ if (plan?.fullScan && rows != null && rows >= THRESHOLDS.big) {
118
+ handled.add(shape.id);
119
+ const idx = suggestIndex(data.adapter, table, shape.filterKeys, shape.sortKeys);
120
+ const g = change(f, "critical", { ...base, note: `Each iteration does a full scan of ${table} (${fmt(rows)} ${u}): N \xD7 ${fmt(rows)} ${u} read.` });
121
+ return idx ? { ...g, fix: `${f.fix} Until then, at least add an index: ${idx}` } : g;
122
+ }
123
+ if (plan?.indexUsed) return { ...f, evidence: { ...base, note: `${base.note}. The inner query uses an index, so each round-trip is cheap, but N round-trips remain.` } };
124
+ return { ...f, evidence: base };
125
+ }
126
+ case "MQ001":
127
+ case "SQ001": {
128
+ if (rows == null) return f;
129
+ handled.add(shape.id);
130
+ if (rows < THRESHOLDS.tiny) return change(f, "low", { ...base, note: `${table} has only ${fmt(rows)} ${u}: harmless today, but it will grow with the table.` });
131
+ if (rows >= THRESHOLDS.huge) return change(f, "critical", { ...base, note: `Reads all ${fmt(rows)} ${u} of ${table} on every call.` });
132
+ return { ...f, evidence: { ...base, note: `Reads all ${fmt(rows)} ${u} of ${table} on every call.` } };
133
+ }
134
+ case "MQ005": {
135
+ handled.add(shape.id);
136
+ const covered = tableIndexes(table).find((i) => indexCovers(i, shape.filterKeys, shape.sortKeys));
137
+ if (covered) return change(f, "low", { ...base, source: "indexes", index: covered.name, note: `Index ${covered.name} already serves this filter and sort; adding a limit is still recommended.` });
138
+ const idx = suggestIndex(data.adapter, table, shape.filterKeys, shape.sortKeys);
139
+ return { ...f, evidence: { ...base, note: `No index serves this sort on ${table}${rows != null ? ` (${fmt(rows)} ${u})` : ""}.` }, fix: idx ? `Add .limit(n) and create: ${idx}` : f.fix };
140
+ }
141
+ case "SQ004":
142
+ case "MQ004":
143
+ case "SQ005":
144
+ case "SQ003":
145
+ case "MQ002":
146
+ if (plan?.fullScan) handled.add(shape.id);
147
+ return { ...f, evidence: base };
148
+ default:
149
+ return f;
150
+ }
151
+ });
152
+ const add = (ruleId, x) => {
153
+ const rule = RULE_MAP.get(ruleId);
154
+ out.push({ ruleId, title: rule.title, fix: x.fix ?? rule.fix, confidence: x.confidence ?? "high", lang: x.lang ?? "db", ...x });
155
+ };
156
+ const dbLoc = (table) => `db://${data.database}${table ? "/" + table : ""}`;
157
+ const seenPlan = /* @__PURE__ */ new Set();
158
+ for (const s of shapes) {
159
+ const plan = data.plans.get(s.id);
160
+ const table = data.shapeTables.get(s.id);
161
+ if (!plan || !table || handled.has(s.id)) continue;
162
+ const rows = rowsOf(table) ?? plan.estRows ?? 0;
163
+ const key = `${table}|${[...s.filterKeys].sort().join(",")}|${s.sortKeys.map((k) => k.key + k.dir).join(",")}|${s.file}`;
164
+ if (seenPlan.has(key)) continue;
165
+ seenPlan.add(key);
166
+ const idx = suggestIndex(data.adapter, table, s.filterKeys, s.sortKeys);
167
+ const snippet = s.sql ?? `${table}.${s.method}(${s.filterKeys.length ? `{ ${s.filterKeys.join(", ")} }` : ""})${s.sortKeys.length ? `.sort({ ${s.sortKeys.map((k) => `${k.key}: ${k.dir}`).join(", ")} })` : ""}`;
168
+ const ev = { source: "explain", collection: table, rows, plan: planText(plan), index: plan.indexUsed, docsExamined: plan.docsExamined, ms: plan.ms, note: "" };
169
+ if (plan.fullScan && rows >= THRESHOLDS.scanMin) {
170
+ add("LV001", {
171
+ severity: rows >= THRESHOLDS.huge ? "critical" : "high",
172
+ db: s.db,
173
+ file: s.file,
174
+ line: s.line,
175
+ column: 1,
176
+ snippet,
177
+ message: `Full ${data.adapter === "mongodb" ? "collection" : "table"} scan of ${table} (${fmt(rows)} ${u})${s.filterKeys.length ? ` filtering on ${s.filterKeys.join(", ")}` : ""}${s.inLoop ? ", inside a loop" : ""}.`,
178
+ fix: idx ? `Create an index: ${idx}` : void 0,
179
+ evidence: { ...ev, note: `${table}: ${fmt(rows)} ${u} \xB7 ${planText(plan)}` },
180
+ fingerprint: fp("LV001", s.file, table, key),
181
+ shapeId: s.id
182
+ });
183
+ } else if (plan.inMemorySort && s.sortKeys.length && rows >= THRESHOLDS.scanMin) {
184
+ add("LV002", {
185
+ severity: "medium",
186
+ db: s.db,
187
+ file: s.file,
188
+ line: s.line,
189
+ column: 1,
190
+ snippet,
191
+ message: `Sorts ${table} in memory on ${s.sortKeys.map((k) => k.key).join(", ")} (${fmt(rows)} ${u}).`,
192
+ fix: idx ? `Create an index: ${idx}` : void 0,
193
+ evidence: { ...ev, note: `${table}: ${fmt(rows)} ${u} \xB7 ${planText(plan)}` },
194
+ fingerprint: fp("LV002", s.file, table, key),
195
+ shapeId: s.id
196
+ });
197
+ }
198
+ }
199
+ for (const q of data.slow) {
200
+ const shape = matchSlowQuery(q, shapes, data.shapeTables);
201
+ const idx = q.table ? suggestIndex(data.adapter, q.table, q.filterKeys, q.sortKeys) : void 0;
202
+ const covered = q.table ? tableIndexes(q.table).some((i) => indexCovers(i, q.filterKeys, q.sortKeys)) : false;
203
+ add("LV003", {
204
+ severity: q.meanMs >= THRESHOLDS.criticalMs ? "critical" : "high",
205
+ db: data.adapter === "mongodb" ? "mongodb" : "sql",
206
+ file: shape?.file ?? dbLoc(q.table),
207
+ line: shape?.line ?? 0,
208
+ column: shape ? 1 : 0,
209
+ lang: shape ? void 0 : "db",
210
+ snippet: q.text.slice(0, 240),
211
+ message: `Averages ${Math.round(q.meanMs)}ms over ${q.calls} call(s)${q.table ? ` on ${q.table}` : ""}${q.plan ? ` (${q.plan})` : ""}${shape ? "" : ". No matching call found in the code."}`,
212
+ fix: idx && !covered ? `Likely missing index: ${idx}` : void 0,
213
+ evidence: { source: "profiler", collection: q.table, ms: Math.round(q.meanMs), plan: q.plan, docsExamined: q.docsExamined, note: `${q.calls} call(s), mean ${Math.round(q.meanMs)}ms${q.docsExamined != null ? `, ${fmt(q.docsExamined)} ${u} examined` : ""}` },
214
+ fingerprint: fp("LV003", q.table, q.filterKeys.join(","), q.sortKeys.map((k) => k.key).join(",")),
215
+ shapeId: shape?.id,
216
+ confidence: shape ? "high" : "medium"
217
+ });
218
+ }
219
+ for (const i of data.indexes) {
220
+ if (i.usage !== 0 || i.primary || i.unique || i.name === "_id_") continue;
221
+ const drop = data.adapter === "mongodb" ? `db.${i.table}.dropIndex("${i.name}")` : `DROP INDEX CONCURRENTLY "${i.name}";`;
222
+ add("LV004", {
223
+ severity: "low",
224
+ db: data.adapter === "mongodb" ? "mongodb" : "sql",
225
+ file: dbLoc(i.table),
226
+ line: 0,
227
+ column: 0,
228
+ snippet: i.definition ?? `${i.name} (${i.keys.map((k) => k.key).join(", ")})`,
229
+ message: `Index ${i.name} on ${i.table} has not been used since statistics were reset.`,
230
+ fix: `Confirm on every replica over a full business cycle, then: ${drop}`,
231
+ evidence: { source: "indexes", collection: i.table, index: i.name, note: `0 uses recorded` },
232
+ fingerprint: fp("LV004", i.table, i.name)
233
+ });
234
+ }
235
+ for (const k of data.fks) {
236
+ const indexed = tableIndexes(k.table).some((i) => i.keys[0]?.key === k.column);
237
+ if (indexed) continue;
238
+ const users = shapes.filter((s2) => data.shapeTables.get(s2.id) === k.table && s2.filterKeys.includes(k.column));
239
+ if (!users.length) continue;
240
+ const s = users.find((x) => x.inLoop) ?? users[0];
241
+ add("LV005", {
242
+ severity: "medium",
243
+ db: "sql",
244
+ file: s.file,
245
+ line: s.line,
246
+ column: 1,
247
+ snippet: s.sql ?? `${k.table}.${k.column} \u2192 ${k.refTable}`,
248
+ message: `${k.table}.${k.column} references ${k.refTable} but has no index; ${users.length} quer${users.length === 1 ? "y filters" : "ies filter"} on it.`,
249
+ fix: `CREATE INDEX CONCURRENTLY ON "${k.table}" ("${k.column}");`,
250
+ evidence: { source: "indexes", collection: k.table, rows: rowsOf(k.table), note: `no index starts with ${k.column}` },
251
+ fingerprint: fp("LV005", k.table, k.column),
252
+ shapeId: s.id
253
+ });
254
+ }
255
+ return out;
256
+ }
257
+
258
+ // src/live/mongo.ts
259
+ var keysOf = (spec) => Object.entries(spec ?? {}).map(([key, v]) => ({ key, dir: Number(v) === -1 ? -1 : 1 }));
260
+ function filterFields(filter) {
261
+ const out = /* @__PURE__ */ new Set();
262
+ const walk = (f) => {
263
+ if (!f || typeof f !== "object") return;
264
+ for (const [k, v] of Object.entries(f)) {
265
+ if (k === "$and" || k === "$or" || k === "$nor") (Array.isArray(v) ? v : []).forEach(walk);
266
+ else if (!k.startsWith("$")) out.add(k);
267
+ }
268
+ };
269
+ walk(filter);
270
+ return [...out];
271
+ }
272
+ function summarisePlan(plan, table) {
273
+ const stages = [];
274
+ let fullScan = false;
275
+ let inMemorySort = false;
276
+ let indexUsed;
277
+ const walk = (n) => {
278
+ if (!n || typeof n !== "object") return;
279
+ const node = n.queryPlan ?? n;
280
+ const stage = node.stage;
281
+ if (stage) {
282
+ stages.push(stage === "IXSCAN" && node.indexName ? `IXSCAN ${node.indexName}` : stage);
283
+ if (stage === "COLLSCAN") fullScan = true;
284
+ if (stage === "SORT") inMemorySort = true;
285
+ if ((stage === "IXSCAN" || stage === "COUNT_SCAN" || stage === "DISTINCT_SCAN") && node.indexName) indexUsed ??= node.indexName;
286
+ if (stage === "IDHACK" || stage === "EXPRESS_IXSCAN" || stage === "EXPRESS_CLUSTERED_IXSCAN") indexUsed ??= "_id_";
287
+ }
288
+ walk(node.inputStage);
289
+ (node.inputStages ?? []).forEach(walk);
290
+ };
291
+ walk(plan);
292
+ return { table, fullScan, inMemorySort, indexUsed, text: stages.reverse().join(" \u2192 ") || "unknown" };
293
+ }
294
+ var MongoAdapter = class {
295
+ // eslint-disable-line @typescript-eslint/no-explicit-any
296
+ constructor(opts) {
297
+ this.opts = opts;
298
+ }
299
+ opts;
300
+ name = "mongodb";
301
+ database = "";
302
+ client;
303
+ // eslint-disable-line @typescript-eslint/no-explicit-any
304
+ db;
305
+ async connect() {
306
+ let mod;
307
+ try {
308
+ mod = await import("mongodb");
309
+ } catch {
310
+ throw new Error('The "mongodb" driver is not installed. Run: npm i -D mongodb');
311
+ }
312
+ this.client = new mod.MongoClient(this.opts.uri, {
313
+ appName: "querydig",
314
+ readPreference: "secondaryPreferred",
315
+ serverSelectionTimeoutMS: 8e3,
316
+ connectTimeoutMS: 8e3,
317
+ retryWrites: false
318
+ });
319
+ await this.client.connect();
320
+ const fromUri = this.client.options?.dbName ?? void 0;
321
+ const name = this.opts.db ?? (fromUri && fromUri !== "test" ? fromUri : void 0);
322
+ if (!name) throw new Error("No database name in the URI. Add it to the path (mongodb://host/mydb) or pass --db <name>.");
323
+ this.database = name;
324
+ this.db = this.client.db(name);
325
+ }
326
+ get t() {
327
+ return this.opts.timeoutMs;
328
+ }
329
+ async tables() {
330
+ const cols = await this.db.listCollections({ type: "collection" }, { nameOnly: true }).toArray();
331
+ const names = cols.map((c) => c.name).filter((n) => !n.startsWith("system."));
332
+ const out = [];
333
+ for (const name of names) {
334
+ try {
335
+ out.push({ name, rows: await this.db.collection(name).estimatedDocumentCount({ maxTimeMS: this.t }) });
336
+ } catch {
337
+ out.push({ name, rows: 0 });
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+ async indexes(tables) {
343
+ const out = [];
344
+ for (const table of tables) {
345
+ const coll = this.db.collection(table);
346
+ let list = [];
347
+ try {
348
+ list = await coll.listIndexes().toArray();
349
+ } catch {
350
+ continue;
351
+ }
352
+ const usage = /* @__PURE__ */ new Map();
353
+ try {
354
+ const stats = await coll.aggregate([{ $indexStats: {} }], { maxTimeMS: this.t }).toArray();
355
+ for (const s of stats) usage.set(s.name, (usage.get(s.name) ?? 0) + Number(s.accesses?.ops ?? 0));
356
+ } catch {
357
+ }
358
+ for (const i of list) {
359
+ out.push({
360
+ table,
361
+ name: i.name,
362
+ keys: keysOf(i.key),
363
+ unique: !!i.unique,
364
+ primary: i.name === "_id_",
365
+ usage: usage.size ? usage.get(i.name) ?? 0 : void 0,
366
+ definition: `${i.name} ${JSON.stringify(i.key)}`
367
+ });
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+ async foreignKeys() {
373
+ return [];
374
+ }
375
+ async explain(shape, table) {
376
+ if (!["read", "readMany", "count"].includes(shape.kind)) return null;
377
+ const filter = Object.fromEntries(shape.filterKeys.map((k) => [k, null]));
378
+ const sort = Object.fromEntries(shape.sortKeys.map((k) => [k.key, k.dir]));
379
+ const verbosity = this.opts.explainExecute ? "executionStats" : "queryPlanner";
380
+ const cursor = this.db.collection(table).find(filter, { sort: shape.sortKeys.length ? sort : void 0, limit: shape.hasLimit ? 1 : void 0, maxTimeMS: this.t });
381
+ const res = await cursor.explain(verbosity);
382
+ const qp = res.queryPlanner ?? res.stages?.[0]?.$cursor?.queryPlanner;
383
+ const sum = summarisePlan(qp?.winningPlan, table);
384
+ const es = res.executionStats;
385
+ return { ...sum, docsExamined: es?.totalDocsExamined, ms: es?.executionTimeMillis };
386
+ }
387
+ async slowQueries(slowMs) {
388
+ let level = 0;
389
+ try {
390
+ const p = await this.db.command({ profile: -1 });
391
+ level = Number(p.was ?? 0);
392
+ } catch {
393
+ return [];
394
+ }
395
+ const prof = this.db.collection("system.profile");
396
+ let rows = [];
397
+ try {
398
+ rows = await prof.aggregate([
399
+ { $match: { millis: { $gte: slowMs }, op: { $in: ["query", "command", "getmore", "update", "remove"] }, appName: { $ne: "querydig" }, ns: { $not: /\.system\./ } } },
400
+ { $group: { _id: { ns: "$ns", h: { $ifNull: ["$queryHash", "$planCacheShapeHash"] } }, calls: { $sum: 1 }, ms: { $avg: "$millis" }, ex: { $avg: "$docsExamined" }, cmd: { $first: "$command" }, plan: { $first: "$planSummary" } } },
401
+ { $sort: { ms: -1 } },
402
+ { $limit: 50 }
403
+ ], { maxTimeMS: this.t }).toArray();
404
+ } catch {
405
+ return [];
406
+ }
407
+ if (!rows.length && level === 0) return [];
408
+ return rows.map((r) => {
409
+ const ns = r._id.ns ?? "";
410
+ const table = ns.includes(".") ? ns.slice(ns.indexOf(".") + 1) : void 0;
411
+ const cmd = r.cmd ?? {};
412
+ const filter = cmd.filter ?? cmd.q ?? cmd.query ?? cmd.pipeline?.[0]?.$match;
413
+ return {
414
+ table,
415
+ filterKeys: filterFields(filter),
416
+ sortKeys: keysOf(cmd.sort),
417
+ meanMs: Number(r.ms),
418
+ calls: Number(r.calls),
419
+ text: JSON.stringify({ [Object.keys(cmd)[0] ?? "op"]: table, filter: filter ?? {}, ...cmd.sort ? { sort: cmd.sort } : {} }),
420
+ plan: r.plan,
421
+ docsExamined: r.ex != null ? Math.round(r.ex) : void 0
422
+ };
423
+ });
424
+ }
425
+ async warnings() {
426
+ const w = [];
427
+ try {
428
+ const p = await this.db.command({ profile: -1 });
429
+ if (Number(p.was ?? 0) === 0) w.push(`Profiling is off on ${this.database}, so slow queries (LV003) can't be read. Enable with db.setProfilingLevel(1, { slowms: 100 }) on a replica.`);
430
+ } catch {
431
+ }
432
+ try {
433
+ const s = await this.db.command({ connectionStatus: 1, showPrivileges: true });
434
+ const users = s.authInfo?.authenticatedUsers ?? [];
435
+ const privs = s.authInfo?.authenticatedUserPrivileges ?? [];
436
+ const writes = privs.some((p) => (p.actions ?? []).some((a) => ["insert", "update", "remove", "dropCollection", "createIndex"].includes(a)));
437
+ if (!users.length) w.push('Connected without authentication. querydig only sends read commands, but prefer a user with the built-in "read" role.');
438
+ else if (writes) w.push('The connected user can write. querydig only sends read commands, but a user with the built-in "read" role is safer.');
439
+ } catch {
440
+ }
441
+ return w;
442
+ }
443
+ async close() {
444
+ await this.client?.close().catch(() => void 0);
445
+ }
446
+ };
447
+
448
+ // src/live/postgres.ts
449
+ var SCHEMA_FILTER = `n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_toast%'`;
450
+ var ident = (s) => `"${s.replace(/"/g, '""')}"`;
451
+ function summarisePgPlan(root, table) {
452
+ const stages = [];
453
+ let fullScan = false;
454
+ let inMemorySort = false;
455
+ let indexUsed;
456
+ const walk = (n) => {
457
+ if (!n) return;
458
+ const t = n["Node Type"];
459
+ const rel = n["Relation Name"];
460
+ stages.push(t === "Seq Scan" && rel ? `Seq Scan on ${rel}` : n["Index Name"] ? `${t} ${n["Index Name"]}` : t);
461
+ if (t === "Seq Scan" && (!rel || rel === table)) fullScan = true;
462
+ if (t === "Sort" || t === "Incremental Sort") inMemorySort = true;
463
+ if (n["Index Name"]) indexUsed ??= n["Index Name"];
464
+ (n.Plans ?? []).forEach(walk);
465
+ };
466
+ walk(root);
467
+ return {
468
+ table,
469
+ fullScan,
470
+ inMemorySort,
471
+ indexUsed,
472
+ text: stages.join(" \u2192 ") || "unknown",
473
+ estRows: root?.["Plan Rows"],
474
+ ms: root?.["Actual Total Time"] != null ? Math.round(root["Actual Total Time"]) : void 0,
475
+ docsExamined: void 0
476
+ };
477
+ }
478
+ var PostgresAdapter = class {
479
+ constructor(opts) {
480
+ this.opts = opts;
481
+ }
482
+ opts;
483
+ name = "postgres";
484
+ database = "";
485
+ client;
486
+ // eslint-disable-line @typescript-eslint/no-explicit-any
487
+ version = 0;
488
+ columns = /* @__PURE__ */ new Map();
489
+ async connect() {
490
+ let pg;
491
+ try {
492
+ pg = (await import("pg")).default;
493
+ } catch {
494
+ throw new Error('The "pg" driver is not installed. Run: npm i -D pg');
495
+ }
496
+ this.client = new pg.Client({ connectionString: this.opts.uri, application_name: "querydig", connectionTimeoutMillis: 8e3, statement_timeout: this.opts.timeoutMs });
497
+ await this.client.connect();
498
+ await this.client.query("SET default_transaction_read_only = on");
499
+ await this.client.query(`SET statement_timeout = ${Math.max(1e3, Math.floor(this.opts.timeoutMs))}`);
500
+ const r = await this.client.query(`SELECT current_database() AS db, current_setting('server_version_num')::int AS v`);
501
+ this.database = r.rows[0].db;
502
+ this.version = r.rows[0].v;
503
+ }
504
+ async q(sql, params = []) {
505
+ return (await this.client.query(sql, params)).rows;
506
+ }
507
+ async tables() {
508
+ const rows = await this.q(`
509
+ SELECT c.relname AS name, c.reltuples::bigint AS reltuples, COALESCE(s.n_live_tup, 0)::bigint AS live, pg_total_relation_size(c.oid)::bigint AS size
510
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
511
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
512
+ WHERE c.relkind IN ('r', 'p') AND ${SCHEMA_FILTER}`);
513
+ const cols = await this.q(`
514
+ SELECT c.relname AS t, a.attname AS col FROM pg_attribute a
515
+ JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace
516
+ WHERE a.attnum > 0 AND NOT a.attisdropped AND c.relkind IN ('r', 'p') AND ${SCHEMA_FILTER}`);
517
+ for (const c of cols) (this.columns.get(c.t) ?? this.columns.set(c.t, /* @__PURE__ */ new Set()).get(c.t)).add(c.col);
518
+ return rows.map((r) => ({ name: r.name, rows: Math.max(Number(r.reltuples), Number(r.live), 0), sizeBytes: Number(r.size) }));
519
+ }
520
+ async indexes() {
521
+ const rows = await this.q(`
522
+ SELECT t.relname AS tbl, i.relname AS idx, ix.indisunique AS uniq, ix.indisprimary AS pk, s.idx_scan AS scans,
523
+ pg_get_indexdef(ix.indexrelid) AS def,
524
+ ARRAY(SELECT a.attname FROM unnest(ix.indkey) WITH ORDINALITY k(attnum, ord)
525
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum ORDER BY k.ord)::text[] AS cols,
526
+ ARRAY(SELECT (o::int & 1) FROM unnest(ix.indoption) AS o)::int[] AS desc_flags
527
+ FROM pg_index ix
528
+ JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid
529
+ JOIN pg_namespace n ON n.oid = t.relnamespace
530
+ LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = ix.indexrelid
531
+ WHERE ${SCHEMA_FILTER}`);
532
+ return rows.map((r) => ({
533
+ table: r.tbl,
534
+ name: r.idx,
535
+ unique: r.uniq,
536
+ primary: r.pk,
537
+ usage: r.scans == null ? void 0 : Number(r.scans),
538
+ definition: r.def,
539
+ keys: r.cols.map((key, i) => ({ key, dir: Number(r.desc_flags[i]) === 1 ? -1 : 1 }))
540
+ }));
541
+ }
542
+ async foreignKeys() {
543
+ const rows = await this.q(`
544
+ SELECT cl.relname AS tbl, a.attname AS col, rt.relname AS ref
545
+ FROM pg_constraint c
546
+ JOIN pg_class cl ON cl.oid = c.conrelid JOIN pg_class rt ON rt.oid = c.confrelid
547
+ JOIN pg_namespace n ON n.oid = cl.relnamespace
548
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
549
+ WHERE c.contype = 'f' AND array_length(c.conkey, 1) = 1 AND ${SCHEMA_FILTER}`);
550
+ return rows.map((r) => ({ table: r.tbl, column: r.col, refTable: r.ref }));
551
+ }
552
+ /** SQL to explain for a shape, or null when it can't be explained safely. */
553
+ explainSql(shape, table) {
554
+ if (shape.sql) {
555
+ const sql = shape.sql.trim().replace(/;\s*$/, "");
556
+ if (!/^\s*(select|with)\b/i.test(sql) || /\?/.test(sql) || /;/.test(sql)) return null;
557
+ return { sql, generic: /\$\d/.test(sql) };
558
+ }
559
+ if (!["read", "readMany", "count"].includes(shape.kind)) return null;
560
+ const cols = this.columns.get(table);
561
+ const filter = shape.filterKeys.filter((k) => cols?.has(k));
562
+ const sort = shape.sortKeys.filter((k) => cols?.has(k.key));
563
+ if (filter.length !== shape.filterKeys.length) return null;
564
+ const where = filter.length ? ` WHERE ${filter.map((k, i) => `${ident(k)} = $${i + 1}`).join(" AND ")}` : "";
565
+ const order = sort.length ? ` ORDER BY ${sort.map((k) => `${ident(k.key)}${k.dir === -1 ? " DESC" : ""}`).join(", ")}` : "";
566
+ return { sql: `SELECT 1 FROM ${ident(table)}${where}${order}${shape.hasLimit ? " LIMIT 1" : ""}`, generic: filter.length > 0 };
567
+ }
568
+ async explain(shape, table) {
569
+ const e = this.explainSql(shape, table);
570
+ if (!e) return null;
571
+ if (e.generic && this.version < 16e4) return null;
572
+ const analyze = this.opts.explainExecute && !e.generic;
573
+ const opts = ["FORMAT JSON", e.generic ? "GENERIC_PLAN" : "", analyze ? "ANALYZE" : ""].filter(Boolean).join(", ");
574
+ const rows = await this.q(`EXPLAIN (${opts}) ${e.sql}`);
575
+ const plan = rows[0]?.["QUERY PLAN"]?.[0]?.Plan;
576
+ return summarisePgPlan(plan, table);
577
+ }
578
+ async slowQueries(slowMs) {
579
+ const ext = await this.q(`SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'`);
580
+ if (!ext.length) return [];
581
+ let rows = [];
582
+ try {
583
+ rows = await this.q(`
584
+ SELECT query, calls, mean_exec_time AS mean FROM pg_stat_statements
585
+ WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
586
+ AND mean_exec_time >= $1 AND query !~* '(pg_catalog|information_schema|pg_class|pg_stat|pg_index|pg_attribute|pg_constraint|pg_extension|pg_roles|pg_database|^\\s*(explain|set|show|begin|commit|rollback)\\b)'
587
+ ORDER BY total_exec_time DESC LIMIT 50`, [slowMs]);
588
+ } catch {
589
+ return [];
590
+ }
591
+ return rows.map((r) => {
592
+ const sh = sqlShape(r.query);
593
+ return { table: sh.table, filterKeys: sh.filterKeys, sortKeys: sh.sortKeys, meanMs: Number(r.mean), calls: Number(r.calls), text: String(r.query).replace(/\s+/g, " ").trim() };
594
+ });
595
+ }
596
+ async warnings() {
597
+ const w = [];
598
+ try {
599
+ const ext = await this.q(`SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'`);
600
+ if (!ext.length) w.push("pg_stat_statements is not installed, so slow queries (LV003) can't be read. Enable it with CREATE EXTENSION pg_stat_statements.");
601
+ const r = await this.q(`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`);
602
+ const writes = await this.q(`SELECT count(*)::int AS n FROM information_schema.table_privileges WHERE grantee = current_user AND privilege_type IN ('INSERT', 'UPDATE', 'DELETE')`);
603
+ if (r[0]?.rolsuper) w.push("Connected as a superuser. querydig runs every statement in a read-only transaction, but a role with pg_read_all_stats and SELECT is safer.");
604
+ else if (writes[0]?.n > 0) w.push("The connected role can write. querydig runs every statement in a read-only transaction, but a read-only role is safer.");
605
+ if (this.version < 16e4) w.push(`PostgreSQL ${Math.floor(this.version / 1e4)} can't plan parameterized queries; upgrade to 16+ for full explain coverage.`);
606
+ } catch {
607
+ }
608
+ return w;
609
+ }
610
+ async close() {
611
+ await this.client?.end().catch(() => void 0);
612
+ }
613
+ };
614
+
615
+ // src/live/index.ts
616
+ function adapterFor(opts) {
617
+ if (/^mongodb(\+srv)?:\/\//i.test(opts.uri)) return new MongoAdapter(opts);
618
+ if (/^postgres(ql)?:\/\//i.test(opts.uri)) return new PostgresAdapter(opts);
619
+ throw new Error("Unsupported --uri. Use mongodb://, mongodb+srv://, postgres:// or postgresql://");
620
+ }
621
+ async function pool(items, n, fn) {
622
+ const out = [];
623
+ let i = 0;
624
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
625
+ while (i < items.length) {
626
+ const idx = i++;
627
+ out[idx] = await fn(items[idx]);
628
+ }
629
+ }));
630
+ return out;
631
+ }
632
+ async function collectLive(shapes, opts, adapter = adapterFor(opts)) {
633
+ try {
634
+ await adapter.connect();
635
+ const tableList = await adapter.tables();
636
+ const tables = new Map(tableList.map((t) => [t.name, t]));
637
+ const relevantDb = adapter.name === "mongodb" ? "mongodb" : "sql";
638
+ const shapeTables = /* @__PURE__ */ new Map();
639
+ for (const s of shapes) {
640
+ if (s.db !== relevantDb && s.db !== "unknown") continue;
641
+ const t = resolveTable(s.collection, tables.keys());
642
+ if (t) shapeTables.set(s.id, t);
643
+ }
644
+ const used = [...new Set(shapeTables.values())];
645
+ const indexes = await adapter.indexes(adapter.name === "mongodb" ? used.length ? used : [...tables.keys()] : [...tables.keys()]);
646
+ const fks = await adapter.foreignKeys().catch(() => []);
647
+ const groups = /* @__PURE__ */ new Map();
648
+ for (const s of shapes) {
649
+ const t = shapeTables.get(s.id);
650
+ if (!t) continue;
651
+ const key = `${t}|${[...s.filterKeys].sort().join(",")}|${s.sortKeys.map((k) => k.key + k.dir).join(",")}|${s.kind}|${s.hasLimit}|${s.sql ?? ""}`;
652
+ (groups.get(key) ?? groups.set(key, []).get(key)).push(s);
653
+ }
654
+ const plans = /* @__PURE__ */ new Map();
655
+ const work = [...groups.values()].slice(0, opts.maxExplain);
656
+ let explained = 0;
657
+ await pool(work, adapter.name === "postgres" ? 1 : 4, async (group) => {
658
+ const s = group[0];
659
+ try {
660
+ const p = await adapter.explain(s, shapeTables.get(s.id));
661
+ if (p) {
662
+ explained++;
663
+ for (const g of group) plans.set(g.id, p);
664
+ }
665
+ } catch {
666
+ }
667
+ });
668
+ const slow = await adapter.slowQueries(opts.slowMs).catch(() => []);
669
+ const warnings = await adapter.warnings().catch(() => []);
670
+ if (groups.size > opts.maxExplain) warnings.push(`Explained ${opts.maxExplain} of ${groups.size} query shapes (raise with --max-explain).`);
671
+ if (!shapeTables.size && shapes.length) warnings.push(`None of the ${shapes.length} queries in the code matched a ${adapter.name === "mongodb" ? "collection" : "table"} in ${adapter.database}. Is this the right database?`);
672
+ return { adapter: adapter.name, database: adapter.database, tables, indexes, plans, shapeTables, slow, fks, explained, warnings };
673
+ } catch (e) {
674
+ throw new Error(redactText(e.message, opts.uri));
675
+ } finally {
676
+ await adapter.close();
677
+ }
678
+ }
679
+ async function runLive(findings, shapes, opts) {
680
+ const adapterName = /^mongodb/i.test(opts.uri) ? "mongodb" : "postgres";
681
+ try {
682
+ const data = await collectLive(shapes, opts);
683
+ return {
684
+ findings: correlate(findings, shapes, data),
685
+ summary: { adapter: data.adapter, database: data.database, tables: data.tables.size, explained: data.explained, slowQueries: data.slow.length, warnings: data.warnings }
686
+ };
687
+ } catch (e) {
688
+ return { findings, summary: { adapter: adapterName, database: "", tables: 0, explained: 0, slowQueries: 0, warnings: [], error: `${e.message} (${redactUri(opts.uri)})` } };
689
+ }
690
+ }
691
+ export {
692
+ adapterFor,
693
+ collectLive,
694
+ correlate,
695
+ redactUri,
696
+ runLive
697
+ };