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.
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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