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

vzakharchenko / forge-sql-orm / 17651561923

11 Sep 2025 04:53PM UTC coverage: 80.79% (-6.0%) from 86.81%
17651561923

push

github

web-flow
Merge pull request #627 from vzakharchenko/caching

added kvs cache

360 of 439 branches covered (82.0%)

Branch coverage included in aggregate %.

593 of 849 new or added lines in 9 files covered. (69.85%)

1 existing line in 1 file now uncovered.

1625 of 2018 relevant lines covered (80.53%)

11.25 hits per line

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

45.13
/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!
NEW
50
    throw new Error(`Value ${seconds} is out of 32-bit integer range`);
×
NEW
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
 */
NEW
76
async function deleteCacheEntriesInBatches(
×
NEW
77
  results: Array<{ key: string }>,
×
NEW
78
  cacheEntityName: string,
×
NEW
79
): Promise<void> {
×
NEW
80
  for (let i = 0; i < results.length; i += CACHE_CONSTANTS.BATCH_SIZE) {
×
NEW
81
    const batch = results.slice(i, i + CACHE_CONSTANTS.BATCH_SIZE);
×
NEW
82
    let transactionBuilder = kvs.transact();
×
NEW
83
    batch.forEach((result) => {
×
NEW
84
      transactionBuilder = transactionBuilder.delete(result.key, { entityName: cacheEntityName });
×
NEW
85
    });
×
NEW
86
    await transactionBuilder.execute();
×
NEW
87
  }
×
NEW
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
 */
NEW
98
async function clearCursorCache(
×
NEW
99
  tables: string[],
×
NEW
100
  cursor: string,
×
NEW
101
  options: ForgeSqlOrmOptions,
×
NEW
102
): Promise<number> {
×
NEW
103
  const cacheEntityName = options.cacheEntityName;
×
NEW
104
  if (!cacheEntityName) {
×
NEW
105
    throw new Error("cacheEntityName is not configured");
×
NEW
106
  }
×
107

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

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

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

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

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

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

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

NEW
138
  if (listResult.nextCursor) {
×
NEW
139
    return (
×
NEW
140
      listResult.results.length + (await clearCursorCache(tables, listResult.nextCursor, options))
×
141
    );
NEW
142
  } else {
×
NEW
143
    return listResult.results.length;
×
NEW
144
  }
×
NEW
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
 */
NEW
154
async function clearExpirationCursorCache(
×
NEW
155
  cursor: string,
×
NEW
156
  options: ForgeSqlOrmOptions,
×
NEW
157
): Promise<number> {
×
NEW
158
  const cacheEntityName = options.cacheEntityName;
×
NEW
159
  if (!cacheEntityName) {
×
NEW
160
    throw new Error("cacheEntityName is not configured");
×
NEW
161
  }
×
162

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

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

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

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

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

NEW
185
  if (listResult.nextCursor) {
×
NEW
186
    return (
×
NEW
187
      listResult.results.length + (await clearExpirationCursorCache(listResult.nextCursor, options))
×
188
    );
NEW
189
  } else {
×
NEW
190
    return listResult.results.length;
×
NEW
191
  }
×
NEW
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
 */
NEW
202
async function executeWithRetry<T>(operation: () => Promise<T>, operationName: string): Promise<T> {
×
NEW
203
  let attempt = 0;
×
NEW
204
  let delay = CACHE_CONSTANTS.INITIAL_RETRY_DELAY;
×
205

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

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

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

NEW
223
  throw new Error(`Maximum retry attempts exceeded for ${operationName}`);
×
NEW
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!
NEW
241
    await clearTablesCache([tableName], options);
×
NEW
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
 */
NEW
252
export async function clearTablesCache(
×
NEW
253
  tables: string[],
×
NEW
254
  options: ForgeSqlOrmOptions,
×
NEW
255
): Promise<void> {
×
NEW
256
  if (!options.cacheEntityName) {
×
NEW
257
    throw new Error("cacheEntityName is not configured");
×
NEW
258
  }
×
259

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

NEW
263
  try {
×
NEW
264
    totalRecords = await executeWithRetry(
×
NEW
265
      () => clearCursorCache(tables, "", options),
×
NEW
266
      "clearing cache",
×
NEW
267
    );
×
NEW
268
  } finally {
×
NEW
269
    if (options.logRawSqlQuery) {
×
NEW
270
      const duration = DateTime.now().toSeconds() - startTime.toSeconds();
×
NEW
271
      console.info(`Cleared ${totalRecords} cache records in ${duration} seconds`);
×
NEW
272
    }
×
NEW
273
  }
×
NEW
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
 */
NEW
281
export async function clearExpiredCache(options: ForgeSqlOrmOptions): Promise<void> {
×
NEW
282
  if (!options.cacheEntityName) {
×
NEW
283
    throw new Error("cacheEntityName is not configured");
×
NEW
284
  }
×
285

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

NEW
289
  try {
×
NEW
290
    totalRecords = await executeWithRetry(
×
NEW
291
      () => clearExpirationCursorCache("", options),
×
NEW
292
      "clearing expired cache",
×
NEW
293
    );
×
NEW
294
  } finally {
×
NEW
295
    const duration = DateTime.now().toSeconds() - startTime.toSeconds();
×
NEW
296
    console.info(`Cleared ${totalRecords} expired cache records in ${duration} seconds`);
×
NEW
297
  }
×
NEW
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!
NEW
326
      console.warn(`Context contains value to clear. Skip getting from cache`);
×
NEW
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!
NEW
340
        console.warn(`Get value from cache, cacheKey: ${key}`);
×
NEW
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(
5✔
362
  query: { toSQL: () => Query },
5✔
363
  options: ForgeSqlOrmOptions,
5✔
364
  results: unknown,
5✔
365
  cacheTtl: number,
5✔
366
): Promise<void> {
5✔
367
  if (!options.cacheEntityName) {
5✔
368
    throw new Error("cacheEntityName is not configured");
1✔
369
  }
1✔
370

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

378
    const sqlQuery = query.toSQL();
5✔
379
    const key = hashKey(sqlQuery);
5✔
380

381
    await kvs.entity(options.cacheEntityName).set(key, {
5✔
382
      [entityQueryName]: sqlQuery.sql.toLowerCase(),
5✔
383
      [expirationName]: nowPlusSeconds(cacheTtl),
5✔
384
      [dataName]: JSON.stringify(results),
5✔
385
    });
5✔
386

387
    if (options.logRawSqlQuery) {
5!
NEW
388
      console.warn(`Store value to cache, cacheKey: ${key}`);
×
NEW
389
    }
×
390
  } catch (error: any) {
5✔
391
    console.error(`Error setting cache: ${error.message}`, error);
2✔
392
  }
2✔
393
}
5✔
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