@memberjunction/generic-database-provider 5.8.0 → 5.10.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.
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * @module @memberjunction/generic-database-provider
17
17
  */
18
- import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, Metadata, QueryInfo, LogError, LogStatus, StripStopWords, } from '@memberjunction/core';
18
+ import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, QueryInfo, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, } from '@memberjunction/core';
19
19
  import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
20
20
  import { v4 as uuidv4 } from 'uuid';
21
21
  import { SqlLoggingSessionImpl } from './SqlLogger.js';
@@ -32,6 +32,44 @@ import { EncryptionEngine } from '@memberjunction/encryption';
32
32
  * to inherit these shared behaviors.
33
33
  */
34
34
  export class GenericDatabaseProvider extends DatabaseProviderBase {
35
+ /**
36
+ * Returns the active local storage provider, lazily creating an
37
+ * {@link InMemoryLocalStorageProvider} if none has been set.
38
+ *
39
+ * This fulfills the abstract `LocalStorageProvider` requirement from
40
+ * {@link ProviderBase} and is shared by all database providers
41
+ * (SQL Server, PostgreSQL, and any future platforms).
42
+ */
43
+ get LocalStorageProvider() {
44
+ if (!this._localStorageProvider) {
45
+ this._localStorageProvider = new InMemoryLocalStorageProvider();
46
+ }
47
+ return this._localStorageProvider;
48
+ }
49
+ /**
50
+ * Replaces the active local storage provider at runtime.
51
+ *
52
+ * Use this to swap from the default in-memory provider to a Redis-backed
53
+ * provider (or any other {@link ILocalStorageProvider}) after reading
54
+ * application configuration.
55
+ *
56
+ * @param provider - The new storage provider to use for all caching operations.
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
61
+ *
62
+ * // During server startup, after config is loaded:
63
+ * const redis = new RedisLocalStorageProvider({
64
+ * url: process.env.REDIS_URL,
65
+ * defaultTTLSeconds: 300
66
+ * });
67
+ * (Metadata.Provider as GenericDatabaseProvider).SetLocalStorageProvider(redis);
68
+ * ```
69
+ */
70
+ SetLocalStorageProvider(provider) {
71
+ this._localStorageProvider = provider;
72
+ }
35
73
  /**************************************************************************/
36
74
  // SQL Logging — Session Management & Statement Logging
37
75
  /**************************************************************************/
@@ -948,9 +986,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
948
986
  if (!user) {
949
987
  return { success: false, results: [], errorMessage: 'No user context available' };
950
988
  }
951
- // Separate items that need cache check from those that don't
952
- const itemsNeedingCacheCheck = [];
989
+ // Separate items by type: no cache check, needs validation
953
990
  const itemsWithoutCacheCheck = [];
991
+ const itemsNeedingValidation = [];
954
992
  const errorResults = [];
955
993
  for (let i = 0; i < params.length; i++) {
956
994
  const item = params[i];
@@ -965,49 +1003,110 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
965
1003
  }
966
1004
  try {
967
1005
  this.CheckUserReadPermissions(entityInfo.Name, user);
968
- const whereSQL = await this.buildWhereClauseForCacheCheck(item.params, entityInfo, user);
969
- itemsNeedingCacheCheck.push({ index: i, item, entityInfo, whereSQL });
1006
+ itemsNeedingValidation.push({ index: i, item, entityInfo });
970
1007
  }
971
1008
  catch (e) {
972
1009
  errorResults.push({ viewIndex: i, status: 'error', errorMessage: e instanceof Error ? e.message : String(e) });
973
1010
  }
974
1011
  }
975
- // Execute batched cache status check
976
- const cacheStatusResults = await this.getBatchedServerCacheStatus(itemsNeedingCacheCheck, contextUser);
977
- // Determine which items are current vs stale, and whether they support differential updates
978
- const differentialItems = [];
979
- const staleItemsNoTracking = [];
1012
+ // Phase 1: Check server's LocalCacheManager first (zero DB hits)
980
1013
  const currentResults = [];
981
- for (const { index, item, entityInfo, whereSQL } of itemsNeedingCacheCheck) {
982
- const serverStatus = cacheStatusResults.get(index);
983
- if (!serverStatus || !serverStatus.success) {
984
- errorResults.push({ viewIndex: index, status: 'error', errorMessage: serverStatus?.errorMessage || 'Failed to get cache status' });
985
- continue;
1014
+ const serverCacheStaleItems = [];
1015
+ const serverCacheMissItems = [];
1016
+ for (const { index, item, entityInfo } of itemsNeedingValidation) {
1017
+ const entityLabel = item.params.EntityName || 'unknown';
1018
+ const resolved = await this.resolveFromServerCache(item, index, entityLabel);
1019
+ if (resolved) {
1020
+ if (resolved.status === 'current') {
1021
+ currentResults.push(resolved.result);
1022
+ }
1023
+ else {
1024
+ serverCacheStaleItems.push({ index, item, entityInfo, serverCached: resolved.serverCached });
1025
+ }
986
1026
  }
987
- if (this.isCacheCurrent(item.cacheStatus, serverStatus)) {
988
- currentResults.push({ viewIndex: index, status: 'current' });
1027
+ else {
1028
+ serverCacheMissItems.push({ index, item, entityInfo });
989
1029
  }
990
- else if (entityInfo.TrackRecordChanges) {
991
- differentialItems.push({
992
- index, params: item.params, entityInfo, whereSQL,
993
- clientMaxUpdatedAt: item.cacheStatus.maxUpdatedAt,
994
- clientRowCount: item.cacheStatus.rowCount,
995
- serverStatus,
996
- });
1030
+ }
1031
+ // Phase 2: For server cache misses, fall back to DB validation
1032
+ const differentialItems = [];
1033
+ const staleItemsNoTracking = [];
1034
+ if (serverCacheMissItems.length > 0) {
1035
+ // Build WHERE clauses and run batched DB status check only for cache misses
1036
+ const itemsForDBCheck = [];
1037
+ for (const { index, item, entityInfo } of serverCacheMissItems) {
1038
+ try {
1039
+ const whereSQL = await this.buildWhereClauseForCacheCheck(item.params, entityInfo, user);
1040
+ itemsForDBCheck.push({ index, item, entityInfo, whereSQL });
1041
+ }
1042
+ catch (e) {
1043
+ errorResults.push({ viewIndex: index, status: 'error', errorMessage: e instanceof Error ? e.message : String(e) });
1044
+ }
997
1045
  }
998
- else {
999
- staleItemsNoTracking.push({ index, params: item.params });
1046
+ const cacheStatusResults = await this.getBatchedServerCacheStatus(itemsForDBCheck, contextUser);
1047
+ for (const { index, item, entityInfo, whereSQL } of itemsForDBCheck) {
1048
+ const serverStatus = cacheStatusResults.get(index);
1049
+ if (!serverStatus || !serverStatus.success) {
1050
+ errorResults.push({ viewIndex: index, status: 'error', errorMessage: serverStatus?.errorMessage || 'Failed to get cache status' });
1051
+ continue;
1052
+ }
1053
+ const entityLabel = item.params.EntityName || 'unknown';
1054
+ if (this.isCacheCurrent(item.cacheStatus, serverStatus)) {
1055
+ LogStatusEx({ message: ` ✅ [SmartCache CURRENT] "${entityLabel}" — client cache matches DB (server cache miss)`, verboseOnly: true });
1056
+ currentResults.push({ viewIndex: index, status: 'current' });
1057
+ }
1058
+ else if (entityInfo.TrackRecordChanges) {
1059
+ LogStatusEx({ message: ` 🔄 [SmartCache DIFFERENTIAL] "${entityLabel}" — sending only changed rows (from DB)`, verboseOnly: true });
1060
+ differentialItems.push({
1061
+ index, params: item.params, entityInfo, whereSQL,
1062
+ clientMaxUpdatedAt: item.cacheStatus.maxUpdatedAt,
1063
+ clientRowCount: item.cacheStatus.rowCount,
1064
+ serverStatus,
1065
+ });
1066
+ }
1067
+ else {
1068
+ LogStatusEx({ message: ` 🔍 [SmartCache STALE] "${entityLabel}" — full refresh from DB (no change tracking)`, verboseOnly: true });
1069
+ staleItemsNoTracking.push({ index, params: item.params });
1070
+ }
1071
+ }
1072
+ }
1073
+ // Phase 3: For items without cacheStatus (client has nothing), check server cache before hitting DB
1074
+ const noCacheStatusServedFromCache = [];
1075
+ const noCacheStatusNeedsDB = [];
1076
+ for (const entry of itemsWithoutCacheCheck) {
1077
+ if (LocalCacheManager.Instance.IsInitialized) {
1078
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(entry.item.params, this.InstanceConnectionString);
1079
+ const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1080
+ if (cached) {
1081
+ const entityLabel = entry.item.params.EntityName || 'unknown';
1082
+ LogStatusEx({ message: ` 📦 [SmartCache SERVE-FROM-CACHE] "${entityLabel}" — client has no cache, serving ${cached.rowCount} rows from server cache, no DB hit`, verboseOnly: true });
1083
+ noCacheStatusServedFromCache.push({ index: entry.index, serverCached: cached });
1084
+ continue;
1085
+ }
1000
1086
  }
1087
+ noCacheStatusNeedsDB.push(entry);
1001
1088
  }
1002
- // Run queries in parallel
1089
+ // Phase 4: Run queries in parallel for items needing data
1003
1090
  const queryPromises = [
1004
- ...itemsWithoutCacheCheck.map(({ index, item }) => this.runFullQueryAndReturn(item.params, index, contextUser)),
1005
- ...staleItemsNoTracking.map(({ index, params: viewParams }) => this.runFullQueryAndReturn(viewParams, index, contextUser)),
1091
+ // Items without cache check — served from server cache
1092
+ ...noCacheStatusServedFromCache.map(({ index, serverCached }) => this.serveFromServerCache(index, serverCached)),
1093
+ // Items without cache check — server cache miss, must hit DB
1094
+ ...noCacheStatusNeedsDB.map(({ index, item }) => this.runFullQueryAndCacheResult(item.params, index, contextUser)),
1095
+ // Server cache stale — serve from server's cached data (zero DB)
1096
+ ...serverCacheStaleItems.map(({ index, serverCached }) => this.serveFromServerCache(index, serverCached)),
1097
+ // DB-validated stale items (no change tracking)
1098
+ ...staleItemsNoTracking.map(({ index, params: viewParams }) => this.runFullQueryAndCacheResult(viewParams, index, contextUser)),
1099
+ // DB-validated differential items
1006
1100
  ...differentialItems.map(({ index, params: viewParams, entityInfo, whereSQL, clientMaxUpdatedAt, clientRowCount, serverStatus }) => this.runDifferentialQueryAndReturn(viewParams, entityInfo, clientMaxUpdatedAt, clientRowCount, serverStatus, whereSQL, index, contextUser)),
1007
1101
  ];
1008
1102
  const fullQueryResults = await Promise.all(queryPromises);
1009
1103
  const allResults = [...errorResults, ...currentResults, ...fullQueryResults];
1010
1104
  allResults.sort((a, b) => a.viewIndex - b.viewIndex);
1105
+ const entities = params.map(p => p.params.EntityName || 'unknown').join(', ');
1106
+ const totalServerCacheHits = (itemsNeedingValidation.length - serverCacheMissItems.length) + noCacheStatusServedFromCache.length;
1107
+ const totalChecked = itemsNeedingValidation.length + itemsWithoutCacheCheck.length;
1108
+ const totalDBQueries = noCacheStatusNeedsDB.length + staleItemsNoTracking.length + differentialItems.length;
1109
+ LogStatusEx({ message: ` 📊 [SmartCache] Batch [${entities}] — ${currentResults.length} current, ${serverCacheStaleItems.length + noCacheStatusServedFromCache.length} served-from-cache, ${differentialItems.length} differential, ${totalDBQueries} full-query, ${errorResults.length} errors (server cache: ${totalServerCacheHits}/${totalChecked} hits)`, verboseOnly: true });
1011
1110
  return { success: true, results: allResults };
1012
1111
  }
1013
1112
  catch (e) {
@@ -1090,6 +1189,53 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1090
1189
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1091
1190
  return { viewIndex, status: 'stale', results: result.Results, maxUpdatedAt, rowCount: result.Results.length };
1092
1191
  }
1192
+ /**
1193
+ * Runs a full query and stores the result in the server's LocalCacheManager.
1194
+ * Used by RunViewsWithCacheCheck to populate the server cache for future requests.
1195
+ */
1196
+ async runFullQueryAndCacheResult(params, viewIndex, contextUser) {
1197
+ const result = await this.runFullQueryAndReturn(params, viewIndex, contextUser);
1198
+ // Cache the result so subsequent RunViewsWithCacheCheck calls can skip DB
1199
+ if (result.status !== 'error' && result.results && LocalCacheManager.Instance.IsInitialized) {
1200
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
1201
+ const maxUpdatedAt = result.maxUpdatedAt || new Date().toISOString();
1202
+ await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.results, maxUpdatedAt);
1203
+ }
1204
+ return result;
1205
+ }
1206
+ /**
1207
+ * Checks the server's LocalCacheManager for cached data matching the client's request.
1208
+ * Returns the resolution if found (either 'current' or server-cached data to serve),
1209
+ * or null if the server cache doesn't have this data.
1210
+ */
1211
+ async resolveFromServerCache(item, index, entityLabel) {
1212
+ if (!LocalCacheManager.Instance.IsInitialized)
1213
+ return null;
1214
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(item.params, this.InstanceConnectionString);
1215
+ const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1216
+ if (!cached)
1217
+ return null;
1218
+ const serverStatus = { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount };
1219
+ if (this.isCacheCurrent(item.cacheStatus, serverStatus)) {
1220
+ LogStatusEx({ message: ` ✅ [SmartCache CURRENT] "${entityLabel}" — client cache matches server cache, no DB hit`, verboseOnly: true });
1221
+ return { status: 'current', result: { viewIndex: index, status: 'current' } };
1222
+ }
1223
+ // Server has newer data than client — we can serve it directly from cache
1224
+ LogStatusEx({ message: ` 📦 [SmartCache SERVE-FROM-CACHE] "${entityLabel}" — serving ${cached.rowCount} rows from server cache, no DB hit`, verboseOnly: true });
1225
+ return { status: 'stale', serverCached: cached };
1226
+ }
1227
+ /**
1228
+ * Packages server-cached data as a RunViewWithCacheCheckResult for return to the client.
1229
+ */
1230
+ async serveFromServerCache(viewIndex, serverCached) {
1231
+ return {
1232
+ viewIndex,
1233
+ status: 'stale',
1234
+ results: serverCached.results,
1235
+ maxUpdatedAt: serverCached.maxUpdatedAt,
1236
+ rowCount: serverCached.rowCount,
1237
+ };
1238
+ }
1093
1239
  /**
1094
1240
  * Runs a differential query and returns only changes since the client's cached state.
1095
1241
  * Includes updated/created rows and deleted record IDs.
@@ -1312,6 +1458,25 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1312
1458
  }
1313
1459
  return null;
1314
1460
  }
1461
+ /**
1462
+ * Validates that a query can be executed by the given user. Checks both permissions
1463
+ * and approval status. Permission failures throw an error. Non-approved status
1464
+ * emits a console warning but allows execution to proceed, enabling query testing
1465
+ * before formal approval.
1466
+ *
1467
+ * @param query - The resolved QueryInfo to validate
1468
+ * @param contextUser - The user attempting to execute the query
1469
+ * @throws Error if the user does not have permission to run the query
1470
+ */
1471
+ ValidateQueryForExecution(query, contextUser) {
1472
+ const user = contextUser || this.CurrentUser;
1473
+ if (user && !query.UserHasRunPermissions(user)) {
1474
+ throw new Error(`User does not have permission to run query '${query.Name}' (ID: ${query.ID})`);
1475
+ }
1476
+ if (query.Status !== 'Approved') {
1477
+ LogStatus(`WARNING: Executing query '${query.Name}' (ID: ${query.ID}) with status '${query.Status}'. Query has not been approved.`);
1478
+ }
1479
+ }
1315
1480
  /**
1316
1481
  * Creates a fresh QueryInfo from a MJQueryEntity and patches the ProviderBase cache.
1317
1482
  */
@@ -1414,7 +1579,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1414
1579
  const quotes = pk.NeedsQuotes ? "'" : '';
1415
1580
  return `${this.QuoteIdentifier(pk.CodeName)}=${quotes}${val.Value}${quotes}`;
1416
1581
  }).join(' AND ');
1417
- const sql = `SELECT * FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${where}`;
1582
+ // Append Read RLS filter if user is not exempt
1583
+ let fullWhere = where;
1584
+ if (user && !entityInfo.UserExemptFromRowLevelSecurity(user, EntityPermissionType.Read)) {
1585
+ const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Read, '');
1586
+ if (rlsWhereClause && rlsWhereClause.length > 0) {
1587
+ fullWhere = `${where} AND (${rlsWhereClause})`;
1588
+ }
1589
+ }
1590
+ const sql = `SELECT * FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${fullWhere}`;
1418
1591
  const rawData = await this.ExecuteSQL(sql, undefined, undefined, user);
1419
1592
  const d = await this.PostProcessRows(rawData, entityInfo, user);
1420
1593
  if (d && d.length > 0) {
@@ -1459,6 +1632,76 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1459
1632
  return null;
1460
1633
  }
1461
1634
  /**************************************************************************/
1635
+ // Row-Level Security Checks
1636
+ /**************************************************************************/
1637
+ /**
1638
+ * Checks whether an existing record passes the RLS filter for a given permission type.
1639
+ * Executes: SELECT COUNT(*) AS cnt FROM view WHERE PK=value AND (RLS filter)
1640
+ * Returns true if the record matches (cnt > 0), false otherwise.
1641
+ */
1642
+ async CheckRecordRLS(entity, user, type) {
1643
+ const entityInfo = entity.EntityInfo;
1644
+ if (entityInfo.UserExemptFromRowLevelSecurity(user, type)) {
1645
+ return true;
1646
+ }
1647
+ const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, type, '');
1648
+ if (!rlsWhereClause || rlsWhereClause.length === 0) {
1649
+ return true;
1650
+ }
1651
+ const pkWhere = entity.PrimaryKeys.map(pk => {
1652
+ const fieldInfo = entityInfo.Fields.find(f => f.Name === pk.Name);
1653
+ const quotes = fieldInfo?.NeedsQuotes ? "'" : '';
1654
+ return `${this.QuoteIdentifier(pk.Name)}=${quotes}${pk.Value}${quotes}`;
1655
+ }).join(' AND ');
1656
+ const sql = `SELECT COUNT(*) AS cnt FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${pkWhere} AND (${rlsWhereClause})`;
1657
+ const result = await this.ExecuteSQL(sql, undefined, undefined, user);
1658
+ return result && result.length > 0 && Number(result[0]['cnt']) > 0;
1659
+ }
1660
+ /**
1661
+ * Checks whether a new record's field values pass the Create RLS filter.
1662
+ * Builds a synthetic single-row subquery from entity field values, then tests the RLS filter against it.
1663
+ */
1664
+ async CheckCreateRLS(entity, user) {
1665
+ const entityInfo = entity.EntityInfo;
1666
+ if (entityInfo.UserExemptFromRowLevelSecurity(user, EntityPermissionType.Create)) {
1667
+ return true;
1668
+ }
1669
+ const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Create, '');
1670
+ if (!rlsWhereClause || rlsWhereClause.length === 0) {
1671
+ return true;
1672
+ }
1673
+ const projections = this.BuildCreateRLSProjections(entity, entityInfo);
1674
+ const sql = `SELECT CASE WHEN (${rlsWhereClause}) THEN 1 ELSE 0 END AS pass FROM (SELECT ${projections}) AS newrow`;
1675
+ const result = await this.ExecuteSQL(sql, undefined, undefined, user);
1676
+ return result && result.length > 0 && Number(result[0]['pass']) === 1;
1677
+ }
1678
+ /**
1679
+ * Builds field projections for the Create RLS synthetic row subquery.
1680
+ * Only includes non-virtual fields that have non-null values.
1681
+ */
1682
+ BuildCreateRLSProjections(entity, entityInfo) {
1683
+ const parts = [];
1684
+ for (const field of entityInfo.Fields) {
1685
+ if (field.IsVirtual)
1686
+ continue;
1687
+ const val = entity.Get(field.Name);
1688
+ if (val == null)
1689
+ continue;
1690
+ let sqlVal;
1691
+ if (typeof val === 'boolean') {
1692
+ sqlVal = val ? '1' : '0';
1693
+ }
1694
+ else if (field.NeedsQuotes) {
1695
+ sqlVal = `'${String(val).replace(/'/g, "''")}'`;
1696
+ }
1697
+ else {
1698
+ sqlVal = String(val);
1699
+ }
1700
+ parts.push(`${sqlVal} AS ${this.QuoteIdentifier(field.Name)}`);
1701
+ }
1702
+ return parts.join(', ');
1703
+ }
1704
+ /**************************************************************************/
1462
1705
  // GetDatasetByName — Shared Implementation
1463
1706
  /**************************************************************************/
1464
1707
  /**
@@ -1499,10 +1742,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1499
1742
  Results: [],
1500
1743
  };
1501
1744
  }
1502
- // Phase 1: Build all item SQL queries
1503
- const queries = [];
1504
- const validItems = [];
1745
+ // Phase 1: Check LocalCacheManager for each item, build SQL only for cache misses
1746
+ const overallStart = performance.now();
1747
+ const cache = LocalCacheManager.Instance;
1748
+ const cacheAvailable = cache.IsInitialized && this.TrustLocalCacheCompletely;
1505
1749
  const errorResults = [];
1750
+ const cachedResults = [];
1751
+ const uncachedQueries = [];
1752
+ const uncachedItems = [];
1753
+ // Track fingerprints for uncached items so we can write-through after SQL
1754
+ const uncachedFingerprints = [];
1755
+ let cacheHitCount = 0;
1756
+ let cacheMissCount = 0;
1506
1757
  for (const item of items) {
1507
1758
  const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
1508
1759
  const entityBaseView = String(item['EntityBaseView']);
@@ -1510,12 +1761,37 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1510
1761
  const entityName = String(item['Entity']);
1511
1762
  const entityID = String(item['EntityID']);
1512
1763
  const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
1513
- let filterSQL = '';
1764
+ // Build effective filter (WhereClause + optional runtime ItemFilter)
1765
+ let effectiveFilter = whereClause;
1514
1766
  if (itemFilters && itemFilters.length > 0) {
1515
1767
  const filter = itemFilters.find(f => f.ItemCode === code);
1516
- if (filter)
1517
- filterSQL = (whereClause ? ' AND ' : ' WHERE ') + '(' + filter.Filter + ')';
1768
+ if (filter) {
1769
+ effectiveFilter = whereClause
1770
+ ? `${whereClause} AND (${filter.Filter})`
1771
+ : filter.Filter;
1772
+ }
1518
1773
  }
1774
+ // Try cache first
1775
+ if (cacheAvailable) {
1776
+ const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
1777
+ const cached = await cache.GetRunViewResult(fingerprint);
1778
+ if (cached) {
1779
+ cacheHitCount++;
1780
+ const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
1781
+ const latestUpdateDate = this.computeLatestUpdateDate(cached.results, dateFieldToCheck, item);
1782
+ cachedResults.push({
1783
+ EntityID: entityID,
1784
+ EntityName: entityName,
1785
+ Code: code,
1786
+ Results: cached.results,
1787
+ LatestUpdateDate: latestUpdateDate,
1788
+ Success: true,
1789
+ });
1790
+ continue; // Skip SQL for this item
1791
+ }
1792
+ }
1793
+ // Cache miss — validate columns and build SQL
1794
+ cacheMissCount++;
1519
1795
  const columns = provider.getColumnsForDatasetItem(item, datasetName);
1520
1796
  if (!columns) {
1521
1797
  errorResults.push({
@@ -1529,22 +1805,30 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1529
1805
  });
1530
1806
  continue;
1531
1807
  }
1532
- queries.push(`SELECT ${columns} FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)} ${whereClause ? 'WHERE ' + whereClause : ''}${filterSQL}`);
1533
- validItems.push(item);
1808
+ const filterSQL = effectiveFilter ? 'WHERE ' + effectiveFilter : '';
1809
+ uncachedQueries.push(`SELECT ${columns} FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)} ${filterSQL}`);
1810
+ uncachedItems.push(item);
1811
+ // Store fingerprint for write-through caching after SQL
1812
+ const fp = cacheAvailable
1813
+ ? cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString)
1814
+ : '';
1815
+ uncachedFingerprints.push(fp);
1534
1816
  }
1535
- // Phase 2: Execute all queries via ExecuteSQLBatch (true batch on SQL Server, parallel on PG)
1817
+ // Phase 2: Execute SQL only for cache misses
1536
1818
  let batchResults = [];
1537
- try {
1538
- batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
1539
- }
1540
- catch (err) {
1541
- LogError(`GetDatasetByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
1542
- // Fall through with empty results
1819
+ if (uncachedQueries.length > 0) {
1820
+ try {
1821
+ batchResults = await provider.ExecuteSQLBatch(uncachedQueries, undefined, undefined, contextUser);
1822
+ }
1823
+ catch (err) {
1824
+ LogError(`GetDatasetByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
1825
+ // Fall through with empty results
1826
+ }
1543
1827
  }
1544
- // Phase 3: Process results per item
1545
- const results = [...errorResults];
1546
- for (let i = 0; i < validItems.length; i++) {
1547
- const item = validItems[i];
1828
+ // Phase 3: Process SQL results and write-through to cache
1829
+ const sqlResults = [];
1830
+ for (let i = 0; i < uncachedItems.length; i++) {
1831
+ const item = uncachedItems[i];
1548
1832
  const entityName = String(item['Entity']);
1549
1833
  const entityID = String(item['EntityID']);
1550
1834
  const code = String(item['Code']);
@@ -1557,20 +1841,16 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1557
1841
  itemData = await provider.PostProcessRows(itemData, entityInfo, contextUser);
1558
1842
  }
1559
1843
  }
1560
- const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
1561
- const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
1562
- const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
1563
- let latestUpdateDate = new Date(1900, 1, 1);
1564
- if (itemData && itemData.length > 0) {
1565
- for (const data of itemData) {
1566
- if (data[dateFieldToCheck] && new Date(String(data[dateFieldToCheck])) > latestUpdateDate) {
1567
- latestUpdateDate = new Date(String(data[dateFieldToCheck]));
1568
- }
1569
- }
1844
+ const latestUpdateDate = this.computeLatestUpdateDate(itemData, dateFieldToCheck, item);
1845
+ // Write-through: cache this result for future requests (including empty results)
1846
+ if (cacheAvailable && uncachedFingerprints[i]) {
1847
+ const maxUpdatedAt = itemData.length > 0
1848
+ ? this.extractMaxUpdatedAtFromRows(itemData, dateFieldToCheck)
1849
+ : new Date(0).toISOString();
1850
+ const syntheticParams = { EntityName: entityName };
1851
+ await cache.SetRunViewResult(uncachedFingerprints[i], syntheticParams, itemData, maxUpdatedAt);
1570
1852
  }
1571
- if (datasetMaxUpdatedAt > latestUpdateDate)
1572
- latestUpdateDate = datasetMaxUpdatedAt;
1573
- results.push({
1853
+ sqlResults.push({
1574
1854
  EntityID: entityID,
1575
1855
  EntityName: entityName,
1576
1856
  Code: code,
@@ -1579,6 +1859,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1579
1859
  Success: itemData !== null && itemData !== undefined,
1580
1860
  });
1581
1861
  }
1862
+ // Merge results: errors + cached + SQL (maintain original item order via code matching)
1863
+ const results = [];
1864
+ for (const item of items) {
1865
+ const code = String(item['Code']);
1866
+ const found = errorResults.find(r => r.Code === code)
1867
+ ?? cachedResults.find(r => r.Code === code)
1868
+ ?? sqlResults.find(r => r.Code === code);
1869
+ if (found)
1870
+ results.push(found);
1871
+ }
1872
+ const elapsedMs = (performance.now() - overallStart).toFixed(1);
1873
+ LogStatusEx({
1874
+ message: `📊 [Dataset] GetDatasetByName("${datasetName}"): ${cacheHitCount} cache hits, ${cacheMissCount} cache misses, ${errorResults.length} errors — ${elapsedMs}ms`,
1875
+ verboseOnly: true
1876
+ });
1582
1877
  // Aggregate results
1583
1878
  const bSuccess = results.every(result => result.Success);
1584
1879
  const latestUpdateDate = results.reduce((acc, result) => {
@@ -1606,9 +1901,12 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1606
1901
  * latest update date. Uses ExecuteSQLBatch for per-item status queries.
1607
1902
  */
1608
1903
  async GetDatasetStatusByName(datasetName, itemFilters, contextUser, providerToUse) {
1904
+ const overallStart = performance.now();
1609
1905
  const provider = (providerToUse ?? this);
1610
1906
  const schema = provider.MJCoreSchemaName;
1611
- // Fetch dataset items metadata
1907
+ const cache = LocalCacheManager.Instance;
1908
+ const cacheAvailable = cache.IsInitialized && this.TrustLocalCacheCompletely;
1909
+ // Fetch dataset items metadata (lightweight — just the dataset definition, not entity data)
1612
1910
  const sSQL = `SELECT di.*, ` +
1613
1911
  `e.${provider.QuoteIdentifier('BaseView')} AS ${provider.QuoteIdentifier('EntityBaseView')}, ` +
1614
1912
  `e.${provider.QuoteIdentifier('SchemaName')} AS ${provider.QuoteIdentifier('EntitySchemaName')}, ` +
@@ -1629,61 +1927,117 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1629
1927
  EntityUpdateDates: [],
1630
1928
  };
1631
1929
  }
1632
- // Build per-item status queries
1633
- const queries = [];
1634
- const itemMeta = [];
1930
+ // Phase 1: Try to derive status from cached data for each item
1931
+ const updateDates = [];
1932
+ let overallLatestDate = new Date(1900, 1, 1);
1933
+ let cacheHitCount = 0;
1934
+ let cacheMissCount = 0;
1935
+ // Collect items that need SQL fallback
1936
+ const uncachedItems = [];
1937
+ const uncachedItemMeta = [];
1635
1938
  for (const item of items) {
1636
- const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
1637
- const entityBaseView = String(item['EntityBaseView']);
1638
1939
  const entityID = String(item['EntityID']);
1639
1940
  const entityName = String(item['Entity']);
1941
+ const code = String(item['Code']);
1640
1942
  const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
1641
- let filterSQL = '';
1943
+ const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
1944
+ // Build effective filter for fingerprint
1945
+ let effectiveFilter = whereClause;
1642
1946
  if (itemFilters && itemFilters.length > 0) {
1643
- const filter = itemFilters.find(f => f.ItemCode === String(item['Code']));
1644
- if (filter)
1645
- filterSQL = ' WHERE ' + filter.Filter;
1947
+ const filter = itemFilters.find(f => f.ItemCode === code);
1948
+ if (filter) {
1949
+ effectiveFilter = whereClause
1950
+ ? `${whereClause} AND (${filter.Filter})`
1951
+ : filter.Filter;
1952
+ }
1646
1953
  }
1647
1954
  const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
1648
1955
  const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
1649
- const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime())).toISOString();
1650
- const statusSQL = `SELECT ` +
1651
- `CASE ` +
1652
- `WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
1653
- `ELSE '${datasetMaxUpdatedAt}' ` +
1654
- `END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
1655
- `COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
1656
- `FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
1657
- queries.push(statusSQL);
1658
- itemMeta.push({ entityID, entityName, datasetMaxUpdatedAt });
1659
- }
1660
- // Execute all status queries via ExecuteSQLBatch
1661
- let batchResults = [];
1662
- try {
1663
- batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
1664
- }
1665
- catch (err) {
1666
- LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
1956
+ const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
1957
+ // Try to derive status from cached data
1958
+ if (cacheAvailable) {
1959
+ const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
1960
+ const cached = await cache.GetRunViewResult(fingerprint);
1961
+ if (cached) {
1962
+ cacheHitCount++;
1963
+ // Derive MAX(dateField) and COUNT(*) directly from cached rows
1964
+ let maxDateFromRows = new Date(1900, 1, 1);
1965
+ for (const row of cached.results) {
1966
+ const record = row;
1967
+ if (record[dateFieldToCheck]) {
1968
+ const d = new Date(String(record[dateFieldToCheck]));
1969
+ if (d > maxDateFromRows)
1970
+ maxDateFromRows = d;
1971
+ }
1972
+ }
1973
+ const updateDate = maxDateFromRows > datasetMaxUpdatedAt ? maxDateFromRows : datasetMaxUpdatedAt;
1974
+ updateDates.push({
1975
+ EntityID: entityID,
1976
+ EntityName: entityName,
1977
+ RowCount: cached.results.length,
1978
+ UpdateDate: updateDate,
1979
+ });
1980
+ if (updateDate > overallLatestDate)
1981
+ overallLatestDate = updateDate;
1982
+ continue; // No SQL needed for this item
1983
+ }
1984
+ }
1985
+ // Cache miss — need SQL fallback
1986
+ cacheMissCount++;
1987
+ uncachedItems.push(item);
1988
+ uncachedItemMeta.push({ entityID, entityName, datasetMaxUpdatedAt: datasetMaxUpdatedAt.toISOString() });
1667
1989
  }
1668
- // Process results
1669
- const updateDates = [];
1670
- let overallLatestDate = new Date(1900, 1, 1);
1671
- for (let i = 0; i < itemMeta.length; i++) {
1672
- const meta = itemMeta[i];
1673
- const statusRows = batchResults[i];
1674
- if (statusRows && statusRows.length > 0) {
1675
- const updateDate = new Date(String(statusRows[0]['UpdateDate']));
1676
- updateDates.push({
1677
- EntityID: meta.entityID,
1678
- EntityName: meta.entityName,
1679
- RowCount: Number(statusRows[0]['TheRowCount']),
1680
- UpdateDate: updateDate,
1681
- });
1682
- if (updateDate > overallLatestDate) {
1683
- overallLatestDate = updateDate;
1990
+ // Phase 2: Execute SQL only for cache misses
1991
+ if (uncachedItems.length > 0) {
1992
+ const queries = uncachedItems.map((item, idx) => {
1993
+ const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
1994
+ const entityBaseView = String(item['EntityBaseView']);
1995
+ const code = String(item['Code']);
1996
+ const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
1997
+ const meta = uncachedItemMeta[idx];
1998
+ let filterSQL = '';
1999
+ if (itemFilters && itemFilters.length > 0) {
2000
+ const filter = itemFilters.find(f => f.ItemCode === code);
2001
+ if (filter)
2002
+ filterSQL = ' WHERE ' + filter.Filter;
2003
+ }
2004
+ return `SELECT ` +
2005
+ `CASE ` +
2006
+ `WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${meta.datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
2007
+ `ELSE '${meta.datasetMaxUpdatedAt}' ` +
2008
+ `END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
2009
+ `COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
2010
+ `FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
2011
+ });
2012
+ let batchResults = [];
2013
+ try {
2014
+ batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
2015
+ }
2016
+ catch (err) {
2017
+ LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
2018
+ }
2019
+ for (let i = 0; i < uncachedItemMeta.length; i++) {
2020
+ const meta = uncachedItemMeta[i];
2021
+ const statusRows = batchResults[i];
2022
+ if (statusRows && statusRows.length > 0) {
2023
+ const updateDate = new Date(String(statusRows[0]['UpdateDate']));
2024
+ updateDates.push({
2025
+ EntityID: meta.entityID,
2026
+ EntityName: meta.entityName,
2027
+ RowCount: Number(statusRows[0]['TheRowCount']),
2028
+ UpdateDate: updateDate,
2029
+ });
2030
+ if (updateDate > overallLatestDate) {
2031
+ overallLatestDate = updateDate;
2032
+ }
1684
2033
  }
1685
2034
  }
1686
2035
  }
2036
+ const elapsedMs = (performance.now() - overallStart).toFixed(1);
2037
+ LogStatusEx({
2038
+ message: `📊 [Dataset Status] GetDatasetStatusByName("${datasetName}"): ${cacheHitCount} cache-derived, ${cacheMissCount} SQL queries — ${elapsedMs}ms`,
2039
+ verboseOnly: true
2040
+ });
1687
2041
  if (updateDates.length === 0) {
1688
2042
  return {
1689
2043
  DatasetID: String(items[0]['DatasetID']),
@@ -1739,5 +2093,56 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1739
2093
  }
1740
2094
  return specifiedColumns.length > 0 ? specifiedColumns.map(col => this.QuoteIdentifier(col.trim())).join(',') : '*';
1741
2095
  }
2096
+ /**************************************************************************/
2097
+ // Dataset Cache Helpers
2098
+ /**************************************************************************/
2099
+ /**
2100
+ * Computes the latest update date for a dataset item from its result rows and dataset metadata.
2101
+ * Used by both the cache-hit and cache-miss paths in GetDatasetByName.
2102
+ * @param rows - The result rows (from cache or SQL)
2103
+ * @param dateFieldToCheck - The field name to scan for latest date
2104
+ * @param item - The dataset item metadata row (contains DatasetItemUpdatedAt, DatasetUpdatedAt)
2105
+ * @returns The latest date across all rows and dataset metadata
2106
+ */
2107
+ computeLatestUpdateDate(rows, dateFieldToCheck, item) {
2108
+ const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
2109
+ const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
2110
+ const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
2111
+ let latestUpdateDate = new Date(1900, 1, 1);
2112
+ if (rows && rows.length > 0) {
2113
+ for (const data of rows) {
2114
+ const record = data;
2115
+ if (record[dateFieldToCheck]) {
2116
+ const d = new Date(String(record[dateFieldToCheck]));
2117
+ if (d > latestUpdateDate)
2118
+ latestUpdateDate = d;
2119
+ }
2120
+ }
2121
+ }
2122
+ if (datasetMaxUpdatedAt > latestUpdateDate)
2123
+ latestUpdateDate = datasetMaxUpdatedAt;
2124
+ return latestUpdateDate;
2125
+ }
2126
+ /**
2127
+ * Extracts the MAX value of a specified date field from result rows as an ISO string.
2128
+ * Used for write-through caching of dataset item results.
2129
+ * @param rows - The result rows
2130
+ * @param dateFieldToCheck - The field name to scan
2131
+ * @returns ISO string of the max date, or current time if no dates found
2132
+ */
2133
+ extractMaxUpdatedAtFromRows(rows, dateFieldToCheck) {
2134
+ let maxDate = null;
2135
+ for (const row of rows) {
2136
+ const record = row;
2137
+ const val = record[dateFieldToCheck];
2138
+ if (val) {
2139
+ const d = val instanceof Date ? val : new Date(val);
2140
+ if (!isNaN(d.getTime()) && (!maxDate || d > maxDate)) {
2141
+ maxDate = d;
2142
+ }
2143
+ }
2144
+ }
2145
+ return maxDate ? maxDate.toISOString() : new Date().toISOString();
2146
+ }
1742
2147
  }
1743
2148
  //# sourceMappingURL=GenericDatabaseProvider.js.map