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.
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ /** MySQL 8 adapter: performance_schema + EXPLAIN FORMAT=JSON + INVISIBLE
3
+ * index verification. FK-required and FULLTEXT indexes are protected. */
4
+ const mysql = require("mysql2/promise");
5
+ const { calcImprovementPct } = require("../planParser");
6
+
7
+ const engine = "mysql";
8
+ const supportsPartialIndexes = false;
9
+
10
+ const connect = (target) => mysql.createConnection({
11
+ host: target.host, port: target.port, database: target.database,
12
+ user: target.user, password: target.password,
13
+ });
14
+ const close = (c) => c.end().catch(() => {});
15
+ const ping = async (c) => { await c.query("SELECT 1"); };
16
+
17
+ async function ensurePrerequisites(target) {
18
+ const status = { checked: true, performance_schema: false, actions_needed: [] };
19
+ let c;
20
+ try {
21
+ c = await connect(target);
22
+ const [[row]] = await c.query("SELECT @@performance_schema AS ps");
23
+ if (row.ps === 1) status.performance_schema = true;
24
+ else status.actions_needed.push(
25
+ "performance_schema is OFF. Set performance_schema=ON in my.cnf and restart MySQL.");
26
+ } catch (e) {
27
+ status.actions_needed.push(`Could not connect to the target database: ${e.message}`);
28
+ } finally { if (c) close(c); }
29
+ Object.assign(target.prerequisites, status);
30
+ return status;
31
+ }
32
+
33
+ async function getSlowQueries(c, minCalls = 1, limit = 30) {
34
+ const [rows] = await c.query(`
35
+ SELECT DIGEST AS queryid, DIGEST_TEXT AS query, COUNT_STAR AS calls,
36
+ AVG_TIMER_WAIT / 1e9 AS mean_exec_time, SUM_TIMER_WAIT / 1e9 AS total_exec_time,
37
+ SUM_ROWS_SENT AS \`rows\`
38
+ FROM performance_schema.events_statements_summary_by_digest
39
+ WHERE SCHEMA_NAME = DATABASE() AND COUNT_STAR >= ?
40
+ AND DIGEST_TEXT NOT LIKE 'EXPLAIN%' AND DIGEST_TEXT NOT LIKE 'CREATE%'
41
+ AND DIGEST_TEXT NOT LIKE 'DROP%' AND DIGEST_TEXT NOT LIKE 'ALTER%'
42
+ AND DIGEST_TEXT NOT LIKE 'INSERT%' AND DIGEST_TEXT NOT LIKE 'SET %'
43
+ AND DIGEST_TEXT NOT LIKE '%performance_schema%' AND DIGEST_TEXT NOT LIKE '%information_schema%'
44
+ ORDER BY AVG_TIMER_WAIT DESC LIMIT ?;`, [minCalls, limit]);
45
+ return rows.map(r => ({ ...r, calls: Number(r.calls), mean_exec_time: Number(r.mean_exec_time),
46
+ total_exec_time: Number(r.total_exec_time), rows: Number(r.rows) }));
47
+ }
48
+
49
+ async function getUnusedIndexes(c, target) {
50
+ const [rows] = await c.query(`
51
+ SELECT p.OBJECT_NAME AS table_name, p.INDEX_NAME AS index_name, p.COUNT_STAR AS idx_scan,
52
+ GROUP_CONCAT(s.COLUMN_NAME ORDER BY s.SEQ_IN_INDEX) AS columns_list
53
+ FROM performance_schema.table_io_waits_summary_by_index_usage p
54
+ JOIN information_schema.STATISTICS s
55
+ ON s.TABLE_SCHEMA = p.OBJECT_SCHEMA AND s.TABLE_NAME = p.OBJECT_NAME
56
+ AND s.INDEX_NAME = p.INDEX_NAME
57
+ WHERE p.OBJECT_SCHEMA = DATABASE() AND p.INDEX_NAME IS NOT NULL
58
+ AND p.INDEX_NAME <> 'PRIMARY' AND p.COUNT_STAR = 0 AND s.NON_UNIQUE = 1
59
+ AND s.INDEX_TYPE = 'BTREE'
60
+ AND NOT EXISTS (
61
+ SELECT 1 FROM information_schema.STATISTICS s1
62
+ JOIN information_schema.KEY_COLUMN_USAGE k
63
+ ON k.TABLE_SCHEMA = s1.TABLE_SCHEMA AND k.TABLE_NAME = s1.TABLE_NAME
64
+ AND k.COLUMN_NAME = s1.COLUMN_NAME AND k.REFERENCED_TABLE_NAME IS NOT NULL
65
+ WHERE s1.TABLE_SCHEMA = p.OBJECT_SCHEMA AND s1.TABLE_NAME = p.OBJECT_NAME
66
+ AND s1.INDEX_NAME = p.INDEX_NAME AND s1.SEQ_IN_INDEX = 1)
67
+ GROUP BY p.OBJECT_NAME, p.INDEX_NAME, p.COUNT_STAR;`);
68
+ const out = [];
69
+ for (const r of rows) {
70
+ let size = 0;
71
+ try {
72
+ const [[sz]] = await c.query(
73
+ `SELECT stat_value * @@innodb_page_size AS b FROM mysql.innodb_index_stats
74
+ WHERE database_name = DATABASE() AND table_name = ? AND index_name = ? AND stat_name = 'size'`,
75
+ [r.table_name, r.index_name]);
76
+ size = sz ? Number(sz.b) : 0;
77
+ } catch { /* size is cosmetic */ }
78
+ out.push({ schemaname: target.database, table_name: r.table_name, index_name: r.index_name,
79
+ idx_scan: Number(r.idx_scan), size_bytes: size,
80
+ indexdef: `CREATE INDEX ${r.index_name} ON ${r.table_name} (${r.columns_list})` });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ async function getAllIndexesForTable(c, table) {
86
+ const [rows] = await c.query(`
87
+ SELECT INDEX_NAME AS n, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
88
+ FROM information_schema.STATISTICS
89
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? GROUP BY INDEX_NAME;`, [table]);
90
+ return rows.map(r => ({ indexname: r.n, indexdef: `INDEX ${r.n} ON ${table} (${r.cols})` }));
91
+ }
92
+
93
+ function prettySize(b) {
94
+ for (const u of ["bytes", "kB", "MB", "GB"]) {
95
+ if (b < 1024) return `${Math.round(b)} ${u}`;
96
+ b /= 1024;
97
+ }
98
+ return `${Math.round(b)} TB`;
99
+ }
100
+
101
+ async function getTableGrowthStats(c) {
102
+ const [rows] = await c.query(`
103
+ SELECT TABLE_NAME AS table_name, COALESCE(TABLE_ROWS, 0) AS live_rows,
104
+ COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) AS total_size_bytes
105
+ FROM information_schema.TABLES
106
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
107
+ ORDER BY total_size_bytes DESC;`);
108
+ return rows.map(r => ({
109
+ table_name: r.table_name, live_rows: Number(r.live_rows), dead_rows: 0,
110
+ total_size: prettySize(Number(r.total_size_bytes)),
111
+ total_size_bytes: Number(r.total_size_bytes), seq_scan: 0, idx_scan: 0,
112
+ }));
113
+ }
114
+
115
+ const substitute = (q, asString) =>
116
+ q.replace(/\.\.\./g, "?").replace(/\?/g, asString ? "'1'" : "1");
117
+
118
+ function walkTables(node, out) {
119
+ if (node && typeof node === "object") {
120
+ if (!Array.isArray(node) && node.access_type !== undefined && node.table_name !== undefined) out.push(node);
121
+ for (const v of Object.values(node)) walkTables(v, out);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ async function explainJson(c, safeQuery) {
127
+ const [rows] = await c.query(`EXPLAIN FORMAT=JSON ${safeQuery}`);
128
+ return JSON.parse(rows[0].EXPLAIN);
129
+ }
130
+
131
+ async function analyzeQuery(c, queryText, minRows) {
132
+ for (const asString of [false, true]) {
133
+ const safeQuery = substitute(queryText, asString);
134
+ let explained;
135
+ try { explained = await explainJson(c, safeQuery); } catch { continue; }
136
+ const qb = explained.query_block || {};
137
+ const oldCost = parseFloat(qb.cost_info?.query_cost) || null;
138
+ const findings = [];
139
+ for (const t of walkTables(qb, [])) {
140
+ if (t.access_type !== "ALL") continue;
141
+ const rows = t.rows_examined_per_scan || 0;
142
+ if (rows <= minRows) continue;
143
+ let filter = (t.attached_condition || "").replace(/`/g, "");
144
+ filter = filter.replace(/\b\w+\.(\w+\.)?/g, "");
145
+ findings.push({ table: t.table_name, filter: filter || null, rows_scanned: rows, cost: null });
146
+ }
147
+ return { oldCost, findings, safeQuery };
148
+ }
149
+ return { oldCost: null, findings: [], safeQuery: null };
150
+ }
151
+
152
+ async function verifyIndexBenefit(c, safeQuery, table, columns, predicate, oldCost) {
153
+ const { indexNameFor, validateIdentifier } = require("../executor");
154
+ if (predicate) throw new Error("MySQL does not support partial indexes");
155
+ validateIdentifier(table);
156
+ columns.forEach(validateIdentifier);
157
+ const indexName = indexNameFor(engine, table, columns);
158
+ let created = false;
159
+ try {
160
+ await c.query(`CREATE INDEX ${indexName} ON ${table} (${columns.join(", ")}) INVISIBLE`);
161
+ created = true;
162
+ await c.query("SET SESSION optimizer_switch = 'use_invisible_indexes=on'");
163
+ const plan = await explainJson(c, safeQuery);
164
+ const newCost = parseFloat(plan.query_block?.cost_info?.query_cost) || oldCost;
165
+ return { improvement: calcImprovementPct(oldCost, newCost), newCost };
166
+ } finally {
167
+ await c.query("SET SESSION optimizer_switch = 'use_invisible_indexes=off'").catch(() => {});
168
+ if (created) await c.query(`DROP INDEX ${indexName} ON ${table}`).catch(() => {});
169
+ }
170
+ }
171
+
172
+ module.exports = {
173
+ engine, supportsPartialIndexes,
174
+ connect, close, ping, ensurePrerequisites,
175
+ getSlowQueries, getUnusedIndexes, getAllIndexesForTable, getTableGrowthStats,
176
+ analyzeQuery, verifyIndexBenefit,
177
+ };
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ /** PostgreSQL adapter: pg_stat_statements + EXPLAIN ANALYZE + HypoPG. */
3
+ const { Client } = require("pg");
4
+ const { calcImprovementPct } = require("../planParser");
5
+
6
+ const engine = "postgres";
7
+ const supportsPartialIndexes = true;
8
+
9
+ async function connect(target) {
10
+ const c = new Client({
11
+ host: target.host, port: target.port, database: target.database,
12
+ user: target.user, password: target.password,
13
+ });
14
+ await c.connect();
15
+ return c;
16
+ }
17
+ const close = (c) => c.end().catch(() => {});
18
+ const ping = async (c) => { await c.query("SELECT 1"); };
19
+
20
+ async function ensurePrerequisites(target) {
21
+ const status = { checked: true, pg_stat_statements: false, hypopg: false, actions_needed: [] };
22
+ let c;
23
+ try {
24
+ c = await connect(target);
25
+ for (const ext of ["pg_stat_statements", "hypopg"]) {
26
+ try { await c.query(`CREATE EXTENSION IF NOT EXISTS ${ext};`); }
27
+ catch (e) {
28
+ const msg = String(e.message).split("\n")[0];
29
+ status.actions_needed.push(
30
+ /is not available|extension control file/.test(msg)
31
+ ? `Extension '${ext}' is not installed on the server. Install the OS package and restart.`
32
+ : `Could not create extension '${ext}': ${msg}`);
33
+ }
34
+ }
35
+ try { await c.query("SELECT count(*) FROM pg_stat_statements;"); status.pg_stat_statements = true; }
36
+ catch { status.actions_needed.push(
37
+ "pg_stat_statements needs preloading: ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements'; then restart PostgreSQL."); }
38
+ try { await c.query("SELECT hypopg_reset();"); status.hypopg = true; }
39
+ catch { if (!status.actions_needed.some(a => a.includes("hypopg")))
40
+ status.actions_needed.push("hypopg extension exists but is not functional."); }
41
+ } catch (e) {
42
+ status.actions_needed.push(`Could not connect to the target database: ${e.message}`);
43
+ } finally { if (c) close(c); }
44
+ Object.assign(target.prerequisites, status);
45
+ return status;
46
+ }
47
+
48
+ async function getSlowQueries(c, minCalls = 1, limit = 30) {
49
+ const { rows } = await c.query(`
50
+ SELECT queryid, query, calls, mean_exec_time, total_exec_time, rows
51
+ FROM pg_stat_statements
52
+ WHERE calls >= $1
53
+ AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
54
+ AND query NOT ILIKE '%pg_stat_statements%' AND query NOT ILIKE 'EXPLAIN%'
55
+ AND query NOT ILIKE 'CREATE%' AND query NOT ILIKE 'DROP%'
56
+ AND query NOT ILIKE 'ALTER%' AND query NOT ILIKE 'INSERT%' AND query NOT ILIKE 'DO %'
57
+ ORDER BY mean_exec_time DESC LIMIT $2;`, [minCalls, limit]);
58
+ return rows;
59
+ }
60
+
61
+ async function getUnusedIndexes(c, target) {
62
+ const { rows } = await c.query(`
63
+ SELECT s.schemaname, s.relname AS table_name, s.indexrelname AS index_name,
64
+ s.idx_scan, pg_relation_size(s.indexrelid) AS size_bytes, pi.indexdef
65
+ FROM pg_stat_user_indexes s
66
+ JOIN pg_index i ON s.indexrelid = i.indexrelid
67
+ JOIN pg_indexes pi ON pi.indexname = s.indexrelname
68
+ WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary
69
+ ORDER BY pg_relation_size(s.indexrelid) DESC;`);
70
+ return rows.map(r => ({ ...r, size_bytes: Number(r.size_bytes), idx_scan: Number(r.idx_scan) }));
71
+ }
72
+
73
+ async function getAllIndexesForTable(c, table) {
74
+ const { rows } = await c.query(
75
+ "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1;", [table]);
76
+ return rows;
77
+ }
78
+
79
+ async function getTableGrowthStats(c) {
80
+ const { rows } = await c.query(`
81
+ SELECT relname AS table_name, n_live_tup AS live_rows, n_dead_tup AS dead_rows,
82
+ pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
83
+ pg_total_relation_size(relid) AS total_size_bytes,
84
+ seq_scan, COALESCE(idx_scan, 0) AS idx_scan
85
+ FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC;`);
86
+ return rows.map(r => ({ ...r, live_rows: Number(r.live_rows), dead_rows: Number(r.dead_rows),
87
+ total_size_bytes: Number(r.total_size_bytes), seq_scan: Number(r.seq_scan), idx_scan: Number(r.idx_scan) }));
88
+ }
89
+
90
+ const substitute = (q, asString) => q.replace(/\$\d+/g, asString ? "'1'" : "1");
91
+
92
+ function findSeqScans(node, out, minRows) {
93
+ if (["Seq Scan", "Parallel Seq Scan"].includes(node["Node Type"])) {
94
+ const scanned = (node["Actual Rows"] || 0) + (node["Rows Removed by Filter"] || 0);
95
+ if (scanned > minRows) out.push({
96
+ table: node["Relation Name"], filter: node["Filter"] || null,
97
+ rows_scanned: scanned, cost: node["Total Cost"],
98
+ });
99
+ }
100
+ for (const child of node.Plans || []) findSeqScans(child, out, minRows);
101
+ return out;
102
+ }
103
+
104
+ async function analyzeQuery(c, queryText, minRows) {
105
+ for (const asString of [false, true]) {
106
+ const safeQuery = substitute(queryText, asString);
107
+ try {
108
+ const { rows } = await c.query(`EXPLAIN (ANALYZE, FORMAT JSON) ${safeQuery}`);
109
+ const plan = rows[0]["QUERY PLAN"][0].Plan;
110
+ return { oldCost: plan["Total Cost"], findings: findSeqScans(plan, [], minRows), safeQuery };
111
+ } catch { await c.query("ROLLBACK").catch(() => {}); }
112
+ }
113
+ return { oldCost: null, findings: [], safeQuery: null };
114
+ }
115
+
116
+ async function verifyIndexBenefit(c, safeQuery, table, columns, predicate, oldCost) {
117
+ const { predicateToSql, validateIdentifier } = require("../executor");
118
+ validateIdentifier(table);
119
+ columns.forEach(validateIdentifier);
120
+ let create = `CREATE INDEX ON ${table} (${columns.join(", ")})`;
121
+ if (predicate) create += ` WHERE ${predicateToSql(predicate)}`;
122
+ try {
123
+ await c.query("SELECT * FROM hypopg_create_index($1)", [create]);
124
+ const { rows } = await c.query(`EXPLAIN (FORMAT JSON) ${safeQuery}`);
125
+ const newCost = rows[0]["QUERY PLAN"][0].Plan["Total Cost"] ?? oldCost;
126
+ return { improvement: calcImprovementPct(oldCost, newCost), newCost };
127
+ } finally {
128
+ await c.query("SELECT hypopg_reset();").catch(() => {});
129
+ }
130
+ }
131
+
132
+ module.exports = {
133
+ engine, supportsPartialIndexes,
134
+ connect, close, ping, ensurePrerequisites,
135
+ getSlowQueries, getUnusedIndexes, getAllIndexesForTable, getTableGrowthStats,
136
+ analyzeQuery, verifyIndexBenefit,
137
+ };
package/src/config.js ADDED
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ require("dotenv").config();
3
+
4
+ const SCHEME_TO_ENGINE = {
5
+ postgres: "postgres", postgresql: "postgres",
6
+ mysql: "mysql", mariadb: "mysql",
7
+ mssql: "mssql", sqlserver: "mssql",
8
+ oracle: "oracle", mongodb: "mongodb", mongo: "mongodb",
9
+ };
10
+ const DEFAULT_PORTS = { postgres: 5432, mysql: 3306, mssql: 1433, oracle: 1521, mongodb: 27017 };
11
+
12
+ function env(name, fallback) {
13
+ const v = process.env[name];
14
+ return v === undefined || v === "" ? fallback : v;
15
+ }
16
+ const bool = (v) => String(v).toLowerCase() === "true";
17
+
18
+ // Every setting must be provided explicitly — via a .env file in the working
19
+ // directory, real environment variables, or the matching CLI flag.
20
+ const REQUIRED_VARS = [
21
+ "DATABASE_URL",
22
+ "PORT",
23
+ "SCAN_INTERVAL_MINUTES",
24
+ "MIN_ROWS_FOR_SEQ_SCAN_FLAG",
25
+ "MIN_IMPROVEMENT_PCT_TO_RECOMMEND",
26
+ "AUTO_CREATE_INDEXES",
27
+ "AUTO_DROP_UNUSED_INDEXES",
28
+ "INDEX_NAME_PREFIX",
29
+ "UNUSED_INDEX_GRACE_MINUTES",
30
+ "REAPPLY_COOLDOWN_MINUTES",
31
+ "STORAGE_LIMIT_MB",
32
+ "STATE_DB_PATH",
33
+ ];
34
+ {
35
+ const missing = REQUIRED_VARS.filter((n) => env(n) === undefined);
36
+ if (missing.length) {
37
+ console.error(
38
+ `dbopt: missing required environment variables:\n` +
39
+ missing.map((n) => ` - ${n}`).join("\n") +
40
+ `\n\nCreate a .env file in the directory you run dbopt from,\n` +
41
+ `defining all of them (copy .env.example from the package for a template):\n` +
42
+ ` node_modules/dbopt-engine/.env.example`
43
+ );
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ const settings = {
49
+ httpPort: parseInt(env("PORT", "8000"), 10),
50
+ scanIntervalMinutes: parseInt(env("SCAN_INTERVAL_MINUTES", "15"), 10),
51
+ minRowsForSeqScanFlag: parseInt(env("MIN_ROWS_FOR_SEQ_SCAN_FLAG", "1000"), 10),
52
+ minImprovementPct: parseFloat(env("MIN_IMPROVEMENT_PCT_TO_RECOMMEND", "30")),
53
+ autoCreateIndexes: bool(env("AUTO_CREATE_INDEXES", "true")),
54
+ autoDropUnusedIndexes: bool(env("AUTO_DROP_UNUSED_INDEXES", "false")),
55
+ indexNamePrefix: env("INDEX_NAME_PREFIX", "opt"),
56
+ unusedIndexGraceMinutes: parseInt(env("UNUSED_INDEX_GRACE_MINUTES", "60"), 10),
57
+ reapplyCooldownMinutes: parseInt(env("REAPPLY_COOLDOWN_MINUTES", "60"), 10),
58
+ storageLimitMb: parseInt(env("STORAGE_LIMIT_MB", "0"), 10),
59
+ historyRetentionDays: parseInt(env("HISTORY_RETENTION_DAYS", "90"), 10),
60
+ stateDbPath: env("STATE_DB_PATH", "optimizer_state.db"),
61
+ targets: [],
62
+ };
63
+
64
+ /** Parse one connection URL: engine://user:pass@host:port/db, plus the
65
+ * ADO-style variant engine://host:port;database=..;user=..;password=.. */
66
+ function parseDatabaseUrl(url) {
67
+ url = url.trim().replace(/^["']|["']$/g, "");
68
+ const sep = url.indexOf("://");
69
+ if (sep < 0) throw new Error("DATABASE_URL must look like engine://user:pass@host:port/dbname");
70
+ const scheme = url.slice(0, sep).toLowerCase();
71
+ const rest = url.slice(sep + 3);
72
+ const engine = SCHEME_TO_ENGINE[scheme];
73
+ if (!engine) throw new Error(`Unknown DATABASE_URL scheme '${scheme}'`);
74
+
75
+ const t = {
76
+ dbEngine: engine,
77
+ host: env("TARGET_DB_HOST", ""),
78
+ port: 0,
79
+ database: env("TARGET_DB_NAME", ""),
80
+ user: env("TARGET_DB_USER", ""),
81
+ password: env("TARGET_DB_PASSWORD", ""),
82
+ };
83
+
84
+ if (rest.includes(";")) {
85
+ const [hostPart, ...params] = rest.split(";");
86
+ const [host, port] = hostPart.split(":");
87
+ const kv = {};
88
+ for (const p of params) {
89
+ const eq = p.indexOf("=");
90
+ if (eq > 0) kv[p.slice(0, eq).trim().toLowerCase()] = p.slice(eq + 1).trim();
91
+ }
92
+ t.host = host;
93
+ t.port = parseInt(port || kv.port || DEFAULT_PORTS[engine], 10);
94
+ t.database = kv.database || kv.dbname || t.database;
95
+ t.user = kv.user || kv.uid || kv.username || t.user;
96
+ t.password = kv.password || kv.pwd || t.password;
97
+ } else {
98
+ const u = new URL(`${scheme === "mongodb" ? "mongodb" : "http"}://${rest}`);
99
+ t.host = u.hostname;
100
+ t.port = u.port ? parseInt(u.port, 10) : DEFAULT_PORTS[engine];
101
+ t.database = decodeURIComponent(u.pathname.replace(/^\//, ""));
102
+ if (u.username) t.user = decodeURIComponent(u.username);
103
+ if (u.password) t.password = decodeURIComponent(u.password);
104
+ }
105
+ if (!t.database) throw new Error(`DATABASE_URL '${scheme}://…' is missing the database name (/dbname)`);
106
+ if (!t.port) t.port = DEFAULT_PORTS[engine] || 5432;
107
+ return t;
108
+ }
109
+
110
+ /* DATABASE_URL holds one or more comma-separated connection URLs, so one
111
+ * process can watch several databases at once. Commas inside passwords must
112
+ * be URL-encoded as %2C (like the other special characters). */
113
+ const urls = String(process.env.DATABASE_URL).split(",").map((s) => s.trim()).filter(Boolean);
114
+ if (!urls.length) {
115
+ console.error("dbopt: DATABASE_URL is empty — set at least one connection URL.");
116
+ process.exit(1);
117
+ }
118
+ settings.targets = urls.map(parseDatabaseUrl);
119
+
120
+ /* Give each target a stable, human-friendly name for the API (?db=<name>)
121
+ * and the dashboard selector: the database name, disambiguated by host and
122
+ * port only when two targets share it. */
123
+ {
124
+ const count = (fn) => settings.targets.reduce((m, t) => {
125
+ const k = fn(t); m[k] = (m[k] || 0) + 1; return m;
126
+ }, {});
127
+ const byDb = count((t) => t.database);
128
+ const byDbHost = count((t) => `${t.database}@${t.host}`);
129
+ const seen = {};
130
+ for (const t of settings.targets) {
131
+ let name = t.database;
132
+ if (byDb[t.database] > 1) name = `${t.database}@${t.host}`;
133
+ if (byDbHost[`${t.database}@${t.host}`] > 1) name = `${t.database}@${t.host}:${t.port}`;
134
+ if (seen[name]) name += `-${++seen[name]}`;
135
+ else seen[name] = 1;
136
+ t.name = name;
137
+ }
138
+ }
139
+
140
+ module.exports = { settings };
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /** Builds and executes index DDL with identifier validation; every action is
3
+ * recorded with rollback SQL. All functions are scoped to one target. */
4
+ const { settings } = require("./config");
5
+
6
+ const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
7
+ const MAX_IDENTIFIER = { postgres: 63, mysql: 64, mssql: 128 };
8
+
9
+ function validateIdentifier(name) {
10
+ if (!name || !SAFE_IDENTIFIER.test(name)) {
11
+ throw new Error(`Unsafe identifier rejected: ${JSON.stringify(name)}`);
12
+ }
13
+ }
14
+
15
+ function indexNameFor(engine, table, columns, partial = false) {
16
+ let name = `${settings.indexNamePrefix}_idx_${table}_${columns.join("_")}`;
17
+ if (partial) name += "_part";
18
+ return name.slice(0, MAX_IDENTIFIER[engine] || 63);
19
+ }
20
+
21
+ function predicateToSql(predicate) {
22
+ validateIdentifier(predicate.column || "");
23
+ if (predicate.op === "IS NOT NULL") return `${predicate.column} IS NOT NULL`;
24
+ if (predicate.op === "=" && ["true", "false"].includes(predicate.value)) {
25
+ return `${predicate.column} = ${predicate.value}`;
26
+ }
27
+ throw new Error(`Unsupported partial-index predicate: ${JSON.stringify(predicate)}`);
28
+ }
29
+
30
+ function buildSqlForRecommendation(target, rec) {
31
+ validateIdentifier(rec.table);
32
+ const engine = target.dbEngine;
33
+ const prefixNote = `The ${settings.indexNamePrefix}_ name prefix keeps this index outside any ORM/migration namespace.`;
34
+
35
+ if (rec.type === "missing_index") {
36
+ const columns = rec.detail.columns;
37
+ columns.forEach(validateIdentifier);
38
+ const predicate = rec.detail.predicate || null;
39
+ const colList = columns.join(", ");
40
+
41
+ if (engine === "mysql" || engine === "mssql") {
42
+ if (predicate) throw new Error("Partial indexes are Postgres-only");
43
+ const indexName = indexNameFor(engine, rec.table, columns);
44
+ validateIdentifier(indexName);
45
+ return {
46
+ index_name: indexName,
47
+ apply_sql: `CREATE INDEX ${indexName} ON ${rec.table} (${colList});`,
48
+ rollback_sql: `DROP INDEX ${indexName} ON ${rec.table};`,
49
+ notes: (engine === "mysql"
50
+ ? "InnoDB online DDL builds the index without locking the table. "
51
+ : "On Enterprise/Azure use WITH (ONLINE = ON) for fully non-blocking builds. ") + prefixNote,
52
+ };
53
+ }
54
+ const where = predicate ? ` WHERE ${predicateToSql(predicate)}` : "";
55
+ const indexName = indexNameFor(engine, rec.table, columns, !!predicate);
56
+ validateIdentifier(indexName);
57
+ return {
58
+ index_name: indexName,
59
+ apply_sql: `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexName} ON ${rec.table} (${colList})${where};`,
60
+ rollback_sql: `DROP INDEX CONCURRENTLY IF EXISTS ${indexName};`,
61
+ notes: "CONCURRENTLY avoids locking the table. " + prefixNote,
62
+ };
63
+ }
64
+
65
+ if (rec.type === "unused_index") {
66
+ const indexName = rec.detail.index_name;
67
+ validateIdentifier(indexName);
68
+ const rollback = rec.detail.original_index_definition ||
69
+ `-- Original definition not captured for ${indexName}.`;
70
+ const dropSql = engine === "postgres"
71
+ ? `DROP INDEX CONCURRENTLY IF EXISTS ${indexName};`
72
+ : `DROP INDEX ${indexName} ON ${rec.table};`;
73
+ return {
74
+ index_name: indexName,
75
+ apply_sql: dropSql,
76
+ rollback_sql: rollback,
77
+ notes: "Zero usage only reflects activity since the last stats reset / server start. " +
78
+ "The original definition is stored as rollback_sql so the drop is fully reversible.",
79
+ };
80
+ }
81
+ throw new Error(`Unknown recommendation type: ${rec.type}`);
82
+ }
83
+
84
+ async function execDdl(target, sqlText) {
85
+ const conn = await target.adapter.connect(target);
86
+ try {
87
+ if (target.adapter.engine === "mssql") await conn.request().batch(sqlText);
88
+ else await conn.query(sqlText);
89
+ } finally {
90
+ target.adapter.close(conn);
91
+ }
92
+ }
93
+
94
+ async function applyRecommendation(target, rec) {
95
+ try {
96
+ const built = buildSqlForRecommendation(target, rec);
97
+ await execDdl(target, built.apply_sql);
98
+ console.log(`[executor] [${target.name}] applied rec #${rec.id}: ${built.apply_sql}`);
99
+ target.store.updateDetail(rec.id, { ...rec.detail, index_name: built.index_name });
100
+ return target.store.markApplied(rec.id, built.apply_sql, built.rollback_sql);
101
+ } catch (e) {
102
+ console.error(`[executor] [${target.name}] failed rec #${rec.id}: ${e.message}`);
103
+ return target.store.markFailed(rec.id, e.message);
104
+ }
105
+ }
106
+
107
+ async function rollbackRecommendation(target, rec) {
108
+ let rollbackSql = rec.rollback_sql;
109
+ if (rec.status !== "applied" || !rollbackSql) {
110
+ throw new Error("Recommendation is not in an applied state with rollback SQL.");
111
+ }
112
+ if (rollbackSql.trimStart().startsWith("--")) {
113
+ throw new Error("No executable rollback SQL was captured for this recommendation.");
114
+ }
115
+ if (target.dbEngine === "postgres" && rec.type === "unused_index" &&
116
+ rollbackSql.toUpperCase().startsWith("CREATE INDEX ")) {
117
+ rollbackSql = "CREATE INDEX CONCURRENTLY " + rollbackSql.slice("CREATE INDEX ".length);
118
+ }
119
+ await execDdl(target, rollbackSql);
120
+ console.log(`[executor] [${target.name}] rolled back rec #${rec.id}`);
121
+ return target.store.updateStatus(rec.id, "rolled_back");
122
+ }
123
+
124
+ module.exports = {
125
+ validateIdentifier, indexNameFor, predicateToSql,
126
+ buildSqlForRecommendation, applyRecommendation, rollbackRecommendation,
127
+ };
package/src/index.js ADDED
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ const path = require("path");
3
+ const express = require("express");
4
+ const { settings } = require("./config");
5
+ const { targets, getTarget } = require("./targets");
6
+ const { router } = require("./routes");
7
+ const { topologyRouter } = require("./topology");
8
+ const { runAnalysisCycle, startScheduler } = require("./scheduler");
9
+
10
+ function createApp() {
11
+ const app = express();
12
+ app.use(express.json());
13
+
14
+ // Every API call is scoped to one target database, chosen with ?db=<name>
15
+ // (see GET /targets). Without the parameter the first target is used.
16
+ app.use((req, res, next) => {
17
+ req.target = getTarget(req.query.db);
18
+ if (!req.target) {
19
+ return res.status(404).json({
20
+ detail: `Unknown database '${req.query.db}'. Configured: ${targets.map((t) => t.name).join(", ")}`,
21
+ });
22
+ }
23
+ next();
24
+ });
25
+
26
+ app.use(router);
27
+ app.use(topologyRouter);
28
+
29
+ const page = (file) => (req, res) => {
30
+ res.set("Cache-Control", "no-store");
31
+ res.sendFile(path.join(__dirname, "static", file));
32
+ };
33
+ app.get("/dashboard", page("dashboard.html"));
34
+ app.get("/topology", page("topology.html"));
35
+ app.get("/", (req, res) => res.redirect("/dashboard"));
36
+ return app;
37
+ }
38
+
39
+ async function start() {
40
+ const app = createApp();
41
+ app.listen(settings.httpPort, () => {
42
+ console.log(`dbopt listening on http://localhost:${settings.httpPort}`);
43
+ for (const t of targets) {
44
+ console.log(` target '${t.name}': ${t.dbEngine} · ${t.host}:${t.port}/${t.database}`);
45
+ }
46
+ console.log(` dashboard: http://localhost:${settings.httpPort}/dashboard`);
47
+ console.log(` topology: http://localhost:${settings.httpPort}/topology`);
48
+ });
49
+ for (const t of targets) await t.adapter.ensurePrerequisites(t);
50
+ startScheduler();
51
+ runAnalysisCycle(); // immediate first cycle, like the Python edition
52
+ return app;
53
+ }
54
+
55
+ module.exports = { createApp, start };
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /** Shared filter-string parsing (engine adapters normalize their plan
3
+ * predicates to "col = value" style strings before calling these). */
4
+
5
+ const stripCasts = (s) => s.replace(/::\w+(\s+\w+)?/g, "");
6
+
7
+ /** '(a = 1) AND (b = 2)' -> ['a','b']. OR can't be served by one index ->
8
+ * first column only. */
9
+ function extractColumnsFromFilter(filterStr) {
10
+ if (!filterStr) return [];
11
+ const cleaned = stripCasts(filterStr);
12
+ const cols = [...cleaned.matchAll(/\(*\s*(\w+)\s*\)*\s*=/g)].map((m) => m[1]);
13
+ const unique = [...new Set(cols)];
14
+ return / OR /.test(cleaned) ? unique.slice(0, 1) : unique;
15
+ }
16
+
17
+ /** Detect filters that call for a partial index (Postgres only). */
18
+ function extractPartialPredicate(filterStr) {
19
+ if (!filterStr) return null;
20
+ const cleaned = stripCasts(filterStr);
21
+ let m = cleaned.match(/(\w+)\s+IS NOT NULL/);
22
+ if (m) return { column: m[1], op: "IS NOT NULL" };
23
+ m = cleaned.match(/(\w+)\s*=\s*(true|false)\b/);
24
+ if (m) return { column: m[1], op: "=", value: m[2] };
25
+ m = cleaned.match(/(?<![A-Z] )NOT\s+\(?(\w+)/);
26
+ if (m) return { column: m[1], op: "=", value: "false" };
27
+ m = cleaned.match(/^\(*\s*(\w+)\s*\)*$/);
28
+ if (m) return { column: m[1], op: "=", value: "true" };
29
+ return null;
30
+ }
31
+
32
+ const calcImprovementPct = (oldCost, newCost) =>
33
+ !oldCost ? 0 : Math.round((1 - newCost / oldCost) * 1000) / 10;
34
+
35
+ module.exports = { extractColumnsFromFilter, extractPartialPredicate, calcImprovementPct };