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

vzakharchenko / forge-sql-orm / 17648728887

11 Sep 2025 03:02PM UTC coverage: 68.742% (-18.1%) from 86.81%
17648728887

Pull #627

github

web-flow
Merge ce989a6a5 into 67ed05a12
Pull Request #627: added kvs cache

287 of 357 branches covered (80.39%)

Branch coverage included in aggregate %.

294 of 825 new or added lines in 9 files covered. (35.64%)

1 existing line in 1 file now uncovered.

1336 of 2004 relevant lines covered (66.67%)

8.61 hits per line

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

6.2
/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
 */
NEW
32
function getCurrentTime(): number {
×
NEW
33
  const dt = DateTime.now();
×
NEW
34
  return Math.floor(dt.toSeconds());
×
NEW
35
}
×
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
 */
NEW
45
function nowPlusSeconds(secondsToAdd: number): number {
×
NEW
46
  const dt = DateTime.now().plus({ seconds: secondsToAdd });
×
NEW
47
  const seconds = Math.floor(dt.toSeconds());
×
48

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

NEW
53
  return seconds;
×
NEW
54
}
×
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
 */
NEW
62
function hashKey(query: Query): string {
×
NEW
63
  const h = crypto.createHash("sha256");
×
NEW
64
  h.update(query.sql);
×
NEW
65
  h.update(JSON.stringify(query.params));
×
NEW
66
  return h.digest("hex").slice(0, CACHE_CONSTANTS.HASH_LENGTH);
×
NEW
67
}
×
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 = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
×
NEW
164
  let entityQueryBuilder = kvs
×
NEW
165
    .entity<{
×
166
      [entityExpirationName]: number;
NEW
167
    }>(cacheEntityName)
×
NEW
168
    .query()
×
NEW
169
    .index(entityExpirationName)
×
NEW
170
    .where(WhereConditions.lessThan(Math.floor(DateTime.now().toSeconds())));
×
171

NEW
172
  if (cursor) {
×
NEW
173
    entityQueryBuilder = entityQueryBuilder.cursor(cursor);
×
NEW
174
  }
×
175
  
NEW
176
  const listResult = await entityQueryBuilder.limit(100).getMany();
×
177
  
NEW
178
  if (options.logRawSqlQuery) {
×
NEW
179
    console.warn(`clear expired Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
×
NEW
180
  }
×
181
  
NEW
182
  await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
×
183
  
NEW
184
  if (listResult.nextCursor) {
×
NEW
185
    return (
×
NEW
186
      listResult.results.length + (await clearExpirationCursorCache(listResult.nextCursor, options))
×
187
    );
NEW
188
  } else {
×
NEW
189
    return listResult.results.length;
×
NEW
190
  }
×
NEW
191
}
×
192

193
/**
194
 * Executes a function with retry logic and exponential backoff.
195
 * 
196
 * @param operation - Function to execute with retry
197
 * @param operationName - Name of the operation for logging
198
 * @param options - ForgeSQL ORM options for logging
199
 * @returns Promise that resolves to the operation result
200
 */
NEW
201
async function executeWithRetry<T>(
×
NEW
202
  operation: () => Promise<T>,
×
NEW
203
  operationName: string
×
NEW
204
): Promise<T> {
×
NEW
205
  let attempt = 0;
×
NEW
206
  let delay = CACHE_CONSTANTS.INITIAL_RETRY_DELAY;
×
207
  
NEW
208
  while (attempt < CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
×
NEW
209
    try {
×
NEW
210
      return await operation();
×
NEW
211
    } catch (err: any) {
×
NEW
212
      console.warn(`Error during ${operationName}: ${err.message}, retry ${attempt}`, err);
×
NEW
213
      attempt++;
×
214
      
NEW
215
      if (attempt >= CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
×
NEW
216
        console.error(`Error during ${operationName}: ${err.message}`, err);
×
NEW
217
        throw err;
×
NEW
218
      }
×
219
      
NEW
220
      await new Promise((resolve) => setTimeout(resolve, delay));
×
NEW
221
      delay *= CACHE_CONSTANTS.RETRY_DELAY_MULTIPLIER;
×
NEW
222
    }
×
NEW
223
  }
×
224
  
NEW
225
  throw new Error(`Maximum retry attempts exceeded for ${operationName}`);
×
NEW
226
}
×
227

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

244
/**
245
 * Clears cache for multiple tables with retry logic and performance logging.
246
 * 
247
 * @param tables - Array of table names to clear cache for
248
 * @param options - ForgeSQL ORM options
249
 * @returns Promise that resolves when cache clearing is complete
250
 */
NEW
251
export async function clearTablesCache(tables: string[], options: ForgeSqlOrmOptions): Promise<void> {
×
NEW
252
  if (!options.cacheEntityName) {
×
NEW
253
    throw new Error("cacheEntityName is not configured");
×
NEW
254
  }
×
255
  
NEW
256
  const startTime = DateTime.now();
×
NEW
257
  let totalRecords = 0;
×
258
  
NEW
259
  try {
×
NEW
260
    totalRecords = await executeWithRetry(
×
NEW
261
      () => clearCursorCache(tables, "", options),
×
NEW
262
      "clearing cache",
×
NEW
263
    );
×
NEW
264
  } finally {
×
NEW
265
    if (options.logRawSqlQuery) {
×
NEW
266
      const duration = DateTime.now().toSeconds() - startTime.toSeconds();
×
NEW
267
      console.info(`Cleared ${totalRecords} cache records in ${duration} seconds`);
×
NEW
268
    }
×
NEW
269
  }
×
NEW
270
}
×
271
/**
272
 * Clears expired cache entries with retry logic and performance logging.
273
 * 
274
 * @param options - ForgeSQL ORM options
275
 * @returns Promise that resolves when expired cache clearing is complete
276
 */
NEW
277
export async function clearExpiredCache(options: ForgeSqlOrmOptions): Promise<void> {
×
NEW
278
  if (!options.cacheEntityName) {
×
NEW
279
    throw new Error("cacheEntityName is not configured");
×
NEW
280
  }
×
281
  
NEW
282
  const startTime = DateTime.now();
×
NEW
283
  let totalRecords = 0;
×
284
  
NEW
285
  try {
×
NEW
286
    totalRecords = await executeWithRetry(
×
NEW
287
      () => clearExpirationCursorCache("", options),
×
NEW
288
      "clearing expired cache",
×
NEW
289
    );
×
NEW
290
  } finally {
×
NEW
291
    const duration = DateTime.now().toSeconds() - startTime.toSeconds();
×
NEW
292
    console.info(`Cleared ${totalRecords} expired cache records in ${duration} seconds`);
×
NEW
293
  }
×
NEW
294
}
×
295

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

NEW
311
  const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
×
NEW
312
  const expirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
×
NEW
313
  const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
×
314
  
NEW
315
  const sqlQuery = query.toSQL();
×
NEW
316
  const key = hashKey(sqlQuery);
×
317
  
318
  // Skip cache if table is in cache context (will be cleared)
NEW
319
  if (await isTableContainsTableInCacheContext(sqlQuery.sql, options)) {
×
NEW
320
    if (options.logRawSqlQuery) {
×
NEW
321
      console.warn(`Context contains value to clear. Skip getting from cache`);
×
NEW
322
    }
×
NEW
323
    return undefined;
×
NEW
324
  }
×
325
  
NEW
326
  try {
×
NEW
327
    const cacheResult = await kvs
×
NEW
328
      .entity<CacheEntity>(options.cacheEntityName)
×
NEW
329
      .get(key);
×
330
      
NEW
331
    if (
×
NEW
332
      cacheResult &&
×
NEW
333
      (cacheResult[expirationName] as number) >= getCurrentTime() &&
×
NEW
334
      sqlQuery.sql.toLowerCase() === cacheResult[entityQueryName]
×
NEW
335
    ) {
×
NEW
336
      if (options.logRawSqlQuery) {
×
NEW
337
        console.warn(`Get value from cache, cacheKey: ${key}`);
×
NEW
338
      }
×
NEW
339
      const results = cacheResult[dataName];
×
NEW
340
      return JSON.parse(results as string);
×
NEW
341
    }
×
NEW
342
  } catch (error: any) {
×
NEW
343
    console.error(`Error getting from cache: ${error.message}`, error);
×
NEW
344
  }
×
345

NEW
346
  return undefined;
×
NEW
347
}
×
348

349
/**
350
 * Stores query results in cache with specified TTL.
351
 * 
352
 * @param query - Query object with toSQL method
353
 * @param options - ForgeSQL ORM options
354
 * @param results - Data to cache
355
 * @param cacheTtl - Time to live in seconds
356
 * @returns Promise that resolves when data is stored in cache
357
 */
NEW
358
export async function setCacheResult(
×
NEW
359
  query: { toSQL: () => Query },
×
NEW
360
  options: ForgeSqlOrmOptions,
×
NEW
361
  results: unknown,
×
NEW
362
  cacheTtl: number,
×
NEW
363
): Promise<void> {
×
NEW
364
  if (!options.cacheEntityName) {
×
NEW
365
    throw new Error("cacheEntityName is not configured");
×
NEW
366
  }
×
367
  
NEW
368
  try {
×
NEW
369
    const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
×
NEW
370
    const expirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
×
NEW
371
    const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
×
372
    
NEW
373
    const sqlQuery = query.toSQL();
×
NEW
374
    const key = hashKey(sqlQuery);
×
375
    
NEW
376
    await kvs.entity(options.cacheEntityName).set(key, {
×
NEW
377
      [entityQueryName]: sqlQuery.sql.toLowerCase(),
×
NEW
378
      [expirationName]: nowPlusSeconds(cacheTtl),
×
NEW
379
      [dataName]: JSON.stringify(results),
×
NEW
380
    });
×
381
    
NEW
382
    if (options.logRawSqlQuery) {
×
NEW
383
      console.warn(`Store value to cache, cacheKey: ${key}`);
×
NEW
384
    }
×
NEW
385
  } catch (error: any) {
×
NEW
386
    console.error(`Error setting cache: ${error.message}`, error);
×
NEW
387
  }
×
NEW
388
}
×
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