• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vzakharchenko / forge-sql-orm / 17693196940

13 Sep 2025 06:55AM UTC coverage: 80.8% (+0.05%) from 80.746%
17693196940

push

github

vzakharchenko
update dependencies

363 of 443 branches covered (81.94%)

Branch coverage included in aggregate %.

16 of 17 new or added lines in 1 file covered. (94.12%)

1 existing line in 1 file now uncovered.

1636 of 2031 relevant lines covered (80.55%)

11.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

47.28
/src/utils/cacheUtils.ts
1
import { DateTime } from "luxon";
1✔
2
import * as crypto from "crypto";
1✔
3
import { Query } from "drizzle-orm";
4
import { AnyMySqlTable } from "drizzle-orm/mysql-core";
5
import { getTableName } from "drizzle-orm/table";
1✔
6
import { Filter, FilterConditions, kvs, WhereConditions } from "@forge/kvs";
1✔
7
import { ForgeSqlOrmOptions } from "../core/ForgeSQLQueryBuilder";
8
import { cacheApplicationContext, isTableContainsTableInCacheContext } from "./cacheContextUtils";
1✔
9

10
// Constants for better maintainability
11
const CACHE_CONSTANTS = {
1✔
12
  BATCH_SIZE: 25,
1✔
13
  MAX_RETRY_ATTEMPTS: 3,
1✔
14
  INITIAL_RETRY_DELAY: 1000,
1✔
15
  RETRY_DELAY_MULTIPLIER: 2,
1✔
16
  DEFAULT_ENTITY_QUERY_NAME: "sql",
1✔
17
  DEFAULT_EXPIRATION_NAME: "expiration",
1✔
18
  DEFAULT_DATA_NAME: "data",
1✔
19
  HASH_LENGTH: 32,
1✔
20
} as const;
1✔
21

22
// Types for better type safety
23
type CacheEntity = {
24
  [key: string]: string | number;
25
};
26

27
/**
28
 * Gets the current Unix timestamp in seconds.
29
 *
30
 * @returns Current timestamp as integer
31
 */
32
function getCurrentTime(): number {
2✔
33
  const dt = DateTime.now();
2✔
34
  return Math.floor(dt.toSeconds());
2✔
35
}
2✔
36

37
/**
38
 * Calculates a future timestamp by adding seconds to the current time.
39
 * Validates that the result is within 32-bit integer range.
40
 *
41
 * @param secondsToAdd - Number of seconds to add to current time
42
 * @returns Future timestamp in seconds
43
 * @throws Error if the result is out of 32-bit integer range
44
 */
45
function nowPlusSeconds(secondsToAdd: number): number {
4✔
46
  const dt = DateTime.now().plus({ seconds: secondsToAdd });
4✔
47
  const seconds = Math.floor(dt.toSeconds());
4✔
48

49
  if (seconds < -2147483648 || seconds > 2147483647) {
4!
50
    throw new Error(`Value ${seconds} is out of 32-bit integer range`);
×
51
  }
×
52

53
  return seconds;
4✔
54
}
4✔
55

56
/**
57
 * Generates a hash key for a query based on its SQL and parameters.
58
 *
59
 * @param query - The Drizzle query object
60
 * @returns 32-character hexadecimal hash
61
 */
62
function hashKey(query: Query): string {
9✔
63
  const h = crypto.createHash("sha256");
9✔
64
  h.update(query.sql);
9✔
65
  h.update(JSON.stringify(query.params));
9✔
66
  return h.digest("hex").slice(0, CACHE_CONSTANTS.HASH_LENGTH);
9✔
67
}
9✔
68

69
/**
70
 * Deletes cache entries in batches to respect Forge limits and timeouts.
71
 *
72
 * @param results - Array of cache entries to delete
73
 * @param cacheEntityName - Name of the cache entity
74
 * @returns Promise that resolves when all deletions are complete
75
 */
76
async function deleteCacheEntriesInBatches(
×
77
  results: Array<{ key: string }>,
×
78
  cacheEntityName: string,
×
79
): Promise<void> {
×
80
  for (let i = 0; i < results.length; i += CACHE_CONSTANTS.BATCH_SIZE) {
×
81
    const batch = results.slice(i, i + CACHE_CONSTANTS.BATCH_SIZE);
×
82
    let transactionBuilder = kvs.transact();
×
83
    batch.forEach((result) => {
×
84
      transactionBuilder = transactionBuilder.delete(result.key, { entityName: cacheEntityName });
×
85
    });
×
86
    await transactionBuilder.execute();
×
87
  }
×
88
}
×
89

90
/**
91
 * Clears cache entries for specific tables using cursor-based pagination.
92
 *
93
 * @param tables - Array of table names to clear cache for
94
 * @param cursor - Pagination cursor for large result sets
95
 * @param options - ForgeSQL ORM options
96
 * @returns Total number of deleted cache entries
97
 */
98
async function clearCursorCache(
×
99
  tables: string[],
×
100
  cursor: string,
×
101
  options: ForgeSqlOrmOptions,
×
102
): Promise<number> {
×
103
  const cacheEntityName = options.cacheEntityName;
×
104
  if (!cacheEntityName) {
×
105
    throw new Error("cacheEntityName is not configured");
×
106
  }
×
107

108
  const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
×
109
  let filters = new Filter<{
×
110
    [entityQueryName]: string;
111
  }>();
×
112

113
  for (const table of tables) {
×
114
    const wrapIfNeeded = options.cacheWrapTable ? `\`${table}\`` : table;
×
115
    filters.or(entityQueryName, FilterConditions.contains(wrapIfNeeded?.toLowerCase()));
×
116
  }
×
117

118
  let entityQueryBuilder = kvs
×
119
    .entity<{
×
120
      [entityQueryName]: string;
121
    }>(cacheEntityName)
×
122
    .query()
×
123
    .index(entityQueryName)
×
124
    .filters(filters);
×
125

126
  if (cursor) {
×
127
    entityQueryBuilder = entityQueryBuilder.cursor(cursor);
×
128
  }
×
129

130
  const listResult = await entityQueryBuilder.limit(100).getMany();
×
131

132
  if (options.logRawSqlQuery) {
×
133
    console.warn(`clear cache Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
×
134
  }
×
135

136
  await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
×
137

138
  if (listResult.nextCursor) {
×
139
    return (
×
140
      listResult.results.length + (await clearCursorCache(tables, listResult.nextCursor, options))
×
141
    );
142
  } else {
×
143
    return listResult.results.length;
×
144
  }
×
145
}
×
146

147
/**
148
 * Clears expired cache entries using cursor-based pagination.
149
 *
150
 * @param cursor - Pagination cursor for large result sets
151
 * @param options - ForgeSQL ORM options
152
 * @returns Total number of deleted expired cache entries
153
 */
154
async function clearExpirationCursorCache(
×
155
  cursor: string,
×
156
  options: ForgeSqlOrmOptions,
×
157
): Promise<number> {
×
158
  const cacheEntityName = options.cacheEntityName;
×
159
  if (!cacheEntityName) {
×
160
    throw new Error("cacheEntityName is not configured");
×
161
  }
×
162

163
  const entityExpirationName =
×
164
    options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
×
165
  let entityQueryBuilder = kvs
×
166
    .entity<{
×
167
      [entityExpirationName]: number;
168
    }>(cacheEntityName)
×
169
    .query()
×
170
    .index(entityExpirationName)
×
171
    .where(WhereConditions.lessThan(Math.floor(DateTime.now().toSeconds())));
×
172

173
  if (cursor) {
×
174
    entityQueryBuilder = entityQueryBuilder.cursor(cursor);
×
175
  }
×
176

177
  const listResult = await entityQueryBuilder.limit(100).getMany();
×
178

179
  if (options.logRawSqlQuery) {
×
180
    console.warn(`clear expired Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
×
181
  }
×
182

183
  await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
×
184

185
  if (listResult.nextCursor) {
×
186
    return (
×
187
      listResult.results.length + (await clearExpirationCursorCache(listResult.nextCursor, options))
×
188
    );
189
  } else {
×
190
    return listResult.results.length;
×
191
  }
×
192
}
×
193

194
/**
195
 * Executes a function with retry logic and exponential backoff.
196
 *
197
 * @param operation - Function to execute with retry
198
 * @param operationName - Name of the operation for logging
199
 * @param options - ForgeSQL ORM options for logging
200
 * @returns Promise that resolves to the operation result
201
 */
202
async function executeWithRetry<T>(operation: () => Promise<T>, operationName: string): Promise<T> {
×
203
  let attempt = 0;
×
204
  let delay = CACHE_CONSTANTS.INITIAL_RETRY_DELAY;
×
205

206
  while (attempt < CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
×
207
    try {
×
208
      return await operation();
×
209
    } catch (err: any) {
×
210
      console.warn(`Error during ${operationName}: ${err.message}, retry ${attempt}`, err);
×
211
      attempt++;
×
212

213
      if (attempt >= CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
×
214
        console.error(`Error during ${operationName}: ${err.message}`, err);
×
215
        throw err;
×
216
      }
×
217

218
      await new Promise((resolve) => setTimeout(resolve, delay));
×
219
      delay *= CACHE_CONSTANTS.RETRY_DELAY_MULTIPLIER;
×
220
    }
×
221
  }
×
222

223
  throw new Error(`Maximum retry attempts exceeded for ${operationName}`);
×
224
}
×
225

226
/**
227
 * Clears cache for a specific table.
228
 * Uses cache context if available, otherwise clears immediately.
229
 *
230
 * @param schema - The table schema to clear cache for
231
 * @param options - ForgeSQL ORM options
232
 */
233
export async function clearCache<T extends AnyMySqlTable>(
1✔
234
  schema: T,
1✔
235
  options: ForgeSqlOrmOptions,
1✔
236
): Promise<void> {
1✔
237
  const tableName = getTableName(schema);
1✔
238
  if (cacheApplicationContext.getStore()) {
1✔
239
    cacheApplicationContext.getStore()?.tables.add(tableName);
1✔
240
  } else {
1!
241
    await clearTablesCache([tableName], options);
×
242
  }
×
243
}
1✔
244

245
/**
246
 * Clears cache for multiple tables with retry logic and performance logging.
247
 *
248
 * @param tables - Array of table names to clear cache for
249
 * @param options - ForgeSQL ORM options
250
 * @returns Promise that resolves when cache clearing is complete
251
 */
252
export async function clearTablesCache(
×
253
  tables: string[],
×
254
  options: ForgeSqlOrmOptions,
×
255
): Promise<void> {
×
256
  if (!options.cacheEntityName) {
×
257
    throw new Error("cacheEntityName is not configured");
×
258
  }
×
259

260
  const startTime = DateTime.now();
×
261
  let totalRecords = 0;
×
262

263
  try {
×
264
    totalRecords = await executeWithRetry(
×
265
      () => clearCursorCache(tables, "", options),
×
266
      "clearing cache",
×
267
    );
×
268
  } finally {
×
269
    if (options.logRawSqlQuery) {
×
270
      const duration = DateTime.now().toSeconds() - startTime.toSeconds();
×
271
      console.info(`Cleared ${totalRecords} cache records in ${duration} seconds`);
×
272
    }
×
273
  }
×
274
}
×
275
/**
276
 * Clears expired cache entries with retry logic and performance logging.
277
 *
278
 * @param options - ForgeSQL ORM options
279
 * @returns Promise that resolves when expired cache clearing is complete
280
 */
281
export async function clearExpiredCache(options: ForgeSqlOrmOptions): Promise<void> {
×
282
  if (!options.cacheEntityName) {
×
283
    throw new Error("cacheEntityName is not configured");
×
284
  }
×
285

286
  const startTime = DateTime.now();
×
287
  let totalRecords = 0;
×
288

289
  try {
×
290
    totalRecords = await executeWithRetry(
×
291
      () => clearExpirationCursorCache("", options),
×
292
      "clearing expired cache",
×
293
    );
×
294
  } finally {
×
295
    const duration = DateTime.now().toSeconds() - startTime.toSeconds();
×
296
    console.info(`Cleared ${totalRecords} expired cache records in ${duration} seconds`);
×
297
  }
×
298
}
×
299

300
/**
301
 * Retrieves data from cache if it exists and is not expired.
302
 *
303
 * @param query - Query object with toSQL method
304
 * @param options - ForgeSQL ORM options
305
 * @returns Cached data if found and valid, undefined otherwise
306
 */
307
export async function getFromCache<T>(
6✔
308
  query: { toSQL: () => Query },
6✔
309
  options: ForgeSqlOrmOptions,
6✔
310
): Promise<T | undefined> {
6✔
311
  if (!options.cacheEntityName) {
6✔
312
    throw new Error("cacheEntityName is not configured");
1✔
313
  }
1✔
314

315
  const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
5✔
316
  const expirationName =
6✔
317
    options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
6✔
318
  const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
6✔
319

320
  const sqlQuery = query.toSQL();
6✔
321
  const key = hashKey(sqlQuery);
6✔
322

323
  // Skip cache if table is in cache context (will be cleared)
324
  if (await isTableContainsTableInCacheContext(sqlQuery.sql, options)) {
6✔
325
    if (options.logRawSqlQuery) {
1!
326
      console.warn(`Context contains value to clear. Skip getting from cache`);
×
327
    }
×
328
    return undefined;
1✔
329
  }
1✔
330

331
  try {
4✔
332
    const cacheResult = await kvs.entity<CacheEntity>(options.cacheEntityName).get(key);
4✔
333

334
    if (
2✔
335
      cacheResult &&
2✔
336
      (cacheResult[expirationName] as number) >= getCurrentTime() &&
2✔
337
      sqlQuery.sql.toLowerCase() === cacheResult[entityQueryName]
1✔
338
    ) {
6✔
339
      if (options.logRawSqlQuery) {
1!
340
        console.warn(`Get value from cache, cacheKey: ${key}`);
×
341
      }
×
342
      const results = cacheResult[dataName];
1✔
343
      return JSON.parse(results as string);
1✔
344
    }
1✔
345
  } catch (error: any) {
6✔
346
    console.error(`Error getting from cache: ${error.message}`, error);
2✔
347
  }
2✔
348

349
  return undefined;
3✔
350
}
3✔
351

352
/**
353
 * Stores query results in cache with specified TTL.
354
 *
355
 * @param query - Query object with toSQL method
356
 * @param options - ForgeSQL ORM options
357
 * @param results - Data to cache
358
 * @param cacheTtl - Time to live in seconds
359
 * @returns Promise that resolves when data is stored in cache
360
 */
361
export async function setCacheResult(
6✔
362
  query: { toSQL: () => Query },
6✔
363
  options: ForgeSqlOrmOptions,
6✔
364
  results: unknown,
6✔
365
  cacheTtl: number,
6✔
366
): Promise<void> {
6✔
367
  if (!options.cacheEntityName) {
6✔
368
    throw new Error("cacheEntityName is not configured");
1✔
369
  }
1✔
370

371
  try {
5✔
372
    const entityQueryName =
5✔
373
      options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
6✔
374
    const expirationName =
6✔
375
      options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
6✔
376
    const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
6✔
377

378
    const sqlQuery = query.toSQL();
6✔
379

380
    // Skip cache if table is in cache context (will be cleared)
381
    if (await isTableContainsTableInCacheContext(sqlQuery.sql, options)) {
6✔
382
      if (options.logRawSqlQuery) {
1!
NEW
383
        console.warn(`Context contains value to clear. Skip setting from cache`);
×
UNCOV
384
      }
×
385
      return;
1✔
386
    }
1✔
387

388
    const key = hashKey(sqlQuery);
4✔
389

390
    await kvs
4✔
391
      .transact()
4✔
392
      .set(
4✔
393
        key,
4✔
394
        {
4✔
395
          [entityQueryName]: sqlQuery.sql.toLowerCase(),
4✔
396
          [expirationName]: nowPlusSeconds(cacheTtl),
4✔
397
          [dataName]: JSON.stringify(results),
4✔
398
        },
4✔
399
        { entityName: options.cacheEntityName },
4✔
400
      )
4✔
401
      .execute();
4✔
402

403
    if (options.logRawSqlQuery) {
6!
404
      console.warn(`Store value to cache, cacheKey: ${key}`);
×
405
    }
×
406
  } catch (error: any) {
6✔
407
    console.error(`Error setting cache: ${error.message}`, error);
2✔
408
  }
2✔
409
}
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc