forge-sql-orm 2.1.14 → 2.1.15
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/README.md +290 -20
- package/dist/core/ForgeSQLORM.d.ts +16 -7
- package/dist/core/ForgeSQLORM.d.ts.map +1 -1
- package/dist/core/ForgeSQLORM.js +73 -15
- package/dist/core/ForgeSQLORM.js.map +1 -1
- package/dist/core/ForgeSQLQueryBuilder.d.ts +13 -4
- package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
- package/dist/core/ForgeSQLQueryBuilder.js.map +1 -1
- package/dist/utils/forgeDriver.d.ts +3 -2
- package/dist/utils/forgeDriver.d.ts.map +1 -1
- package/dist/utils/forgeDriver.js +20 -16
- package/dist/utils/forgeDriver.js.map +1 -1
- package/dist/utils/metadataContextUtils.d.ts +27 -1
- package/dist/utils/metadataContextUtils.d.ts.map +1 -1
- package/dist/utils/metadataContextUtils.js +215 -10
- package/dist/utils/metadataContextUtils.js.map +1 -1
- package/dist/webtriggers/index.d.ts +1 -0
- package/dist/webtriggers/index.d.ts.map +1 -1
- package/dist/webtriggers/index.js +1 -0
- package/dist/webtriggers/index.js.map +1 -1
- package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts +60 -0
- package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts.map +1 -0
- package/dist/webtriggers/topSlowestStatementLastHourTrigger.js +55 -0
- package/dist/webtriggers/topSlowestStatementLastHourTrigger.js.map +1 -0
- package/package.json +10 -9
- package/src/core/ForgeSQLORM.ts +78 -14
- package/src/core/ForgeSQLQueryBuilder.ts +13 -3
- package/src/utils/forgeDriver.ts +34 -19
- package/src/utils/metadataContextUtils.ts +267 -10
- package/src/webtriggers/index.ts +1 -0
- package/src/webtriggers/topSlowestStatementLastHourTrigger.ts +69 -0
|
@@ -1,32 +1,289 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { ForgeSQLMetadata } from "./forgeDriver";
|
|
3
3
|
import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
|
|
4
|
+
import { ExplainAnalyzeRow } from "../core/SystemTables";
|
|
4
5
|
import { printQueriesWithPlan } from "./sqlUtils";
|
|
6
|
+
import { Parser } from "node-sql-parser";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_WINDOW_SIZE = 15 * 1000;
|
|
9
|
+
|
|
10
|
+
type Statistic = { query: string; params: unknown[]; metadata: ForgeSQLMetadata };
|
|
11
|
+
|
|
12
|
+
export type QueryPlanMode = "TopSlowest" | "SummaryTable";
|
|
13
|
+
|
|
14
|
+
export type MetadataQueryOptions = {
|
|
15
|
+
mode?: QueryPlanMode;
|
|
16
|
+
summaryTableWindowTime?: number;
|
|
17
|
+
topQueries?: number;
|
|
18
|
+
showSlowestPlans?: boolean;
|
|
19
|
+
normalizeQuery?: boolean;
|
|
20
|
+
};
|
|
5
21
|
|
|
6
22
|
export type MetadataQueryContext = {
|
|
7
23
|
totalDbExecutionTime: number;
|
|
8
24
|
totalResponseSize: number;
|
|
9
25
|
beginTime: Date;
|
|
26
|
+
options?: MetadataQueryOptions;
|
|
27
|
+
statistics: Statistic[];
|
|
10
28
|
printQueriesWithPlan: () => Promise<void>;
|
|
11
29
|
forgeSQLORM: ForgeSqlOperation;
|
|
12
30
|
};
|
|
31
|
+
|
|
13
32
|
export const metadataQueryContext = new AsyncLocalStorage<MetadataQueryContext>();
|
|
14
33
|
|
|
15
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Creates default options for metadata query context.
|
|
36
|
+
* @returns Default options object
|
|
37
|
+
*/
|
|
38
|
+
function createDefaultOptions(): Required<MetadataQueryOptions> {
|
|
39
|
+
return {
|
|
40
|
+
mode: "TopSlowest",
|
|
41
|
+
topQueries: 1,
|
|
42
|
+
summaryTableWindowTime: DEFAULT_WINDOW_SIZE,
|
|
43
|
+
showSlowestPlans: true,
|
|
44
|
+
normalizeQuery: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Merges provided options with defaults, using defaults only for undefined fields.
|
|
50
|
+
* @param options - Optional partial options to merge
|
|
51
|
+
* @returns Complete options object with all fields set
|
|
52
|
+
*/
|
|
53
|
+
function mergeOptionsWithDefaults(options?: MetadataQueryOptions): Required<MetadataQueryOptions> {
|
|
54
|
+
const defaults = createDefaultOptions();
|
|
55
|
+
return {
|
|
56
|
+
mode: options?.mode ?? defaults.mode,
|
|
57
|
+
topQueries: options?.topQueries ?? defaults.topQueries,
|
|
58
|
+
summaryTableWindowTime: options?.summaryTableWindowTime ?? defaults.summaryTableWindowTime,
|
|
59
|
+
showSlowestPlans: options?.showSlowestPlans ?? defaults.showSlowestPlans,
|
|
60
|
+
normalizeQuery: options?.normalizeQuery ?? defaults.normalizeQuery,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalizes SQL query using regex fallback by replacing parameter values with placeholders.
|
|
66
|
+
* Replaces string literals, numeric values, and boolean values with '?' for logging.
|
|
67
|
+
*
|
|
68
|
+
* Note: This is a fallback function used when node-sql-parser fails.
|
|
69
|
+
* It uses simple, safe regex patterns to avoid ReDoS (Regular Expression Denial of Service) vulnerabilities.
|
|
70
|
+
* For proper handling of escaped quotes and complex SQL, use the main normalizeSqlForLogging function
|
|
71
|
+
* which uses node-sql-parser.
|
|
72
|
+
*
|
|
73
|
+
* @param sql - SQL query string to normalize
|
|
74
|
+
* @returns Normalized SQL string with parameters replaced by '?'
|
|
75
|
+
*/
|
|
76
|
+
function normalizeSqlForLoggingRegex(sql: string): string {
|
|
77
|
+
let normalized = sql;
|
|
78
|
+
|
|
79
|
+
// Replace string literals (single quotes) - using simple greedy match
|
|
80
|
+
// This avoids catastrophic backtracking by using a simple [^']* pattern
|
|
81
|
+
// Note: This does not handle SQL-style escaped quotes (doubled quotes: '')
|
|
82
|
+
// For proper handling, use the main normalizeSqlForLogging function with node-sql-parser
|
|
83
|
+
normalized = normalized.replace(/'[^']*'/g, "?");
|
|
84
|
+
|
|
85
|
+
// Replace string literals (double quotes) - using simple greedy match
|
|
86
|
+
// Same safety considerations as above
|
|
87
|
+
normalized = normalized.replace(/"[^"]*"/g, "?");
|
|
88
|
+
|
|
89
|
+
// Replace numeric literals - simplified pattern to avoid backtracking
|
|
90
|
+
// Match: optional minus, digits, optional decimal point and more digits
|
|
91
|
+
// Using word boundaries (\b) for safety - avoids complex lookahead/lookbehind
|
|
92
|
+
normalized = normalized.replace(/\b-?\d+\.?\d*\b/g, "?");
|
|
93
|
+
|
|
94
|
+
// Replace boolean literals - safe pattern with word boundaries
|
|
95
|
+
// Simple alternation with word boundaries - no nested quantifiers
|
|
96
|
+
normalized = normalized.replace(/\b(true|false)\b/gi, "?");
|
|
97
|
+
|
|
98
|
+
// Replace NULL values (but be careful not to replace in identifiers)
|
|
99
|
+
// Simple word boundary match - safe from backtracking
|
|
100
|
+
normalized = normalized.replace(/\bNULL\b/gi, "?");
|
|
101
|
+
|
|
102
|
+
return normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalizes SQL query by replacing parameter values with placeholders.
|
|
107
|
+
* First attempts to use node-sql-parser for structure normalization, then applies regex for value replacement.
|
|
108
|
+
* Falls back to regex-based normalization if parsing fails.
|
|
109
|
+
* @param sql - SQL query string to normalize
|
|
110
|
+
* @returns Normalized SQL string with parameters replaced by '?'
|
|
111
|
+
*/
|
|
112
|
+
function normalizeSqlForLogging(sql: string): string {
|
|
113
|
+
try {
|
|
114
|
+
const parser = new Parser();
|
|
115
|
+
const ast = parser.astify(sql.trim());
|
|
116
|
+
|
|
117
|
+
// Convert AST back to SQL (this normalizes structure and formatting)
|
|
118
|
+
const normalized = parser.sqlify(Array.isArray(ast) ? ast[0] : ast);
|
|
119
|
+
|
|
120
|
+
// Apply regex-based value replacement to the normalized SQL
|
|
121
|
+
// This handles the case where sqlify might preserve some literal values
|
|
122
|
+
let result = normalizeSqlForLoggingRegex(normalized.trim());
|
|
123
|
+
|
|
124
|
+
// Remove backticks added by sqlify for cleaner logging (optional - can be removed if backticks are preferred)
|
|
125
|
+
result = result.replace(/`/g, "");
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
//eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// If parsing fails, fall back to regex-based normalization
|
|
131
|
+
return normalizeSqlForLoggingRegex(sql);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Formats an execution plan array into a readable string representation.
|
|
137
|
+
* @param planRows - Array of ExplainAnalyzeRow objects representing the execution plan
|
|
138
|
+
* @returns Formatted string representation of the execution plan
|
|
139
|
+
*/
|
|
140
|
+
function formatExplainPlan(planRows: ExplainAnalyzeRow[]): string {
|
|
141
|
+
if (!planRows || planRows.length === 0) {
|
|
142
|
+
return "No execution plan available";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
|
|
147
|
+
for (const row of planRows) {
|
|
148
|
+
const parts: string[] = [];
|
|
149
|
+
|
|
150
|
+
if (row.id) parts.push(row.id);
|
|
151
|
+
if (row.task) parts.push(`task:${row.task}`);
|
|
152
|
+
if (row.operatorInfo) parts.push(row.operatorInfo);
|
|
153
|
+
|
|
154
|
+
const rowInfo: string[] = [];
|
|
155
|
+
if (row.estRows) rowInfo.push(`estRows:${row.estRows}`);
|
|
156
|
+
if (row.actRows) rowInfo.push(`actRows:${row.actRows}`);
|
|
157
|
+
if (rowInfo.length > 0) parts.push(`[${rowInfo.join(", ")}]`);
|
|
158
|
+
|
|
159
|
+
if (row.executionInfo) parts.push(`execution info:${row.executionInfo}`);
|
|
160
|
+
|
|
161
|
+
const resourceInfo: string[] = [];
|
|
162
|
+
if (row.memory) resourceInfo.push(`memory:${row.memory}`);
|
|
163
|
+
if (row.disk) resourceInfo.push(`disk:${row.disk}`);
|
|
164
|
+
if (resourceInfo.length > 0) parts.push(`(${resourceInfo.join(", ")})`);
|
|
165
|
+
|
|
166
|
+
if (row.accessObject) parts.push(`access object:${row.accessObject}`);
|
|
167
|
+
|
|
168
|
+
lines.push(parts.join(" | "));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Prints query plans using summary tables if mode is SummaryTable and within time window.
|
|
176
|
+
* @param context - The metadata query context
|
|
177
|
+
* @param options - The merged options with defaults
|
|
178
|
+
* @returns Promise that resolves when plans are printed
|
|
179
|
+
*/
|
|
180
|
+
async function printPlansUsingSummaryTables(
|
|
181
|
+
context: MetadataQueryContext,
|
|
182
|
+
options: Required<MetadataQueryOptions>,
|
|
183
|
+
): Promise<boolean> {
|
|
184
|
+
const timeDiff = Date.now() - context.beginTime.getTime();
|
|
185
|
+
|
|
186
|
+
if (options.mode !== "SummaryTable") {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (timeDiff <= options.summaryTableWindowTime) {
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
192
|
+
const summaryTableDiffMs = Date.now() - context.beginTime.getTime();
|
|
193
|
+
await printQueriesWithPlan(context.forgeSQLORM, summaryTableDiffMs);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.warn("Summary table window expired — showing query plans instead");
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Prints query plans for the top slowest queries.
|
|
203
|
+
* @param context - The metadata query context
|
|
204
|
+
* @param options - The merged options with defaults
|
|
205
|
+
* @returns Promise that resolves when plans are printed
|
|
206
|
+
*/
|
|
207
|
+
async function printTopQueriesPlans(
|
|
208
|
+
context: MetadataQueryContext,
|
|
209
|
+
options: Required<MetadataQueryOptions>,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const topQueries = context.statistics
|
|
212
|
+
.sort((a, b) => b.metadata.dbExecutionTime - a.metadata.dbExecutionTime)
|
|
213
|
+
.slice(0, options.topQueries);
|
|
214
|
+
|
|
215
|
+
for (const query of topQueries) {
|
|
216
|
+
const normalizedQuery = options.normalizeQuery
|
|
217
|
+
? normalizeSqlForLogging(query.query)
|
|
218
|
+
: query.query;
|
|
219
|
+
if (options.showSlowestPlans) {
|
|
220
|
+
const explainAnalyzeRows = await context.forgeSQLORM
|
|
221
|
+
.analyze()
|
|
222
|
+
.explainAnalyzeRaw(query.query, query.params);
|
|
223
|
+
const formattedPlan = formatExplainPlan(explainAnalyzeRows);
|
|
224
|
+
// eslint-disable-next-line no-console
|
|
225
|
+
console.warn(
|
|
226
|
+
`SQL: ${normalizedQuery} | Time: ${query.metadata.dbExecutionTime} ms\n Plan:\n${formattedPlan}`,
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
// eslint-disable-next-line no-console
|
|
230
|
+
console.warn(`SQL: ${normalizedQuery} | Time: ${query.metadata.dbExecutionTime} ms`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Saves query metadata to the current context and sets up the printQueriesWithPlan function.
|
|
237
|
+
* @param stringQuery - The SQL query string
|
|
238
|
+
* @param params - Query parameters
|
|
239
|
+
* @param metadata - Query execution metadata
|
|
240
|
+
*/
|
|
241
|
+
export async function saveMetaDataToContext(
|
|
242
|
+
stringQuery: string,
|
|
243
|
+
params: unknown[],
|
|
244
|
+
metadata: ForgeSQLMetadata,
|
|
245
|
+
): Promise<void> {
|
|
16
246
|
const context = metadataQueryContext.getStore();
|
|
17
|
-
if (context) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
247
|
+
if (!context) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Initialize statistics array if needed
|
|
252
|
+
if (!context.statistics) {
|
|
253
|
+
context.statistics = [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Merge options with defaults
|
|
257
|
+
context.options = mergeOptionsWithDefaults(context.options);
|
|
258
|
+
|
|
259
|
+
// Add query statistics
|
|
260
|
+
context.statistics.push({ query: stringQuery, params, metadata });
|
|
261
|
+
|
|
262
|
+
// Set up printQueriesWithPlan function
|
|
263
|
+
context.printQueriesWithPlan = async () => {
|
|
264
|
+
const options = mergeOptionsWithDefaults(context.options);
|
|
265
|
+
|
|
266
|
+
// Try to use summary tables first if enabled
|
|
267
|
+
const usedSummaryTables = await printPlansUsingSummaryTables(context, options);
|
|
268
|
+
if (usedSummaryTables) {
|
|
269
|
+
return;
|
|
25
270
|
}
|
|
26
|
-
|
|
271
|
+
|
|
272
|
+
// Fall back to printing top queries plans
|
|
273
|
+
await printTopQueriesPlans(context, options);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Update aggregated metrics
|
|
277
|
+
if (metadata) {
|
|
278
|
+
context.totalResponseSize += metadata.responseSize;
|
|
279
|
+
context.totalDbExecutionTime += metadata.dbExecutionTime;
|
|
27
280
|
}
|
|
28
281
|
}
|
|
29
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Gets the latest metadata from the current context.
|
|
285
|
+
* @returns The current metadata context or undefined if not in a context
|
|
286
|
+
*/
|
|
30
287
|
export async function getLastestMetadata(): Promise<MetadataQueryContext | undefined> {
|
|
31
288
|
return metadataQueryContext.getStore();
|
|
32
289
|
}
|
package/src/webtriggers/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./fetchSchemaWebTrigger";
|
|
|
4
4
|
export * from "./dropTablesMigrationWebTrigger";
|
|
5
5
|
export * from "./clearCacheSchedulerTrigger";
|
|
6
6
|
export * from "./slowQuerySchedulerTrigger";
|
|
7
|
+
export * from "./topSlowestStatementLastHourTrigger";
|
|
7
8
|
|
|
8
9
|
export interface TriggerResponse<BODY> {
|
|
9
10
|
body?: BODY;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
|
|
2
|
+
import { slowQuerySchedulerTrigger, TriggerResponse } from "./";
|
|
3
|
+
import { OperationType } from "../utils/requestTypeContextUtils";
|
|
4
|
+
|
|
5
|
+
export interface TriggerOptions {
|
|
6
|
+
warnThresholdMs?: number;
|
|
7
|
+
memoryThresholdBytes?: number;
|
|
8
|
+
showPlan?: boolean;
|
|
9
|
+
operationType?: OperationType;
|
|
10
|
+
topN?: number;
|
|
11
|
+
hours?: number;
|
|
12
|
+
tables?: "SUMMARY_AND_HISTORY" | "CLUSTER_SUMMARY_AND_HISTORY";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @deprecated This function is deprecated and will be removed in a future version.
|
|
17
|
+
*
|
|
18
|
+
* This function was previously a complex implementation that directly queried
|
|
19
|
+
* CLUSTER_STATEMENTS_SUMMARY tables to analyze query performance. However, this approach
|
|
20
|
+
* had reliability issues with long-running functions where metadata could be evicted
|
|
21
|
+
* before the function completes.
|
|
22
|
+
*
|
|
23
|
+
* The recommended replacement is to use the new observability system with `executeWithMetadata`:
|
|
24
|
+
* - **TopSlowest mode** (default): Deterministic logging of SQL digests executed in resolvers
|
|
25
|
+
* - **SummaryTable mode** (optional): Uses CLUSTER_STATEMENTS_SUMMARY with a short memory window
|
|
26
|
+
* - Automatic fallback mechanisms for long-running functions
|
|
27
|
+
* - More reliable post-mortem diagnostics for Timeout and OOM errors
|
|
28
|
+
*
|
|
29
|
+
* Note: `slowQuerySchedulerTrigger` is a different function that analyzes TiDB's slow query log
|
|
30
|
+
* and is not a direct replacement for this function.
|
|
31
|
+
*
|
|
32
|
+
* For more details on the improvements and migration path, see:
|
|
33
|
+
* https://community.developer.atlassian.com/t/practical-sql-observability-for-forge-apps-with-forge-sql-orm/123456
|
|
34
|
+
*
|
|
35
|
+
* @param orm - ForgeSQL ORM instance
|
|
36
|
+
* @param options - Configuration options (currently passed to slowQuerySchedulerTrigger as a temporary wrapper)
|
|
37
|
+
* @returns Promise<TriggerResponse<string>> - HTTP response with query results or error
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* // Old usage (deprecated):
|
|
42
|
+
* await topSlowestStatementLastHourTrigger(forgeSQL, { hours: 1 });
|
|
43
|
+
*
|
|
44
|
+
* // New usage (recommended - use executeWithMetadata in your resolvers):
|
|
45
|
+
* await forgeSQL.executeWithMetadata(
|
|
46
|
+
* async () => {
|
|
47
|
+
* // your resolver logic
|
|
48
|
+
* },
|
|
49
|
+
* async (totalDbTime, totalResponseSize, printPlan) => {
|
|
50
|
+
* // custom observability logic
|
|
51
|
+
* if (totalDbTime > 1000) await printPlan();
|
|
52
|
+
* },
|
|
53
|
+
* {
|
|
54
|
+
* mode: "TopSlowest",
|
|
55
|
+
* topQueries: 1,
|
|
56
|
+
* showSlowestPlans: true
|
|
57
|
+
* }
|
|
58
|
+
* );
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export const topSlowestStatementLastHourTrigger = async (
|
|
62
|
+
orm: ForgeSqlOperation,
|
|
63
|
+
options?: TriggerOptions,
|
|
64
|
+
): Promise<TriggerResponse<string>> => {
|
|
65
|
+
return slowQuerySchedulerTrigger(
|
|
66
|
+
orm,
|
|
67
|
+
options ? { timeout: 3000, hours: options.hours ?? 1 } : { timeout: 3000, hours: 1 },
|
|
68
|
+
);
|
|
69
|
+
};
|