forge-sql-orm 2.1.4 → 2.1.6

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.
Files changed (52) hide show
  1. package/README.md +195 -27
  2. package/dist/ForgeSQLORM.js +632 -192
  3. package/dist/ForgeSQLORM.js.map +1 -1
  4. package/dist/ForgeSQLORM.mjs +632 -192
  5. package/dist/ForgeSQLORM.mjs.map +1 -1
  6. package/dist/core/ForgeSQLCrudOperations.d.ts.map +1 -1
  7. package/dist/core/ForgeSQLORM.d.ts +114 -3
  8. package/dist/core/ForgeSQLORM.d.ts.map +1 -1
  9. package/dist/core/ForgeSQLQueryBuilder.d.ts +125 -7
  10. package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
  11. package/dist/core/ForgeSQLSelectOperations.d.ts.map +1 -1
  12. package/dist/core/SystemTables.d.ts +3654 -0
  13. package/dist/core/SystemTables.d.ts.map +1 -1
  14. package/dist/lib/drizzle/extensions/additionalActions.d.ts +2 -2
  15. package/dist/lib/drizzle/extensions/additionalActions.d.ts.map +1 -1
  16. package/dist/utils/cacheContextUtils.d.ts.map +1 -1
  17. package/dist/utils/cacheUtils.d.ts.map +1 -1
  18. package/dist/utils/forgeDriver.d.ts +71 -3
  19. package/dist/utils/forgeDriver.d.ts.map +1 -1
  20. package/dist/utils/forgeDriverProxy.d.ts.map +1 -1
  21. package/dist/utils/metadataContextUtils.d.ts +11 -0
  22. package/dist/utils/metadataContextUtils.d.ts.map +1 -0
  23. package/dist/utils/requestTypeContextUtils.d.ts +8 -0
  24. package/dist/utils/requestTypeContextUtils.d.ts.map +1 -0
  25. package/dist/utils/sqlUtils.d.ts.map +1 -1
  26. package/dist/webtriggers/applyMigrationsWebTrigger.d.ts.map +1 -1
  27. package/dist/webtriggers/clearCacheSchedulerTrigger.d.ts.map +1 -1
  28. package/dist/webtriggers/dropMigrationWebTrigger.d.ts.map +1 -1
  29. package/dist/webtriggers/dropTablesMigrationWebTrigger.d.ts.map +1 -1
  30. package/dist/webtriggers/fetchSchemaWebTrigger.d.ts.map +1 -1
  31. package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts +85 -43
  32. package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts.map +1 -1
  33. package/package.json +9 -9
  34. package/src/core/ForgeSQLCrudOperations.ts +3 -0
  35. package/src/core/ForgeSQLORM.ts +287 -9
  36. package/src/core/ForgeSQLQueryBuilder.ts +138 -8
  37. package/src/core/ForgeSQLSelectOperations.ts +2 -0
  38. package/src/core/SystemTables.ts +16 -0
  39. package/src/lib/drizzle/extensions/additionalActions.ts +10 -12
  40. package/src/utils/cacheContextUtils.ts +4 -2
  41. package/src/utils/cacheUtils.ts +20 -8
  42. package/src/utils/forgeDriver.ts +223 -23
  43. package/src/utils/forgeDriverProxy.ts +2 -0
  44. package/src/utils/metadataContextUtils.ts +22 -0
  45. package/src/utils/requestTypeContextUtils.ts +11 -0
  46. package/src/utils/sqlUtils.ts +1 -0
  47. package/src/webtriggers/applyMigrationsWebTrigger.ts +9 -6
  48. package/src/webtriggers/clearCacheSchedulerTrigger.ts +1 -0
  49. package/src/webtriggers/dropMigrationWebTrigger.ts +2 -0
  50. package/src/webtriggers/dropTablesMigrationWebTrigger.ts +2 -0
  51. package/src/webtriggers/fetchSchemaWebTrigger.ts +1 -0
  52. package/src/webtriggers/topSlowestStatementLastHourTrigger.ts +515 -257
@@ -1,305 +1,563 @@
1
1
  import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
2
- import { clusterStatementsSummary, clusterStatementsSummaryHistory } from "../core/SystemTables";
2
+ import {
3
+ clusterStatementsSummary,
4
+ clusterStatementsSummaryHistory,
5
+ statementsSummary,
6
+ } from "../core/SystemTables";
3
7
  import { desc, gte, sql } from "drizzle-orm";
4
8
  import { unionAll } from "drizzle-orm/mysql-core";
5
9
  import { formatLimitOffset } from "../utils/sqlUtils";
10
+ import { OperationType } from "../utils/requestTypeContextUtils";
11
+
12
+ // Constants
13
+ const DEFAULT_MEMORY_THRESHOLD = 8 * 1024 * 1024; // 8MB
14
+ const DEFAULT_TIMEOUT = 300; // 300ms
15
+ const DEFAULT_TOP_N = 1;
16
+ const DEFAULT_HOURS = 1;
17
+ const DEFAULT_TABLES = "CLUSTER_SUMMARY_AND_HISTORY" as const;
18
+ const MAX_QUERY_TIMEOUT_MS = 3_000;
19
+ const MAX_SQL_LENGTH = 1000;
20
+ const RETRY_ATTEMPTS = 2;
21
+ const RETRY_BASE_DELAY_MS = 1_000;
22
+
23
+ // Types
24
+ interface TriggerOptions {
25
+ warnThresholdMs?: number;
26
+ memoryThresholdBytes?: number;
27
+ showPlan?: boolean;
28
+ operationType?: OperationType;
29
+ topN?: number;
30
+ hours?: number;
31
+ tables?: "SUMMARY_AND_HISTORY" | "CLUSTER_SUMMARY_AND_HISTORY";
32
+ }
33
+
34
+ interface FormattedQueryResult {
35
+ rank: number;
36
+ digest: string;
37
+ stmtType: string;
38
+ schemaName: string;
39
+ execCount: number;
40
+ avgLatencyMs: number;
41
+ maxLatencyMs: number;
42
+ minLatencyMs: number;
43
+ avgProcessTimeMs: number;
44
+ avgWaitTimeMs: number;
45
+ avgBackoffTimeMs: number;
46
+ avgMemMB: number;
47
+ maxMemMB: number;
48
+ avgMemBytes: number;
49
+ maxMemBytes: number;
50
+ avgTotalKeys: number;
51
+ firstSeen: string;
52
+ lastSeen: string;
53
+ planInCache: boolean;
54
+ planCacheHits: number;
55
+ digestText: string;
56
+ plan?: string;
57
+ }
58
+
59
+ interface TriggerResponse {
60
+ headers: { "Content-Type": string[] };
61
+ statusCode: number;
62
+ statusText?: string;
63
+ body: string;
64
+ }
65
+
66
+ // Utility Functions
67
+ /**
68
+ * Converts nanoseconds to milliseconds for better readability
69
+ */
70
+ const nsToMs = (value: unknown): number => {
71
+ const n = Number(value);
72
+ return Number.isFinite(n) ? n / 1e6 : NaN;
73
+ };
74
+
75
+ /**
76
+ * Converts bytes to megabytes for better readability
77
+ */
78
+ const bytesToMB = (value: unknown): number => {
79
+ const n = Number(value);
80
+ return Number.isFinite(n) ? n / (1024 * 1024) : NaN;
81
+ };
82
+
83
+ /**
84
+ * JSON stringify replacer to handle BigInt values safely
85
+ */
86
+ const jsonSafeStringify = (value: unknown): string =>
87
+ JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
88
+
89
+ /**
90
+ * Sanitizes SQL for safe logging by removing comments, replacing literals, and truncating
91
+ */
92
+ const sanitizeSQL = (sql: string, maxLen = MAX_SQL_LENGTH): string => {
93
+ let s = sql;
94
+
95
+ // Remove comments (-- ... and /* ... */)
96
+ s = s.replace(/--[^\n\r]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
97
+
98
+ // Replace string literals with '?'
99
+ s = s.replace(/'(?:\\'|[^'])*'/g, "?");
100
+
101
+ // Replace numbers with '?'
102
+ s = s.replace(/\b-?\d+(?:\.\d+)?\b/g, "?");
103
+
104
+ // Normalize whitespace
105
+ s = s.replace(/\s+/g, " ").trim();
106
+
107
+ // Truncate long queries
108
+ if (s.length > maxLen) {
109
+ s = s.slice(0, maxLen) + " …[truncated]";
110
+ }
111
+
112
+ return s;
113
+ };
114
+
115
+ /**
116
+ * Promise timeout helper that rejects if the promise doesn't settle within the specified time
117
+ */
118
+ const withTimeout = async <T>(promise: Promise<T>, ms: number): Promise<T> => {
119
+ let timer: ReturnType<typeof setTimeout> | undefined;
120
+ try {
121
+ return await Promise.race<T>([
122
+ promise,
123
+ new Promise<T>((_resolve, reject) => {
124
+ timer = setTimeout(() => reject(new Error(`TIMEOUT:${ms}`)), ms);
125
+ }),
126
+ ]);
127
+ } finally {
128
+ if (timer) clearTimeout(timer);
129
+ }
130
+ };
131
+
132
+ /**
133
+ * Sleep utility function
134
+ */
135
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
136
+
137
+ /**
138
+ * Executes a task with retries and exponential backoff
139
+ */
140
+ const executeWithRetries = async <T>(task: () => Promise<T>, label: string): Promise<T> => {
141
+ let attempt = 0;
142
+ let delay = RETRY_BASE_DELAY_MS;
143
+
144
+ while (true) {
145
+ try {
146
+ attempt++;
147
+ return await task();
148
+ } catch (error: any) {
149
+ const msg = String(error?.message ?? error);
150
+ const isTimeout = msg.startsWith("TIMEOUT:");
151
+
152
+ if (attempt > RETRY_ATTEMPTS) throw error;
153
+ // eslint-disable-next-line no-console
154
+ console.warn(
155
+ `${label}: attempt ${attempt} failed${isTimeout ? " (timeout)" : ""}; retrying in ${delay}ms...`,
156
+ error,
157
+ );
158
+
159
+ await sleep(delay);
160
+ delay *= 2; // Exponential backoff
161
+ }
162
+ }
163
+ };
164
+
165
+ /**
166
+ * Creates error response for failed operations
167
+ */
168
+ const createErrorResponse = (message: string, error?: any): TriggerResponse => ({
169
+ headers: { "Content-Type": ["application/json"] },
170
+ statusCode: 500,
171
+ statusText: "Internal Server Error",
172
+ body: jsonSafeStringify({
173
+ success: false,
174
+ message,
175
+ error: error?.cause?.context?.debug?.sqlMessage ?? error?.cause?.message ?? error?.message,
176
+ timestamp: new Date().toISOString(),
177
+ }),
178
+ });
179
+
180
+ /**
181
+ * Creates success response with query results
182
+ */
183
+ const createSuccessResponse = (
184
+ formatted: FormattedQueryResult[],
185
+ options: Required<TriggerOptions>,
186
+ ): TriggerResponse => ({
187
+ headers: { "Content-Type": ["application/json"] },
188
+ statusCode: 200,
189
+ statusText: "OK",
190
+ body: jsonSafeStringify({
191
+ success: true,
192
+ window: `last_${options.hours}h`,
193
+ top: options.topN,
194
+ warnThresholdMs: options.warnThresholdMs,
195
+ memoryThresholdBytes: options.memoryThresholdBytes,
196
+ showPlan: options.showPlan,
197
+ rows: formatted,
198
+ generatedAt: new Date().toISOString(),
199
+ }),
200
+ });
201
+
202
+ // Query Building Functions
203
+ /**
204
+ * Creates the selection shape for query results
205
+ */
206
+ const createSelectShape = (table: any) => ({
207
+ digest: table.digest,
208
+ stmtType: table.stmtType,
209
+ schemaName: table.schemaName,
210
+ execCount: table.execCount,
211
+ avgLatencyNs: table.avgLatency,
212
+ maxLatencyNs: table.maxLatency,
213
+ minLatencyNs: table.minLatency,
214
+ avgProcessTimeNs: table.avgProcessTime,
215
+ avgWaitTimeNs: table.avgWaitTime,
216
+ avgBackoffTimeNs: table.avgBackoffTime,
217
+ avgTotalKeys: table.avgTotalKeys,
218
+ firstSeen: table.firstSeen,
219
+ lastSeen: table.lastSeen,
220
+ planInCache: table.planInCache,
221
+ planCacheHits: table.planCacheHits,
222
+ digestText: table.digestText,
223
+ plan: table.plan,
224
+ avgMemBytes: (table as any).avgMem,
225
+ maxMemBytes: (table as any).maxMem,
226
+ });
6
227
 
7
228
  /**
8
- * Scheduler trigger: log and return the single slowest statement from the last hour, filtered by latency OR memory usage.
229
+ * Builds the combined query from multiple tables
230
+ */
231
+ const buildCombinedQuery = (orm: ForgeSqlOperation, options: Required<TriggerOptions>) => {
232
+ const summaryHistory = statementsSummary;
233
+ const summary = statementsSummary;
234
+ const summaryHistoryCluster = clusterStatementsSummaryHistory;
235
+ const summaryCluster = clusterStatementsSummary;
236
+
237
+ // Time filters for last N hours
238
+ const lastHoursFilter = (table: any) =>
239
+ gte(table.summaryEndTime, sql`DATE_SUB(NOW(), INTERVAL ${options.hours} HOUR)`);
240
+
241
+ // Build queries for each table
242
+ const qHistory = orm
243
+ .getDrizzleQueryBuilder()
244
+ .select(createSelectShape(summaryHistory))
245
+ .from(summaryHistory)
246
+ .where(lastHoursFilter(summaryHistory));
247
+
248
+ const qSummary = orm
249
+ .getDrizzleQueryBuilder()
250
+ .select(createSelectShape(summary))
251
+ .from(summary)
252
+ .where(lastHoursFilter(summary));
253
+
254
+ const qHistoryCluster = orm
255
+ .getDrizzleQueryBuilder()
256
+ .select(createSelectShape(summaryHistoryCluster))
257
+ .from(summaryHistoryCluster)
258
+ .where(lastHoursFilter(summaryHistoryCluster));
259
+
260
+ const qSummaryCluster = orm
261
+ .getDrizzleQueryBuilder()
262
+ .select(createSelectShape(summaryCluster))
263
+ .from(summaryCluster)
264
+ .where(lastHoursFilter(summaryCluster));
265
+
266
+ // Combine tables based on configuration
267
+ switch (options.tables) {
268
+ case "SUMMARY_AND_HISTORY":
269
+ return unionAll(qHistory, qSummary).as("combined");
270
+ case "CLUSTER_SUMMARY_AND_HISTORY":
271
+ return unionAll(qHistoryCluster, qSummaryCluster).as("combined");
272
+ default:
273
+ throw new Error(`Unsupported table configuration: ${options.tables}`);
274
+ }
275
+ };
276
+
277
+ /**
278
+ * Builds the final grouped query with filtering and sorting
279
+ */
280
+ const buildGroupedQuery = (orm: ForgeSqlOperation, combined: any) => {
281
+ return orm
282
+ .getDrizzleQueryBuilder()
283
+ .select({
284
+ digest: combined.digest,
285
+ stmtType: combined.stmtType,
286
+ schemaName: combined.schemaName,
287
+ execCount: sql<number>`SUM(${combined.execCount})`.as("execCount"),
288
+ avgLatencyNs: sql<number>`MAX(${combined.avgLatencyNs})`.as("avgLatencyNs"),
289
+ maxLatencyNs: sql<number>`MAX(${combined.maxLatencyNs})`.as("maxLatencyNs"),
290
+ minLatencyNs: sql<number>`MIN(${combined.minLatencyNs})`.as("minLatencyNs"),
291
+ avgProcessTimeNs: sql<number>`MAX(${combined.avgProcessTimeNs})`.as("avgProcessTimeNs"),
292
+ avgWaitTimeNs: sql<number>`MAX(${combined.avgWaitTimeNs})`.as("avgWaitTimeNs"),
293
+ avgBackoffTimeNs: sql<number>`MAX(${combined.avgBackoffTimeNs})`.as("avgBackoffTimeNs"),
294
+ avgMemBytes: sql<number>`MAX(${combined.avgMemBytes})`.as("avgMemBytes"),
295
+ maxMemBytes: sql<number>`MAX(${combined.maxMemBytes})`.as("maxMemBytes"),
296
+ avgTotalKeys: sql<number>`MAX(${combined.avgTotalKeys})`.as("avgTotalKeys"),
297
+ firstSeen: sql<string>`MIN(${combined.firstSeen})`.as("firstSeen"),
298
+ lastSeen: sql<string>`MAX(${combined.lastSeen})`.as("lastSeen"),
299
+ planInCache: sql<boolean>`MAX(${combined.planInCache})`.as("planInCache"),
300
+ planCacheHits: sql<number>`SUM(${combined.planCacheHits})`.as("planCacheHits"),
301
+ digestText: sql<string>`MAX(${combined.digestText})`.as("digestText"),
302
+ plan: sql<string>`MAX(${combined.plan})`.as("plan"),
303
+ })
304
+ .from(combined)
305
+ .where(
306
+ sql`COALESCE(${combined.digest}, '') <> '' AND COALESCE(${combined.digestText}, '') <> '' AND COALESCE(${combined.stmtType}, '') NOT IN ('Use','Set','Show')`,
307
+ )
308
+ .groupBy(combined.digest, combined.stmtType, combined.schemaName)
309
+ .as("grouped");
310
+ };
311
+
312
+ /**
313
+ * Builds the final query with filtering, sorting, and limiting
314
+ */
315
+ const buildFinalQuery = (
316
+ orm: ForgeSqlOperation,
317
+ grouped: any,
318
+ options: Required<TriggerOptions>,
319
+ ) => {
320
+ const thresholdNs = Math.floor(options.warnThresholdMs * 1e6);
321
+ const memoryThresholdBytes = options.memoryThresholdBytes;
322
+
323
+ const query = orm
324
+ .getDrizzleQueryBuilder()
325
+ .select({
326
+ digest: grouped.digest,
327
+ stmtType: grouped.stmtType,
328
+ schemaName: grouped.schemaName,
329
+ execCount: grouped.execCount,
330
+ avgLatencyNs: grouped.avgLatencyNs,
331
+ maxLatencyNs: grouped.maxLatencyNs,
332
+ minLatencyNs: grouped.minLatencyNs,
333
+ avgProcessTimeNs: grouped.avgProcessTimeNs,
334
+ avgWaitTimeNs: grouped.avgWaitTimeNs,
335
+ avgBackoffTimeNs: grouped.avgBackoffTimeNs,
336
+ avgMemBytes: grouped.avgMemBytes,
337
+ maxMemBytes: grouped.maxMemBytes,
338
+ avgTotalKeys: grouped.avgTotalKeys,
339
+ firstSeen: grouped.firstSeen,
340
+ lastSeen: grouped.lastSeen,
341
+ planInCache: grouped.planInCache,
342
+ planCacheHits: grouped.planCacheHits,
343
+ digestText: grouped.digestText,
344
+ plan: grouped.plan,
345
+ })
346
+ .from(grouped)
347
+ .where(
348
+ sql`${grouped.avgLatencyNs} > ${thresholdNs} OR ${grouped.avgMemBytes} > ${memoryThresholdBytes}`,
349
+ )
350
+ .orderBy(desc(grouped.avgLatencyNs))
351
+ .limit(formatLimitOffset(options.topN));
352
+
353
+ // Execute with DDL context if specified
354
+ if (options.operationType === "DDL") {
355
+ return orm.executeDDLActions(async () => await query);
356
+ }
357
+
358
+ return query;
359
+ };
360
+
361
+ /**
362
+ * Formats query results for output
363
+ */
364
+ const formatQueryResults = (
365
+ rows: any[],
366
+ options: Required<TriggerOptions>,
367
+ ): FormattedQueryResult[] => {
368
+ return rows.map((row, index) => ({
369
+ rank: index + 1,
370
+ digest: row.digest,
371
+ stmtType: row.stmtType,
372
+ schemaName: row.schemaName,
373
+ execCount: row.execCount,
374
+ avgLatencyMs: nsToMs(row.avgLatencyNs),
375
+ maxLatencyMs: nsToMs(row.maxLatencyNs),
376
+ minLatencyMs: nsToMs(row.minLatencyNs),
377
+ avgProcessTimeMs: nsToMs(row.avgProcessTimeNs),
378
+ avgWaitTimeMs: nsToMs(row.avgWaitTimeNs),
379
+ avgBackoffTimeMs: nsToMs(row.avgBackoffTimeNs),
380
+ avgMemMB: bytesToMB(row.avgMemBytes),
381
+ maxMemMB: bytesToMB(row.maxMemBytes),
382
+ avgMemBytes: row.avgMemBytes,
383
+ maxMemBytes: row.maxMemBytes,
384
+ avgTotalKeys: row.avgTotalKeys,
385
+ firstSeen: row.firstSeen,
386
+ lastSeen: row.lastSeen,
387
+ planInCache: row.planInCache,
388
+ planCacheHits: row.planCacheHits,
389
+ digestText: options.operationType === "DDL" ? row.digestText : sanitizeSQL(row.digestText),
390
+ plan: options.showPlan ? row.plan : undefined,
391
+ }));
392
+ };
393
+
394
+ /**
395
+ * Logs formatted query results to console
396
+ */
397
+ const logQueryResults = (
398
+ formatted: FormattedQueryResult[],
399
+ options: Required<TriggerOptions>,
400
+ ): void => {
401
+ for (const result of formatted) {
402
+ // eslint-disable-next-line no-console
403
+ console.warn(
404
+ `${result.rank}. ${result.stmtType} avg=${result.avgLatencyMs?.toFixed?.(2)}ms max=${result.maxLatencyMs?.toFixed?.(2)}ms mem≈${result.avgMemMB?.toFixed?.(2)}MB(max ${result.maxMemMB?.toFixed?.(2)}MB) exec=${result.execCount} \n` +
405
+ ` digest=${result.digest}\n` +
406
+ ` sql=${(result.digestText || "").slice(0, 300)}${result.digestText && result.digestText.length > 300 ? "…" : ""}`,
407
+ );
408
+
409
+ if (options.showPlan && result.plan) {
410
+ // eslint-disable-next-line no-console
411
+ console.warn(` full plan:\n${result.plan}`);
412
+ }
413
+ }
414
+ };
415
+
416
+ /**
417
+ * Performance monitoring scheduler trigger for Atlassian Forge SQL.
9
418
  *
10
- * When scheduled (e.g. hourly), this trigger queries
11
- * INFORMATION_SCHEMA.CLUSTER_STATEMENTS_SUMMARY_HISTORY for the last hour
12
- * and prints the TOP 1 entry (by AVG_LATENCY) if it exceeds either threshold.
419
+ * This trigger analyzes query performance from the last hour and identifies slow or memory-intensive queries
420
+ * that exceed configurable thresholds. It's designed specifically for Atlassian Forge's 16 MiB memory limit
421
+ * and provides detailed insights for query optimization.
13
422
  *
14
- * **OR Logic**: Statements are included if they exceed EITHER threshold:
15
- * - avgLatencyMs > warnThresholdMs OR
16
- * - avgMemBytes > memoryThresholdBytes
423
+ * ## Key Features
424
+ * - **Memory-focused monitoring**: Primary focus on memory usage with configurable thresholds
425
+ * - **Atlassian 16 MiB limit awareness**: Designed specifically for Forge SQL's memory constraints
426
+ * - **Execution plan analysis**: Shows detailed query plans to help optimize memory consumption
427
+ * - **Configurable thresholds**: Set custom memory usage and latency thresholds
428
+ * - **Automatic filtering**: Excludes system queries (`Use`, `Set`, `Show`) and empty queries
429
+ * - **Scheduled monitoring**: Run automatically on configurable intervals
17
430
  *
18
- * **Pro Tips:**
19
- * - Memory-only monitoring: Set warnThresholdMs to 10000ms (effectively disabled)
20
- * - Latency-only monitoring: Set memoryThresholdBytes to 16MB (16 * 1024 * 1024) (effectively disabled)
21
- * - Combined monitoring: Use both thresholds for comprehensive monitoring
431
+ * ## OR Logic Thresholds
432
+ * Statements are included if they exceed **EITHER** threshold:
433
+ * - `avgLatencyMs > warnThresholdMs` **OR**
434
+ * - `avgMemBytes > memoryThresholdBytes`
22
435
  *
23
- * Excludes statements with empty `digestText`, empty `digest`, or service statements (`Use`, `Set`, `Show`).
436
+ * ## Configuration Tips
437
+ * - **Memory-only monitoring**: Set `warnThresholdMs` to 10000ms (effectively disabled)
438
+ * - **Latency-only monitoring**: Set `memoryThresholdBytes` to 16MB (16 * 1024 * 1024) (effectively disabled)
439
+ * - **Combined monitoring**: Use both thresholds for comprehensive monitoring
440
+ * - **Conservative monitoring**: 4MB warning (25% of 16MB limit)
441
+ * - **Default monitoring**: 8MB warning (50% of 16MB limit)
442
+ * - **Aggressive monitoring**: 12MB warning (75% of 16MB limit)
24
443
  *
25
- * Logging rule:
26
- * - Query exceeds warnThresholdMs OR memoryThresholdBytes → console.warn (logged)
27
- * - otherwise not logged
444
+ * ## Exclusions
445
+ * - Statements with empty `digestText` or `digest`
446
+ * - Service statements (`Use`, `Set`, `Show`)
447
+ * - Queries that don't exceed either threshold
28
448
  *
29
- * @param orm ForgeSQL ORM instance (required)
30
- * @param warnThresholdMs Milliseconds threshold for logging and filtering (default: 300ms)
31
- * @param memoryThresholdBytes Bytes threshold for average memory usage (default: 8MB)
32
- * @returns HTTP response with a JSON payload containing the filtered rows
449
+ * @param orm - ForgeSQL ORM instance (required)
450
+ * @param options - Configuration options
451
+ * @param options.warnThresholdMs - Milliseconds threshold for latency monitoring (default: 300ms)
452
+ * @param options.memoryThresholdBytes - Bytes threshold for memory usage monitoring (default: 8MB)
453
+ * @param options.showPlan - Whether to include execution plan in logs (default: false)
454
+ * @param options.operationType - Operation type context for query execution (default: "DML")
455
+ * @param options.topN - Number of top slow queries to return (default: 1)
456
+ * @param options.hours - Number of hours to look back (default: 1)
457
+ * @param options.tables - Table configuration to use (default: "CLUSTER_SUMMARY_AND_HISTORY")
458
+ * @returns Promise<TriggerResponse> - HTTP response with query results or error
33
459
  *
34
460
  * @example
35
- * ```ts
461
+ * ```typescript
36
462
  * import ForgeSQL, { topSlowestStatementLastHourTrigger } from "forge-sql-orm";
37
463
  *
38
- * const FORGE_SQL_ORM = new ForgeSQL();
464
+ * const forgeSQL = new ForgeSQL();
39
465
  *
40
466
  * // Default thresholds: 300ms latency OR 8MB memory
41
- * export const topSlowQueryTrigger = () =>
42
- * topSlowestStatementLastHourTrigger(FORGE_SQL_ORM);
467
+ * export const performanceTrigger = () =>
468
+ * topSlowestStatementLastHourTrigger(forgeSQL);
43
469
  *
44
- * // Only latency monitoring: 500ms threshold (memory effectively disabled)
45
- * export const latencyOnlyTrigger = () =>
46
- * topSlowestStatementLastHourTrigger(FORGE_SQL_ORM, 500, 16 * 1024 * 1024);
470
+ * // Conservative memory monitoring: 4MB threshold
471
+ * export const conservativeTrigger = () =>
472
+ * topSlowestStatementLastHourTrigger(forgeSQL, {
473
+ * memoryThresholdBytes: 4 * 1024 * 1024
474
+ * });
47
475
  *
48
- * // Only memory monitoring: 4MB threshold (latency effectively disabled)
476
+ * // Memory-only monitoring: 4MB threshold (latency effectively disabled)
49
477
  * export const memoryOnlyTrigger = () =>
50
- * topSlowestStatementLastHourTrigger(FORGE_SQL_ORM, 10000, 4 * 1024 * 1024);
478
+ * topSlowestStatementLastHourTrigger(forgeSQL, {
479
+ * warnThresholdMs: 10000,
480
+ * memoryThresholdBytes: 4 * 1024 * 1024
481
+ * });
51
482
  *
52
- * // Both thresholds: 500ms latency OR 8MB memory
53
- * export const bothThresholdsTrigger = () =>
54
- * topSlowestStatementLastHourTrigger(FORGE_SQL_ORM, 500, 8 * 1024 * 1024);
483
+ * // With execution plan in logs
484
+ * export const withPlanTrigger = () =>
485
+ * topSlowestStatementLastHourTrigger(forgeSQL, { showPlan: true });
55
486
  * ```
56
487
  *
57
488
  * @example
58
489
  * ```yaml
490
+ * # manifest.yml configuration
59
491
  * scheduledTrigger:
60
- * - key: top-slow-query-trigger
61
- * function: topSlowQueryTrigger
492
+ * - key: performance-trigger
493
+ * function: performanceTrigger
62
494
  * interval: hour
63
495
  *
64
496
  * function:
65
- * - key: topSlowQueryTrigger
66
- * handler: index.topSlowQueryTrigger
497
+ * - key: performanceTrigger
498
+ * handler: index.performanceTrigger
67
499
  * ```
68
500
  */
69
- // Main scheduler trigger function to log the single slowest SQL statement from the last hour.
501
+ /**
502
+ * Main scheduler trigger function to log the single slowest SQL statement from the last hour.
503
+ *
504
+ * @param orm - ForgeSQL ORM instance (required)
505
+ * @param options - Configuration options
506
+ * @returns Promise<TriggerResponse> - HTTP response with query results or error
507
+ */
70
508
  export const topSlowestStatementLastHourTrigger = async (
71
509
  orm: ForgeSqlOperation,
72
- // warnThresholdMs: Only log queries whose average latency (ms) exceeds this threshold (default: 300ms)
73
- warnThresholdMs: number = 300,
74
- // memoryThresholdBytes: Also include queries whose avg memory usage exceeds this threshold (default: 8MB)
75
- memoryThresholdBytes: number = 8 * 1024 * 1024,
76
- ) => {
77
- // Helper: Convert nanoseconds to milliseconds (for latency fields)
78
- const nsToMs = (v: unknown) => {
79
- const n = Number(v);
80
- return Number.isFinite(n) ? n / 1e6 : NaN;
81
- };
510
+ options?: TriggerOptions,
511
+ ): Promise<TriggerResponse> => {
512
+ // Validate required parameters
513
+ if (!orm) {
514
+ return createErrorResponse("ORM instance is required");
515
+ }
82
516
 
83
- // Helper: Convert bytes to megabytes (for memory fields)
84
- const bytesToMB = (v: unknown) => {
85
- const n = Number(v);
86
- return Number.isFinite(n) ? n / (1024 * 1024) : NaN;
517
+ // Merge options with defaults
518
+ const mergedOptions: Required<TriggerOptions> = {
519
+ warnThresholdMs: options?.warnThresholdMs ?? DEFAULT_TIMEOUT,
520
+ memoryThresholdBytes: options?.memoryThresholdBytes ?? DEFAULT_MEMORY_THRESHOLD,
521
+ showPlan: options?.showPlan ?? false,
522
+ operationType: options?.operationType ?? "DML",
523
+ topN: options?.topN ?? DEFAULT_TOP_N,
524
+ hours: options?.hours ?? DEFAULT_HOURS,
525
+ tables: options?.tables ?? DEFAULT_TABLES,
87
526
  };
88
527
 
89
- // Helper: JSON.stringify replacer to handle BigInt values (so BigInt serializes as string)
90
- const jsonSafeStringify = (value: unknown) =>
91
- JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
528
+ try {
529
+ // Build the combined query from multiple tables
530
+ const combined = buildCombinedQuery(orm, mergedOptions);
92
531
 
93
- // Number of top slow queries to fetch
94
- const TOP_N = 1;
532
+ // Build the grouped query with filtering and aggregation
533
+ const grouped = buildGroupedQuery(orm, combined);
95
534
 
96
- try {
97
- // Get references to system summary tables
98
- const summaryHistory = clusterStatementsSummaryHistory;
99
- const summary = clusterStatementsSummary;
100
- // Helper to define the selected fields (selection shape) for both tables
101
- const selectShape = (t: typeof summaryHistory | typeof summary) => ({
102
- digest: t.digest,
103
- stmtType: t.stmtType,
104
- schemaName: t.schemaName,
105
- execCount: t.execCount,
106
-
107
- avgLatencyNs: t.avgLatency,
108
- maxLatencyNs: t.maxLatency,
109
- minLatencyNs: t.minLatency,
110
-
111
- avgProcessTimeNs: t.avgProcessTime,
112
- avgWaitTimeNs: t.avgWaitTime,
113
- avgBackoffTimeNs: t.avgBackoffTime,
114
-
115
- avgTotalKeys: t.avgTotalKeys,
116
- firstSeen: t.firstSeen,
117
- lastSeen: t.lastSeen,
118
- planInCache: t.planInCache,
119
- planCacheHits: t.planCacheHits,
120
- digestText: t.digestText,
121
- plan: t.plan,
122
- avgMemBytes: (t as any).avgMem,
123
- maxMemBytes: (t as any).maxMem,
124
- });
125
-
126
- // Filters: Only include rows from the last hour for each table
127
- const lastHourFilterHistory = gte(
128
- summaryHistory.summaryEndTime,
129
- sql`DATE_SUB(NOW(), INTERVAL 1 HOUR)`,
130
- );
131
- const lastHourFilterSummary = gte(
132
- summary.summaryEndTime,
133
- sql`DATE_SUB(NOW(), INTERVAL 1 HOUR)`,
535
+ // Build the final query with filtering, sorting, and limiting
536
+ const finalQuery = buildFinalQuery(orm, grouped, mergedOptions);
537
+
538
+ // Execute the query with retries and timeout
539
+ const rows = await executeWithRetries(
540
+ () => withTimeout(finalQuery, MAX_QUERY_TIMEOUT_MS),
541
+ "topSlowestStatementLastHourTrigger",
134
542
  );
135
543
 
136
- // Query for summary history table (last hour)
137
- const qHistory = orm
138
- .getDrizzleQueryBuilder()
139
- .select(selectShape(summaryHistory))
140
- .from(summaryHistory)
141
- .where(lastHourFilterHistory);
142
-
143
- // Query for summary table (last hour)
144
- const qSummary = orm
145
- .getDrizzleQueryBuilder()
146
- .select(selectShape(summary))
147
- .from(summary)
148
- .where(lastHourFilterSummary);
149
-
150
- // Use UNION ALL to combine results from both tables (avoids duplicates, keeps all rows)
151
- // This is necessary because some statements may only be present in one of the tables.
152
- const combined = unionAll(qHistory, qSummary).as("combined");
153
-
154
- // Threshold in nanoseconds (warnThresholdMs → ns)
155
- const thresholdNs = Math.floor(warnThresholdMs * 1e6);
156
- // memoryThresholdBytes is already provided in bytes (default 8MB)
157
-
158
- // Group duplicates by digest+stmtType+schemaName and aggregate metrics
159
- const grouped = orm
160
- .getDrizzleQueryBuilder()
161
- .select({
162
- digest: combined.digest,
163
- stmtType: combined.stmtType,
164
- schemaName: combined.schemaName,
165
- execCount: sql<number>`SUM(${combined.execCount})`.as("execCount"),
166
-
167
- avgLatencyNs: sql<number>`MAX(${combined.avgLatencyNs})`.as("avgLatencyNs"),
168
- maxLatencyNs: sql<number>`MAX(${combined.maxLatencyNs})`.as("maxLatencyNs"),
169
- minLatencyNs: sql<number>`MIN(${combined.minLatencyNs})`.as("minLatencyNs"),
170
-
171
- avgProcessTimeNs: sql<number>`MAX(${combined.avgProcessTimeNs})`.as("avgProcessTimeNs"),
172
- avgWaitTimeNs: sql<number>`MAX(${combined.avgWaitTimeNs})`.as("avgWaitTimeNs"),
173
- avgBackoffTimeNs: sql<number>`MAX(${combined.avgBackoffTimeNs})`.as("avgBackoffTimeNs"),
174
-
175
- avgMemBytes: sql<number>`MAX(${combined.avgMemBytes})`.as("avgMemBytes"),
176
- maxMemBytes: sql<number>`MAX(${combined.maxMemBytes})`.as("maxMemBytes"),
177
-
178
- avgTotalKeys: sql<number>`MAX(${combined.avgTotalKeys})`.as("avgTotalKeys"),
179
- firstSeen: sql<string>`MIN(${combined.firstSeen})`.as("firstSeen"),
180
- lastSeen: sql<string>`MAX(${combined.lastSeen})`.as("lastSeen"),
181
- planInCache: sql<boolean>`MAX(${combined.planInCache})`.as("planInCache"),
182
- planCacheHits: sql<number>`SUM(${combined.planCacheHits})`.as("planCacheHits"),
183
- // Prefer a non-empty sample text/plan via MAX; acceptable for de-dup
184
- digestText: sql<string>`MAX(${combined.digestText})`.as("digestText"),
185
- plan: sql<string>`MAX(${combined.plan})`.as("plan"),
186
- })
187
- .from(combined)
188
- .where(
189
- sql`COALESCE(${combined.digest}, '') <> '' AND COALESCE(${combined.digestText}, '') <> '' AND COALESCE(${combined.stmtType}, '') NOT IN ('Use','Set','Show')`,
190
- )
191
- .groupBy(combined.digest, combined.stmtType, combined.schemaName)
192
- .as("grouped");
193
-
194
- // Final selection: filter by threshold, sort by avg latency desc, limit TOP_N
195
- const rows = await orm
196
- .getDrizzleQueryBuilder()
197
- .select({
198
- digest: grouped.digest,
199
- stmtType: grouped.stmtType,
200
- schemaName: grouped.schemaName,
201
- execCount: grouped.execCount,
202
-
203
- avgLatencyNs: grouped.avgLatencyNs,
204
- maxLatencyNs: grouped.maxLatencyNs,
205
- minLatencyNs: grouped.minLatencyNs,
206
-
207
- avgProcessTimeNs: grouped.avgProcessTimeNs,
208
- avgWaitTimeNs: grouped.avgWaitTimeNs,
209
- avgBackoffTimeNs: grouped.avgBackoffTimeNs,
210
-
211
- avgMemBytes: grouped.avgMemBytes,
212
- maxMemBytes: grouped.maxMemBytes,
213
-
214
- avgTotalKeys: grouped.avgTotalKeys,
215
- firstSeen: grouped.firstSeen,
216
- lastSeen: grouped.lastSeen,
217
- planInCache: grouped.planInCache,
218
- planCacheHits: grouped.planCacheHits,
219
- digestText: grouped.digestText,
220
- plan: grouped.plan,
221
- })
222
- .from(grouped)
223
- .where(
224
- sql`${grouped.avgLatencyNs} > ${thresholdNs} OR ${grouped.avgMemBytes} > ${memoryThresholdBytes}`,
225
- )
226
- .orderBy(desc(grouped.avgLatencyNs))
227
- .limit(formatLimitOffset(TOP_N));
228
-
229
- // Map each row into a formatted object with ms and rank, for easier consumption/logging
230
- const formatted = rows.map((r, i) => ({
231
- rank: i + 1, // 1-based rank in the top N
232
- digest: r.digest,
233
- stmtType: r.stmtType,
234
- schemaName: r.schemaName,
235
- execCount: r.execCount,
236
- avgLatencyMs: nsToMs(r.avgLatencyNs), // Convert ns to ms for readability
237
- maxLatencyMs: nsToMs(r.maxLatencyNs),
238
- minLatencyMs: nsToMs(r.minLatencyNs),
239
- avgProcessTimeMs: nsToMs(r.avgProcessTimeNs),
240
- avgWaitTimeMs: nsToMs(r.avgWaitTimeNs),
241
- avgBackoffTimeMs: nsToMs(r.avgBackoffTimeNs),
242
- avgMemMB: bytesToMB(r.avgMemBytes),
243
- maxMemMB: bytesToMB(r.maxMemBytes),
244
- avgMemBytes: r.avgMemBytes,
245
- maxMemBytes: r.maxMemBytes,
246
- avgTotalKeys: r.avgTotalKeys,
247
- firstSeen: r.firstSeen,
248
- lastSeen: r.lastSeen,
249
- planInCache: r.planInCache,
250
- planCacheHits: r.planCacheHits,
251
- digestText: r.digestText,
252
- plan: r.plan,
253
- }));
254
-
255
- // Log each entry (SQL already filtered by threshold)
256
- for (const f of formatted) {
257
- // eslint-disable-next-line no-console
258
- console.warn(
259
- `${f.rank}. ${f.stmtType} avg=${f.avgLatencyMs?.toFixed?.(2)}ms max=${f.maxLatencyMs?.toFixed?.(2)}ms mem≈${f.avgMemMB?.toFixed?.(2)}MB(max ${f.maxMemMB?.toFixed?.(2)}MB) exec=${f.execCount} \n` +
260
- ` digest=${f.digest}\n` +
261
- ` sql=${(f.digestText || "").slice(0, 300)}${f.digestText && f.digestText.length > 300 ? "…" : ""}`,
262
- );
263
- if (f.plan) {
264
- // print full plan separately (not truncated)
265
- // eslint-disable-next-line no-console
266
- console.warn(` full plan:\n${f.plan}`);
267
- }
268
- }
544
+ // Format the results for output
545
+ const formatted = formatQueryResults(rows, mergedOptions);
269
546
 
270
- // Return HTTP response with JSON payload of the results
271
- return {
272
- headers: { "Content-Type": ["application/json"] },
273
- statusCode: 200,
274
- statusText: "OK",
275
- body: jsonSafeStringify({
276
- success: true,
277
- window: "last_1h",
278
- top: TOP_N,
279
- warnThresholdMs,
280
- memoryThresholdBytes,
281
- rows: formatted,
282
- generatedAt: new Date().toISOString(),
283
- }),
284
- };
547
+ // Log the results to console
548
+ logQueryResults(formatted, mergedOptions);
549
+
550
+ // Return success response
551
+ return createSuccessResponse(formatted, mergedOptions);
285
552
  } catch (error: any) {
286
- // Catch any error (DB, logic, etc) and log with details for debugging
287
- // This ensures the scheduler never crashes and always returns a response.
553
+ // Log error details for debugging
288
554
  // eslint-disable-next-line no-console
289
- console.error(
290
- "Error in topSlowestStatementLastHourTrigger:",
555
+ console.warn(
556
+ "Error in topSlowestStatementLastHourTrigger (one-off errors can be ignored; if it recurs, investigate):",
291
557
  error?.cause?.context?.debug?.sqlMessage ?? error?.cause ?? error,
292
558
  );
293
- return {
294
- headers: { "Content-Type": ["application/json"] },
295
- statusCode: 500,
296
- statusText: "Internal Server Error",
297
- body: jsonSafeStringify({
298
- success: false,
299
- message: "Failed to fetch or log slow queries",
300
- error: error?.cause?.context?.debug?.sqlMessage ?? error?.cause?.message,
301
- timestamp: new Date().toISOString(),
302
- }),
303
- };
559
+
560
+ // Return error response
561
+ return createErrorResponse("Failed to fetch or log slow queries", error);
304
562
  }
305
563
  };