@memberjunction/sqlserver-dataprovider 2.127.0 → 2.129.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.
@@ -51,6 +51,7 @@ const rxjs_1 = require("rxjs");
51
51
  const SQLServerTransactionGroup_1 = require("./SQLServerTransactionGroup");
52
52
  const SqlLogger_js_1 = require("./SqlLogger.js");
53
53
  const actions_1 = require("@memberjunction/actions");
54
+ const encryption_1 = require("@memberjunction/encryption");
54
55
  const uuid_1 = require("uuid");
55
56
  /**
56
57
  * Core SQL execution function - handles the actual database query execution
@@ -610,7 +611,8 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
610
611
  /**************************************************************************/
611
612
  // START ---- IRunQueryProvider
612
613
  /**************************************************************************/
613
- async RunQuery(params, contextUser) {
614
+ async InternalRunQuery(params, contextUser) {
615
+ // This is the internal implementation - pre/post processing is handled by ProviderBase.RunQuery()
614
616
  try {
615
617
  // Find and validate query
616
618
  const query = await this.findAndValidateQuery(params, contextUser);
@@ -806,6 +808,231 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
806
808
  this.queryCache.set(query.ID, parameters, results, cacheConfig);
807
809
  (0, core_1.LogStatus)(`Cached results for query ${query.Name} (${query.ID})`);
808
810
  }
811
+ /**
812
+ * Internal implementation of batch query execution.
813
+ * Runs multiple queries in parallel for efficiency.
814
+ * @param params - Array of query parameters
815
+ * @param contextUser - Optional user context for permissions
816
+ * @returns Array of query results
817
+ */
818
+ async InternalRunQueries(params, contextUser) {
819
+ // This is the internal implementation - pre/post processing is handled by ProviderBase.RunQueries()
820
+ // Run all queries in parallel
821
+ const promises = params.map((p) => this.InternalRunQuery(p, contextUser));
822
+ return Promise.all(promises);
823
+ }
824
+ /**
825
+ * RunQueriesWithCacheCheck - Smart cache validation for batch RunQueries.
826
+ * For each query request, if cacheStatus is provided, uses the Query's CacheValidationSQL
827
+ * to check if the cached data is still current by comparing MAX(__mj_UpdatedAt) and COUNT(*)
828
+ * with client's values. Returns 'current' if cache is valid (no data), or 'stale' with fresh data.
829
+ *
830
+ * Queries without CacheValidationSQL configured will return 'no_validation' status with full data.
831
+ */
832
+ async RunQueriesWithCacheCheck(params, contextUser) {
833
+ try {
834
+ const user = contextUser || this.CurrentUser;
835
+ if (!user) {
836
+ return {
837
+ success: false,
838
+ results: [],
839
+ errorMessage: 'No user context available',
840
+ };
841
+ }
842
+ // Separate items that need cache check from those that don't
843
+ const itemsNeedingCacheCheck = [];
844
+ const itemsWithoutCacheCheck = [];
845
+ const itemsWithoutValidationSQL = [];
846
+ const errorResults = [];
847
+ // Pre-process all items to resolve query info and validate
848
+ for (let i = 0; i < params.length; i++) {
849
+ const item = params[i];
850
+ // Resolve query info
851
+ const queryInfo = this.resolveQueryInfo(item.params);
852
+ if (!queryInfo) {
853
+ errorResults.push({
854
+ queryIndex: i,
855
+ queryId: item.params.QueryID || '',
856
+ status: 'error',
857
+ errorMessage: `Query not found: ${item.params.QueryID || item.params.QueryName}`,
858
+ });
859
+ continue;
860
+ }
861
+ // Check permissions
862
+ if (!queryInfo.UserCanRun(user)) {
863
+ errorResults.push({
864
+ queryIndex: i,
865
+ queryId: queryInfo.ID,
866
+ status: 'error',
867
+ errorMessage: `User does not have permission to run query: ${queryInfo.Name}`,
868
+ });
869
+ continue;
870
+ }
871
+ if (!item.cacheStatus) {
872
+ // No cache status provided - will run full query
873
+ itemsWithoutCacheCheck.push({ index: i, item });
874
+ continue;
875
+ }
876
+ // Check if query has CacheValidationSQL
877
+ if (!queryInfo.CacheValidationSQL) {
878
+ // No validation SQL configured - will run full query and return 'no_validation'
879
+ itemsWithoutValidationSQL.push({ index: i, item, queryInfo });
880
+ continue;
881
+ }
882
+ itemsNeedingCacheCheck.push({ index: i, item, queryInfo });
883
+ }
884
+ // Execute batched cache status check for all items that need it
885
+ const cacheStatusResults = await this.getBatchedQueryCacheStatus(itemsNeedingCacheCheck, contextUser);
886
+ // Determine which items are current vs stale
887
+ const staleItems = [];
888
+ const currentResults = [];
889
+ for (const { index, item, queryInfo } of itemsNeedingCacheCheck) {
890
+ const serverStatus = cacheStatusResults.get(index);
891
+ if (!serverStatus || !serverStatus.success) {
892
+ errorResults.push({
893
+ queryIndex: index,
894
+ queryId: queryInfo.ID,
895
+ status: 'error',
896
+ errorMessage: serverStatus?.errorMessage || 'Failed to get cache status',
897
+ });
898
+ continue;
899
+ }
900
+ const isCurrent = this.isCacheCurrent(item.cacheStatus, serverStatus);
901
+ if (isCurrent) {
902
+ currentResults.push({
903
+ queryIndex: index,
904
+ queryId: queryInfo.ID,
905
+ status: 'current',
906
+ });
907
+ }
908
+ else {
909
+ staleItems.push({ index, params: item.params, queryInfo });
910
+ }
911
+ }
912
+ // Run full queries in parallel for:
913
+ // 1. Items without cache status (no fingerprint from client)
914
+ // 2. Items without CacheValidationSQL (always return data with 'no_validation' status)
915
+ // 3. Items with stale cache
916
+ const fullQueryPromises = [
917
+ ...itemsWithoutCacheCheck.map(({ index, item }) => this.runFullQueryAndReturnForQuery(item.params, index, 'stale', contextUser)),
918
+ ...itemsWithoutValidationSQL.map(({ index, item, queryInfo }) => this.runFullQueryAndReturnForQuery(item.params, index, 'no_validation', contextUser, queryInfo.ID)),
919
+ ...staleItems.map(({ index, params: queryParams, queryInfo }) => this.runFullQueryAndReturnForQuery(queryParams, index, 'stale', contextUser, queryInfo.ID)),
920
+ ];
921
+ const fullQueryResults = await Promise.all(fullQueryPromises);
922
+ // Combine all results and sort by queryIndex
923
+ const allResults = [...errorResults, ...currentResults, ...fullQueryResults];
924
+ allResults.sort((a, b) => a.queryIndex - b.queryIndex);
925
+ return {
926
+ success: true,
927
+ results: allResults,
928
+ };
929
+ }
930
+ catch (e) {
931
+ (0, core_1.LogError)(e);
932
+ return {
933
+ success: false,
934
+ results: [],
935
+ errorMessage: e instanceof Error ? e.message : String(e),
936
+ };
937
+ }
938
+ }
939
+ /**
940
+ * Resolves QueryInfo from RunQueryParams (by ID or Name+CategoryPath).
941
+ */
942
+ resolveQueryInfo(params) {
943
+ if (params.QueryID) {
944
+ return this.Queries.find((q) => q.ID === params.QueryID);
945
+ }
946
+ if (params.QueryName) {
947
+ // Match by name and optional category path
948
+ const matchingQueries = this.Queries.filter((q) => q.Name.trim().toLowerCase() === params.QueryName?.trim().toLowerCase());
949
+ if (matchingQueries.length === 0)
950
+ return undefined;
951
+ if (matchingQueries.length === 1)
952
+ return matchingQueries[0];
953
+ // Multiple matches - use CategoryPath or CategoryID to disambiguate
954
+ if (params.CategoryPath) {
955
+ const byPath = matchingQueries.find((q) => q.CategoryPath.toLowerCase() === params.CategoryPath?.toLowerCase());
956
+ if (byPath)
957
+ return byPath;
958
+ }
959
+ if (params.CategoryID) {
960
+ const byId = matchingQueries.find((q) => q.CategoryID === params.CategoryID);
961
+ if (byId)
962
+ return byId;
963
+ }
964
+ // Return first match if no category disambiguation
965
+ return matchingQueries[0];
966
+ }
967
+ return undefined;
968
+ }
969
+ /**
970
+ * Executes a batched cache status check for multiple queries using their CacheValidationSQL.
971
+ */
972
+ async getBatchedQueryCacheStatus(items, contextUser) {
973
+ const results = new Map();
974
+ if (items.length === 0) {
975
+ return results;
976
+ }
977
+ // Build array of SQL statements for batch execution
978
+ const sqlStatements = [];
979
+ for (const { queryInfo } of items) {
980
+ // CacheValidationSQL should return MaxUpdatedAt and RowCount
981
+ sqlStatements.push(queryInfo.CacheValidationSQL);
982
+ }
983
+ try {
984
+ // Execute the batched SQL
985
+ const resultSets = await this.ExecuteSQLBatch(sqlStatements, undefined, undefined, contextUser);
986
+ // Process each result set and map to the corresponding item index
987
+ for (let i = 0; i < items.length; i++) {
988
+ const { index } = items[i];
989
+ const resultSet = resultSets[i];
990
+ if (resultSet && resultSet.length > 0) {
991
+ const row = resultSet[0];
992
+ results.set(index, {
993
+ success: true,
994
+ rowCount: row.RowCount,
995
+ maxUpdatedAt: row.MaxUpdatedAt ? new Date(row.MaxUpdatedAt).toISOString() : undefined,
996
+ });
997
+ }
998
+ else {
999
+ results.set(index, { success: true, rowCount: 0, maxUpdatedAt: undefined });
1000
+ }
1001
+ }
1002
+ }
1003
+ catch (e) {
1004
+ // If batch fails, mark all items as failed
1005
+ const errorMessage = e instanceof Error ? e.message : String(e);
1006
+ for (const { index } of items) {
1007
+ results.set(index, { success: false, errorMessage });
1008
+ }
1009
+ }
1010
+ return results;
1011
+ }
1012
+ /**
1013
+ * Runs the full query and returns results with cache metadata.
1014
+ */
1015
+ async runFullQueryAndReturnForQuery(params, queryIndex, status, contextUser, queryId) {
1016
+ const result = await this.InternalRunQuery(params, contextUser);
1017
+ if (!result.Success) {
1018
+ return {
1019
+ queryIndex,
1020
+ queryId: queryId || result.QueryID || '',
1021
+ status: 'error',
1022
+ errorMessage: result.ErrorMessage || 'Unknown error executing query',
1023
+ };
1024
+ }
1025
+ // Extract maxUpdatedAt from results
1026
+ const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1027
+ return {
1028
+ queryIndex,
1029
+ queryId: result.QueryID,
1030
+ status,
1031
+ results: result.Results,
1032
+ maxUpdatedAt,
1033
+ rowCount: result.Results.length,
1034
+ };
1035
+ }
809
1036
  /**************************************************************************/
810
1037
  // END ---- IRunQueryProvider
811
1038
  /**************************************************************************/
@@ -872,10 +1099,8 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
872
1099
  /**************************************************************************/
873
1100
  // START ---- IRunViewProvider
874
1101
  /**************************************************************************/
875
- async RunView(params, contextUser) {
876
- // add call to pre-processor that was previously in the @memberjunction/core RunView class but now
877
- // is handled in ProviderBase when sub-classes like this one invoke the pre/post process properly
878
- await this.PreProcessRunView(params, contextUser);
1102
+ async InternalRunView(params, contextUser) {
1103
+ // This is the internal implementation - pre/post processing is handled by ProviderBase.RunView()
879
1104
  const startTime = new Date();
880
1105
  try {
881
1106
  if (params) {
@@ -1078,9 +1303,6 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
1078
1303
  Success: true,
1079
1304
  ErrorMessage: null,
1080
1305
  };
1081
- // add call to post-processor that was previously in the @memberjunction/core RunView class but now
1082
- // is handled in ProviderBase when sub-classes like this one invoke the post process properly
1083
- await this.PostProcessRunView(result, params, contextUser);
1084
1306
  return result;
1085
1307
  }
1086
1308
  else {
@@ -1101,16 +1323,269 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
1101
1323
  };
1102
1324
  }
1103
1325
  }
1104
- async RunViews(params, contextUser) {
1105
- // pre-process in base class
1106
- await this.PreProcessRunViews(params, contextUser);
1107
- // do the work
1108
- const promises = params.map((p) => this.RunView(p, contextUser));
1326
+ async InternalRunViews(params, contextUser) {
1327
+ // This is the internal implementation - pre/post processing is handled by ProviderBase.RunViews()
1328
+ // Note: We call InternalRunView directly since we're already inside the internal flow
1329
+ const promises = params.map((p) => this.InternalRunView(p, contextUser));
1109
1330
  const results = await Promise.all(promises);
1110
- // post-process in base class
1111
- await this.PostProcessRunViews(results, params, contextUser);
1112
1331
  return results;
1113
1332
  }
1333
+ /**
1334
+ * RunViewsWithCacheCheck - Smart cache validation for batch RunViews.
1335
+ * For each view request, if cacheStatus is provided, first checks if the cache is current
1336
+ * by comparing MAX(__mj_UpdatedAt) and COUNT(*) with client's values.
1337
+ * Returns 'current' if cache is valid (no data), or 'stale' with fresh data if cache is outdated.
1338
+ *
1339
+ * Optimized to batch all cache status checks into a single SQL call with multiple result sets.
1340
+ */
1341
+ async RunViewsWithCacheCheck(params, contextUser) {
1342
+ try {
1343
+ const user = contextUser || this.CurrentUser;
1344
+ if (!user) {
1345
+ return {
1346
+ success: false,
1347
+ results: [],
1348
+ errorMessage: 'No user context available',
1349
+ };
1350
+ }
1351
+ // Separate items that need cache check from those that don't
1352
+ const itemsNeedingCacheCheck = [];
1353
+ const itemsWithoutCacheCheck = [];
1354
+ const errorResults = [];
1355
+ // Pre-process all items to build WHERE clauses and validate
1356
+ for (let i = 0; i < params.length; i++) {
1357
+ const item = params[i];
1358
+ if (!item.cacheStatus) {
1359
+ // No cache status - will run full query
1360
+ itemsWithoutCacheCheck.push({ index: i, item });
1361
+ continue;
1362
+ }
1363
+ // Get entity info
1364
+ const entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === item.params.EntityName?.trim().toLowerCase());
1365
+ if (!entityInfo) {
1366
+ errorResults.push({
1367
+ viewIndex: i,
1368
+ status: 'error',
1369
+ errorMessage: `Entity ${item.params.EntityName} not found in metadata`,
1370
+ });
1371
+ continue;
1372
+ }
1373
+ try {
1374
+ // Check permissions
1375
+ this.CheckUserReadPermissions(entityInfo.Name, user);
1376
+ // Build WHERE clause
1377
+ const whereSQL = await this.buildWhereClauseForCacheCheck(item.params, entityInfo, user);
1378
+ itemsNeedingCacheCheck.push({ index: i, item, entityInfo, whereSQL });
1379
+ }
1380
+ catch (e) {
1381
+ errorResults.push({
1382
+ viewIndex: i,
1383
+ status: 'error',
1384
+ errorMessage: e instanceof Error ? e.message : String(e),
1385
+ });
1386
+ }
1387
+ }
1388
+ // Execute batched cache status check for all items that need it
1389
+ const cacheStatusResults = await this.getBatchedServerCacheStatus(itemsNeedingCacheCheck, contextUser);
1390
+ // Determine which items are current vs stale
1391
+ const staleItems = [];
1392
+ const currentResults = [];
1393
+ for (const { index, item } of itemsNeedingCacheCheck) {
1394
+ const serverStatus = cacheStatusResults.get(index);
1395
+ if (!serverStatus || !serverStatus.success) {
1396
+ errorResults.push({
1397
+ viewIndex: index,
1398
+ status: 'error',
1399
+ errorMessage: serverStatus?.errorMessage || 'Failed to get cache status',
1400
+ });
1401
+ continue;
1402
+ }
1403
+ const isCurrent = this.isCacheCurrent(item.cacheStatus, serverStatus);
1404
+ if (isCurrent) {
1405
+ currentResults.push({
1406
+ viewIndex: index,
1407
+ status: 'current',
1408
+ });
1409
+ }
1410
+ else {
1411
+ staleItems.push({ index, params: item.params });
1412
+ }
1413
+ }
1414
+ // Run full queries in parallel for:
1415
+ // 1. Items without cache status (no fingerprint from client)
1416
+ // 2. Items with stale cache
1417
+ const fullQueryPromises = [
1418
+ ...itemsWithoutCacheCheck.map(({ index, item }) => this.runFullQueryAndReturn(item.params, index, contextUser)),
1419
+ ...staleItems.map(({ index, params: viewParams }) => this.runFullQueryAndReturn(viewParams, index, contextUser)),
1420
+ ];
1421
+ const fullQueryResults = await Promise.all(fullQueryPromises);
1422
+ // Combine all results and sort by viewIndex
1423
+ const allResults = [...errorResults, ...currentResults, ...fullQueryResults];
1424
+ allResults.sort((a, b) => a.viewIndex - b.viewIndex);
1425
+ return {
1426
+ success: true,
1427
+ results: allResults,
1428
+ };
1429
+ }
1430
+ catch (e) {
1431
+ (0, core_1.LogError)(e);
1432
+ return {
1433
+ success: false,
1434
+ results: [],
1435
+ errorMessage: e instanceof Error ? e.message : String(e),
1436
+ };
1437
+ }
1438
+ }
1439
+ /**
1440
+ * Executes a batched cache status check for multiple views in a single SQL call.
1441
+ * Uses multiple result sets to return status for each view efficiently.
1442
+ */
1443
+ async getBatchedServerCacheStatus(items, contextUser) {
1444
+ const results = new Map();
1445
+ if (items.length === 0) {
1446
+ return results;
1447
+ }
1448
+ // Build array of SQL statements for batch execution
1449
+ const sqlStatements = [];
1450
+ for (const { entityInfo, whereSQL } of items) {
1451
+ const statusSQL = `SELECT COUNT(*) AS TotalRows, MAX(__mj_UpdatedAt) AS MaxUpdatedAt FROM [${entityInfo.SchemaName}].${entityInfo.BaseView}${whereSQL ? ' WHERE ' + whereSQL : ''}`;
1452
+ sqlStatements.push(statusSQL);
1453
+ }
1454
+ try {
1455
+ // Execute the batched SQL using existing ExecuteSQLBatch method
1456
+ const resultSets = await this.ExecuteSQLBatch(sqlStatements, undefined, undefined, contextUser);
1457
+ // Process each result set and map to the corresponding item index
1458
+ for (let i = 0; i < items.length; i++) {
1459
+ const { index } = items[i];
1460
+ const resultSet = resultSets[i];
1461
+ if (resultSet && resultSet.length > 0) {
1462
+ const row = resultSet[0];
1463
+ results.set(index, {
1464
+ success: true,
1465
+ rowCount: row.TotalRows,
1466
+ maxUpdatedAt: row.MaxUpdatedAt ? new Date(row.MaxUpdatedAt).toISOString() : undefined,
1467
+ });
1468
+ }
1469
+ else {
1470
+ results.set(index, { success: true, rowCount: 0, maxUpdatedAt: undefined });
1471
+ }
1472
+ }
1473
+ }
1474
+ catch (e) {
1475
+ // If batch fails, mark all items as failed
1476
+ const errorMessage = e instanceof Error ? e.message : String(e);
1477
+ for (const { index } of items) {
1478
+ results.set(index, { success: false, errorMessage });
1479
+ }
1480
+ }
1481
+ return results;
1482
+ }
1483
+ /**
1484
+ * Builds the WHERE clause for cache status check, using same logic as InternalRunView.
1485
+ */
1486
+ async buildWhereClauseForCacheCheck(params, entityInfo, user) {
1487
+ let whereSQL = '';
1488
+ let bHasWhere = false;
1489
+ // Extra filter
1490
+ if (params.ExtraFilter && params.ExtraFilter.length > 0) {
1491
+ if (!this.validateUserProvidedSQLClause(params.ExtraFilter)) {
1492
+ throw new Error(`Invalid Extra Filter: ${params.ExtraFilter}`);
1493
+ }
1494
+ whereSQL = `(${params.ExtraFilter})`;
1495
+ bHasWhere = true;
1496
+ }
1497
+ // User search string
1498
+ if (params.UserSearchString && params.UserSearchString.length > 0) {
1499
+ if (!this.validateUserProvidedSQLClause(params.UserSearchString)) {
1500
+ throw new Error(`Invalid User Search SQL clause: ${params.UserSearchString}`);
1501
+ }
1502
+ const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, params.UserSearchString);
1503
+ if (sUserSearchSQL.length > 0) {
1504
+ if (bHasWhere) {
1505
+ whereSQL += ` AND (${sUserSearchSQL})`;
1506
+ }
1507
+ else {
1508
+ whereSQL = `(${sUserSearchSQL})`;
1509
+ bHasWhere = true;
1510
+ }
1511
+ }
1512
+ }
1513
+ // Row Level Security
1514
+ if (!entityInfo.UserExemptFromRowLevelSecurity(user, core_1.EntityPermissionType.Read)) {
1515
+ const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, core_1.EntityPermissionType.Read, '');
1516
+ if (rlsWhereClause && rlsWhereClause.length > 0) {
1517
+ if (bHasWhere) {
1518
+ whereSQL += ` AND (${rlsWhereClause})`;
1519
+ }
1520
+ else {
1521
+ whereSQL = `(${rlsWhereClause})`;
1522
+ }
1523
+ }
1524
+ }
1525
+ return whereSQL;
1526
+ }
1527
+ /**
1528
+ * Compares client cache status with server status to determine if cache is current.
1529
+ */
1530
+ isCacheCurrent(clientStatus, serverStatus) {
1531
+ // Row count must match
1532
+ if (clientStatus.rowCount !== serverStatus.rowCount) {
1533
+ return false;
1534
+ }
1535
+ // Compare maxUpdatedAt dates
1536
+ const clientDate = new Date(clientStatus.maxUpdatedAt);
1537
+ const serverDate = serverStatus.maxUpdatedAt ? new Date(serverStatus.maxUpdatedAt) : null;
1538
+ if (!serverDate) {
1539
+ // No records on server, so if client has any, it's stale
1540
+ return clientStatus.rowCount === 0;
1541
+ }
1542
+ // Dates must match (compare as ISO strings for precision)
1543
+ return clientDate.toISOString() === serverDate.toISOString();
1544
+ }
1545
+ /**
1546
+ * Runs the full query and returns results with cache metadata.
1547
+ */
1548
+ async runFullQueryAndReturn(params, viewIndex, contextUser) {
1549
+ const result = await this.InternalRunView(params, contextUser);
1550
+ if (!result.Success) {
1551
+ return {
1552
+ viewIndex,
1553
+ status: 'error',
1554
+ errorMessage: result.ErrorMessage || 'Unknown error executing view',
1555
+ };
1556
+ }
1557
+ // Extract maxUpdatedAt from results
1558
+ const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1559
+ return {
1560
+ viewIndex,
1561
+ status: 'stale',
1562
+ results: result.Results,
1563
+ maxUpdatedAt,
1564
+ rowCount: result.Results.length,
1565
+ };
1566
+ }
1567
+ /**
1568
+ * Extracts the maximum __mj_UpdatedAt value from a result set.
1569
+ * @param results - Array of result objects that may contain __mj_UpdatedAt
1570
+ * @returns ISO string of the max timestamp, or current time if none found
1571
+ */
1572
+ extractMaxUpdatedAt(results) {
1573
+ if (!results || results.length === 0) {
1574
+ return new Date().toISOString();
1575
+ }
1576
+ let maxDate = null;
1577
+ for (const row of results) {
1578
+ const rowObj = row;
1579
+ const updatedAt = rowObj['__mj_UpdatedAt'];
1580
+ if (updatedAt) {
1581
+ const date = new Date(updatedAt);
1582
+ if (!isNaN(date.getTime()) && (!maxDate || date > maxDate)) {
1583
+ maxDate = date;
1584
+ }
1585
+ }
1586
+ }
1587
+ return maxDate ? maxDate.toISOString() : new Date().toISOString();
1588
+ }
1114
1589
  validateUserProvidedSQLClause(clause) {
1115
1590
  // First, remove all string literals from the clause to avoid false positives
1116
1591
  // This regex matches both single and double quoted strings, handling escaped quotes
@@ -1726,20 +2201,38 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
1726
2201
  }
1727
2202
  }
1728
2203
  /**
1729
- * This function generates the SQL Statement that will Save a record to the database, it is generally used by the Save() method of this class, but it is marked as public because
1730
- * it is also used by the SQLServerTransactionGroup to regenerate Save SQL if any values were changed by the transaction group due to transaction variables being set into the object.
2204
+ * Generates the SQL Statement that will Save a record to the database.
2205
+ *
2206
+ * This method is used by the Save() method of this class, but it is marked as public because
2207
+ * it is also used by the SQLServerTransactionGroup to regenerate Save SQL if any values were
2208
+ * changed by the transaction group due to transaction variables being set into the object.
2209
+ *
2210
+ * @param entity - The entity to generate save SQL for
2211
+ * @param bNewRecord - Whether this is a new record (create) or existing record (update)
2212
+ * @param spName - The stored procedure name to call
2213
+ * @param user - The user context for the operation
2214
+ * @returns The full SQL statement for the save operation
2215
+ *
2216
+ * @security This method handles field-level encryption transparently.
2217
+ * Fields marked with Encrypt=true will have their values encrypted
2218
+ * before being included in the SQL statement.
1731
2219
  */
1732
- GetSaveSQL(entity, bNewRecord, spName, user) {
1733
- const result = this.GetSaveSQLWithDetails(entity, bNewRecord, spName, user);
2220
+ async GetSaveSQL(entity, bNewRecord, spName, user) {
2221
+ const result = await this.GetSaveSQLWithDetails(entity, bNewRecord, spName, user);
1734
2222
  return result.fullSQL;
1735
2223
  }
1736
2224
  /**
1737
2225
  * This function generates both the full SQL (with record change metadata) and the simple stored procedure call
1738
2226
  * @returns Object with fullSQL and simpleSQL properties
2227
+ *
2228
+ * @security This method handles field-level encryption transparently.
2229
+ * Fields marked with Encrypt=true will have their values encrypted
2230
+ * before being included in the SQL statement.
1739
2231
  */
1740
- GetSaveSQLWithDetails(entity, bNewRecord, spName, user) {
2232
+ async GetSaveSQLWithDetails(entity, bNewRecord, spName, user) {
1741
2233
  // Generate the stored procedure parameters - now returns an object with structured SQL
1742
- const spParams = this.generateSPParams(entity, !bNewRecord);
2234
+ // This is async because it may need to encrypt field values
2235
+ const spParams = await this.generateSPParams(entity, !bNewRecord, user);
1743
2236
  // Build the simple SQL - use the new DECLARE/SET/EXEC pattern
1744
2237
  let sSimpleSQL;
1745
2238
  const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${spParams.execParams}`;
@@ -1978,7 +2471,9 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
1978
2471
  // but they are supported (for now)
1979
2472
  await this.HandleEntityAIActions(entity, 'save', true, user);
1980
2473
  }
1981
- const sqlDetails = this.GetSaveSQLWithDetails(entity, bNewRecord, spName, user);
2474
+ // Generate the SQL for the save operation
2475
+ // This is async because it may need to encrypt field values
2476
+ const sqlDetails = await this.GetSaveSQLWithDetails(entity, bNewRecord, spName, user);
1982
2477
  const sSQL = sqlDetails.fullSQL;
1983
2478
  if (entity.TransactionGroup && !bReplay /*we never participate in a transaction if we're in replay mode*/) {
1984
2479
  // we have a transaction group, need to play nice and be part of it
@@ -2033,7 +2528,8 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
2033
2528
  description: `Save ${entity.EntityInfo.Name}`,
2034
2529
  simpleSQLFallback: entity.EntityInfo.TrackRecordChanges ? sqlDetails.simpleSQL : undefined
2035
2530
  }, user);
2036
- result = await this.ProcessEntityRows(rawResult, entity.EntityInfo);
2531
+ // Process rows with user context for decryption
2532
+ result = await this.ProcessEntityRows(rawResult, entity.EntityInfo, user);
2037
2533
  }
2038
2534
  catch (e) {
2039
2535
  throw e; // rethrow
@@ -2110,7 +2606,23 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
2110
2606
  }
2111
2607
  return sRet;
2112
2608
  }
2113
- generateSPParams(entity, isUpdate) {
2609
+ /**
2610
+ * Generates the stored procedure parameters for a save operation.
2611
+ *
2612
+ * This method handles:
2613
+ * - Value type conversions (datetimeoffset, uniqueidentifier, etc.)
2614
+ * - Field-level encryption for fields marked with Encrypt=true
2615
+ * - Primary key handling for create/update operations
2616
+ *
2617
+ * @param entity - The entity being saved
2618
+ * @param isUpdate - Whether this is an update (true) or create (false) operation
2619
+ * @param contextUser - The user context for encryption operations
2620
+ * @returns An object containing the SQL components for the stored procedure call
2621
+ *
2622
+ * @security Fields with Encrypt=true are encrypted before being sent to the database.
2623
+ * Encryption uses the key specified in EncryptionKeyID.
2624
+ */
2625
+ async generateSPParams(entity, isUpdate, contextUser) {
2114
2626
  // Generate a unique suffix for variable names to avoid collisions in batch scripts
2115
2627
  const uniqueSuffix = '_' + (0, uuid_1.v4)().substring(0, 8).replace(/-/g, '');
2116
2628
  const declarations = [];
@@ -2118,6 +2630,8 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
2118
2630
  const execParams = [];
2119
2631
  let simpleParams = '';
2120
2632
  let bFirst = true;
2633
+ // Get the encryption engine instance (lazy - only used if needed)
2634
+ let encryptionEngine = null;
2121
2635
  for (let i = 0; i < entity.EntityInfo.Fields.length; i++) {
2122
2636
  const f = entity.EntityInfo.Fields[i];
2123
2637
  // For CREATE operations, include primary keys that are not auto-increment and have actual values
@@ -2148,6 +2662,36 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
2148
2662
  continue; // skip this field entirely by going to the next iteration of the loop
2149
2663
  }
2150
2664
  }
2665
+ // ========================================================================
2666
+ // FIELD-LEVEL ENCRYPTION
2667
+ // If the field is marked for encryption and has a non-null value,
2668
+ // encrypt it before storing in the database.
2669
+ // ========================================================================
2670
+ if (f.Encrypt && f.EncryptionKeyID && value !== null && value !== undefined) {
2671
+ // Lazy-load encryption engine only when needed
2672
+ if (!encryptionEngine) {
2673
+ encryptionEngine = encryption_1.EncryptionEngine.Instance;
2674
+ await encryptionEngine.Config(false, contextUser);
2675
+ }
2676
+ // Only encrypt if the value is not already encrypted
2677
+ // This handles cases where values may already be encrypted (e.g., re-save scenarios)
2678
+ const keyMarker = encryptionEngine.GetKeyByID(f.EncryptionKeyID)?.Marker;
2679
+ if (!encryptionEngine.IsEncrypted(value, keyMarker)) {
2680
+ try {
2681
+ // Convert value to string for encryption if it isn't already
2682
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
2683
+ value = await encryptionEngine.Encrypt(stringValue, f.EncryptionKeyID, contextUser);
2684
+ }
2685
+ catch (encryptError) {
2686
+ // Log the error but throw to prevent unencrypted storage
2687
+ // SECURITY: Never store unencrypted data in an encrypted field
2688
+ const message = encryptError instanceof Error ? encryptError.message : String(encryptError);
2689
+ throw new Error(`Failed to encrypt field "${f.Name}" on entity "${entity.EntityInfo.Name}": ${message}. ` +
2690
+ 'The save operation has been aborted to prevent storing unencrypted sensitive data.');
2691
+ }
2692
+ }
2693
+ }
2694
+ // ========================================================================
2151
2695
  // Generate variable name with unique suffix
2152
2696
  const varName = `@${f.CodeName}${uniqueSuffix}`;
2153
2697
  // Add declaration with proper SQL type using existing SQLFullType property
@@ -2547,7 +3091,8 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
2547
3091
  }).join(' AND ');
2548
3092
  const sql = `SELECT * FROM [${entity.EntityInfo.SchemaName}].${entity.EntityInfo.BaseView} WHERE ${where}`;
2549
3093
  const rawData = await this.ExecuteSQL(sql, undefined, undefined, user);
2550
- const d = await this.ProcessEntityRows(rawData, entity.EntityInfo);
3094
+ // Process rows with user context for decryption
3095
+ const d = await this.ProcessEntityRows(rawData, entity.EntityInfo, user);
2551
3096
  if (d && d.length > 0) {
2552
3097
  // got the record, now process the relationships if there are any
2553
3098
  const ret = d[0];
@@ -3181,29 +3726,50 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
3181
3726
  return ret;
3182
3727
  }
3183
3728
  /**
3184
- * Processes entity rows returned from SQL Server to handle timezone conversions for datetime fields.
3729
+ * Processes entity rows returned from SQL Server to handle:
3730
+ * 1. Timezone conversions for datetime fields
3731
+ * 2. Field-level decryption for encrypted fields
3732
+ *
3185
3733
  * This method specifically handles the conversion of datetime2 fields (which SQL Server returns without timezone info)
3186
3734
  * to proper UTC dates, preventing JavaScript from incorrectly interpreting them as local time.
3187
3735
  *
3736
+ * For encrypted fields, this method decrypts values at the data provider level.
3737
+ * API-level filtering (AllowDecryptInAPI/SendEncryptedValue) is handled by the GraphQL layer.
3738
+ *
3188
3739
  * @param rows The raw result rows from SQL Server
3189
3740
  * @param entityInfo The entity metadata to determine field types
3190
- * @returns The processed rows with corrected datetime values
3741
+ * @param contextUser Optional user context for decryption operations
3742
+ * @returns The processed rows with corrected datetime values and decrypted fields
3743
+ *
3744
+ * @security Encrypted fields are decrypted here for internal use.
3745
+ * The API layer handles response filtering based on AllowDecryptInAPI settings.
3191
3746
  */
3192
- async ProcessEntityRows(rows, entityInfo) {
3747
+ async ProcessEntityRows(rows, entityInfo, contextUser) {
3193
3748
  if (!rows || rows.length === 0) {
3194
3749
  return rows;
3195
3750
  }
3196
3751
  // Find all datetime fields in the entity
3197
3752
  const datetimeFields = entityInfo.Fields.filter((field) => field.TSType === core_1.EntityFieldTSType.Date);
3198
- // If there are no datetime fields, return the rows as-is
3199
- if (datetimeFields.length === 0) {
3753
+ // Find all encrypted fields in the entity
3754
+ const encryptedFields = entityInfo.Fields.filter((field) => field.Encrypt && field.EncryptionKeyID);
3755
+ // If there are no fields requiring processing, return the rows as-is
3756
+ if (datetimeFields.length === 0 && encryptedFields.length === 0) {
3200
3757
  return rows;
3201
3758
  }
3202
3759
  // Check if we need datetimeoffset adjustment (lazy loaded on first use)
3203
- const needsAdjustment = await this.NeedsDatetimeOffsetAdjustment();
3204
- // Process each row
3205
- return rows.map((row) => {
3760
+ const needsAdjustment = datetimeFields.length > 0 ? await this.NeedsDatetimeOffsetAdjustment() : false;
3761
+ // Get encryption engine instance (lazy - only if we have encrypted fields)
3762
+ let encryptionEngine = null;
3763
+ if (encryptedFields.length > 0) {
3764
+ encryptionEngine = encryption_1.EncryptionEngine.Instance;
3765
+ await encryptionEngine.Config(false, contextUser);
3766
+ }
3767
+ // Process each row - need to use Promise.all for async decryption
3768
+ const processedRows = await Promise.all(rows.map(async (row) => {
3206
3769
  const processedRow = { ...row };
3770
+ // ========================================================================
3771
+ // DATETIME FIELD PROCESSING
3772
+ // ========================================================================
3207
3773
  for (const field of datetimeFields) {
3208
3774
  const fieldValue = processedRow[field.Name];
3209
3775
  // Skip null/undefined values
@@ -3261,8 +3827,39 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
3261
3827
  }
3262
3828
  // For other types (date, time), leave as-is
3263
3829
  }
3830
+ // ========================================================================
3831
+ // ENCRYPTED FIELD PROCESSING (DECRYPTION)
3832
+ // Decrypt at the data provider level for internal use.
3833
+ // API-level filtering based on AllowDecryptInAPI is handled by GraphQL resolvers.
3834
+ // ========================================================================
3835
+ if (encryptionEngine && encryptedFields.length > 0) {
3836
+ for (const field of encryptedFields) {
3837
+ const fieldValue = processedRow[field.Name];
3838
+ // Skip null/undefined/empty values
3839
+ if (fieldValue === null || fieldValue === undefined || fieldValue === '') {
3840
+ continue;
3841
+ }
3842
+ // Only decrypt if the value is actually encrypted
3843
+ const keyMarker = field.EncryptionKeyID ? encryptionEngine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
3844
+ if (typeof fieldValue === 'string' && encryptionEngine.IsEncrypted(fieldValue, keyMarker)) {
3845
+ try {
3846
+ const decryptedValue = await encryptionEngine.Decrypt(fieldValue, contextUser);
3847
+ processedRow[field.Name] = decryptedValue;
3848
+ }
3849
+ catch (decryptError) {
3850
+ // Log error but don't fail the entire operation
3851
+ // Return the encrypted value so the caller knows something is wrong
3852
+ const message = decryptError instanceof Error ? decryptError.message : String(decryptError);
3853
+ (0, core_1.LogError)(`Failed to decrypt field "${field.Name}" on entity "${entityInfo.Name}": ${message}. ` +
3854
+ 'The encrypted value will be returned unchanged.');
3855
+ // Keep the encrypted value in the row - let the caller decide what to do
3856
+ }
3857
+ }
3858
+ }
3859
+ }
3264
3860
  return processedRow;
3265
- });
3861
+ }));
3862
+ return processedRows;
3266
3863
  }
3267
3864
  /**
3268
3865
  * Static method for executing SQL with proper handling of connections and logging.
@@ -3922,29 +4519,48 @@ class SQLServerDataProvider extends core_1.DatabaseProviderBase {
3922
4519
  }
3923
4520
  }
3924
4521
  exports.SQLServerDataProvider = SQLServerDataProvider;
3925
- // This implementation is purely in memory and doesn't bother to persist to a file. It is fine to load it once per server instance load
4522
+ /**
4523
+ * In-memory storage provider for Node.js server-side usage.
4524
+ * Uses a Map of Maps structure for category isolation:
4525
+ * Map<category, Map<key, value>>
4526
+ *
4527
+ * This implementation is purely in-memory and doesn't persist to disk.
4528
+ * Data is retained for the lifetime of the server process.
4529
+ */
3926
4530
  class NodeLocalStorageProvider {
3927
- _localStorage = {};
3928
- async GetItem(key) {
3929
- return new Promise((resolve) => {
3930
- if (this._localStorage.hasOwnProperty(key))
3931
- resolve(this._localStorage[key]);
3932
- else
3933
- resolve(null);
3934
- });
4531
+ static DEFAULT_CATEGORY = 'default';
4532
+ _storage = new Map();
4533
+ /**
4534
+ * Gets or creates a category map
4535
+ */
4536
+ getCategoryMap(category) {
4537
+ const cat = category || NodeLocalStorageProvider.DEFAULT_CATEGORY;
4538
+ let categoryMap = this._storage.get(cat);
4539
+ if (!categoryMap) {
4540
+ categoryMap = new Map();
4541
+ this._storage.set(cat, categoryMap);
4542
+ }
4543
+ return categoryMap;
3935
4544
  }
3936
- async SetItem(key, value) {
3937
- return new Promise((resolve) => {
3938
- this._localStorage[key] = value;
3939
- resolve();
3940
- });
4545
+ async GetItem(key, category) {
4546
+ const categoryMap = this.getCategoryMap(category || NodeLocalStorageProvider.DEFAULT_CATEGORY);
4547
+ return categoryMap.get(key) ?? null;
3941
4548
  }
3942
- async Remove(key) {
3943
- return new Promise((resolve) => {
3944
- if (this._localStorage.hasOwnProperty(key))
3945
- delete this._localStorage[key];
3946
- resolve();
3947
- });
4549
+ async SetItem(key, value, category) {
4550
+ const categoryMap = this.getCategoryMap(category || NodeLocalStorageProvider.DEFAULT_CATEGORY);
4551
+ categoryMap.set(key, value);
4552
+ }
4553
+ async Remove(key, category) {
4554
+ const categoryMap = this.getCategoryMap(category || NodeLocalStorageProvider.DEFAULT_CATEGORY);
4555
+ categoryMap.delete(key);
4556
+ }
4557
+ async ClearCategory(category) {
4558
+ const cat = category || NodeLocalStorageProvider.DEFAULT_CATEGORY;
4559
+ this._storage.delete(cat);
4560
+ }
4561
+ async GetCategoryKeys(category) {
4562
+ const categoryMap = this._storage.get(category || NodeLocalStorageProvider.DEFAULT_CATEGORY);
4563
+ return categoryMap ? Array.from(categoryMap.keys()) : [];
3948
4564
  }
3949
4565
  }
3950
4566
  //# sourceMappingURL=SQLServerDataProvider.js.map