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.
419 lines (371 loc) • 13.1 kB
text/typescript
import { DateTime } from "luxon";
import * as crypto from "node:crypto";
import { Query } from "drizzle-orm";
import { AnyMySqlTable } from "drizzle-orm/mysql-core";
import { getTableName } from "drizzle-orm/table";
import { Filter, FilterConditions, kvs, WhereConditions } from "@forge/kvs";
import { ForgeSqlOrmOptions } from "../core/ForgeSQLQueryBuilder";
import { cacheApplicationContext, isTableContainsTableInCacheContext } from "./cacheContextUtils";
import { extractBacktickedValues } from "./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,
} as const;
// Types for better type safety
type CacheEntity = {
[key: string]: string | number;
};
/**
* Gets the current Unix timestamp in seconds.
*
* @returns Current timestamp as integer
*/
function getCurrentTime(): number {
const dt = 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: number): number {
const dt = 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
*/
export function hashKey(query: Query): string {
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: Array<{ key: string }>,
cacheEntityName: string,
): Promise<void> {
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.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: string[],
cursor: string,
options: ForgeSqlOrmOptions,
): Promise<number> {
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 Filter<{
[entityQueryName]: string;
}>();
for (const table of tables) {
const wrapIfNeeded = options.cacheWrapTable ? `\`${table}\`` : table;
filters.or(entityQueryName, FilterConditions.contains(wrapIfNeeded?.toLowerCase()));
}
let entityQueryBuilder = kvs
.entity<{
[entityQueryName]: string;
}>(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: string,
options: ForgeSqlOrmOptions,
): Promise<number> {
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
.entity<{
[entityExpirationName]: number;
}>(cacheEntityName)
.query()
.index(entityExpirationName)
.where(WhereConditions.lessThan(Math.floor(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<T>(operation: () => Promise<T>, operationName: string): Promise<T> {
let attempt = 0;
let delay = CACHE_CONSTANTS.INITIAL_RETRY_DELAY;
while (attempt < CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
try {
return await operation();
} catch (err: any) {
// 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
*/
export async function clearCache<T extends AnyMySqlTable>(
schema: T,
options: ForgeSqlOrmOptions,
): Promise<void> {
const tableName = getTableName(schema);
if (cacheApplicationContext.getStore()) {
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
*/
export async function clearTablesCache(
tables: string[],
options: ForgeSqlOrmOptions,
): Promise<void> {
if (!options.cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
const startTime = DateTime.now();
let totalRecords = 0;
try {
totalRecords = await executeWithRetry(
() => clearCursorCache(tables, "", options),
"clearing cache",
);
} finally {
if (options.logCache) {
const duration = 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
*/
export async function clearExpiredCache(options: ForgeSqlOrmOptions): Promise<void> {
if (!options.cacheEntityName) {
throw new Error("cacheEntityName is not configured");
}
const startTime = DateTime.now();
let totalRecords = 0;
try {
totalRecords = await executeWithRetry(
() => clearExpirationCursorCache("", options),
"clearing expired cache",
);
} finally {
const duration = 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
*/
export async function getFromCache<T>(
query: { toSQL: () => Query },
options: ForgeSqlOrmOptions,
): Promise<T | undefined> {
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 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.entity<CacheEntity>(options.cacheEntityName).get(key);
if (
cacheResult &&
(cacheResult[expirationName] as number) >= getCurrentTime() &&
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 as string);
}
} catch (error: any) {
// 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
*/
export async function setCacheResult(
query: { toSQL: () => Query },
options: ForgeSqlOrmOptions,
results: unknown,
cacheTtl: number,
): Promise<void> {
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 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
.transact()
.set(
key,
{
[entityQueryName]: 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: any) {
// eslint-disable-next-line no-console
console.error(`Error setting cache: ${error.message}`, error);
}
}