dbopt-engine 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.
- package/.env.example +19 -0
- package/README.md +143 -0
- package/bin/cli.js +51 -0
- package/package.json +46 -0
- package/src/adapters/index.js +20 -0
- package/src/adapters/mssql.js +236 -0
- package/src/adapters/mysql.js +177 -0
- package/src/adapters/postgres.js +137 -0
- package/src/config.js +140 -0
- package/src/executor.js +127 -0
- package/src/index.js +55 -0
- package/src/planParser.js +35 -0
- package/src/routes.js +160 -0
- package/src/scheduler.js +132 -0
- package/src/static/dashboard.html +551 -0
- package/src/static/topology.html +926 -0
- package/src/store.js +155 -0
- package/src/targets.js +17 -0
- package/src/topology.js +217 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SQLite-backed recommendations + action history (better-sqlite3, sync).
|
|
4
|
+
* One state file serves all target databases: every row is tagged with its
|
|
5
|
+
* target (engine://host:port/db) and storeFor(target) returns an API whose
|
|
6
|
+
* every query is scoped to that target, so a recommendation recorded for one
|
|
7
|
+
* database can never be executed against another.
|
|
8
|
+
*/
|
|
9
|
+
const Database = require("better-sqlite3");
|
|
10
|
+
const { settings } = require("./config");
|
|
11
|
+
|
|
12
|
+
const db = new Database(settings.stateDbPath);
|
|
13
|
+
db.pragma("journal_mode = WAL");
|
|
14
|
+
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS recommendations (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
target TEXT NOT NULL DEFAULT '',
|
|
19
|
+
type TEXT NOT NULL,
|
|
20
|
+
table_name TEXT NOT NULL,
|
|
21
|
+
detail TEXT NOT NULL,
|
|
22
|
+
improvement_pct REAL,
|
|
23
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
24
|
+
fingerprint TEXT NOT NULL,
|
|
25
|
+
created_at TEXT NOT NULL,
|
|
26
|
+
applied_at TEXT,
|
|
27
|
+
executed_sql TEXT,
|
|
28
|
+
rollback_sql TEXT,
|
|
29
|
+
error TEXT
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_recs_target_fp ON recommendations (target, fingerprint);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS size_snapshots (
|
|
33
|
+
target TEXT NOT NULL, taken_at TEXT NOT NULL, total_bytes INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
`);
|
|
36
|
+
// retention: prune resolved history; pending is never pruned
|
|
37
|
+
const cutoff = new Date(Date.now() - settings.historyRetentionDays * 86400e3).toISOString();
|
|
38
|
+
db.prepare("DELETE FROM recommendations WHERE status != 'pending' AND created_at < ?").run(cutoff);
|
|
39
|
+
|
|
40
|
+
const now = () => new Date().toISOString();
|
|
41
|
+
const rowToRec = (r) => r && {
|
|
42
|
+
id: r.id, type: r.type, table: r.table_name, detail: JSON.parse(r.detail),
|
|
43
|
+
improvement_pct: r.improvement_pct, status: r.status, created_at: r.created_at,
|
|
44
|
+
applied_at: r.applied_at, executed_sql: r.executed_sql,
|
|
45
|
+
rollback_sql: r.rollback_sql, error: r.error,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function fingerprint(type, table, detail) {
|
|
49
|
+
if (type === "missing_index") {
|
|
50
|
+
const cols = [...(detail.columns || [])].sort().join(",");
|
|
51
|
+
const p = detail.predicate;
|
|
52
|
+
const pp = p ? `:${p.column} ${p.op}${p.value != null ? " " + p.value : ""}` : "";
|
|
53
|
+
return `${type}:${table}:${cols}${pp}`;
|
|
54
|
+
}
|
|
55
|
+
if (type === "unused_index") return `${type}:${table}:${detail.index_name}`;
|
|
56
|
+
return `${type}:${table}:${JSON.stringify(detail)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** All read/write operations for one target database. */
|
|
60
|
+
function storeFor(cfg) {
|
|
61
|
+
const target = `${cfg.dbEngine}://${cfg.host}:${cfg.port}/${cfg.database}`;
|
|
62
|
+
|
|
63
|
+
function getRecommendation(id) {
|
|
64
|
+
return rowToRec(db.prepare("SELECT * FROM recommendations WHERE id=? AND target=?").get(id, target));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function addRecommendation(type, table, detail, improvementPct = null) {
|
|
68
|
+
const fp = fingerprint(type, table, detail);
|
|
69
|
+
const existing = db.prepare(
|
|
70
|
+
"SELECT * FROM recommendations WHERE target=? AND fingerprint=? AND status='pending'"
|
|
71
|
+
).get(target, fp);
|
|
72
|
+
if (existing) return rowToRec(existing);
|
|
73
|
+
const info = db.prepare(
|
|
74
|
+
`INSERT INTO recommendations (target,type,table_name,detail,improvement_pct,status,fingerprint,created_at)
|
|
75
|
+
VALUES (?,?,?,?,?,'pending',?,?)`
|
|
76
|
+
).run(target, type, table, JSON.stringify(detail), improvementPct, fp, now());
|
|
77
|
+
return getRecommendation(info.lastInsertRowid);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function listRecommendations(status = null) {
|
|
81
|
+
const rows = status
|
|
82
|
+
? db.prepare("SELECT * FROM recommendations WHERE target=? AND status=? ORDER BY created_at DESC").all(target, status)
|
|
83
|
+
: db.prepare("SELECT * FROM recommendations WHERE target=? ORDER BY created_at DESC").all(target);
|
|
84
|
+
return rows.map(rowToRec);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function updateStatus(id, status) {
|
|
88
|
+
db.prepare("UPDATE recommendations SET status=? WHERE id=? AND target=?").run(status, id, target);
|
|
89
|
+
return getRecommendation(id);
|
|
90
|
+
}
|
|
91
|
+
function updateDetail(id, detail) {
|
|
92
|
+
db.prepare("UPDATE recommendations SET detail=? WHERE id=? AND target=?")
|
|
93
|
+
.run(JSON.stringify(detail), id, target);
|
|
94
|
+
}
|
|
95
|
+
function markApplied(id, executedSql, rollbackSql) {
|
|
96
|
+
db.prepare(`UPDATE recommendations SET status='applied', applied_at=?, executed_sql=?,
|
|
97
|
+
rollback_sql=?, error=NULL WHERE id=? AND target=?`)
|
|
98
|
+
.run(now(), executedSql, rollbackSql, id, target);
|
|
99
|
+
return getRecommendation(id);
|
|
100
|
+
}
|
|
101
|
+
function markFailed(id, error) {
|
|
102
|
+
db.prepare("UPDATE recommendations SET status='failed', error=? WHERE id=? AND target=?")
|
|
103
|
+
.run(String(error), id, target);
|
|
104
|
+
return getRecommendation(id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function recentlyActioned(fp, withinMinutes) {
|
|
108
|
+
const cut = new Date(Date.now() - withinMinutes * 60e3).toISOString();
|
|
109
|
+
return !!db.prepare(
|
|
110
|
+
`SELECT 1 FROM recommendations WHERE target=? AND fingerprint=? AND status IN ('applied','failed')
|
|
111
|
+
AND COALESCE(applied_at, created_at) >= ? LIMIT 1`
|
|
112
|
+
).get(target, fp, cut);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ownIndexCreatedWithin(indexName, withinMinutes) {
|
|
116
|
+
const cut = new Date(Date.now() - withinMinutes * 60e3).toISOString();
|
|
117
|
+
return !!db.prepare(
|
|
118
|
+
`SELECT 1 FROM recommendations WHERE target=? AND type='missing_index' AND status='applied'
|
|
119
|
+
AND applied_at >= ? AND detail LIKE ? LIMIT 1`
|
|
120
|
+
).get(target, cut, `%"index_name":"${indexName}"%`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const listActions = (limit = 50) =>
|
|
124
|
+
db.prepare(
|
|
125
|
+
`SELECT * FROM recommendations WHERE target=? AND status IN ('applied','failed','rolled_back')
|
|
126
|
+
ORDER BY COALESCE(applied_at, created_at) DESC LIMIT ?`
|
|
127
|
+
).all(target, limit).map(rowToRec);
|
|
128
|
+
|
|
129
|
+
function recordSizeSnapshot(totalBytes) {
|
|
130
|
+
db.prepare("INSERT INTO size_snapshots (target, taken_at, total_bytes) VALUES (?,?,?)")
|
|
131
|
+
.run(target, now(), totalBytes);
|
|
132
|
+
const cut = new Date(Date.now() - 30 * 86400e3).toISOString();
|
|
133
|
+
db.prepare("DELETE FROM size_snapshots WHERE target=? AND taken_at < ?").run(target, cut);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sizeGrowthPerDay() {
|
|
137
|
+
const since = new Date(Date.now() - 7 * 86400e3).toISOString();
|
|
138
|
+
const rows = db.prepare(
|
|
139
|
+
"SELECT taken_at, total_bytes FROM size_snapshots WHERE target=? AND taken_at >= ? ORDER BY taken_at"
|
|
140
|
+
).all(target, since);
|
|
141
|
+
if (rows.length < 2) return null;
|
|
142
|
+
const hours = (new Date(rows.at(-1).taken_at) - new Date(rows[0].taken_at)) / 3600e3;
|
|
143
|
+
if (hours < 2) return null;
|
|
144
|
+
return (rows.at(-1).total_bytes - rows[0].total_bytes) / (hours / 24);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
fingerprint, addRecommendation, listRecommendations, getRecommendation,
|
|
149
|
+
updateStatus, updateDetail, markApplied, markFailed,
|
|
150
|
+
recentlyActioned, ownIndexCreatedWithin, listActions,
|
|
151
|
+
recordSizeSnapshot, sizeGrowthPerDay,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { fingerprint, storeFor };
|
package/src/targets.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** One target object per configured database: connection config + engine
|
|
3
|
+
* adapter + a state store scoped to it + its prerequisites status. */
|
|
4
|
+
const { settings } = require("./config");
|
|
5
|
+
const { adapterFor } = require("./adapters");
|
|
6
|
+
const { storeFor } = require("./store");
|
|
7
|
+
|
|
8
|
+
const targets = settings.targets.map((cfg) => {
|
|
9
|
+
const t = { ...cfg, adapter: adapterFor(cfg.dbEngine), prerequisites: { checked: false } };
|
|
10
|
+
t.store = storeFor(t);
|
|
11
|
+
return t;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** No name -> the first (default) target; unknown name -> undefined. */
|
|
15
|
+
const getTarget = (name) => (name ? targets.find((t) => t.name === name) : targets[0]);
|
|
16
|
+
|
|
17
|
+
module.exports = { targets, getTarget };
|
package/src/topology.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Schema topology API: real table graph + FK edges + query-flow parsing. */
|
|
3
|
+
const express = require("express");
|
|
4
|
+
const { settings } = require("./config");
|
|
5
|
+
const { withConn, wrap } = require("./routes");
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
const FK_QUERIES = {
|
|
10
|
+
postgres: `
|
|
11
|
+
SELECT tc.table_name AS from_table, kcu.column_name AS from_column,
|
|
12
|
+
ccu.table_name AS to_table, ccu.column_name AS to_column, tc.constraint_name
|
|
13
|
+
FROM information_schema.table_constraints tc
|
|
14
|
+
JOIN information_schema.key_column_usage kcu
|
|
15
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema
|
|
16
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
17
|
+
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
|
18
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`,
|
|
19
|
+
mysql: `
|
|
20
|
+
SELECT TABLE_NAME AS from_table, COLUMN_NAME AS from_column,
|
|
21
|
+
REFERENCED_TABLE_NAME AS to_table, REFERENCED_COLUMN_NAME AS to_column, CONSTRAINT_NAME AS constraint_name
|
|
22
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
23
|
+
WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
|
24
|
+
mssql: `
|
|
25
|
+
SELECT tp.name AS from_table, cp.name AS from_column, tr.name AS to_table,
|
|
26
|
+
cr.name AS to_column, fk.name AS constraint_name
|
|
27
|
+
FROM sys.foreign_key_columns fkc
|
|
28
|
+
JOIN sys.foreign_keys fk ON fk.object_id = fkc.constraint_object_id
|
|
29
|
+
JOIN sys.tables tp ON tp.object_id = fkc.parent_object_id
|
|
30
|
+
JOIN sys.columns cp ON cp.object_id = fkc.parent_object_id AND cp.column_id = fkc.parent_column_id
|
|
31
|
+
JOIN sys.tables tr ON tr.object_id = fkc.referenced_object_id
|
|
32
|
+
JOIN sys.columns cr ON cr.object_id = fkc.referenced_object_id AND cr.column_id = fkc.referenced_column_id`,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const COL_COUNT = {
|
|
36
|
+
postgres: `SELECT table_name AS t, COUNT(*) AS n FROM information_schema.columns
|
|
37
|
+
WHERE table_schema='public' GROUP BY table_name`,
|
|
38
|
+
mysql: `SELECT TABLE_NAME AS t, COUNT(*) AS n FROM information_schema.COLUMNS
|
|
39
|
+
WHERE TABLE_SCHEMA = DATABASE() GROUP BY TABLE_NAME`,
|
|
40
|
+
mssql: `SELECT t.name AS t, COUNT(*) AS n FROM sys.tables t
|
|
41
|
+
JOIN sys.columns c ON c.object_id = t.object_id GROUP BY t.name`,
|
|
42
|
+
};
|
|
43
|
+
const IDX_COUNT = {
|
|
44
|
+
postgres: `SELECT tablename AS t, COUNT(*) AS n FROM pg_indexes WHERE schemaname='public' GROUP BY tablename`,
|
|
45
|
+
mysql: `SELECT TABLE_NAME AS t, COUNT(DISTINCT INDEX_NAME) AS n FROM information_schema.STATISTICS
|
|
46
|
+
WHERE TABLE_SCHEMA = DATABASE() GROUP BY TABLE_NAME`,
|
|
47
|
+
mssql: `SELECT t.name AS t, COUNT(*) AS n FROM sys.tables t
|
|
48
|
+
JOIN sys.indexes i ON i.object_id = t.object_id AND i.name IS NOT NULL GROUP BY t.name`,
|
|
49
|
+
};
|
|
50
|
+
const COLUMNS_OF = {
|
|
51
|
+
postgres: { sql: `SELECT column_name AS name, data_type AS type, is_nullable AS nullable
|
|
52
|
+
FROM information_schema.columns WHERE table_schema='public' AND table_name = $1
|
|
53
|
+
ORDER BY ordinal_position`, style: "pg" },
|
|
54
|
+
mysql: { sql: `SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable
|
|
55
|
+
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
|
56
|
+
ORDER BY ORDINAL_POSITION`, style: "my" },
|
|
57
|
+
mssql: { sql: `SELECT c.name, ty.name AS type,
|
|
58
|
+
CASE WHEN c.is_nullable = 1 THEN 'YES' ELSE 'NO' END AS nullable
|
|
59
|
+
FROM sys.columns c JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
|
60
|
+
WHERE c.object_id = OBJECT_ID(@t) ORDER BY c.column_id`, style: "ms" },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
async function rawRows(engine, conn, sqlText, param) {
|
|
64
|
+
if (engine === "postgres") {
|
|
65
|
+
const { rows } = await conn.query(sqlText, param !== undefined ? [param] : undefined);
|
|
66
|
+
return rows;
|
|
67
|
+
}
|
|
68
|
+
if (engine === "mysql") {
|
|
69
|
+
const [rows] = await conn.query(sqlText, param !== undefined ? [param] : undefined);
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
const req = conn.request();
|
|
73
|
+
if (param !== undefined) req.input("t", param);
|
|
74
|
+
return (await req.query(sqlText)).recordset;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
router.get("/topology/schema", wrap(async (req, res) => {
|
|
78
|
+
const t = req.target;
|
|
79
|
+
const data = await withConn(t, async (c) => {
|
|
80
|
+
const tables = await t.adapter.getTableGrowthStats(c);
|
|
81
|
+
const cols = await rawRows(t.dbEngine, c, COL_COUNT[t.dbEngine]);
|
|
82
|
+
const idxs = await rawRows(t.dbEngine, c, IDX_COUNT[t.dbEngine]);
|
|
83
|
+
const fks = await rawRows(t.dbEngine, c, FK_QUERIES[t.dbEngine]);
|
|
84
|
+
return { tables, cols, idxs, fks };
|
|
85
|
+
});
|
|
86
|
+
const colMap = Object.fromEntries(data.cols.map((r) => [r.t, Number(r.n)]));
|
|
87
|
+
const idxMap = Object.fromEntries(data.idxs.map((r) => [r.t, Number(r.n)]));
|
|
88
|
+
const nodes = data.tables.map((t) => ({
|
|
89
|
+
id: t.table_name, rows: t.live_rows, size: t.total_size,
|
|
90
|
+
size_bytes: t.total_size_bytes,
|
|
91
|
+
columns: colMap[t.table_name] ?? null, indexes: idxMap[t.table_name] ?? 0,
|
|
92
|
+
}));
|
|
93
|
+
const byKey = {};
|
|
94
|
+
for (const f of data.fks) {
|
|
95
|
+
const key = `${f.constraint_name}|${f.from_table}|${f.to_table}`;
|
|
96
|
+
const e = (byKey[key] ||= { from: f.from_table, to: f.to_table,
|
|
97
|
+
constraint: f.constraint_name, from_columns: [], to_columns: [] });
|
|
98
|
+
e.from_columns.push(f.from_column);
|
|
99
|
+
e.to_columns.push(f.to_column);
|
|
100
|
+
}
|
|
101
|
+
res.json({ engine: t.dbEngine, nodes, edges: Object.values(byKey) });
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
router.get("/topology/table/:table", wrap(async (req, res) => {
|
|
105
|
+
const table = req.params.table;
|
|
106
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
|
|
107
|
+
return res.status(400).json({ detail: "Invalid table name" });
|
|
108
|
+
}
|
|
109
|
+
const t = req.target;
|
|
110
|
+
const spec = COLUMNS_OF[t.dbEngine];
|
|
111
|
+
const data = await withConn(t, async (c) => ({
|
|
112
|
+
columns: await rawRows(t.dbEngine, c, spec.sql, table),
|
|
113
|
+
indexes: await t.adapter.getAllIndexesForTable(c, table),
|
|
114
|
+
}));
|
|
115
|
+
res.json({ table, ...data });
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
/* --------------------------- query flow parse -------------------------- */
|
|
119
|
+
const FROM_RE = /\bFROM\s+[\["`]?(\w+)[\]"`]?(?:\s+(?:AS\s+)?(\w+))?/i;
|
|
120
|
+
const JOIN_RE = /\b(LEFT|RIGHT|INNER|FULL|CROSS)?\s*(?:OUTER\s+)?JOIN\s+[\["`]?(\w+)[\]"`]?(?:\s+(?:AS\s+)?(\w+))?(?:\s+ON\s+([\s\S]+?))?(?=\b(?:LEFT|RIGHT|INNER|FULL|CROSS|JOIN|WHERE|GROUP|ORDER|LIMIT|FETCH)\b|$)/gi;
|
|
121
|
+
const KEYWORDS = new Set(["on","where","and","or","select","join","left","right","inner","outer","full","cross","group","order","as"]);
|
|
122
|
+
|
|
123
|
+
router.post("/topology/analyze-query", express.json(), wrap(async (req, res) => {
|
|
124
|
+
const sqlText = (req.body || {}).sql || "";
|
|
125
|
+
if (!sqlText.trim()) return res.status(400).json({ detail: "No SQL provided" });
|
|
126
|
+
const m = FROM_RE.exec(sqlText);
|
|
127
|
+
if (!m) return res.status(400).json({ detail: "Could not find a FROM clause" });
|
|
128
|
+
|
|
129
|
+
const aliases = {};
|
|
130
|
+
const rootTable = m[1];
|
|
131
|
+
aliases[(m[2] || rootTable).toLowerCase()] = rootTable;
|
|
132
|
+
aliases[rootTable.toLowerCase()] = rootTable;
|
|
133
|
+
|
|
134
|
+
const steps = [{ table: rootTable, edge: null }];
|
|
135
|
+
for (const jm of sqlText.matchAll(JOIN_RE)) {
|
|
136
|
+
const [, joinType, table, alias, onClause] = jm;
|
|
137
|
+
if (KEYWORDS.has(table.toLowerCase())) continue;
|
|
138
|
+
aliases[(alias || table).toLowerCase()] = table;
|
|
139
|
+
aliases[table.toLowerCase()] = table;
|
|
140
|
+
let edge = null;
|
|
141
|
+
if (onClause) {
|
|
142
|
+
const pair = /(\w+)\.[\["`]?(\w+)[\]"`]?\s*=\s*(\w+)\.[\["`]?(\w+)[\]"`]?/.exec(onClause);
|
|
143
|
+
if (pair) edge = {
|
|
144
|
+
from: aliases[pair[1].toLowerCase()] || pair[1], from_column: pair[2],
|
|
145
|
+
to: aliases[pair[3].toLowerCase()] || pair[3], to_column: pair[4],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
steps.push({ table, join_type: (joinType || "INNER").toUpperCase(), edge });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let cost = null;
|
|
152
|
+
try {
|
|
153
|
+
const r = await withConn(req.target, (c) =>
|
|
154
|
+
req.target.adapter.analyzeQuery(c, sqlText, Number.MAX_SAFE_INTEGER));
|
|
155
|
+
cost = r.oldCost;
|
|
156
|
+
} catch { /* cost is optional */ }
|
|
157
|
+
|
|
158
|
+
res.json({ steps, join_count: steps.length - 1, plan_cost: cost,
|
|
159
|
+
engine: req.target.dbEngine, tables: steps.map((s) => s.table) });
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
/* ------------------------------- alerts -------------------------------- */
|
|
163
|
+
router.get("/topology/alerts", wrap(async (req, res) => {
|
|
164
|
+
const t = req.target;
|
|
165
|
+
const alerts = [];
|
|
166
|
+
const add = (level, message) => alerts.push({ level, message });
|
|
167
|
+
|
|
168
|
+
for (const a of t.prerequisites.actions_needed || []) add("warning", `Setup needed: ${a}`);
|
|
169
|
+
|
|
170
|
+
const { tables, slow } = await withConn(t, async (c) => ({
|
|
171
|
+
tables: await t.adapter.getTableGrowthStats(c),
|
|
172
|
+
slow: await t.adapter.getSlowQueries(c, 1, 1),
|
|
173
|
+
}));
|
|
174
|
+
const total = tables.reduce((s, x) => s + (x.total_size_bytes || 0), 0);
|
|
175
|
+
const totalMb = total / 1048576;
|
|
176
|
+
const growth = t.store.sizeGrowthPerDay();
|
|
177
|
+
|
|
178
|
+
if (settings.storageLimitMb > 0) {
|
|
179
|
+
const pct = (totalMb / settings.storageLimitMb) * 100;
|
|
180
|
+
if (pct >= 90) add("critical",
|
|
181
|
+
`RUNNING OUT OF STORAGE: database uses ${totalMb.toFixed(0)} MB of the ${settings.storageLimitMb} MB limit (${pct.toFixed(0)}%).`);
|
|
182
|
+
else if (pct >= 75) add("warning",
|
|
183
|
+
`Storage ${pct.toFixed(0)}% used (${totalMb.toFixed(0)} of ${settings.storageLimitMb} MB).`);
|
|
184
|
+
if (growth && growth > 0) {
|
|
185
|
+
const daysLeft = (settings.storageLimitMb * 1048576 - total) / growth;
|
|
186
|
+
if (daysLeft < 7) add("critical",
|
|
187
|
+
`At the current growth rate (+${(growth / 1048576).toFixed(1)} MB/day) storage runs out in ~${Math.max(daysLeft, 0).toFixed(1)} days.`);
|
|
188
|
+
else if (daysLeft < 30) add("warning",
|
|
189
|
+
`Storage projected full in ~${daysLeft.toFixed(0)} days (+${(growth / 1048576).toFixed(1)} MB/day).`);
|
|
190
|
+
}
|
|
191
|
+
} else if (growth && growth > 0) {
|
|
192
|
+
add("info", `Database growing +${(growth / 1048576).toFixed(1)} MB/day (now ${totalMb.toFixed(0)} MB). Set STORAGE_LIMIT_MB for capacity alerts.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const pending = t.store.listRecommendations("pending");
|
|
196
|
+
const missing = pending.filter((r) => r.type === "missing_index").length;
|
|
197
|
+
const unusedMb = pending.filter((r) => r.type === "unused_index")
|
|
198
|
+
.reduce((s, r) => s + (r.detail.size_mb || 0), 0);
|
|
199
|
+
if (missing) add("info", settings.autoCreateIndexes
|
|
200
|
+
? `${missing} verified missing index(es) pending — auto-apply will handle them next cycle.`
|
|
201
|
+
: `${missing} verified missing index(es) waiting for manual apply.`);
|
|
202
|
+
if (unusedMb >= 10) add("info", `${unusedMb.toFixed(0)} MB reclaimable from unused indexes.`);
|
|
203
|
+
|
|
204
|
+
const failed = t.store.listActions(20).filter((a) => a.status === "failed");
|
|
205
|
+
if (failed.length) add("warning", `${failed.length} recent action(s) failed — see the dashboard actions table.`);
|
|
206
|
+
if (slow[0] && (slow[0].mean_exec_time || 0) >= 2000) {
|
|
207
|
+
add("warning", `Slowest query currently averages ${(slow[0].mean_exec_time / 1000).toFixed(1)}s per call.`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
res.json({
|
|
211
|
+
alerts, total_size_mb: Math.round(totalMb * 10) / 10,
|
|
212
|
+
growth_mb_per_day: growth ? Math.round((growth / 1048576) * 100) / 100 : null,
|
|
213
|
+
storage_limit_mb: settings.storageLimitMb || null,
|
|
214
|
+
});
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
module.exports = { topologyRouter: router };
|