forge-sql-orm 2.0.29 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1090 -81
  2. package/dist/ForgeSQLORM.js +1090 -69
  3. package/dist/ForgeSQLORM.js.map +1 -1
  4. package/dist/ForgeSQLORM.mjs +1073 -69
  5. package/dist/ForgeSQLORM.mjs.map +1 -1
  6. package/dist/core/ForgeSQLAnalyseOperations.d.ts +1 -1
  7. package/dist/core/ForgeSQLAnalyseOperations.d.ts.map +1 -1
  8. package/dist/core/ForgeSQLCacheOperations.d.ts +119 -0
  9. package/dist/core/ForgeSQLCacheOperations.d.ts.map +1 -0
  10. package/dist/core/ForgeSQLCrudOperations.d.ts +38 -22
  11. package/dist/core/ForgeSQLCrudOperations.d.ts.map +1 -1
  12. package/dist/core/ForgeSQLORM.d.ts +104 -13
  13. package/dist/core/ForgeSQLORM.d.ts.map +1 -1
  14. package/dist/core/ForgeSQLQueryBuilder.d.ts +243 -15
  15. package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/lib/drizzle/extensions/additionalActions.d.ts +42 -0
  19. package/dist/lib/drizzle/extensions/additionalActions.d.ts.map +1 -0
  20. package/dist/utils/cacheContextUtils.d.ts +123 -0
  21. package/dist/utils/cacheContextUtils.d.ts.map +1 -0
  22. package/dist/utils/cacheUtils.d.ts +56 -0
  23. package/dist/utils/cacheUtils.d.ts.map +1 -0
  24. package/dist/utils/sqlUtils.d.ts +8 -0
  25. package/dist/utils/sqlUtils.d.ts.map +1 -1
  26. package/dist/webtriggers/clearCacheSchedulerTrigger.d.ts +46 -0
  27. package/dist/webtriggers/clearCacheSchedulerTrigger.d.ts.map +1 -0
  28. package/dist/webtriggers/index.d.ts +1 -0
  29. package/dist/webtriggers/index.d.ts.map +1 -1
  30. package/package.json +15 -12
  31. package/src/core/ForgeSQLAnalyseOperations.ts +1 -1
  32. package/src/core/ForgeSQLCacheOperations.ts +195 -0
  33. package/src/core/ForgeSQLCrudOperations.ts +49 -40
  34. package/src/core/ForgeSQLORM.ts +443 -34
  35. package/src/core/ForgeSQLQueryBuilder.ts +291 -20
  36. package/src/index.ts +1 -1
  37. package/src/lib/drizzle/extensions/additionalActions.ts +548 -0
  38. package/src/lib/drizzle/extensions/types.d.ts +68 -10
  39. package/src/utils/cacheContextUtils.ts +210 -0
  40. package/src/utils/cacheUtils.ts +403 -0
  41. package/src/utils/sqlUtils.ts +29 -12
  42. package/src/webtriggers/clearCacheSchedulerTrigger.ts +79 -0
  43. package/src/webtriggers/index.ts +1 -0
  44. package/dist/lib/drizzle/extensions/selectAliased.d.ts +0 -9
  45. package/dist/lib/drizzle/extensions/selectAliased.d.ts.map +0 -1
  46. package/src/lib/drizzle/extensions/selectAliased.ts +0 -72
@@ -1,24 +1,28 @@
1
1
  import { isTable, sql, eq, and } from "drizzle-orm";
2
2
  import { DateTime } from "luxon";
3
3
  import { isSQLWrapper } from "drizzle-orm/sql/sql";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { getTableName } from "drizzle-orm/table";
6
+ import * as crypto from "crypto";
7
+ import { kvs, WhereConditions, Filter, FilterConditions } from "@forge/kvs";
4
8
  import { sql as sql$1, migrationRunner } from "@forge/sql";
5
9
  import { drizzle } from "drizzle-orm/mysql-proxy";
6
10
  import { customType, mysqlTable, timestamp, varchar, bigint } from "drizzle-orm/mysql-core";
7
- import { getTableName } from "drizzle-orm/table";
8
11
  const parseDateTime = (value, format) => {
9
12
  let result;
10
13
  if (value instanceof Date) {
11
- return value;
12
- }
13
- const dt = DateTime.fromFormat(value, format);
14
- if (dt.isValid) {
15
- result = dt.toJSDate();
14
+ result = value;
16
15
  } else {
17
- const isoDt = DateTime.fromISO(value);
18
- if (isoDt.isValid) {
19
- result = isoDt.toJSDate();
16
+ const dt = DateTime.fromFormat(value, format);
17
+ if (dt.isValid) {
18
+ result = dt.toJSDate();
20
19
  } else {
21
- result = new Date(value);
20
+ const isoDt = DateTime.fromISO(value);
21
+ if (isoDt.isValid) {
22
+ result = isoDt.toJSDate();
23
+ } else {
24
+ result = new Date(value);
25
+ }
22
26
  }
23
27
  }
24
28
  if (isNaN(result.getTime())) {
@@ -26,6 +30,14 @@ const parseDateTime = (value, format) => {
26
30
  }
27
31
  return result;
28
32
  };
33
+ function formatDateTime(value, format) {
34
+ const fromJSDate = DateTime.fromJSDate(value);
35
+ if (fromJSDate.isValid) {
36
+ return fromJSDate.toFormat(format);
37
+ } else {
38
+ throw new Error("Invalid Date");
39
+ }
40
+ }
29
41
  function getPrimaryKeys(table) {
30
42
  const { columns, primaryKeys } = getTableMetadata(table);
31
43
  const columnPrimaryKeys = Object.entries(columns).filter(([, column]) => column.primary);
@@ -288,6 +300,275 @@ function formatLimitOffset(limitOrOffset) {
288
300
  function nextVal(sequenceName) {
289
301
  return sql.raw(`NEXTVAL(${sequenceName})`);
290
302
  }
303
+ const CACHE_CONSTANTS = {
304
+ BATCH_SIZE: 25,
305
+ MAX_RETRY_ATTEMPTS: 3,
306
+ INITIAL_RETRY_DELAY: 1e3,
307
+ RETRY_DELAY_MULTIPLIER: 2,
308
+ DEFAULT_ENTITY_QUERY_NAME: "sql",
309
+ DEFAULT_EXPIRATION_NAME: "expiration",
310
+ DEFAULT_DATA_NAME: "data",
311
+ HASH_LENGTH: 32
312
+ };
313
+ function getCurrentTime() {
314
+ const dt = DateTime.now();
315
+ return Math.floor(dt.toSeconds());
316
+ }
317
+ function nowPlusSeconds(secondsToAdd) {
318
+ const dt = DateTime.now().plus({ seconds: secondsToAdd });
319
+ return Math.floor(dt.toSeconds());
320
+ }
321
+ function hashKey(query) {
322
+ const h = crypto.createHash("sha256");
323
+ h.update(query.sql.toLowerCase());
324
+ h.update(JSON.stringify(query.params));
325
+ return "CachedQuery_" + h.digest("hex").slice(0, CACHE_CONSTANTS.HASH_LENGTH);
326
+ }
327
+ async function deleteCacheEntriesInBatches(results, cacheEntityName) {
328
+ for (let i = 0; i < results.length; i += CACHE_CONSTANTS.BATCH_SIZE) {
329
+ const batch = results.slice(i, i + CACHE_CONSTANTS.BATCH_SIZE);
330
+ let transactionBuilder = kvs.transact();
331
+ batch.forEach((result) => {
332
+ transactionBuilder = transactionBuilder.delete(result.key, { entityName: cacheEntityName });
333
+ });
334
+ await transactionBuilder.execute();
335
+ }
336
+ }
337
+ async function clearCursorCache(tables, cursor, options) {
338
+ const cacheEntityName = options.cacheEntityName;
339
+ if (!cacheEntityName) {
340
+ throw new Error("cacheEntityName is not configured");
341
+ }
342
+ const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
343
+ let filters = new Filter();
344
+ for (const table of tables) {
345
+ const wrapIfNeeded = options.cacheWrapTable ? `\`${table}\`` : table;
346
+ filters.or(entityQueryName, FilterConditions.contains(wrapIfNeeded?.toLowerCase()));
347
+ }
348
+ let entityQueryBuilder = kvs.entity(cacheEntityName).query().index(entityQueryName).filters(filters);
349
+ if (cursor) {
350
+ entityQueryBuilder = entityQueryBuilder.cursor(cursor);
351
+ }
352
+ const listResult = await entityQueryBuilder.limit(100).getMany();
353
+ if (options.logRawSqlQuery) {
354
+ console.warn(`clear cache Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
355
+ }
356
+ await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
357
+ if (listResult.nextCursor) {
358
+ return listResult.results.length + await clearCursorCache(tables, listResult.nextCursor, options);
359
+ } else {
360
+ return listResult.results.length;
361
+ }
362
+ }
363
+ async function clearExpirationCursorCache(cursor, options) {
364
+ const cacheEntityName = options.cacheEntityName;
365
+ if (!cacheEntityName) {
366
+ throw new Error("cacheEntityName is not configured");
367
+ }
368
+ const entityExpirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
369
+ let entityQueryBuilder = kvs.entity(cacheEntityName).query().index(entityExpirationName).where(WhereConditions.lessThan(Math.floor(DateTime.now().toSeconds())));
370
+ if (cursor) {
371
+ entityQueryBuilder = entityQueryBuilder.cursor(cursor);
372
+ }
373
+ const listResult = await entityQueryBuilder.limit(100).getMany();
374
+ if (options.logRawSqlQuery) {
375
+ console.warn(`clear expired Records: ${JSON.stringify(listResult.results.map((r) => r.key))}`);
376
+ }
377
+ await deleteCacheEntriesInBatches(listResult.results, cacheEntityName);
378
+ if (listResult.nextCursor) {
379
+ return listResult.results.length + await clearExpirationCursorCache(listResult.nextCursor, options);
380
+ } else {
381
+ return listResult.results.length;
382
+ }
383
+ }
384
+ async function executeWithRetry(operation, operationName) {
385
+ let attempt = 0;
386
+ let delay = CACHE_CONSTANTS.INITIAL_RETRY_DELAY;
387
+ while (attempt < CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
388
+ try {
389
+ return await operation();
390
+ } catch (err) {
391
+ console.warn(`Error during ${operationName}: ${err.message}, retry ${attempt}`, err);
392
+ attempt++;
393
+ if (attempt >= CACHE_CONSTANTS.MAX_RETRY_ATTEMPTS) {
394
+ console.error(`Error during ${operationName}: ${err.message}`, err);
395
+ throw err;
396
+ }
397
+ await new Promise((resolve) => setTimeout(resolve, delay));
398
+ delay *= CACHE_CONSTANTS.RETRY_DELAY_MULTIPLIER;
399
+ }
400
+ }
401
+ throw new Error(`Maximum retry attempts exceeded for ${operationName}`);
402
+ }
403
+ async function clearCache(schema, options) {
404
+ const tableName = getTableName(schema);
405
+ if (cacheApplicationContext.getStore()) {
406
+ cacheApplicationContext.getStore()?.tables.add(tableName);
407
+ } else {
408
+ await clearTablesCache([tableName], options);
409
+ }
410
+ }
411
+ async function clearTablesCache(tables, options) {
412
+ if (!options.cacheEntityName) {
413
+ throw new Error("cacheEntityName is not configured");
414
+ }
415
+ const startTime = DateTime.now();
416
+ let totalRecords = 0;
417
+ try {
418
+ totalRecords = await executeWithRetry(
419
+ () => clearCursorCache(tables, "", options),
420
+ "clearing cache"
421
+ );
422
+ } finally {
423
+ if (options.logRawSqlQuery) {
424
+ const duration = DateTime.now().toSeconds() - startTime.toSeconds();
425
+ console.info(`Cleared ${totalRecords} cache records in ${duration} seconds`);
426
+ }
427
+ }
428
+ }
429
+ async function clearExpiredCache(options) {
430
+ if (!options.cacheEntityName) {
431
+ throw new Error("cacheEntityName is not configured");
432
+ }
433
+ const startTime = DateTime.now();
434
+ let totalRecords = 0;
435
+ try {
436
+ totalRecords = await executeWithRetry(
437
+ () => clearExpirationCursorCache("", options),
438
+ "clearing expired cache"
439
+ );
440
+ } finally {
441
+ const duration = DateTime.now().toSeconds() - startTime.toSeconds();
442
+ console.info(`Cleared ${totalRecords} expired cache records in ${duration} seconds`);
443
+ }
444
+ }
445
+ async function getFromCache(query, options) {
446
+ if (!options.cacheEntityName) {
447
+ throw new Error("cacheEntityName is not configured");
448
+ }
449
+ const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
450
+ const expirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
451
+ const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
452
+ const sqlQuery = query.toSQL();
453
+ const key = hashKey(sqlQuery);
454
+ if (await isTableContainsTableInCacheContext(sqlQuery.sql, options)) {
455
+ if (options.logRawSqlQuery) {
456
+ console.warn(`Context contains value to clear. Skip getting from cache`);
457
+ }
458
+ return void 0;
459
+ }
460
+ try {
461
+ const cacheResult = await kvs.entity(options.cacheEntityName).get(key);
462
+ if (cacheResult && cacheResult[expirationName] >= getCurrentTime() && sqlQuery.sql.toLowerCase() === cacheResult[entityQueryName]) {
463
+ if (options.logRawSqlQuery) {
464
+ console.warn(`Get value from cache, cacheKey: ${key}`);
465
+ }
466
+ const results = cacheResult[dataName];
467
+ return JSON.parse(results);
468
+ }
469
+ } catch (error) {
470
+ console.error(`Error getting from cache: ${error.message}`, error);
471
+ }
472
+ return void 0;
473
+ }
474
+ async function setCacheResult(query, options, results, cacheTtl) {
475
+ if (!options.cacheEntityName) {
476
+ throw new Error("cacheEntityName is not configured");
477
+ }
478
+ try {
479
+ const entityQueryName = options.cacheEntityQueryName ?? CACHE_CONSTANTS.DEFAULT_ENTITY_QUERY_NAME;
480
+ const expirationName = options.cacheEntityExpirationName ?? CACHE_CONSTANTS.DEFAULT_EXPIRATION_NAME;
481
+ const dataName = options.cacheEntityDataName ?? CACHE_CONSTANTS.DEFAULT_DATA_NAME;
482
+ const sqlQuery = query.toSQL();
483
+ if (await isTableContainsTableInCacheContext(sqlQuery.sql, options)) {
484
+ if (options.logRawSqlQuery) {
485
+ console.warn(`Context contains value to clear. Skip setting from cache`);
486
+ }
487
+ return;
488
+ }
489
+ const key = hashKey(sqlQuery);
490
+ await kvs.transact().set(
491
+ key,
492
+ {
493
+ [entityQueryName]: sqlQuery.sql.toLowerCase(),
494
+ [expirationName]: nowPlusSeconds(cacheTtl),
495
+ [dataName]: JSON.stringify(results)
496
+ },
497
+ { entityName: options.cacheEntityName }
498
+ ).execute();
499
+ if (options.logRawSqlQuery) {
500
+ console.warn(`Store value to cache, cacheKey: ${key}`);
501
+ }
502
+ } catch (error) {
503
+ console.error(`Error setting cache: ${error.message}`, error);
504
+ }
505
+ }
506
+ const cacheApplicationContext = new AsyncLocalStorage();
507
+ const localCacheApplicationContext = new AsyncLocalStorage();
508
+ async function saveTableIfInsideCacheContext(table) {
509
+ const context = cacheApplicationContext.getStore();
510
+ if (context) {
511
+ const tableName = getTableName(table).toLowerCase();
512
+ context.tables.add(tableName);
513
+ }
514
+ }
515
+ async function saveQueryLocalCacheQuery(query, rows) {
516
+ const context = localCacheApplicationContext.getStore();
517
+ if (context) {
518
+ if (!context.cache) {
519
+ context.cache = {};
520
+ }
521
+ const sql2 = query;
522
+ const key = hashKey(sql2.toSQL());
523
+ context.cache[key] = {
524
+ sql: sql2.toSQL().sql.toLowerCase(),
525
+ data: rows
526
+ };
527
+ }
528
+ }
529
+ async function getQueryLocalCacheQuery(query) {
530
+ const context = localCacheApplicationContext.getStore();
531
+ if (context) {
532
+ if (!context.cache) {
533
+ context.cache = {};
534
+ }
535
+ const sql2 = query;
536
+ const key = hashKey(sql2.toSQL());
537
+ if (context.cache[key] && context.cache[key].sql === sql2.toSQL().sql.toLowerCase()) {
538
+ return context.cache[key].data;
539
+ }
540
+ }
541
+ return void 0;
542
+ }
543
+ async function evictLocalCacheQuery(table, options) {
544
+ const context = localCacheApplicationContext.getStore();
545
+ if (context) {
546
+ if (!context.cache) {
547
+ context.cache = {};
548
+ }
549
+ const tableName = getTableName(table);
550
+ const searchString = options.cacheWrapTable ? `\`${tableName}\`` : tableName;
551
+ const keyToEvicts = [];
552
+ Object.keys(context.cache).forEach((key) => {
553
+ if (context.cache[key].sql.includes(searchString)) {
554
+ keyToEvicts.push(key);
555
+ }
556
+ });
557
+ keyToEvicts.forEach((key) => delete context.cache[key]);
558
+ }
559
+ }
560
+ async function isTableContainsTableInCacheContext(sql2, options) {
561
+ const context = cacheApplicationContext.getStore();
562
+ if (!context) {
563
+ return false;
564
+ }
565
+ const tables = Array.from(context.tables);
566
+ const lowerSql = sql2.toLowerCase();
567
+ return tables.some((table) => {
568
+ const tablePattern = options.cacheWrapTable ? `\`${table}\`` : table;
569
+ return lowerSql.includes(tablePattern);
570
+ });
571
+ }
291
572
  class ForgeSQLCrudOperations {
292
573
  forgeOperations;
293
574
  options;
@@ -304,12 +585,17 @@ class ForgeSQLCrudOperations {
304
585
  * Inserts records into the database with optional versioning support.
305
586
  * If a version field exists in the schema, versioning is applied.
306
587
  *
588
+ * This method automatically handles:
589
+ * - Version field initialization for optimistic locking
590
+ * - Batch insertion for multiple records
591
+ * - Duplicate key handling with optional updates
592
+ *
307
593
  * @template T - The type of the table schema
308
- * @param {T} schema - The entity schema
309
- * @param {Partial<InferInsertModel<T>>[]} models - Array of entities to insert
310
- * @param {boolean} [updateIfExists=false] - Whether to update existing records
311
- * @returns {Promise<number>} The number of inserted rows
312
- * @throws {Error} If the insert operation fails
594
+ * @param schema - The entity schema
595
+ * @param models - Array of entities to insert
596
+ * @param updateIfExists - Whether to update existing records (default: false)
597
+ * @returns Promise that resolves to the number of inserted rows
598
+ * @throws Error if the insert operation fails
313
599
  */
314
600
  async insert(schema, models, updateIfExists = false) {
315
601
  if (!models?.length) return 0;
@@ -318,25 +604,32 @@ class ForgeSQLCrudOperations {
318
604
  const preparedModels = models.map(
319
605
  (model) => this.prepareModelWithVersion(model, versionMetadata, columns)
320
606
  );
321
- const queryBuilder = this.forgeOperations.getDrizzleQueryBuilder().insert(schema).values(preparedModels);
607
+ const queryBuilder = this.forgeOperations.insert(schema).values(preparedModels);
322
608
  const finalQuery = updateIfExists ? queryBuilder.onDuplicateKeyUpdate({
323
609
  set: Object.fromEntries(
324
610
  Object.keys(preparedModels[0]).map((key) => [key, schema[key]])
325
611
  )
326
612
  }) : queryBuilder;
327
613
  const result = await finalQuery;
614
+ await saveTableIfInsideCacheContext(schema);
328
615
  return result[0].insertId;
329
616
  }
330
617
  /**
331
618
  * Deletes a record by its primary key with optional version check.
332
619
  * If versioning is enabled, ensures the record hasn't been modified since last read.
333
620
  *
621
+ * This method automatically handles:
622
+ * - Single primary key validation
623
+ * - Optimistic locking checks if versioning is enabled
624
+ * - Version field validation before deletion
625
+ *
334
626
  * @template T - The type of the table schema
335
- * @param {unknown} id - The ID of the record to delete
336
- * @param {T} schema - The entity schema
337
- * @returns {Promise<number>} Number of affected rows
338
- * @throws {Error} If the delete operation fails
339
- * @throws {Error} If multiple primary keys are found
627
+ * @param id - The ID of the record to delete
628
+ * @param schema - The entity schema
629
+ * @returns Promise that resolves to the number of affected rows
630
+ * @throws Error if the delete operation fails
631
+ * @throws Error if multiple primary keys are found
632
+ * @throws Error if optimistic locking check fails
340
633
  */
341
634
  async deleteById(id, schema) {
342
635
  const { tableName, columns } = getTableMetadata(schema);
@@ -357,8 +650,12 @@ class ForgeSQLCrudOperations {
357
650
  conditions.push(eq(versionField, oldModel[versionMetadata.fieldName]));
358
651
  }
359
652
  }
360
- const queryBuilder = this.forgeOperations.getDrizzleQueryBuilder().delete(schema).where(and(...conditions));
653
+ const queryBuilder = this.forgeOperations.delete(schema).where(and(...conditions));
361
654
  const result = await queryBuilder;
655
+ if (versionMetadata && result[0].affectedRows === 0) {
656
+ throw new Error(`Optimistic locking failed: record with primary key ${id} has been modified`);
657
+ }
658
+ await saveTableIfInsideCacheContext(schema);
362
659
  return result[0].affectedRows;
363
660
  }
364
661
  /**
@@ -368,13 +665,19 @@ class ForgeSQLCrudOperations {
368
665
  * - Checks for concurrent modifications
369
666
  * - Increments the version on successful update
370
667
  *
668
+ * This method automatically handles:
669
+ * - Primary key validation
670
+ * - Version field retrieval and validation
671
+ * - Optimistic locking conflict detection
672
+ * - Version field incrementation
673
+ *
371
674
  * @template T - The type of the table schema
372
- * @param {Partial<InferInsertModel<T>>} entity - The entity with updated values
373
- * @param {T} schema - The entity schema
374
- * @returns {Promise<number>} Number of affected rows
375
- * @throws {Error} If the primary key is not provided
376
- * @throws {Error} If optimistic locking check fails
377
- * @throws {Error} If multiple primary keys are found
675
+ * @param entity - The entity with updated values (must include primary key)
676
+ * @param schema - The entity schema
677
+ * @returns Promise that resolves to the number of affected rows
678
+ * @throws Error if the primary key is not provided
679
+ * @throws Error if optimistic locking check fails
680
+ * @throws Error if multiple primary keys are found
378
681
  */
379
682
  async updateById(entity, schema) {
380
683
  const { tableName, columns } = getTableMetadata(schema);
@@ -404,13 +707,14 @@ class ForgeSQLCrudOperations {
404
707
  conditions.push(eq(versionField, currentVersion));
405
708
  }
406
709
  }
407
- const queryBuilder = this.forgeOperations.getDrizzleQueryBuilder().update(schema).set(updateData).where(and(...conditions));
710
+ const queryBuilder = this.forgeOperations.update(schema).set(updateData).where(and(...conditions));
408
711
  const result = await queryBuilder;
409
712
  if (versionMetadata && result[0].affectedRows === 0) {
410
713
  throw new Error(
411
714
  `Optimistic locking failed: record with primary key ${entity[primaryKeyName]} has been modified`
412
715
  );
413
716
  }
717
+ await saveTableIfInsideCacheContext(schema);
414
718
  return result[0].affectedRows;
415
719
  }
416
720
  /**
@@ -429,8 +733,9 @@ class ForgeSQLCrudOperations {
429
733
  if (!where) {
430
734
  throw new Error("WHERE conditions must be provided");
431
735
  }
432
- const queryBuilder = this.forgeOperations.getDrizzleQueryBuilder().update(schema).set(updateData).where(where);
736
+ const queryBuilder = this.forgeOperations.update(schema).set(updateData).where(where);
433
737
  const result = await queryBuilder;
738
+ await saveTableIfInsideCacheContext(schema);
434
739
  return result[0].affectedRows;
435
740
  }
436
741
  // Helper methods
@@ -574,7 +879,7 @@ class ForgeSQLCrudOperations {
574
879
  const [versionFieldName, versionFieldColumn] = versionField;
575
880
  const primaryKeys = this.getPrimaryKeys(schema);
576
881
  const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
577
- const resultQuery = this.forgeOperations.getDrizzleQueryBuilder().select({
882
+ const resultQuery = this.forgeOperations.select({
578
883
  [primaryKeyName]: primaryKeyColumn,
579
884
  [versionFieldName]: versionFieldColumn
580
885
  }).from(schema).where(eq(primaryKeyColumn, primaryKeyValues[primaryKeyName]));
@@ -719,7 +1024,125 @@ function createForgeDriverProxy(options, logRawSqlQuery) {
719
1024
  return forgeDriver(modifiedQuery, params, method);
720
1025
  };
721
1026
  }
722
- function createAliasedSelectBuilder(db, fields, selectFn) {
1027
+ function shouldClearCacheOnError(error) {
1028
+ if (error?.code === "VALIDATION_ERROR" || error?.code === "CONSTRAINT_ERROR" || error?.message && /validation/i.exec(error.message)) {
1029
+ return false;
1030
+ }
1031
+ if (error?.code === "DEADLOCK" || error?.code === "LOCK_WAIT_TIMEOUT" || error?.code === "CONNECTION_ERROR" || error?.message && /timeout/i.exec(error.message) || error?.message && /connection/i.exec(error.message)) {
1032
+ return true;
1033
+ }
1034
+ return true;
1035
+ }
1036
+ async function handleSuccessfulExecution(rows, onfulfilled, table, options, isCached) {
1037
+ try {
1038
+ await evictLocalCacheQuery(table, options);
1039
+ await saveTableIfInsideCacheContext(table);
1040
+ if (isCached && !cacheApplicationContext.getStore()) {
1041
+ await clearCache(table, options);
1042
+ }
1043
+ const result = onfulfilled?.(rows);
1044
+ return result;
1045
+ } catch (error) {
1046
+ if (shouldClearCacheOnError(error)) {
1047
+ await evictLocalCacheQuery(table, options);
1048
+ if (isCached) {
1049
+ await clearCache(table, options).catch(() => {
1050
+ console.warn("Ignore cache clear errors");
1051
+ });
1052
+ } else {
1053
+ await saveTableIfInsideCacheContext(table);
1054
+ }
1055
+ }
1056
+ throw error;
1057
+ }
1058
+ }
1059
+ function handleFunctionCall(value, target, args, table, options, isCached) {
1060
+ const result = value.apply(target, args);
1061
+ if (typeof result === "object" && result !== null && "execute" in result) {
1062
+ return wrapCacheEvictBuilder(result, table, options, isCached);
1063
+ }
1064
+ return result;
1065
+ }
1066
+ const wrapCacheEvictBuilder = (rawBuilder, table, options, isCached) => {
1067
+ return new Proxy(rawBuilder, {
1068
+ get(target, prop, receiver) {
1069
+ if (prop === "then") {
1070
+ return (onfulfilled, onrejected) => target.execute().then(
1071
+ (rows) => handleSuccessfulExecution(rows, onfulfilled, table, options, isCached),
1072
+ onrejected
1073
+ );
1074
+ }
1075
+ const value = Reflect.get(target, prop, receiver);
1076
+ if (typeof value === "function") {
1077
+ return (...args) => handleFunctionCall(value, target, args, table, options, isCached);
1078
+ }
1079
+ return value;
1080
+ }
1081
+ });
1082
+ };
1083
+ function insertAndEvictCacheBuilder(db, table, options, isCached) {
1084
+ const builder = db.insert(table);
1085
+ return wrapCacheEvictBuilder(
1086
+ builder,
1087
+ table,
1088
+ options,
1089
+ isCached
1090
+ );
1091
+ }
1092
+ function updateAndEvictCacheBuilder(db, table, options, isCached) {
1093
+ const builder = db.update(table);
1094
+ return wrapCacheEvictBuilder(
1095
+ builder,
1096
+ table,
1097
+ options,
1098
+ isCached
1099
+ );
1100
+ }
1101
+ function deleteAndEvictCacheBuilder(db, table, options, isCached) {
1102
+ const builder = db.delete(table);
1103
+ return wrapCacheEvictBuilder(
1104
+ builder,
1105
+ table,
1106
+ options,
1107
+ isCached
1108
+ );
1109
+ }
1110
+ async function handleCachedQuery(target, options, cacheTtl, selections, aliasMap, onfulfilled, onrejected) {
1111
+ try {
1112
+ const localCached = await getQueryLocalCacheQuery(target);
1113
+ if (localCached) {
1114
+ return onfulfilled?.(localCached);
1115
+ }
1116
+ const cacheResult = await getFromCache(target, options);
1117
+ if (cacheResult) {
1118
+ return onfulfilled?.(cacheResult);
1119
+ }
1120
+ const rows = await target.execute();
1121
+ const transformed = applyFromDriverTransform(rows, selections, aliasMap);
1122
+ await saveQueryLocalCacheQuery(target, transformed);
1123
+ await setCacheResult(target, options, transformed, cacheTtl).catch((cacheError) => {
1124
+ console.warn("Cache set error:", cacheError);
1125
+ });
1126
+ return onfulfilled?.(transformed);
1127
+ } catch (error) {
1128
+ return onrejected?.(error);
1129
+ }
1130
+ }
1131
+ async function handleNonCachedQuery(target, selections, aliasMap, onfulfilled, onrejected) {
1132
+ try {
1133
+ const localCached = await getQueryLocalCacheQuery(target);
1134
+ if (localCached) {
1135
+ return onfulfilled?.(localCached);
1136
+ }
1137
+ const rows = await target.execute();
1138
+ const transformed = applyFromDriverTransform(rows, selections, aliasMap);
1139
+ await saveQueryLocalCacheQuery(target, transformed);
1140
+ return onfulfilled?.(transformed);
1141
+ } catch (error) {
1142
+ return onrejected?.(error);
1143
+ }
1144
+ }
1145
+ function createAliasedSelectBuilder(db, fields, selectFn, useCache, options, cacheTtl) {
723
1146
  const { selections, aliasMap } = mapSelectFieldsWithAlias(fields);
724
1147
  const builder = selectFn(selections);
725
1148
  const wrapBuilder = (rawBuilder) => {
@@ -732,10 +1155,22 @@ function createAliasedSelectBuilder(db, fields, selectFn) {
732
1155
  };
733
1156
  }
734
1157
  if (prop === "then") {
735
- return (onfulfilled, onrejected) => target.execute().then((rows) => {
736
- const transformed = applyFromDriverTransform(rows, selections, aliasMap);
737
- return onfulfilled?.(transformed);
738
- }, onrejected);
1158
+ return (onfulfilled, onrejected) => {
1159
+ if (useCache) {
1160
+ const ttl = cacheTtl ?? options.cacheTTL ?? 120;
1161
+ return handleCachedQuery(
1162
+ target,
1163
+ options,
1164
+ ttl,
1165
+ selections,
1166
+ aliasMap,
1167
+ onfulfilled,
1168
+ onrejected
1169
+ );
1170
+ } else {
1171
+ return handleNonCachedQuery(target, selections, aliasMap, onfulfilled, onrejected);
1172
+ }
1173
+ };
739
1174
  }
740
1175
  const value = Reflect.get(target, prop, receiver);
741
1176
  if (typeof value === "function") {
@@ -753,12 +1188,72 @@ function createAliasedSelectBuilder(db, fields, selectFn) {
753
1188
  };
754
1189
  return wrapBuilder(builder);
755
1190
  }
756
- function patchDbWithSelectAliased(db) {
1191
+ const DEFAULT_OPTIONS = {
1192
+ logRawSqlQuery: false,
1193
+ disableOptimisticLocking: false,
1194
+ cacheTTL: 120,
1195
+ cacheWrapTable: true,
1196
+ cacheEntityQueryName: "sql",
1197
+ cacheEntityExpirationName: "expiration",
1198
+ cacheEntityDataName: "data"
1199
+ };
1200
+ function patchDbWithSelectAliased(db, options) {
1201
+ const newOptions = { ...DEFAULT_OPTIONS, ...options };
757
1202
  db.selectAliased = function(fields) {
758
- return createAliasedSelectBuilder(db, fields, (selections) => db.select(selections));
1203
+ return createAliasedSelectBuilder(
1204
+ db,
1205
+ fields,
1206
+ (selections) => db.select(selections),
1207
+ false,
1208
+ newOptions
1209
+ );
759
1210
  };
760
1211
  db.selectAliasedDistinct = function(fields) {
761
- return createAliasedSelectBuilder(db, fields, (selections) => db.selectDistinct(selections));
1212
+ return createAliasedSelectBuilder(
1213
+ db,
1214
+ fields,
1215
+ (selections) => db.selectDistinct(selections),
1216
+ false,
1217
+ newOptions
1218
+ );
1219
+ };
1220
+ db.selectAliasedCacheable = function(fields, cacheTtl) {
1221
+ return createAliasedSelectBuilder(
1222
+ db,
1223
+ fields,
1224
+ (selections) => db.select(selections),
1225
+ true,
1226
+ newOptions,
1227
+ cacheTtl
1228
+ );
1229
+ };
1230
+ db.selectAliasedDistinctCacheable = function(fields, cacheTtl) {
1231
+ return createAliasedSelectBuilder(
1232
+ db,
1233
+ fields,
1234
+ (selections) => db.selectDistinct(selections),
1235
+ true,
1236
+ newOptions,
1237
+ cacheTtl
1238
+ );
1239
+ };
1240
+ db.insertWithCacheContext = function(table) {
1241
+ return insertAndEvictCacheBuilder(db, table, newOptions, false);
1242
+ };
1243
+ db.insertAndEvictCache = function(table) {
1244
+ return insertAndEvictCacheBuilder(db, table, newOptions, true);
1245
+ };
1246
+ db.updateWithCacheContext = function(table) {
1247
+ return updateAndEvictCacheBuilder(db, table, newOptions, false);
1248
+ };
1249
+ db.updateAndEvictCache = function(table) {
1250
+ return updateAndEvictCacheBuilder(db, table, newOptions, true);
1251
+ };
1252
+ db.deleteWithCacheContext = function(table) {
1253
+ return deleteAndEvictCacheBuilder(db, table, newOptions, false);
1254
+ };
1255
+ db.deleteAndEvictCache = function(table) {
1256
+ return deleteAndEvictCacheBuilder(db, table, newOptions, true);
762
1257
  };
763
1258
  return db;
764
1259
  }
@@ -910,14 +1405,14 @@ class ForgeSQLAnalyseOperation {
910
1405
  * @returns {string} The SQL query for cluster statement history
911
1406
  */
912
1407
  buildClusterStatementQuery(tables, from, to) {
913
- const formatDateTime = (date) => DateTime.fromJSDate(date).toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
1408
+ const formatDateTime2 = (date) => DateTime.fromJSDate(date).toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
914
1409
  const tableConditions = tables.map((table) => `TABLE_NAMES LIKE CONCAT(SCHEMA_NAME, '.', '%', '${table}', '%')`).join(" OR ");
915
1410
  const timeConditions = [];
916
1411
  if (from) {
917
- timeConditions.push(`SUMMARY_BEGIN_TIME >= '${formatDateTime(from)}'`);
1412
+ timeConditions.push(`SUMMARY_BEGIN_TIME >= '${formatDateTime2(from)}'`);
918
1413
  }
919
1414
  if (to) {
920
- timeConditions.push(`SUMMARY_END_TIME <= '${formatDateTime(to)}'`);
1415
+ timeConditions.push(`SUMMARY_END_TIME <= '${formatDateTime2(to)}'`);
921
1416
  }
922
1417
  let whereClauses;
923
1418
  if (tableConditions?.length) {
@@ -990,12 +1485,167 @@ class ForgeSQLAnalyseOperation {
990
1485
  return this.analyzeQueriesHistoryRaw(tableNames, fromDate, toDate);
991
1486
  }
992
1487
  }
1488
+ class ForgeSQLCacheOperations {
1489
+ options;
1490
+ forgeOperations;
1491
+ /**
1492
+ * Creates a new instance of ForgeSQLCacheOperations.
1493
+ *
1494
+ * @param options - Configuration options for the ORM
1495
+ * @param forgeOperations - The ForgeSQL operations instance
1496
+ */
1497
+ constructor(options, forgeOperations) {
1498
+ this.options = options;
1499
+ this.forgeOperations = forgeOperations;
1500
+ }
1501
+ /**
1502
+ * Evicts cache for multiple tables using Drizzle table objects.
1503
+ *
1504
+ * @param tables - Array of Drizzle table objects to clear cache for
1505
+ * @returns Promise that resolves when cache eviction is complete
1506
+ * @throws Error if cacheEntityName is not configured
1507
+ */
1508
+ async evictCacheEntities(tables) {
1509
+ if (!this.options.cacheEntityName) {
1510
+ throw new Error("cacheEntityName is not configured");
1511
+ }
1512
+ await this.evictCache(tables.map((t) => getTableName(t)));
1513
+ }
1514
+ /**
1515
+ * Evicts cache for multiple tables by their names.
1516
+ *
1517
+ * @param tables - Array of table names to clear cache for
1518
+ * @returns Promise that resolves when cache eviction is complete
1519
+ * @throws Error if cacheEntityName is not configured
1520
+ */
1521
+ async evictCache(tables) {
1522
+ if (!this.options.cacheEntityName) {
1523
+ throw new Error("cacheEntityName is not configured");
1524
+ }
1525
+ await clearTablesCache(tables, this.options);
1526
+ }
1527
+ /**
1528
+ * Inserts records with optimistic locking/versioning and automatically evicts cache.
1529
+ *
1530
+ * This method uses `modifyWithVersioning().insert()` internally, providing:
1531
+ * - Automatic version field initialization
1532
+ * - Optimistic locking support
1533
+ * - Cache eviction after successful operation
1534
+ *
1535
+ * @param schema - The table schema
1536
+ * @param models - Array of entities to insert
1537
+ * @param updateIfExists - Whether to update existing records
1538
+ * @returns Promise that resolves to the number of inserted rows
1539
+ * @throws Error if cacheEntityName is not configured
1540
+ * @throws Error if optimistic locking check fails
1541
+ */
1542
+ async insert(schema, models, updateIfExists) {
1543
+ this.validateCacheConfiguration();
1544
+ const number = await this.forgeOperations.modifyWithVersioning().insert(schema, models, updateIfExists);
1545
+ await clearCache(schema, this.options);
1546
+ return number;
1547
+ }
1548
+ /**
1549
+ * Deletes a record by ID with optimistic locking/versioning and automatically evicts cache.
1550
+ *
1551
+ * This method uses `modifyWithVersioning().deleteById()` internally, providing:
1552
+ * - Optimistic locking checks before deletion
1553
+ * - Version field validation
1554
+ * - Cache eviction after successful operation
1555
+ *
1556
+ * @param id - The ID of the record to delete
1557
+ * @param schema - The table schema
1558
+ * @returns Promise that resolves to the number of affected rows
1559
+ * @throws Error if cacheEntityName is not configured
1560
+ * @throws Error if optimistic locking check fails
1561
+ */
1562
+ async deleteById(id, schema) {
1563
+ this.validateCacheConfiguration();
1564
+ const number = await this.forgeOperations.modifyWithVersioning().deleteById(id, schema);
1565
+ await clearCache(schema, this.options);
1566
+ return number;
1567
+ }
1568
+ /**
1569
+ * Updates a record by ID with optimistic locking/versioning and automatically evicts cache.
1570
+ *
1571
+ * This method uses `modifyWithVersioning().updateById()` internally, providing:
1572
+ * - Optimistic locking checks before update
1573
+ * - Version field incrementation
1574
+ * - Cache eviction after successful operation
1575
+ *
1576
+ * @param entity - The entity with updated values (must include primary key)
1577
+ * @param schema - The table schema
1578
+ * @returns Promise that resolves to the number of affected rows
1579
+ * @throws Error if cacheEntityName is not configured
1580
+ * @throws Error if optimistic locking check fails
1581
+ */
1582
+ async updateById(entity, schema) {
1583
+ this.validateCacheConfiguration();
1584
+ const number = await this.forgeOperations.modifyWithVersioning().updateById(entity, schema);
1585
+ await clearCache(schema, this.options);
1586
+ return number;
1587
+ }
1588
+ /**
1589
+ * Updates fields based on conditions with optimistic locking/versioning and automatically evicts cache.
1590
+ *
1591
+ * This method uses `modifyWithVersioning().updateFields()` internally, providing:
1592
+ * - Optimistic locking support (if version field is configured)
1593
+ * - Version field validation and incrementation
1594
+ * - Cache eviction after successful operation
1595
+ *
1596
+ * @param updateData - The data to update
1597
+ * @param schema - The table schema
1598
+ * @param where - Optional WHERE conditions
1599
+ * @returns Promise that resolves to the number of affected rows
1600
+ * @throws Error if cacheEntityName is not configured
1601
+ * @throws Error if optimistic locking check fails
1602
+ */
1603
+ async updateFields(updateData, schema, where) {
1604
+ this.validateCacheConfiguration();
1605
+ const number = await this.forgeOperations.modifyWithVersioning().updateFields(updateData, schema, where);
1606
+ await clearCache(schema, this.options);
1607
+ return number;
1608
+ }
1609
+ /**
1610
+ * Executes a query with caching support.
1611
+ * First checks cache, if not found executes query and stores result in cache.
1612
+ *
1613
+ * @param query - The Drizzle query to execute
1614
+ * @param cacheTtl - Optional cache TTL override
1615
+ * @returns Promise that resolves to the query results
1616
+ * @throws Error if cacheEntityName is not configured
1617
+ */
1618
+ async executeQuery(query, cacheTtl) {
1619
+ this.validateCacheConfiguration();
1620
+ const sqlQuery = query;
1621
+ const cacheResult = await getFromCache(sqlQuery, this.options);
1622
+ if (cacheResult) {
1623
+ return cacheResult;
1624
+ }
1625
+ const results = await query;
1626
+ await setCacheResult(sqlQuery, this.options, results, cacheTtl ?? this.options.cacheTTL ?? 60);
1627
+ return results;
1628
+ }
1629
+ /**
1630
+ * Validates that cache configuration is properly set up.
1631
+ *
1632
+ * @throws Error if cacheEntityName is not configured
1633
+ * @private
1634
+ */
1635
+ validateCacheConfiguration() {
1636
+ if (!this.options.cacheEntityName) {
1637
+ throw new Error("cacheEntityName is not configured");
1638
+ }
1639
+ }
1640
+ }
993
1641
  class ForgeSQLORMImpl {
994
1642
  static instance = null;
995
1643
  drizzle;
996
1644
  crudOperations;
997
1645
  fetchOperations;
998
1646
  analyzeOperations;
1647
+ cacheOperations;
1648
+ options;
999
1649
  /**
1000
1650
  * Private constructor to enforce singleton behavior.
1001
1651
  * @param options - Options for configuring ForgeSQL ORM behavior.
@@ -1004,28 +1654,185 @@ class ForgeSQLORMImpl {
1004
1654
  try {
1005
1655
  const newOptions = options ?? {
1006
1656
  logRawSqlQuery: false,
1007
- disableOptimisticLocking: false
1657
+ disableOptimisticLocking: false,
1658
+ cacheWrapTable: true,
1659
+ cacheTTL: 120,
1660
+ cacheEntityQueryName: "sql",
1661
+ cacheEntityExpirationName: "expiration",
1662
+ cacheEntityDataName: "data"
1008
1663
  };
1664
+ this.options = newOptions;
1009
1665
  if (newOptions.logRawSqlQuery) {
1010
1666
  console.debug("Initializing ForgeSQLORM...");
1011
1667
  }
1012
1668
  const proxiedDriver = createForgeDriverProxy(newOptions.hints, newOptions.logRawSqlQuery);
1013
1669
  this.drizzle = patchDbWithSelectAliased(
1014
- drizzle(proxiedDriver, { logger: newOptions.logRawSqlQuery })
1670
+ drizzle(proxiedDriver, { logger: newOptions.logRawSqlQuery }),
1671
+ newOptions
1015
1672
  );
1016
1673
  this.crudOperations = new ForgeSQLCrudOperations(this, newOptions);
1017
1674
  this.fetchOperations = new ForgeSQLSelectOperations(newOptions);
1018
1675
  this.analyzeOperations = new ForgeSQLAnalyseOperation(this);
1676
+ this.cacheOperations = new ForgeSQLCacheOperations(newOptions, this);
1019
1677
  } catch (error) {
1020
1678
  console.error("ForgeSQLORM initialization failed:", error);
1021
1679
  throw error;
1022
1680
  }
1023
1681
  }
1682
+ /**
1683
+ * Executes operations within a cache context that collects cache eviction events.
1684
+ * All clearCache calls within the context are collected and executed in batch at the end.
1685
+ * Queries executed within this context will bypass cache for tables that were marked for clearing.
1686
+ *
1687
+ * This is useful for:
1688
+ * - Batch operations that affect multiple tables
1689
+ * - Transaction-like operations where you want to clear cache only at the end
1690
+ * - Performance optimization by reducing cache clear operations
1691
+ *
1692
+ * @param cacheContext - Function containing operations that may trigger cache evictions
1693
+ * @returns Promise that resolves when all operations and cache clearing are complete
1694
+ *
1695
+ * @example
1696
+ * ```typescript
1697
+ * await forgeSQL.executeWithCacheContext(async () => {
1698
+ * await forgeSQL.modifyWithVersioning().insert(users, userData);
1699
+ * await forgeSQL.modifyWithVersioning().insert(orders, orderData);
1700
+ * // Cache for both users and orders tables will be cleared at the end
1701
+ * });
1702
+ * ```
1703
+ */
1704
+ executeWithCacheContext(cacheContext) {
1705
+ return this.executeWithCacheContextAndReturnValue(cacheContext);
1706
+ }
1707
+ /**
1708
+ * Executes operations within a cache context and returns a value.
1709
+ * All clearCache calls within the context are collected and executed in batch at the end.
1710
+ * Queries executed within this context will bypass cache for tables that were marked for clearing.
1711
+ *
1712
+ * @param cacheContext - Function containing operations that may trigger cache evictions
1713
+ * @returns Promise that resolves to the return value of the cacheContext function
1714
+ *
1715
+ * @example
1716
+ * ```typescript
1717
+ * const result = await forgeSQL.executeWithCacheContextAndReturnValue(async () => {
1718
+ * await forgeSQL.modifyWithVersioning().insert(users, userData);
1719
+ * return await forgeSQL.fetch().executeQueryOnlyOne(selectUserQuery);
1720
+ * });
1721
+ * ```
1722
+ */
1723
+ async executeWithCacheContextAndReturnValue(cacheContext) {
1724
+ return await this.executeWithLocalCacheContextAndReturnValue(
1725
+ async () => await cacheApplicationContext.run({ tables: /* @__PURE__ */ new Set() }, async () => {
1726
+ try {
1727
+ return await cacheContext();
1728
+ } finally {
1729
+ await clearTablesCache(
1730
+ Array.from(cacheApplicationContext.getStore()?.tables ?? []),
1731
+ this.options
1732
+ );
1733
+ }
1734
+ })
1735
+ );
1736
+ }
1737
+ /**
1738
+ * Executes operations within a local cache context and returns a value.
1739
+ * This provides in-memory caching for select queries within a single request scope.
1740
+ *
1741
+ * @param cacheContext - Function containing operations that will benefit from local caching
1742
+ * @returns Promise that resolves to the return value of the cacheContext function
1743
+ */
1744
+ async executeWithLocalCacheContextAndReturnValue(cacheContext) {
1745
+ return await localCacheApplicationContext.run({ cache: {} }, async () => {
1746
+ return await cacheContext();
1747
+ });
1748
+ }
1749
+ /**
1750
+ * Executes operations within a local cache context.
1751
+ * This provides in-memory caching for select queries within a single request scope.
1752
+ *
1753
+ * @param cacheContext - Function containing operations that will benefit from local caching
1754
+ * @returns Promise that resolves when all operations are complete
1755
+ */
1756
+ executeWithLocalContext(cacheContext) {
1757
+ return this.executeWithLocalCacheContextAndReturnValue(cacheContext);
1758
+ }
1759
+ /**
1760
+ * Creates an insert query builder.
1761
+ *
1762
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
1763
+ * For versioned inserts, use `modifyWithVersioning().insert()` or `modifyWithVersioningAndEvictCache().insert()` instead.
1764
+ *
1765
+ * @param table - The table to insert into
1766
+ * @returns Insert query builder (no versioning, no cache management)
1767
+ */
1768
+ insert(table) {
1769
+ return this.drizzle.insertWithCacheContext(table);
1770
+ }
1771
+ /**
1772
+ * Creates an insert query builder that automatically evicts cache after execution.
1773
+ *
1774
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
1775
+ * For versioned inserts, use `modifyWithVersioning().insert()` or `modifyWithVersioningAndEvictCache().insert()` instead.
1776
+ *
1777
+ * @param table - The table to insert into
1778
+ * @returns Insert query builder with automatic cache eviction (no versioning)
1779
+ */
1780
+ insertAndEvictCache(table) {
1781
+ return this.drizzle.insertAndEvictCache(table);
1782
+ }
1783
+ /**
1784
+ * Creates an update query builder that automatically evicts cache after execution.
1785
+ *
1786
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
1787
+ * For versioned updates, use `modifyWithVersioning().updateById()` or `modifyWithVersioningAndEvictCache().updateById()` instead.
1788
+ *
1789
+ * @param table - The table to update
1790
+ * @returns Update query builder with automatic cache eviction (no versioning)
1791
+ */
1792
+ updateAndEvictCache(table) {
1793
+ return this.drizzle.updateAndEvictCache(table);
1794
+ }
1795
+ /**
1796
+ * Creates an update query builder.
1797
+ *
1798
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
1799
+ * For versioned updates, use `modifyWithVersioning().updateById()` or `modifyWithVersioningAndEvictCache().updateById()` instead.
1800
+ *
1801
+ * @param table - The table to update
1802
+ * @returns Update query builder (no versioning, no cache management)
1803
+ */
1804
+ update(table) {
1805
+ return this.drizzle.updateWithCacheContext(table);
1806
+ }
1807
+ /**
1808
+ * Creates a delete query builder.
1809
+ *
1810
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
1811
+ * For versioned deletes, use `modifyWithVersioning().deleteById()` or `modifyWithVersioningAndEvictCache().deleteById()` instead.
1812
+ *
1813
+ * @param table - The table to delete from
1814
+ * @returns Delete query builder (no versioning, no cache management)
1815
+ */
1816
+ delete(table) {
1817
+ return this.drizzle.deleteWithCacheContext(table);
1818
+ }
1819
+ /**
1820
+ * Creates a delete query builder that automatically evicts cache after execution.
1821
+ *
1822
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
1823
+ * For versioned deletes, use `modifyWithVersioning().deleteById()` or `modifyWithVersioningAndEvictCache().deleteById()` instead.
1824
+ *
1825
+ * @param table - The table to delete from
1826
+ * @returns Delete query builder with automatic cache eviction (no versioning)
1827
+ */
1828
+ deleteAndEvictCache(table) {
1829
+ return this.drizzle.deleteAndEvictCache(table);
1830
+ }
1024
1831
  /**
1025
1832
  * Create the modify operations instance.
1026
1833
  * @returns modify operations.
1027
1834
  */
1028
- modify() {
1835
+ modifyWithVersioning() {
1029
1836
  return this.crudOperations;
1030
1837
  }
1031
1838
  /**
@@ -1037,13 +1844,6 @@ class ForgeSQLORMImpl {
1037
1844
  ForgeSQLORMImpl.instance ??= new ForgeSQLORMImpl(options);
1038
1845
  return ForgeSQLORMImpl.instance;
1039
1846
  }
1040
- /**
1041
- * Retrieves the CRUD operations instance.
1042
- * @returns CRUD operations.
1043
- */
1044
- crud() {
1045
- return this.modify();
1046
- }
1047
1847
  /**
1048
1848
  * Retrieves the fetch operations instance.
1049
1849
  * @returns Fetch operations.
@@ -1051,9 +1851,26 @@ class ForgeSQLORMImpl {
1051
1851
  fetch() {
1052
1852
  return this.fetchOperations;
1053
1853
  }
1854
+ /**
1855
+ * Provides query analysis capabilities including EXPLAIN ANALYZE and slow query analysis.
1856
+ * @returns {SchemaAnalyzeForgeSql} Interface for analyzing query performance
1857
+ */
1054
1858
  analyze() {
1055
1859
  return this.analyzeOperations;
1056
1860
  }
1861
+ /**
1862
+ * Provides schema-level SQL operations with optimistic locking/versioning and automatic cache eviction.
1863
+ *
1864
+ * This method returns operations that use `modifyWithVersioning()` internally, providing:
1865
+ * - Optimistic locking support
1866
+ * - Automatic version field management
1867
+ * - Cache eviction after successful operations
1868
+ *
1869
+ * @returns {ForgeSQLCacheOperations} Interface for executing versioned SQL operations with cache management
1870
+ */
1871
+ modifyWithVersioningAndEvictCache() {
1872
+ return this.cacheOperations;
1873
+ }
1057
1874
  /**
1058
1875
  * Returns a Drizzle query builder instance.
1059
1876
  *
@@ -1110,12 +1927,162 @@ class ForgeSQLORMImpl {
1110
1927
  }
1111
1928
  return this.drizzle.selectAliasedDistinct(fields);
1112
1929
  }
1930
+ /**
1931
+ * Creates a cacheable select query with unique field aliases to prevent field name collisions in joins.
1932
+ * This is particularly useful when working with Atlassian Forge SQL, which collapses fields with the same name in joined tables.
1933
+ *
1934
+ * @template TSelection - The type of the selected fields
1935
+ * @param {TSelection} fields - Object containing the fields to select, with table schemas as values
1936
+ * @param {number} cacheTTL - cache ttl optional default is 60 sec.
1937
+ * @returns {MySqlSelectBuilder<TSelection, MySql2PreparedQueryHKT>} A select query builder with unique field aliases
1938
+ * @throws {Error} If fields parameter is empty
1939
+ * @example
1940
+ * ```typescript
1941
+ * await forgeSQL
1942
+ * .selectCacheable({user: users, order: orders},60)
1943
+ * .from(orders)
1944
+ * .innerJoin(users, eq(orders.userId, users.id));
1945
+ * ```
1946
+ */
1947
+ selectCacheable(fields, cacheTTL) {
1948
+ if (!fields) {
1949
+ throw new Error("fields is empty");
1950
+ }
1951
+ return this.drizzle.selectAliasedCacheable(fields, cacheTTL);
1952
+ }
1953
+ /**
1954
+ * Creates a cacheable distinct select query with unique field aliases to prevent field name collisions in joins.
1955
+ * This is particularly useful when working with Atlassian Forge SQL, which collapses fields with the same name in joined tables.
1956
+ *
1957
+ * @template TSelection - The type of the selected fields
1958
+ * @param {TSelection} fields - Object containing the fields to select, with table schemas as values
1959
+ * @param {number} cacheTTL - cache ttl optional default is 60 sec.
1960
+ * @returns {MySqlSelectBuilder<TSelection, MySql2PreparedQueryHKT>} A distinct select query builder with unique field aliases
1961
+ * @throws {Error} If fields parameter is empty
1962
+ * @example
1963
+ * ```typescript
1964
+ * await forgeSQL
1965
+ * .selectDistinctCacheable({user: users, order: orders}, 60)
1966
+ * .from(orders)
1967
+ * .innerJoin(users, eq(orders.userId, users.id));
1968
+ * ```
1969
+ */
1970
+ selectDistinctCacheable(fields, cacheTTL) {
1971
+ if (!fields) {
1972
+ throw new Error("fields is empty");
1973
+ }
1974
+ return this.drizzle.selectAliasedDistinctCacheable(fields, cacheTTL);
1975
+ }
1113
1976
  }
1114
1977
  class ForgeSQLORM {
1115
1978
  ormInstance;
1116
1979
  constructor(options) {
1117
1980
  this.ormInstance = ForgeSQLORMImpl.getInstance(options);
1118
1981
  }
1982
+ selectCacheable(fields, cacheTTL) {
1983
+ return this.ormInstance.selectCacheable(fields, cacheTTL);
1984
+ }
1985
+ selectDistinctCacheable(fields, cacheTTL) {
1986
+ return this.ormInstance.selectDistinctCacheable(fields, cacheTTL);
1987
+ }
1988
+ executeWithCacheContext(cacheContext) {
1989
+ return this.ormInstance.executeWithCacheContext(cacheContext);
1990
+ }
1991
+ executeWithCacheContextAndReturnValue(cacheContext) {
1992
+ return this.ormInstance.executeWithCacheContextAndReturnValue(cacheContext);
1993
+ }
1994
+ /**
1995
+ * Executes operations within a local cache context.
1996
+ * This provides in-memory caching for select queries within a single request scope.
1997
+ *
1998
+ * @param cacheContext - Function containing operations that will benefit from local caching
1999
+ * @returns Promise that resolves when all operations are complete
2000
+ */
2001
+ executeWithLocalContext(cacheContext) {
2002
+ return this.ormInstance.executeWithLocalContext(cacheContext);
2003
+ }
2004
+ /**
2005
+ * Executes operations within a local cache context and returns a value.
2006
+ * This provides in-memory caching for select queries within a single request scope.
2007
+ *
2008
+ * @param cacheContext - Function containing operations that will benefit from local caching
2009
+ * @returns Promise that resolves to the return value of the cacheContext function
2010
+ */
2011
+ executeWithLocalCacheContextAndReturnValue(cacheContext) {
2012
+ return this.ormInstance.executeWithLocalCacheContextAndReturnValue(cacheContext);
2013
+ }
2014
+ /**
2015
+ * Creates an insert query builder.
2016
+ *
2017
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
2018
+ * For versioned inserts, use `modifyWithVersioning().insert()` or `modifyWithVersioningAndEvictCache().insert()` instead.
2019
+ *
2020
+ * @param table - The table to insert into
2021
+ * @returns Insert query builder (no versioning, no cache management)
2022
+ */
2023
+ insert(table) {
2024
+ return this.ormInstance.insert(table);
2025
+ }
2026
+ /**
2027
+ * Creates an insert query builder that automatically evicts cache after execution.
2028
+ *
2029
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
2030
+ * For versioned inserts, use `modifyWithVersioning().insert()` or `modifyWithVersioningAndEvictCache().insert()` instead.
2031
+ *
2032
+ * @param table - The table to insert into
2033
+ * @returns Insert query builder with automatic cache eviction (no versioning)
2034
+ */
2035
+ insertAndEvictCache(table) {
2036
+ return this.ormInstance.insertAndEvictCache(table);
2037
+ }
2038
+ /**
2039
+ * Creates an update query builder.
2040
+ *
2041
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
2042
+ * For versioned updates, use `modifyWithVersioning().updateById()` or `modifyWithVersioningAndEvictCache().updateById()` instead.
2043
+ *
2044
+ * @param table - The table to update
2045
+ * @returns Update query builder (no versioning, no cache management)
2046
+ */
2047
+ update(table) {
2048
+ return this.ormInstance.update(table);
2049
+ }
2050
+ /**
2051
+ * Creates an update query builder that automatically evicts cache after execution.
2052
+ *
2053
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
2054
+ * For versioned updates, use `modifyWithVersioning().updateById()` or `modifyWithVersioningAndEvictCache().updateById()` instead.
2055
+ *
2056
+ * @param table - The table to update
2057
+ * @returns Update query builder with automatic cache eviction (no versioning)
2058
+ */
2059
+ updateAndEvictCache(table) {
2060
+ return this.ormInstance.updateAndEvictCache(table);
2061
+ }
2062
+ /**
2063
+ * Creates a delete query builder.
2064
+ *
2065
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
2066
+ * For versioned deletes, use `modifyWithVersioning().deleteById()` or `modifyWithVersioningAndEvictCache().deleteById()` instead.
2067
+ *
2068
+ * @param table - The table to delete from
2069
+ * @returns Delete query builder (no versioning, no cache management)
2070
+ */
2071
+ delete(table) {
2072
+ return this.ormInstance.delete(table);
2073
+ }
2074
+ /**
2075
+ * Creates a delete query builder that automatically evicts cache after execution.
2076
+ *
2077
+ * ⚠️ **IMPORTANT**: This method does NOT support optimistic locking/versioning.
2078
+ * For versioned deletes, use `modifyWithVersioning().deleteById()` or `modifyWithVersioningAndEvictCache().deleteById()` instead.
2079
+ *
2080
+ * @param table - The table to delete from
2081
+ * @returns Delete query builder with automatic cache eviction (no versioning)
2082
+ */
2083
+ deleteAndEvictCache(table) {
2084
+ return this.ormInstance.deleteAndEvictCache(table);
2085
+ }
1119
2086
  /**
1120
2087
  * Creates a select query with unique field aliases to prevent field name collisions in joins.
1121
2088
  * This is particularly useful when working with Atlassian Forge SQL, which collapses fields with the same name in joined tables.
@@ -1154,19 +2121,12 @@ class ForgeSQLORM {
1154
2121
  selectDistinct(fields) {
1155
2122
  return this.ormInstance.selectDistinct(fields);
1156
2123
  }
1157
- /**
1158
- * Proxies the `crud` method from `ForgeSQLORMImpl`.
1159
- * @returns CRUD operations.
1160
- */
1161
- crud() {
1162
- return this.ormInstance.modify();
1163
- }
1164
2124
  /**
1165
2125
  * Proxies the `modify` method from `ForgeSQLORMImpl`.
1166
2126
  * @returns Modify operations.
1167
2127
  */
1168
- modify() {
1169
- return this.ormInstance.modify();
2128
+ modifyWithVersioning() {
2129
+ return this.ormInstance.modifyWithVersioning();
1170
2130
  }
1171
2131
  /**
1172
2132
  * Proxies the `fetch` method from `ForgeSQLORMImpl`.
@@ -1182,13 +2142,16 @@ class ForgeSQLORM {
1182
2142
  analyze() {
1183
2143
  return this.ormInstance.analyze();
1184
2144
  }
2145
+ /**
2146
+ * Provides schema-level SQL cacheable operations with type safety.
2147
+ * @returns {ForgeSQLCacheOperations} Interface for executing schema-bound SQL queries
2148
+ */
2149
+ modifyWithVersioningAndEvictCache() {
2150
+ return this.ormInstance.modifyWithVersioningAndEvictCache();
2151
+ }
1185
2152
  /**
1186
2153
  * Returns a Drizzle query builder instance.
1187
2154
  *
1188
- * ⚠️ IMPORTANT: This method should be used ONLY for query building purposes.
1189
- * The returned instance should NOT be used for direct database connections or query execution.
1190
- * All database operations should be performed through Forge SQL's executeRawSQL or executeRawUpdateSQL methods.
1191
- *
1192
2155
  * @returns A Drizzle query builder instance for query construction only.
1193
2156
  */
1194
2157
  getDrizzleQueryBuilder() {
@@ -1200,7 +2163,7 @@ const forgeDateTimeString = customType({
1200
2163
  return "datetime";
1201
2164
  },
1202
2165
  toDriver(value) {
1203
- return DateTime.fromJSDate(value).toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
2166
+ return formatDateTime(value, "yyyy-LL-dd' 'HH:mm:ss.SSS");
1204
2167
  },
1205
2168
  fromDriver(value) {
1206
2169
  const format = "yyyy-LL-dd'T'HH:mm:ss.SSS";
@@ -1212,7 +2175,7 @@ const forgeTimestampString = customType({
1212
2175
  return "timestamp";
1213
2176
  },
1214
2177
  toDriver(value) {
1215
- return DateTime.fromJSDate(value).toFormat("yyyy-LL-dd'T'HH:mm:ss.SSS");
2178
+ return formatDateTime(value, "yyyy-LL-dd' 'HH:mm:ss.SSS");
1216
2179
  },
1217
2180
  fromDriver(value) {
1218
2181
  const format = "yyyy-LL-dd'T'HH:mm:ss.SSS";
@@ -1224,7 +2187,7 @@ const forgeDateString = customType({
1224
2187
  return "date";
1225
2188
  },
1226
2189
  toDriver(value) {
1227
- return DateTime.fromJSDate(value).toFormat("yyyy-LL-dd");
2190
+ return formatDateTime(value, "yyyy-LL-dd");
1228
2191
  },
1229
2192
  fromDriver(value) {
1230
2193
  const format = "yyyy-LL-dd";
@@ -1236,7 +2199,7 @@ const forgeTimeString = customType({
1236
2199
  return "time";
1237
2200
  },
1238
2201
  toDriver(value) {
1239
- return DateTime.fromJSDate(value).toFormat("HH:mm:ss.SSS");
2202
+ return formatDateTime(value, "HH:mm:ss.SSS");
1240
2203
  },
1241
2204
  fromDriver(value) {
1242
2205
  return parseDateTime(value, "HH:mm:ss.SSS");
@@ -1353,6 +2316,45 @@ async function dropTableSchemaMigrations() {
1353
2316
  return getHttpResponse(500, errorMessage);
1354
2317
  }
1355
2318
  }
2319
+ const clearCacheSchedulerTrigger = async (options) => {
2320
+ try {
2321
+ const newOptions = options ?? {
2322
+ logRawSqlQuery: false,
2323
+ disableOptimisticLocking: false,
2324
+ cacheTTL: 120,
2325
+ cacheEntityName: "cache",
2326
+ cacheEntityQueryName: "sql",
2327
+ cacheEntityExpirationName: "expiration",
2328
+ cacheEntityDataName: "data"
2329
+ };
2330
+ if (!newOptions.cacheEntityName) {
2331
+ throw new Error("cacheEntityName is not configured");
2332
+ }
2333
+ await clearExpiredCache(newOptions);
2334
+ return {
2335
+ headers: { "Content-Type": ["application/json"] },
2336
+ statusCode: 200,
2337
+ statusText: "OK",
2338
+ body: JSON.stringify({
2339
+ success: true,
2340
+ message: "Cache cleanup completed successfully",
2341
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2342
+ })
2343
+ };
2344
+ } catch (error) {
2345
+ console.error("Error during cache cleanup: ", JSON.stringify(error));
2346
+ return {
2347
+ headers: { "Content-Type": ["application/json"] },
2348
+ statusCode: 500,
2349
+ statusText: "Internal Server Error",
2350
+ body: JSON.stringify({
2351
+ success: false,
2352
+ error: error instanceof Error ? error.message : "Unknown error during cache cleanup",
2353
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2354
+ })
2355
+ };
2356
+ }
2357
+ };
1356
2358
  const getHttpResponse = (statusCode, body) => {
1357
2359
  let statusText = "";
1358
2360
  if (statusCode === 200) {
@@ -1372,6 +2374,7 @@ export {
1372
2374
  ForgeSQLSelectOperations,
1373
2375
  applyFromDriverTransform,
1374
2376
  applySchemaMigrations,
2377
+ clearCacheSchedulerTrigger,
1375
2378
  ForgeSQLORM as default,
1376
2379
  dropSchemaMigrations,
1377
2380
  dropTableSchemaMigrations,
@@ -1382,6 +2385,7 @@ export {
1382
2385
  forgeSystemTables,
1383
2386
  forgeTimeString,
1384
2387
  forgeTimestampString,
2388
+ formatDateTime,
1385
2389
  formatLimitOffset,
1386
2390
  generateDropTableStatements,
1387
2391
  getHttpResponse,