UNPKG

forge-sql-orm

Version:

Drizzle ORM integration for Atlassian @forge/sql. Provides a custom driver, schema migration, two levels of caching (local and global via @forge/kvs), optimistic locking, and query analysis.

336 lines 14.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.metadataQueryContext = void 0; exports.saveMetaDataToContext = saveMetaDataToContext; exports.printDegradationQueries = printDegradationQueries; exports.getLastestMetadata = getLastestMetadata; const node_async_hooks_1 = require("node:async_hooks"); const sqlUtils_1 = require("./sqlUtils"); const node_sql_parser_1 = require("node-sql-parser"); const events_1 = require("@forge/events"); const TIMEOUT_ASYNC_EVENT_SENT = 1200; const DEFAULT_WINDOW_SIZE = 15 * 1000; exports.metadataQueryContext = new node_async_hooks_1.AsyncLocalStorage(); /** * Creates default options for metadata query context. * @returns Default options object */ function createDefaultOptions() { return { mode: "TopSlowest", topQueries: 1, summaryTableWindowTime: DEFAULT_WINDOW_SIZE, showSlowestPlans: true, normalizeQuery: true, asyncQueueName: "", }; } /** * Merges provided options with defaults, using defaults only for undefined fields. * @param options - Optional partial options to merge * @returns Complete options object with all fields set */ function mergeOptionsWithDefaults(options) { const defaults = createDefaultOptions(); return { mode: options?.mode ?? defaults.mode, topQueries: options?.topQueries ?? defaults.topQueries, summaryTableWindowTime: options?.summaryTableWindowTime ?? defaults.summaryTableWindowTime, showSlowestPlans: options?.showSlowestPlans ?? defaults.showSlowestPlans, normalizeQuery: options?.normalizeQuery ?? defaults.normalizeQuery, asyncQueueName: options?.asyncQueueName ?? defaults.asyncQueueName, }; } /** * Normalizes SQL query using regex fallback by replacing parameter values with placeholders. * Replaces string literals, numeric values, and boolean values with '?' for logging. * * Note: This is a fallback function used when node-sql-parser fails. * It uses simple, safe regex patterns to avoid ReDoS (Regular Expression Denial of Service) vulnerabilities. * For proper handling of escaped quotes and complex SQL, use the main normalizeSqlForLogging function * which uses node-sql-parser. * * @param sql - SQL query string to normalize * @returns Normalized SQL string with parameters replaced by '?' */ function normalizeSqlForLoggingRegex(sql) { let normalized = sql; // Replace string literals (single quotes) - using simple greedy match // This avoids catastrophic backtracking by using a simple [^']* pattern // Note: This does not handle SQL-style escaped quotes (doubled quotes: '') // For proper handling, use the main normalizeSqlForLogging function with node-sql-parser normalized = normalized.replace(/'[^']*'/g, "?"); // Replace string literals (double quotes) - using simple greedy match // Same safety considerations as above normalized = normalized.replace(/"[^"]*"/g, "?"); // Replace numeric literals - simplified pattern to avoid backtracking // Match: optional minus, digits, optional decimal point and more digits // Using word boundaries (\b) for safety - avoids complex lookahead/lookbehind normalized = normalized.replace(/\b-?\d+\.?\d*\b/g, "?"); // Replace boolean literals - safe pattern with word boundaries // Simple alternation with word boundaries - no nested quantifiers normalized = normalized.replace(/\b(true|false)\b/gi, "?"); // Replace NULL values (but be careful not to replace in identifiers) // Simple word boundary match - safe from backtracking normalized = normalized.replace(/\bNULL\b/gi, "?"); return normalized; } /** * Normalizes SQL query by replacing parameter values with placeholders. * First attempts to use node-sql-parser for structure normalization, then applies regex for value replacement. * Falls back to regex-based normalization if parsing fails. * @param sql - SQL query string to normalize * @returns Normalized SQL string with parameters replaced by '?' */ function normalizeSqlForLogging(sql) { try { const parser = new node_sql_parser_1.Parser(); const ast = parser.astify(sql.trim()); // Convert AST back to SQL (this normalizes structure and formatting) const normalized = parser.sqlify(Array.isArray(ast) ? ast[0] : ast); // Apply regex-based value replacement to the normalized SQL // This handles the case where sqlify might preserve some literal values let result = normalizeSqlForLoggingRegex(normalized.trim()); // Remove backticks added by sqlify for cleaner logging (optional - can be removed if backticks are preferred) result = result.replace(/`/g, ""); return result; //eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // If parsing fails, fall back to regex-based normalization return normalizeSqlForLoggingRegex(sql); } } /** * Formats row information (estRows, actRows) into a string. * @param row - ExplainAnalyzeRow object * @returns Formatted row info string or null if no row info available */ function formatRowInfo(row) { const rowInfo = []; if (row.estRows) rowInfo.push(`estRows:${row.estRows}`); if (row.actRows) rowInfo.push(`actRows:${row.actRows}`); return rowInfo.length > 0 ? `[${rowInfo.join(", ")}]` : null; } /** * Formats resource information (memory, disk) into a string. * @param row - ExplainAnalyzeRow object * @returns Formatted resource info string or null if no resource info available */ function formatResourceInfo(row) { const resourceInfo = []; if (row.memory) resourceInfo.push(`memory:${row.memory}`); if (row.disk) resourceInfo.push(`disk:${row.disk}`); return resourceInfo.length > 0 ? `(${resourceInfo.join(", ")})` : null; } /** * Formats a single execution plan row into a string. * @param row - ExplainAnalyzeRow object * @returns Formatted string representation of the row */ function formatPlanRow(row) { const parts = []; if (row.id) parts.push(row.id); if (row.task) parts.push(`task:${row.task}`); if (row.operatorInfo) parts.push(row.operatorInfo); const rowInfo = formatRowInfo(row); if (rowInfo) parts.push(rowInfo); if (row.executionInfo) parts.push(`execution info:${row.executionInfo}`); const resourceInfo = formatResourceInfo(row); if (resourceInfo) parts.push(resourceInfo); if (row.accessObject) parts.push(`access object:${row.accessObject}`); return parts.join(" | "); } /** * Formats an execution plan array into a readable string representation. * @param planRows - Array of ExplainAnalyzeRow objects representing the execution plan * @returns Formatted string representation of the execution plan */ function formatExplainPlan(planRows) { if (!planRows || planRows.length === 0) { return "No execution plan available"; } return planRows.map(formatPlanRow).join("\n"); } /** * Prints query plans using summary tables if mode is SummaryTable and within time window. * * Attempts to use CLUSTER_STATEMENTS_SUMMARY table for query analysis if: * - Mode is set to "SummaryTable" * - Time since query execution start is within the configured window * * @param context - The async event payload containing query statistics and options * @param forgeSQLORM - The ForgeSQL operation instance for database access * @returns Promise that resolves to true if summary tables were used, false otherwise */ async function printPlansUsingSummaryTables(context, forgeSQLORM) { const timeDiff = Date.now() - context.beginTime.getTime(); const options = context.options; if (options.mode !== "SummaryTable") { return false; } if (timeDiff <= options.summaryTableWindowTime) { await new Promise((resolve) => setTimeout(resolve, 200)); const summaryTableDiffMs = Date.now() - context.beginTime.getTime(); await (0, sqlUtils_1.printQueriesWithPlan)(forgeSQLORM, summaryTableDiffMs); return true; } // eslint-disable-next-line no-console console.warn("Summary table window expired — showing query plans instead"); return false; } /** * Prints query plans for the top slowest queries from the statistics. * * Sorts queries by execution time and prints the top N queries (based on topQueries option). * For each query, it can optionally print the execution plan using EXPLAIN ANALYZE. * * @param context - The async event payload containing query statistics and options * @param forgeSQLORM - The ForgeSQL operation instance for database access * @returns Promise that resolves when all query plans are printed */ async function printTopQueriesPlans(context, forgeSQLORM) { const options = context.options; const topQueries = context.statistics .toSorted((a, b) => b.metadata.dbExecutionTime - a.metadata.dbExecutionTime) .slice(0, options.topQueries); for (const query of topQueries) { const normalizedQuery = options.normalizeQuery ? normalizeSqlForLogging(query.query) : query.query; if (options.showSlowestPlans) { const explainAnalyzeRows = await forgeSQLORM .analyze() .explainAnalyzeRaw(query.query, query.params); const formattedPlan = formatExplainPlan(explainAnalyzeRows); // eslint-disable-next-line no-console console.warn(`SQL: ${normalizedQuery} | Time: ${query.metadata.dbExecutionTime} ms\n Plan:\n${formattedPlan}`); } else { // eslint-disable-next-line no-console console.warn(`SQL: ${normalizedQuery} | Time: ${query.metadata.dbExecutionTime} ms`); } } } /** * Saves query metadata to the current context and sets up the printQueriesWithPlan function. * * This function accumulates query statistics in the async context. When printQueriesWithPlan * is called, it can either: * - Queue the analysis for async processing (if asyncQueueName is provided) * - Execute the analysis synchronously (fallback or if asyncQueueName is not set) * * For async processing, the function sends an event to the specified queue with a timeout. * If the event cannot be sent within the timeout, it falls back to synchronous execution. * * @param stringQuery - The SQL query string * @param params - Query parameters used in the query * @param metadata - Query execution metadata including execution time and response size * * @example * ```typescript * await FORGE_SQL_ORM.executeWithMetadata( * async () => { * // ... queries ... * }, * async (totalDbExecutionTime, totalResponseSize, printQueries) => { * if (totalDbExecutionTime > threshold) { * await printQueries(); // Will use async queue if configured * } * }, * { asyncQueueName: "degradationQueue" } * ); * ``` */ async function saveMetaDataToContext(stringQuery, params, metadata) { const context = exports.metadataQueryContext.getStore(); if (!context) { return; } // Initialize statistics array if needed if (!context.statistics) { context.statistics = []; } // Merge options with defaults context.options = mergeOptionsWithDefaults(context.options); // Add query statistics context.statistics.push({ query: stringQuery, params, metadata }); // Set up printQueriesWithPlan function context.printQueriesWithPlan = async () => { const options = mergeOptionsWithDefaults(context.options); const param = { statistics: context.statistics, totalDbExecutionTime: context.totalDbExecutionTime, totalResponseSize: context.totalResponseSize, beginTime: context.beginTime, options, }; if (options.asyncQueueName) { const queue = new events_1.Queue({ key: options.asyncQueueName }); try { const eventInfo = await (0, sqlUtils_1.withTimeout)(queue.push({ body: param, concurrency: { key: "orm_" + options.asyncQueueName, limit: 2, }, }), `Event was not sent within ${TIMEOUT_ASYNC_EVENT_SENT}ms`, TIMEOUT_ASYNC_EVENT_SENT); // eslint-disable-next-line no-console console.warn(`[Performance Analysis] Query degradation event queued for async processing | Job ID: ${eventInfo.jobId} | Total DB time: ${context.totalDbExecutionTime}ms | Queries: ${context.statistics.length} | Look for consumer log with jobId: ${eventInfo.jobId}`); return; } catch (e) { // eslint-disable-next-line no-console console.warn("Async printing failed — falling back to synchronous execution: " + e.message, e); } } await printDegradationQueries(context.forgeSQLORM, param); }; // Update aggregated metrics if (metadata) { context.totalResponseSize += metadata.responseSize; context.totalDbExecutionTime += metadata.dbExecutionTime; } } /** * Prints query degradation analysis for the provided event payload. * * This function processes query degradation events (either from async queue or synchronous call). * It first attempts to use summary tables (CLUSTER_STATEMENTS_SUMMARY) if configured and within * the time window. Otherwise, it falls back to printing execution plans for the top slowest queries. * * @param forgeSQLORM - The ForgeSQL operation instance for database access * @param params - The async event payload containing query statistics, options, and metadata * @returns Promise that resolves when query analysis is complete * * @see printPlansUsingSummaryTables - For summary table analysis * @see printTopQueriesPlans - For top slowest queries analysis */ async function printDegradationQueries(forgeSQLORM, params) { // Try to use summary tables first if enabled const usedSummaryTables = await printPlansUsingSummaryTables(params, forgeSQLORM); if (usedSummaryTables) { return; } // Fall back to printing top queries plans await printTopQueriesPlans(params, forgeSQLORM); } /** * Gets the latest metadata from the current context. * @returns The current metadata context or undefined if not in a context */ async function getLastestMetadata() { return exports.metadataQueryContext.getStore(); } //# sourceMappingURL=metadataContextUtils.js.map