postgres-scout-mcp 1.0.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/LICENSE +190 -0
- package/README.md +234 -0
- package/bin/cli.js +67 -0
- package/dist/config/environment.js +52 -0
- package/dist/index.js +59 -0
- package/dist/server/setup.js +122 -0
- package/dist/tools/data-quality.js +442 -0
- package/dist/tools/database.js +148 -0
- package/dist/tools/export.js +223 -0
- package/dist/tools/index.js +52 -0
- package/dist/tools/live-monitoring.js +369 -0
- package/dist/tools/maintenance.js +617 -0
- package/dist/tools/monitoring.js +286 -0
- package/dist/tools/mutations.js +410 -0
- package/dist/tools/optimization.js +1094 -0
- package/dist/tools/query.js +138 -0
- package/dist/tools/relationships.js +261 -0
- package/dist/tools/schema.js +253 -0
- package/dist/tools/temporal.js +313 -0
- package/dist/types.js +2 -0
- package/dist/utils/database.js +123 -0
- package/dist/utils/logger.js +73 -0
- package/dist/utils/query-builder.js +180 -0
- package/dist/utils/rate-limiter.js +39 -0
- package/dist/utils/result-formatter.js +42 -0
- package/dist/utils/sanitize.js +525 -0
- package/dist/utils/zod-to-json-schema.js +85 -0
- package/package.json +58 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { executeInternalQuery } from '../utils/database.js';
|
|
3
|
+
import { escapeIdentifier, sanitizeIdentifier, normalizeInterval, validateOrderBy } from '../utils/sanitize.js';
|
|
4
|
+
import { WhereConditionSchema, buildWhereClause } from '../utils/query-builder.js';
|
|
5
|
+
const FindRecentSchema = z.object({
|
|
6
|
+
table: z.string(),
|
|
7
|
+
timestampColumn: z.string(),
|
|
8
|
+
timeWindow: z.string().describe('Time interval, e.g., "7 days", "2 hours", "30 minutes", or shorthand "7d", "2h", "30m", "1y"'),
|
|
9
|
+
schema: z.string().optional().default('public'),
|
|
10
|
+
where: z.array(WhereConditionSchema).optional(),
|
|
11
|
+
limit: z.number().optional().default(100),
|
|
12
|
+
orderBy: z.string().optional()
|
|
13
|
+
});
|
|
14
|
+
const AnalyzeTimeSeriesSchema = z.object({
|
|
15
|
+
table: z.string(),
|
|
16
|
+
timestampColumn: z.string(),
|
|
17
|
+
valueColumn: z.string(),
|
|
18
|
+
schema: z.string().optional().default('public'),
|
|
19
|
+
groupBy: z.enum(['hour', 'day', 'week', 'month']).optional().default('day'),
|
|
20
|
+
aggregation: z.enum(['sum', 'avg', 'count', 'min', 'max']).optional().default('sum'),
|
|
21
|
+
startDate: z.string().optional(),
|
|
22
|
+
endDate: z.string().optional(),
|
|
23
|
+
includeMovingAverage: z.boolean().optional().default(true),
|
|
24
|
+
movingAverageWindow: z.number().optional().default(7)
|
|
25
|
+
});
|
|
26
|
+
const DetectSeasonalitySchema = z.object({
|
|
27
|
+
table: z.string(),
|
|
28
|
+
timestampColumn: z.string(),
|
|
29
|
+
valueColumn: z.string(),
|
|
30
|
+
schema: z.string().optional().default('public'),
|
|
31
|
+
groupBy: z.enum(['day_of_week', 'day_of_month', 'month', 'quarter']).optional().default('day_of_week'),
|
|
32
|
+
minPeriods: z.number().optional().default(4)
|
|
33
|
+
});
|
|
34
|
+
export async function findRecent(connection, logger, args) {
|
|
35
|
+
const { table, timestampColumn, timeWindow, schema, where, limit, orderBy } = args;
|
|
36
|
+
logger.info('findRecent', 'Finding recent records', { table, timeWindow });
|
|
37
|
+
const normalizedTimeWindow = normalizeInterval(timeWindow);
|
|
38
|
+
if (orderBy) {
|
|
39
|
+
validateOrderBy(orderBy);
|
|
40
|
+
}
|
|
41
|
+
const sanitizedSchema = sanitizeIdentifier(schema);
|
|
42
|
+
const sanitizedTable = sanitizeIdentifier(table);
|
|
43
|
+
const sanitizedTimestamp = sanitizeIdentifier(timestampColumn);
|
|
44
|
+
let whereClause = '';
|
|
45
|
+
let countWhereClause = '';
|
|
46
|
+
let queryParams = [limit];
|
|
47
|
+
let countParams = [];
|
|
48
|
+
if (where && where.length > 0) {
|
|
49
|
+
const built = buildWhereClause(where, 2); // $1 is limit
|
|
50
|
+
whereClause = `AND (${built.clause})`;
|
|
51
|
+
queryParams = [limit, ...built.params];
|
|
52
|
+
const countBuilt = buildWhereClause(where, 1); // no limit offset
|
|
53
|
+
countWhereClause = `AND (${countBuilt.clause})`;
|
|
54
|
+
countParams = countBuilt.params;
|
|
55
|
+
}
|
|
56
|
+
const orderClause = orderBy || `${escapeIdentifier(sanitizedTimestamp)} DESC`;
|
|
57
|
+
const query = `
|
|
58
|
+
SELECT *
|
|
59
|
+
FROM ${escapeIdentifier(sanitizedSchema)}.${escapeIdentifier(sanitizedTable)}
|
|
60
|
+
WHERE ${escapeIdentifier(sanitizedTimestamp)} >= NOW() - INTERVAL '${normalizedTimeWindow}'
|
|
61
|
+
${whereClause}
|
|
62
|
+
ORDER BY ${orderClause}
|
|
63
|
+
LIMIT $1
|
|
64
|
+
`;
|
|
65
|
+
const countQuery = `
|
|
66
|
+
SELECT
|
|
67
|
+
COUNT(*) as rows_found,
|
|
68
|
+
NOW() - INTERVAL '${normalizedTimeWindow}' as threshold
|
|
69
|
+
FROM ${escapeIdentifier(sanitizedSchema)}.${escapeIdentifier(sanitizedTable)}
|
|
70
|
+
WHERE ${escapeIdentifier(sanitizedTimestamp)} >= NOW() - INTERVAL '${normalizedTimeWindow}'
|
|
71
|
+
${countWhereClause}
|
|
72
|
+
`;
|
|
73
|
+
const [result, countResult] = await Promise.all([
|
|
74
|
+
executeInternalQuery(connection, logger, { query, params: queryParams }),
|
|
75
|
+
executeInternalQuery(connection, logger, { query: countQuery, params: countParams })
|
|
76
|
+
]);
|
|
77
|
+
return {
|
|
78
|
+
table,
|
|
79
|
+
schema,
|
|
80
|
+
timestampColumn,
|
|
81
|
+
timeWindow: `Last ${normalizedTimeWindow}`,
|
|
82
|
+
threshold: countResult.rows[0]?.threshold,
|
|
83
|
+
rowsFound: parseInt(countResult.rows[0]?.rows_found || '0', 10),
|
|
84
|
+
rows: result.rows
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export async function analyzeTimeSeries(connection, logger, args) {
|
|
88
|
+
const { table, timestampColumn, valueColumn, schema, groupBy, aggregation, startDate, endDate, includeMovingAverage, movingAverageWindow } = args;
|
|
89
|
+
logger.info('analyzeTimeSeries', 'Analyzing time series', { table, groupBy, aggregation });
|
|
90
|
+
const sanitizedSchema = sanitizeIdentifier(schema);
|
|
91
|
+
const sanitizedTable = sanitizeIdentifier(table);
|
|
92
|
+
const sanitizedTimestamp = sanitizeIdentifier(timestampColumn);
|
|
93
|
+
const sanitizedValue = sanitizeIdentifier(valueColumn);
|
|
94
|
+
const dateGroupMap = {
|
|
95
|
+
hour: `DATE_TRUNC('hour', ${escapeIdentifier(sanitizedTimestamp)})`,
|
|
96
|
+
day: `DATE_TRUNC('day', ${escapeIdentifier(sanitizedTimestamp)})`,
|
|
97
|
+
week: `DATE_TRUNC('week', ${escapeIdentifier(sanitizedTimestamp)})`,
|
|
98
|
+
month: `DATE_TRUNC('month', ${escapeIdentifier(sanitizedTimestamp)})`
|
|
99
|
+
};
|
|
100
|
+
const aggMap = {
|
|
101
|
+
sum: `SUM(${escapeIdentifier(sanitizedValue)})`,
|
|
102
|
+
avg: `AVG(${escapeIdentifier(sanitizedValue)})`,
|
|
103
|
+
count: 'COUNT(*)',
|
|
104
|
+
min: `MIN(${escapeIdentifier(sanitizedValue)})`,
|
|
105
|
+
max: `MAX(${escapeIdentifier(sanitizedValue)})`
|
|
106
|
+
};
|
|
107
|
+
const dateFilter = [];
|
|
108
|
+
const params = [];
|
|
109
|
+
if (startDate) {
|
|
110
|
+
params.push(startDate);
|
|
111
|
+
dateFilter.push(`${escapeIdentifier(sanitizedTimestamp)} >= $${params.length}`);
|
|
112
|
+
}
|
|
113
|
+
if (endDate) {
|
|
114
|
+
params.push(endDate);
|
|
115
|
+
dateFilter.push(`${escapeIdentifier(sanitizedTimestamp)} <= $${params.length}`);
|
|
116
|
+
}
|
|
117
|
+
const whereClause = dateFilter.length > 0 ? `WHERE ${dateFilter.join(' AND ')}` : '';
|
|
118
|
+
const baseQuery = `
|
|
119
|
+
WITH time_series AS (
|
|
120
|
+
SELECT
|
|
121
|
+
${dateGroupMap[groupBy]} as period,
|
|
122
|
+
${aggMap[aggregation]} as value,
|
|
123
|
+
COUNT(*) as count
|
|
124
|
+
FROM ${escapeIdentifier(sanitizedSchema)}.${escapeIdentifier(sanitizedTable)}
|
|
125
|
+
${whereClause}
|
|
126
|
+
GROUP BY ${dateGroupMap[groupBy]}
|
|
127
|
+
ORDER BY period
|
|
128
|
+
)
|
|
129
|
+
SELECT
|
|
130
|
+
period,
|
|
131
|
+
value,
|
|
132
|
+
count,
|
|
133
|
+
${includeMovingAverage ? `
|
|
134
|
+
AVG(value) OVER (
|
|
135
|
+
ORDER BY period
|
|
136
|
+
ROWS BETWEEN ${movingAverageWindow - 1} PRECEDING AND CURRENT ROW
|
|
137
|
+
) as moving_average,
|
|
138
|
+
` : ''}
|
|
139
|
+
LAG(value) OVER (ORDER BY period) as previous_value,
|
|
140
|
+
CASE
|
|
141
|
+
WHEN LAG(value) OVER (ORDER BY period) IS NOT NULL AND LAG(value) OVER (ORDER BY period) != 0
|
|
142
|
+
THEN ((value - LAG(value) OVER (ORDER BY period)) / LAG(value) OVER (ORDER BY period) * 100)
|
|
143
|
+
ELSE NULL
|
|
144
|
+
END as percent_change
|
|
145
|
+
FROM time_series
|
|
146
|
+
ORDER BY period
|
|
147
|
+
`;
|
|
148
|
+
const statsQuery = `
|
|
149
|
+
SELECT
|
|
150
|
+
${aggMap[aggregation]} as total,
|
|
151
|
+
AVG(${escapeIdentifier(sanitizedValue)}) as average,
|
|
152
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ${escapeIdentifier(sanitizedValue)}) as median,
|
|
153
|
+
STDDEV(${escapeIdentifier(sanitizedValue)}) as std_dev,
|
|
154
|
+
MIN(${escapeIdentifier(sanitizedValue)}) as min,
|
|
155
|
+
MAX(${escapeIdentifier(sanitizedValue)}) as max
|
|
156
|
+
FROM ${escapeIdentifier(sanitizedSchema)}.${escapeIdentifier(sanitizedTable)}
|
|
157
|
+
${whereClause}
|
|
158
|
+
`;
|
|
159
|
+
const [timeSeriesResult, statsResult] = await Promise.all([
|
|
160
|
+
executeInternalQuery(connection, logger, { query: baseQuery, params }),
|
|
161
|
+
executeInternalQuery(connection, logger, { query: statsQuery, params })
|
|
162
|
+
]);
|
|
163
|
+
const timeSeries = timeSeriesResult.rows.map(row => {
|
|
164
|
+
const ma = includeMovingAverage ? parseFloat(row.moving_average) : undefined;
|
|
165
|
+
const value = parseFloat(row.value);
|
|
166
|
+
let isAnomaly = false;
|
|
167
|
+
let anomalyReason = undefined;
|
|
168
|
+
if (includeMovingAverage && ma && Math.abs(value - ma) > ma * 1.5) {
|
|
169
|
+
isAnomaly = true;
|
|
170
|
+
anomalyReason = `Value is ${(value / ma).toFixed(1)}x the moving average`;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
period: row.period,
|
|
174
|
+
value: parseFloat(row.value),
|
|
175
|
+
count: parseInt(row.count, 10),
|
|
176
|
+
...(includeMovingAverage && { movingAverage: ma }),
|
|
177
|
+
percentChange: row.percent_change ? parseFloat(row.percent_change).toFixed(2) : null,
|
|
178
|
+
isAnomaly,
|
|
179
|
+
...(anomalyReason && { anomalyReason })
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
const anomalyCount = timeSeries.filter(t => t.isAnomaly).length;
|
|
183
|
+
const stats = statsResult.rows[0];
|
|
184
|
+
const statistics = {
|
|
185
|
+
total: parseFloat(stats.total || '0'),
|
|
186
|
+
average: parseFloat(stats.average || '0'),
|
|
187
|
+
median: parseFloat(stats.median || '0'),
|
|
188
|
+
stdDev: parseFloat(stats.std_dev || '0'),
|
|
189
|
+
min: parseFloat(stats.min || '0'),
|
|
190
|
+
max: parseFloat(stats.max || '0'),
|
|
191
|
+
anomalyCount
|
|
192
|
+
};
|
|
193
|
+
const recommendations = [];
|
|
194
|
+
if (anomalyCount > 0) {
|
|
195
|
+
recommendations.push(`⚠ ${anomalyCount} anomalies detected - investigate unusual spikes or drops`);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
recommendations.push('✓ No significant anomalies detected');
|
|
199
|
+
}
|
|
200
|
+
const avgChange = timeSeries
|
|
201
|
+
.filter(t => t.percentChange !== null)
|
|
202
|
+
.reduce((sum, t) => sum + parseFloat(t.percentChange || '0'), 0) / timeSeries.length;
|
|
203
|
+
if (avgChange > 5) {
|
|
204
|
+
recommendations.push('✓ Positive growth trend observed');
|
|
205
|
+
}
|
|
206
|
+
else if (avgChange < -5) {
|
|
207
|
+
recommendations.push('⚠ Declining trend observed');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
recommendations.push('✓ Stable trend');
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
table,
|
|
214
|
+
schema,
|
|
215
|
+
period: startDate && endDate ? `${startDate} to ${endDate}` : 'All time',
|
|
216
|
+
groupBy,
|
|
217
|
+
aggregation,
|
|
218
|
+
timeSeries,
|
|
219
|
+
statistics,
|
|
220
|
+
recommendations
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
export async function detectSeasonality(connection, logger, args) {
|
|
224
|
+
const { table, timestampColumn, valueColumn, schema, groupBy, minPeriods } = args;
|
|
225
|
+
logger.info('detectSeasonality', 'Detecting seasonal patterns', { table, groupBy });
|
|
226
|
+
const sanitizedSchema = sanitizeIdentifier(schema);
|
|
227
|
+
const sanitizedTable = sanitizeIdentifier(table);
|
|
228
|
+
const sanitizedTimestamp = sanitizeIdentifier(timestampColumn);
|
|
229
|
+
const sanitizedValue = sanitizeIdentifier(valueColumn);
|
|
230
|
+
const patternMap = {
|
|
231
|
+
day_of_week: `TO_CHAR(${escapeIdentifier(sanitizedTimestamp)}, 'Day')`,
|
|
232
|
+
day_of_month: `EXTRACT(DAY FROM ${escapeIdentifier(sanitizedTimestamp)})`,
|
|
233
|
+
month: `TO_CHAR(${escapeIdentifier(sanitizedTimestamp)}, 'Month')`,
|
|
234
|
+
quarter: `EXTRACT(QUARTER FROM ${escapeIdentifier(sanitizedTimestamp)})`
|
|
235
|
+
};
|
|
236
|
+
const query = `
|
|
237
|
+
SELECT
|
|
238
|
+
${patternMap[groupBy]} as period,
|
|
239
|
+
AVG(${escapeIdentifier(sanitizedValue)}) as avg_value,
|
|
240
|
+
STDDEV(${escapeIdentifier(sanitizedValue)}) as std_dev,
|
|
241
|
+
COUNT(DISTINCT DATE_TRUNC('${groupBy === 'day_of_week' ? 'week' : 'month'}', ${escapeIdentifier(sanitizedTimestamp)})) as period_count,
|
|
242
|
+
MIN(${escapeIdentifier(sanitizedValue)}) as min_value,
|
|
243
|
+
MAX(${escapeIdentifier(sanitizedValue)}) as max_value
|
|
244
|
+
FROM ${escapeIdentifier(sanitizedSchema)}.${escapeIdentifier(sanitizedTable)}
|
|
245
|
+
GROUP BY ${patternMap[groupBy]}
|
|
246
|
+
HAVING COUNT(DISTINCT DATE_TRUNC('${groupBy === 'day_of_week' ? 'week' : 'month'}', ${escapeIdentifier(sanitizedTimestamp)})) >= $1
|
|
247
|
+
ORDER BY
|
|
248
|
+
CASE ${patternMap[groupBy]}
|
|
249
|
+
WHEN 'Monday' THEN 1
|
|
250
|
+
WHEN 'Tuesday' THEN 2
|
|
251
|
+
WHEN 'Wednesday' THEN 3
|
|
252
|
+
WHEN 'Thursday' THEN 4
|
|
253
|
+
WHEN 'Friday' THEN 5
|
|
254
|
+
WHEN 'Saturday' THEN 6
|
|
255
|
+
WHEN 'Sunday' THEN 7
|
|
256
|
+
ELSE ${patternMap[groupBy]}::int
|
|
257
|
+
END
|
|
258
|
+
`;
|
|
259
|
+
const result = await executeInternalQuery(connection, logger, {
|
|
260
|
+
query,
|
|
261
|
+
params: [minPeriods]
|
|
262
|
+
});
|
|
263
|
+
const patterns = result.rows.map(row => ({
|
|
264
|
+
period: typeof row.period === 'string' ? row.period.trim() : row.period,
|
|
265
|
+
avgValue: parseFloat(row.avg_value),
|
|
266
|
+
stdDev: parseFloat(row.std_dev || '0'),
|
|
267
|
+
coefficient: parseFloat(row.std_dev || '0') / parseFloat(row.avg_value || '1'),
|
|
268
|
+
minValue: parseFloat(row.min_value),
|
|
269
|
+
maxValue: parseFloat(row.max_value),
|
|
270
|
+
periodsAnalyzed: parseInt(row.period_count, 10)
|
|
271
|
+
}));
|
|
272
|
+
const insights = [];
|
|
273
|
+
if (patterns.length > 0) {
|
|
274
|
+
insights.push('✓ Seasonal pattern detected');
|
|
275
|
+
const maxPattern = patterns.reduce((max, p) => p.avgValue > max.avgValue ? p : max);
|
|
276
|
+
const minPattern = patterns.reduce((min, p) => p.avgValue < min.avgValue ? p : min);
|
|
277
|
+
insights.push(`${maxPattern.period} has highest average value (${maxPattern.avgValue.toFixed(2)})`);
|
|
278
|
+
insights.push(`${minPattern.period} has lowest average value (${minPattern.avgValue.toFixed(2)})`);
|
|
279
|
+
const avgCoefficient = patterns.reduce((sum, p) => sum + p.coefficient, 0) / patterns.length;
|
|
280
|
+
if (avgCoefficient < 0.2) {
|
|
281
|
+
insights.push('✓ Strong consistent pattern (low variance)');
|
|
282
|
+
}
|
|
283
|
+
else if (avgCoefficient > 0.5) {
|
|
284
|
+
insights.push('⚠ High variance in pattern - less predictable');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
insights.push('⚠ Insufficient data to detect seasonal patterns');
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
table,
|
|
292
|
+
schema,
|
|
293
|
+
pattern: groupBy,
|
|
294
|
+
periodsAnalyzed: patterns.length > 0 ? patterns[0].periodsAnalyzed : 0,
|
|
295
|
+
patterns,
|
|
296
|
+
insights
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
export const temporalTools = {
|
|
300
|
+
findRecent: {
|
|
301
|
+
schema: FindRecentSchema,
|
|
302
|
+
handler: findRecent
|
|
303
|
+
},
|
|
304
|
+
analyzeTimeSeries: {
|
|
305
|
+
schema: AnalyzeTimeSeriesSchema,
|
|
306
|
+
handler: analyzeTimeSeries
|
|
307
|
+
},
|
|
308
|
+
detectSeasonality: {
|
|
309
|
+
schema: DetectSeasonalitySchema,
|
|
310
|
+
handler: detectSeasonality
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
//# sourceMappingURL=temporal.js.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import { sanitizeQuery, parseIntSafe } from './sanitize.js';
|
|
3
|
+
import { formatError } from './result-formatter.js';
|
|
4
|
+
export async function createDatabaseConnection(config, logger) {
|
|
5
|
+
const pool = new Pool({
|
|
6
|
+
connectionString: config.connectionString,
|
|
7
|
+
max: parseIntSafe(process.env.PGMAXPOOLSIZE || '10', 10),
|
|
8
|
+
min: parseIntSafe(process.env.PGMINPOOLSIZE || '2', 2),
|
|
9
|
+
idleTimeoutMillis: parseIntSafe(process.env.PGIDLETIMEOUT || '10000', 10000),
|
|
10
|
+
connectionTimeoutMillis: 5000,
|
|
11
|
+
});
|
|
12
|
+
pool.on('error', (err) => {
|
|
13
|
+
logger.error('database', 'Unexpected database error', { error: err.message });
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
const client = await pool.connect();
|
|
17
|
+
logger.info('database', 'Database connection established');
|
|
18
|
+
client.release();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
logger.error('database', 'Failed to connect to database', { error: formatError(error) });
|
|
22
|
+
throw new Error(`Database connection failed: ${formatError(error)}`);
|
|
23
|
+
}
|
|
24
|
+
return { pool, config };
|
|
25
|
+
}
|
|
26
|
+
export async function executeQuery(connection, logger, params) {
|
|
27
|
+
const { query, params: queryParams = [], options = {} } = params;
|
|
28
|
+
const { config, pool } = connection;
|
|
29
|
+
sanitizeQuery(query, config.mode, { internal: options.internal });
|
|
30
|
+
const timeout = options.timeout || config.queryTimeout;
|
|
31
|
+
const maxRows = options.maxRows || config.maxResultRows;
|
|
32
|
+
logger.debug('query', 'Executing query', {
|
|
33
|
+
query: query.substring(0, 200),
|
|
34
|
+
params: queryParams,
|
|
35
|
+
timeout,
|
|
36
|
+
maxRows
|
|
37
|
+
});
|
|
38
|
+
let client = null;
|
|
39
|
+
const startTime = Date.now();
|
|
40
|
+
try {
|
|
41
|
+
client = await pool.connect();
|
|
42
|
+
if (!Number.isFinite(timeout) || timeout < 0) {
|
|
43
|
+
throw new Error('Invalid timeout value');
|
|
44
|
+
}
|
|
45
|
+
await client.query(`SET statement_timeout = ${Math.floor(timeout)}`);
|
|
46
|
+
const result = await client.query({
|
|
47
|
+
text: query,
|
|
48
|
+
values: queryParams
|
|
49
|
+
});
|
|
50
|
+
const executionTime = Date.now() - startTime;
|
|
51
|
+
if (result.rowCount && result.rowCount > maxRows) {
|
|
52
|
+
logger.warn('query', 'Result set exceeds max rows', {
|
|
53
|
+
rowCount: result.rowCount,
|
|
54
|
+
maxRows
|
|
55
|
+
});
|
|
56
|
+
result.rows = result.rows.slice(0, maxRows);
|
|
57
|
+
result.rowCount = maxRows;
|
|
58
|
+
}
|
|
59
|
+
logger.info('query', 'Query executed successfully', {
|
|
60
|
+
rowCount: result.rowCount,
|
|
61
|
+
executionTimeMs: executionTime
|
|
62
|
+
});
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
const executionTime = Date.now() - startTime;
|
|
67
|
+
logger.error('query', 'Query execution failed', {
|
|
68
|
+
error: formatError(error),
|
|
69
|
+
executionTimeMs: executionTime,
|
|
70
|
+
query: query.substring(0, 200)
|
|
71
|
+
});
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (client) {
|
|
76
|
+
client.release();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function executeInternalQuery(connection, logger, params) {
|
|
81
|
+
return executeQuery(connection, logger, {
|
|
82
|
+
...params,
|
|
83
|
+
options: { ...params.options, internal: true }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export async function getCurrentDatabaseName(connection, logger) {
|
|
87
|
+
const query = 'SELECT current_database() as name';
|
|
88
|
+
const result = await executeInternalQuery(connection, logger, { query });
|
|
89
|
+
return result.rows[0]?.name || connection.pool.options.database || 'current';
|
|
90
|
+
}
|
|
91
|
+
export async function ensureDatabaseExists(connection, logger, database) {
|
|
92
|
+
const query = `
|
|
93
|
+
SELECT 1
|
|
94
|
+
FROM pg_catalog.pg_database
|
|
95
|
+
WHERE datname = $1
|
|
96
|
+
`;
|
|
97
|
+
const result = await executeInternalQuery(connection, logger, { query, params: [database] });
|
|
98
|
+
if (result.rowCount === 0) {
|
|
99
|
+
throw new Error(`Database "${database}" does not exist`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function closeDatabaseConnection(connection, logger) {
|
|
103
|
+
try {
|
|
104
|
+
await connection.pool.end();
|
|
105
|
+
logger.info('database', 'Database connection closed');
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
logger.error('database', 'Error closing database connection', { error: formatError(error) });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function testConnection(pool) {
|
|
113
|
+
try {
|
|
114
|
+
const client = await pool.connect();
|
|
115
|
+
await client.query('SELECT 1');
|
|
116
|
+
client.release();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=database.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { sanitizeLogValue } from './sanitize.js';
|
|
4
|
+
export class Logger {
|
|
5
|
+
logDir;
|
|
6
|
+
logLevel;
|
|
7
|
+
toolLogPath;
|
|
8
|
+
errorLogPath;
|
|
9
|
+
constructor(logDir, logLevel = 'info') {
|
|
10
|
+
this.logDir = logDir;
|
|
11
|
+
this.logLevel = logLevel;
|
|
12
|
+
this.toolLogPath = join(logDir, 'tool-usage.log');
|
|
13
|
+
this.errorLogPath = join(logDir, 'error.log');
|
|
14
|
+
if (!existsSync(logDir)) {
|
|
15
|
+
mkdirSync(logDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
getLevelPriority(level) {
|
|
19
|
+
const priorities = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
20
|
+
return priorities[level] ?? 1;
|
|
21
|
+
}
|
|
22
|
+
shouldLog(level) {
|
|
23
|
+
return this.getLevelPriority(level) >= this.getLevelPriority(this.logLevel);
|
|
24
|
+
}
|
|
25
|
+
formatLogEntry(entry) {
|
|
26
|
+
const timestamp = entry.timestamp.toISOString();
|
|
27
|
+
const safeMessage = sanitizeLogValue(entry.message);
|
|
28
|
+
const safeTool = sanitizeLogValue(entry.tool);
|
|
29
|
+
const dataStr = entry.data ? `, Data: ${sanitizeLogValue(entry.data)}` : '';
|
|
30
|
+
return `${timestamp} [${entry.level.toUpperCase()}] Tool: ${safeTool}, Message: ${safeMessage}${dataStr}\n`;
|
|
31
|
+
}
|
|
32
|
+
writeLog(filePath, content) {
|
|
33
|
+
try {
|
|
34
|
+
appendFileSync(filePath, content, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(`Failed to write to log file ${filePath}:`, error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
log(level, tool, message, data) {
|
|
41
|
+
if (!this.shouldLog(level))
|
|
42
|
+
return;
|
|
43
|
+
const entry = {
|
|
44
|
+
timestamp: new Date(),
|
|
45
|
+
level,
|
|
46
|
+
tool,
|
|
47
|
+
message,
|
|
48
|
+
data
|
|
49
|
+
};
|
|
50
|
+
const formatted = this.formatLogEntry(entry);
|
|
51
|
+
console.error(formatted.trim());
|
|
52
|
+
this.writeLog(this.toolLogPath, formatted);
|
|
53
|
+
if (level === 'error') {
|
|
54
|
+
this.writeLog(this.errorLogPath, formatted);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
debug(tool, message, data) {
|
|
58
|
+
this.log('debug', tool, message, data);
|
|
59
|
+
}
|
|
60
|
+
info(tool, message, data) {
|
|
61
|
+
this.log('info', tool, message, data);
|
|
62
|
+
}
|
|
63
|
+
warn(tool, message, data) {
|
|
64
|
+
this.log('warn', tool, message, data);
|
|
65
|
+
}
|
|
66
|
+
error(tool, message, data) {
|
|
67
|
+
this.log('error', tool, message, data);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export const createLogger = (logDir, logLevel = 'info') => {
|
|
71
|
+
return new Logger(logDir, logLevel);
|
|
72
|
+
};
|
|
73
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { sanitizeIdentifier, escapeIdentifier } from './sanitize.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const ComparisonOpSchema = z.enum(['=', '!=', '>', '<', '>=', '<=', 'LIKE', 'ILIKE']);
|
|
4
|
+
const ComparisonConditionSchema = z.object({
|
|
5
|
+
field: z.string(),
|
|
6
|
+
op: ComparisonOpSchema,
|
|
7
|
+
value: z.union([z.string(), z.number(), z.boolean()])
|
|
8
|
+
}).strict();
|
|
9
|
+
const InConditionSchema = z.object({
|
|
10
|
+
field: z.string(),
|
|
11
|
+
op: z.enum(['IN', 'NOT IN']),
|
|
12
|
+
value: z.array(z.union([z.string(), z.number()])).min(1)
|
|
13
|
+
}).strict();
|
|
14
|
+
const NullConditionSchema = z.object({
|
|
15
|
+
field: z.string(),
|
|
16
|
+
op: z.enum(['IS NULL', 'IS NOT NULL'])
|
|
17
|
+
}).strict();
|
|
18
|
+
const BetweenConditionSchema = z.object({
|
|
19
|
+
field: z.string(),
|
|
20
|
+
op: z.literal('BETWEEN'),
|
|
21
|
+
value: z.tuple([z.union([z.string(), z.number()]), z.union([z.string(), z.number()])])
|
|
22
|
+
}).strict();
|
|
23
|
+
const LeafConditionSchema = z.union([
|
|
24
|
+
ComparisonConditionSchema,
|
|
25
|
+
InConditionSchema,
|
|
26
|
+
NullConditionSchema,
|
|
27
|
+
BetweenConditionSchema
|
|
28
|
+
]);
|
|
29
|
+
export const WhereConditionSchema = z.lazy(() => z.union([
|
|
30
|
+
LeafConditionSchema,
|
|
31
|
+
z.object({ and: z.array(WhereConditionSchema).min(1) }).strict(),
|
|
32
|
+
z.object({ or: z.array(WhereConditionSchema).min(1) }).strict()
|
|
33
|
+
]));
|
|
34
|
+
// --- Trivially-true condition detection ---
|
|
35
|
+
const MAX_SAFE_BETWEEN_RANGE = 1_000_000_000; // 1 billion — any range wider than this is suspicious
|
|
36
|
+
const COMPLEMENTARY_OPS = [
|
|
37
|
+
{ op1: '>', op2: '<=' },
|
|
38
|
+
{ op1: '>=', op2: '<' },
|
|
39
|
+
{ op1: '=', op2: '!=' },
|
|
40
|
+
{ op1: 'IS NULL', op2: 'IS NOT NULL' },
|
|
41
|
+
];
|
|
42
|
+
function checkOrGroupTriviallyTrue(conditions) {
|
|
43
|
+
const leaves = conditions.filter((c) => 'op' in c);
|
|
44
|
+
for (let i = 0; i < leaves.length; i++) {
|
|
45
|
+
for (let j = i + 1; j < leaves.length; j++) {
|
|
46
|
+
const a = leaves[i];
|
|
47
|
+
const b = leaves[j];
|
|
48
|
+
if (a.field !== b.field)
|
|
49
|
+
continue;
|
|
50
|
+
for (const pair of COMPLEMENTARY_OPS) {
|
|
51
|
+
const match = (a.op === pair.op1 && b.op === pair.op2) ||
|
|
52
|
+
(a.op === pair.op2 && b.op === pair.op1);
|
|
53
|
+
if (!match)
|
|
54
|
+
continue;
|
|
55
|
+
if (pair.op1 === 'IS NULL') {
|
|
56
|
+
throw new Error(`Trivially true condition detected: OR(${a.field} IS NULL, ${a.field} IS NOT NULL) matches all rows.`);
|
|
57
|
+
}
|
|
58
|
+
if ('value' in a && 'value' in b && a.value === b.value) {
|
|
59
|
+
throw new Error(`Trivially true condition detected: OR(${a.field} ${a.op} ${a.value}, ${b.field} ${b.op} ${b.value}) matches all rows.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function checkLeafTriviallyTrue(condition) {
|
|
66
|
+
if ('and' in condition) {
|
|
67
|
+
for (const c of condition.and)
|
|
68
|
+
checkLeafTriviallyTrue(c);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if ('or' in condition) {
|
|
72
|
+
checkOrGroupTriviallyTrue(condition.or);
|
|
73
|
+
for (const c of condition.or)
|
|
74
|
+
checkLeafTriviallyTrue(c);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// LIKE/ILIKE wildcard-only patterns (%, %%, _%, %_%, __, etc.)
|
|
78
|
+
if ((condition.op === 'LIKE' || condition.op === 'ILIKE') && 'value' in condition) {
|
|
79
|
+
const val = String(condition.value);
|
|
80
|
+
if (val.replace(/[%_]/g, '') === '') {
|
|
81
|
+
throw new Error(`Trivially true condition detected: ${condition.op} '${val}' matches all rows. Use a more specific pattern.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// BETWEEN with extreme numeric range
|
|
85
|
+
if (condition.op === 'BETWEEN' && 'value' in condition) {
|
|
86
|
+
const [low, high] = condition.value;
|
|
87
|
+
if (typeof low === 'number' && typeof high === 'number') {
|
|
88
|
+
const range = high - low;
|
|
89
|
+
if (range >= MAX_SAFE_BETWEEN_RANGE) {
|
|
90
|
+
throw new Error(`Trivially true condition detected: BETWEEN ${low} AND ${high} spans ${range.toLocaleString()} values. Use a narrower range.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function assertNotTriviallyTrue(conditions) {
|
|
96
|
+
// Flatten: if there's exactly one top-level condition and it's an AND group, unwrap it
|
|
97
|
+
let effective = conditions;
|
|
98
|
+
if (effective.length === 1 && 'and' in effective[0]) {
|
|
99
|
+
effective = effective[0].and;
|
|
100
|
+
}
|
|
101
|
+
// Check: sole IS NOT NULL
|
|
102
|
+
if (effective.length === 1) {
|
|
103
|
+
const c = effective[0];
|
|
104
|
+
if ('op' in c && c.op === 'IS NOT NULL') {
|
|
105
|
+
throw new Error('Trivially true condition detected: IS NOT NULL as the sole WHERE condition matches all non-null rows. Add additional conditions to narrow the scope.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Recursively check all leaf conditions for trivially-true patterns
|
|
109
|
+
for (const condition of effective) {
|
|
110
|
+
checkLeafTriviallyTrue(condition);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// --- Builder ---
|
|
114
|
+
function buildCondition(condition, paramCounter, params) {
|
|
115
|
+
// AND group
|
|
116
|
+
if ('and' in condition) {
|
|
117
|
+
const parts = condition.and.map(c => buildCondition(c, paramCounter, params));
|
|
118
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(' AND ')})`;
|
|
119
|
+
}
|
|
120
|
+
// OR group
|
|
121
|
+
if ('or' in condition) {
|
|
122
|
+
const parts = condition.or.map(c => buildCondition(c, paramCounter, params));
|
|
123
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(' OR ')})`;
|
|
124
|
+
}
|
|
125
|
+
// Leaf condition — sanitize and escape the field name
|
|
126
|
+
const escapedField = escapeIdentifier(sanitizeIdentifier(condition.field));
|
|
127
|
+
if (condition.op === 'IS NULL') {
|
|
128
|
+
return `${escapedField} IS NULL`;
|
|
129
|
+
}
|
|
130
|
+
if (condition.op === 'IS NOT NULL') {
|
|
131
|
+
return `${escapedField} IS NOT NULL`;
|
|
132
|
+
}
|
|
133
|
+
if (condition.op === 'IN' || condition.op === 'NOT IN') {
|
|
134
|
+
if (condition.value.length === 0) {
|
|
135
|
+
throw new Error(`${condition.op} requires at least one value`);
|
|
136
|
+
}
|
|
137
|
+
const placeholders = condition.value.map(v => {
|
|
138
|
+
params.push(v);
|
|
139
|
+
return `$${paramCounter.value++}`;
|
|
140
|
+
});
|
|
141
|
+
return `${escapedField} ${condition.op} (${placeholders.join(', ')})`;
|
|
142
|
+
}
|
|
143
|
+
if (condition.op === 'BETWEEN') {
|
|
144
|
+
const p1 = `$${paramCounter.value++}`;
|
|
145
|
+
params.push(condition.value[0]);
|
|
146
|
+
const p2 = `$${paramCounter.value++}`;
|
|
147
|
+
params.push(condition.value[1]);
|
|
148
|
+
return `${escapedField} BETWEEN ${p1} AND ${p2}`;
|
|
149
|
+
}
|
|
150
|
+
// Comparison operators: =, !=, >, <, >=, <=, LIKE, ILIKE
|
|
151
|
+
const placeholder = `$${paramCounter.value++}`;
|
|
152
|
+
params.push(condition.value);
|
|
153
|
+
return `${escapedField} ${condition.op} ${placeholder}`;
|
|
154
|
+
}
|
|
155
|
+
export function buildWhereClause(conditions, startParam = 1) {
|
|
156
|
+
if (conditions.length === 0) {
|
|
157
|
+
return { clause: '', params: [] };
|
|
158
|
+
}
|
|
159
|
+
assertNotTriviallyTrue(conditions);
|
|
160
|
+
const params = [];
|
|
161
|
+
const paramCounter = { value: startParam };
|
|
162
|
+
const parts = conditions.map(c => buildCondition(c, paramCounter, params));
|
|
163
|
+
return {
|
|
164
|
+
clause: parts.join(' AND '),
|
|
165
|
+
params
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// --- Formatting utilities (unchanged) ---
|
|
169
|
+
export function formatBytes(bytes) {
|
|
170
|
+
if (bytes === 0)
|
|
171
|
+
return '0 Bytes';
|
|
172
|
+
const k = 1024;
|
|
173
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
174
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
175
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
176
|
+
}
|
|
177
|
+
export function formatNumber(num) {
|
|
178
|
+
return num.toLocaleString();
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=query-builder.js.map
|