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.
364 lines • 14.5 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.hashKey = hashKey;
exports.clearCache = clearCache;
exports.clearTablesCache = clearTablesCache;
exports.clearExpiredCache = clearExpiredCache;
exports.getFromCache = getFromCache;
exports.setCacheResult = setCacheResult;
const luxon_1 = require("luxon");
const crypto = __importStar(require("node:crypto"));
const table_1 = require("drizzle-orm/table");
const kvs_1 = require("@forge/kvs");
const cacheContextUtils_1 = require("./cacheContextUtils");
const cacheTableUtils_1 = require("./cacheTableUtils");
// Constants for better maintainability
const CACHE_CONSTANTS = {
BATCH_SIZE: 25,
MAX_RETRY_ATTEMPTS: 3,
INITIAL_RETRY_DELAY: 1000,
RETRY_DELAY_MULTIPLIER: 2,
DEFAULT_ENTITY_QUERY_NAME: "sql",
DEFAULT_EXPIRATION_NAME: "expiration",
DEFAULT_DATA_NAME: "data",
HASH_LENGTH: 32,
};
/**
* Gets the current Unix timestamp in seconds.
*
* @returns Current timestamp as integer
*/
function getCurrentTime() {
const dt = luxon_1.DateTime.now();
return Math.floor(dt.toSeconds());
}
/**
* Calculates a future timestamp by adding seconds to the current time.
* Validates that the result is within 32-bit integer range.
*
* @param secondsToAdd - Number of seconds to add to current time
* @returns Future timestamp in seconds
* @throws Error if the result is out of 32-bit integer range
*/
function nowPlusSeconds(secondsToAdd) {
const dt = luxon_1.DateTime.now().plus({ seconds: secondsToAdd });
return Math.floor(dt.toSeconds());
}
/**
* Generates a hash key for a query based on its SQL and parameters.
*
* @param query - The Drizzle query object
* @returns 32-character hexadecimal hash
*/
function hashKey(query) {
const h = crypto.createHash("sha256");
h.update(query.sql.toLowerCase());
h.update(JSON.stringify(query.params));
return "CachedQuery_" + h.digest("hex").slice(0, CACHE_CONSTANTS.HASH_LENGTH);
}
/**
* Deletes cache entries in batches to respect Forge limits and timeouts.
*
* @param results - Array of cache entries to delete
* @param cacheEntityName - Name of the cache entity
* @returns Promise that resolves when all deletions are complete
*/
async function deleteCacheEntriesInBatches(results, cacheEntityName) {
for (let i = 0; i < results.length; i += CACHE_CONSTANTS.BATCH_SIZE) {
const batch = results.slice(i, i + CACHE_CONSTANTS.BATCH_SIZE);
let transactionBuilder = kvs_1.kvs.transact();
for (const result of batch) {
transactionBuilder = transactionBuilder.delete(result.key, { entityName: cacheEntityName });
}
await transactionBuilder.execute();
}
}
/**
* Clears cache entries for specific tables using cursor-based pagination.
*
* @param tables - Array of table names to clear cache for
* @param cursor - Pagination cursor for large result sets
* @param options - ForgeSQL ORM options
* @returns Total number of deleted cache entries
*/
async function clearCursorCache(tables, cursor, options) {
const cacheEntityName = options.cacheEntityName;
if (!cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
let filters = new kvs_1.Filter();
for (const table of tables) {
const wrapIfNeeded = options.cacheWrapTable ? `\`${table}\`` : table;
filters.or(entityQueryName, kvs_1.FilterConditions.contains(wrapIfNeeded?.toLowerCase()));
}
let entityQueryBuilder = kvs_1.kvs
.entity(cacheEntityName)
.query()
.index(entityQueryName)
.filters(filters);
if (cursor) {
entityQueryBuilder = entityQueryBuilder.cursor(cursor);
}
const listResult = await entityQueryBuilder.limit(100).getMany();
if (options.logCache) {
// eslint-disable-next-line no-console
console.warn(`clear cache Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
}
await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
if (listResult.nextCursor) {
return (listResult.results.length + (await clearCursorCache(tables, listResult.nextCursor, options)));
}
else {
return listResult.results.length;
}
}
/**
* Clears expired cache entries using cursor-based pagination.
*
* @param cursor - Pagination cursor for large result sets
* @param options - ForgeSQL ORM options
* @returns Total number of deleted expired cache entries
*/
async function clearExpirationCursorCache(cursor, options) {
const cacheEntityName = options.cacheEntityName;
if (!cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
const entityExpirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
let entityQueryBuilder = kvs_1.kvs
.entity(cacheEntityName)
.query()
.index(entityExpirationName)
.where(kvs_1.WhereConditions.lessThan(Math.floor(luxon_1.DateTime.now().toSeconds())));
if (cursor) {
entityQueryBuilder = entityQueryBuilder.cursor(cursor);
}
const listResult = await entityQueryBuilder.limit(100).getMany();
if (options.logCache) {
// eslint-disable-next-line no-console
console.warn(`clear expired Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
}
await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
if (listResult.nextCursor) {
return (listResult.results.length + (await clearExpirationCursorCache(listResult.nextCursor, options)));
}
else {
return listResult.results.length;
}
}
/**
* Executes a function with retry logic and exponential backoff.
*
* @param operation - Function to execute with retry
* @param operationName - Name of the operation for logging
* @param options - ForgeSQL ORM options for logging
* @returns Promise that resolves to the operation result
*/
async function executeWithRetry(operation, operationName) {
let attempt = 0;
let delay = CACHE_CONSTANTS.INITIAL_RETRY_DELAY;
while (attempt < CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
try {
return await operation();
}
catch (err) {
// eslint-disable-next-line no-console
console.warn(`Error during ${operationName}: ${err.message}, retry ${attempt}`, err);
attempt++;
if (attempt >= CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
// eslint-disable-next-line no-console
console.error(`Error during ${operationName}: ${err.message}`, err);
throw err;
}
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= CACHE_CONSTANTS.RETRY_DELAY_MULTIPLIER;
}
}
throw new Error(`Maximum retry attempts exceeded for ${operationName}`);
}
/**
* Clears cache for a specific table.
* Uses cache context if available, otherwise clears immediately.
*
* @param schema - The table schema to clear cache for
* @param options - ForgeSQL ORM options
*/
async function clearCache(schema, options) {
const tableName = (0, table_1.getTableName)(schema);
if (cacheContextUtils_1.cacheApplicationContext.getStore()) {
cacheContextUtils_1.cacheApplicationContext.getStore()?.tables.add(tableName);
}
else {
await clearTablesCache([tableName], options);
}
}
/**
* Clears cache for multiple tables with retry logic and performance logging.
*
* @param tables - Array of table names to clear cache for
* @param options - ForgeSQL ORM options
* @returns Promise that resolves when cache clearing is complete
*/
async function clearTablesCache(tables, options) {
if (!options.cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
const startTime = luxon_1.DateTime.now();
let totalRecords = 0;
try {
totalRecords = await executeWithRetry(() => clearCursorCache(tables, "", options), "clearing cache");
}
finally {
if (options.logCache) {
const duration = luxon_1.DateTime.now().toSeconds() - startTime.toSeconds();
// eslint-disable-next-line no-console
console.info(`Cleared ${totalRecords} cache records in ${duration} seconds`);
}
}
}
/**
* Clears expired cache entries with retry logic and performance logging.
*
* @param options - ForgeSQL ORM options
* @returns Promise that resolves when expired cache clearing is complete
*/
async function clearExpiredCache(options) {
if (!options.cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
const startTime = luxon_1.DateTime.now();
let totalRecords = 0;
try {
totalRecords = await executeWithRetry(() => clearExpirationCursorCache("", options), "clearing expired cache");
}
finally {
const duration = luxon_1.DateTime.now().toSeconds() - startTime.toSeconds();
if (options?.logCache) {
// eslint-disable-next-line no-console
console.debug(`Cleared ${totalRecords} expired cache records in ${duration} seconds`);
}
}
}
/**
* Retrieves data from cache if it exists and is not expired.
*
* @param query - Query object with toSQL method
* @param options - ForgeSQL ORM options
* @returns Cached data if found and valid, undefined otherwise
*/
async function getFromCache(query, options) {
if (!options.cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
const expirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
const sqlQuery = query.toSQL();
const key = hashKey(sqlQuery);
// Skip cache if table is in cache context (will be cleared)
if (await (0, cacheContextUtils_1.isTableContainsTableInCacheContext)(sqlQuery.sql, options)) {
if (options.logCache) {
// eslint-disable-next-line no-console
console.warn(`Context contains value to clear. Skip getting from cache`);
}
return undefined;
}
try {
const cacheResult = await kvs_1.kvs.entity(options.cacheEntityName).get(key);
if (cacheResult &&
cacheResult[expirationName] >= getCurrentTime() &&
(0, cacheTableUtils_1.extractBacktickedValues)(sqlQuery.sql, options) === cacheResult[entityQueryName]) {
if (options.logCache) {
// eslint-disable-next-line no-console
console.warn(`Get value from cache, cacheKey: ${key}`);
}
const results = cacheResult[dataName];
return JSON.parse(results);
}
}
catch (error) {
// eslint-disable-next-line no-console
console.error(`Error getting from cache: ${error.message}`, error);
}
return undefined;
}
/**
* Stores query results in cache with specified TTL.
*
* @param query - Query object with toSQL method
* @param options - ForgeSQL ORM options
* @param results - Data to cache
* @param cacheTtl - Time to live in seconds
* @returns Promise that resolves when data is stored in cache
*/
async function setCacheResult(query, options, results, cacheTtl) {
if (!options.cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
try {
const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
const expirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
const sqlQuery = query.toSQL();
// Skip cache if table is in cache context (will be cleared)
if (await (0, cacheContextUtils_1.isTableContainsTableInCacheContext)(sqlQuery.sql, options)) {
if (options.logCache) {
// eslint-disable-next-line no-console
console.warn(`Context contains value to clear. Skip setting from cache`);
}
return;
}
const key = hashKey(sqlQuery);
await kvs_1.kvs
.transact()
.set(key, {
[entityQueryName]: (0, cacheTableUtils_1.extractBacktickedValues)(sqlQuery.sql, options),
[expirationName]: nowPlusSeconds(cacheTtl),
[dataName]: JSON.stringify(results),
}, { entityName: options.cacheEntityName })
.execute();
if (options.logCache) {
// eslint-disable-next-line no-console
console.warn(`Store value to cache, cacheKey: ${key}`);
}
}
catch (error) {
// eslint-disable-next-line no-console
console.error(`Error setting cache: ${error.message}`, error);
}
}
//# sourceMappingURL=cacheUtils.js.map