@memberjunction/core 6.2.0-edge.6 → 6.2.0-edge.8

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 (96) hide show
  1. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  2. package/dist/generic/EntityFieldRules.js +1 -0
  3. package/dist/generic/EntityFieldRules.js.map +1 -1
  4. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.d.ts +165 -12
  7. package/dist/generic/baseEngine.d.ts.map +1 -1
  8. package/dist/generic/baseEngine.js +622 -98
  9. package/dist/generic/baseEngine.js.map +1 -1
  10. package/dist/generic/baseEngineRegistry.d.ts +10 -2
  11. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  12. package/dist/generic/baseEngineRegistry.js +33 -5
  13. package/dist/generic/baseEngineRegistry.js.map +1 -1
  14. package/dist/generic/baseEntity.d.ts +121 -6
  15. package/dist/generic/baseEntity.d.ts.map +1 -1
  16. package/dist/generic/baseEntity.js +1144 -64
  17. package/dist/generic/baseEntity.js.map +1 -1
  18. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  19. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  20. package/dist/generic/baseRemotableOperation.js +112 -8
  21. package/dist/generic/baseRemotableOperation.js.map +1 -1
  22. package/dist/generic/databaseProviderBase.d.ts +37 -0
  23. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  24. package/dist/generic/databaseProviderBase.js +262 -46
  25. package/dist/generic/databaseProviderBase.js.map +1 -1
  26. package/dist/generic/entityInfo.d.ts +25 -1
  27. package/dist/generic/entityInfo.d.ts.map +1 -1
  28. package/dist/generic/entityInfo.js +70 -12
  29. package/dist/generic/entityInfo.js.map +1 -1
  30. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  31. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  32. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  33. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  34. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  35. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  36. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  37. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  38. package/dist/generic/interfaces.d.ts +50 -1
  39. package/dist/generic/interfaces.d.ts.map +1 -1
  40. package/dist/generic/interfaces.js.map +1 -1
  41. package/dist/generic/listMembershipFilter.d.ts +5 -0
  42. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  43. package/dist/generic/listMembershipFilter.js +23 -0
  44. package/dist/generic/listMembershipFilter.js.map +1 -0
  45. package/dist/generic/localCacheManager.d.ts +69 -5
  46. package/dist/generic/localCacheManager.d.ts.map +1 -1
  47. package/dist/generic/localCacheManager.js +253 -18
  48. package/dist/generic/localCacheManager.js.map +1 -1
  49. package/dist/generic/logging.d.ts +105 -38
  50. package/dist/generic/logging.d.ts.map +1 -1
  51. package/dist/generic/logging.js +196 -5
  52. package/dist/generic/logging.js.map +1 -1
  53. package/dist/generic/providerBase.d.ts +198 -3
  54. package/dist/generic/providerBase.d.ts.map +1 -1
  55. package/dist/generic/providerBase.js +721 -223
  56. package/dist/generic/providerBase.js.map +1 -1
  57. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  58. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  59. package/dist/generic/relatedRecordCollection.js +11 -2
  60. package/dist/generic/relatedRecordCollection.js.map +1 -1
  61. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  62. package/dist/generic/remoteOperationDispatch.js +14 -2
  63. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  64. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  65. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  66. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  67. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  68. package/dist/generic/sharedLoad.d.ts +42 -0
  69. package/dist/generic/sharedLoad.d.ts.map +1 -0
  70. package/dist/generic/sharedLoad.js +74 -0
  71. package/dist/generic/sharedLoad.js.map +1 -0
  72. package/dist/generic/telemetryManager.d.ts +31 -0
  73. package/dist/generic/telemetryManager.d.ts.map +1 -1
  74. package/dist/generic/telemetryManager.js +85 -9
  75. package/dist/generic/telemetryManager.js.map +1 -1
  76. package/dist/generic/transactionGroup.d.ts +19 -0
  77. package/dist/generic/transactionGroup.d.ts.map +1 -1
  78. package/dist/generic/transactionGroup.js +55 -7
  79. package/dist/generic/transactionGroup.js.map +1 -1
  80. package/dist/generic/transportError.d.ts +41 -0
  81. package/dist/generic/transportError.d.ts.map +1 -0
  82. package/dist/generic/transportError.js +167 -0
  83. package/dist/generic/transportError.js.map +1 -0
  84. package/dist/generic/util.d.ts +13 -1
  85. package/dist/generic/util.d.ts.map +1 -1
  86. package/dist/generic/util.js +53 -4
  87. package/dist/generic/util.js.map +1 -1
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/views/runView.d.ts +23 -10
  93. package/dist/views/runView.d.ts.map +1 -1
  94. package/dist/views/runView.js +5 -1
  95. package/dist/views/runView.js.map +1 -1
  96. package/package.json +3 -3
@@ -1,4 +1,4 @@
1
- import { BaseSingleton, MJGlobal, MJEventType } from "@memberjunction/global";
1
+ import { BaseSingleton, MJGlobal, MJEventType, IsValueEncrypted, ENCRYPTED_SENTINEL } from "@memberjunction/global";
2
2
  import { IsMaterializedDataSource, NormalizeAlternateBaseViewName } from "../views/runView.js";
3
3
  import { GetEffectiveAlternateBaseViewName } from "./alternateBaseViews.js";
4
4
  import { ViewPropertyValuesKey } from "../views/viewProperties.js";
@@ -467,6 +467,17 @@ export class LocalCacheManager extends BaseSingleton {
467
467
  || this.hasNarrowingSegment(parts)
468
468
  || this.hasAggregates(parts);
469
469
  }
470
+ /**
471
+ * Returns true when a cached `RunView` slot identified by `fingerprint` is eligible for
472
+ * in-place maintenance across entity saves (unfiltered, unordered, uncapped, no aggregates
473
+ * or narrowing segments).
474
+ */
475
+ IsRunViewFingerprintMaintainableOnSave(fingerprint) {
476
+ if (!fingerprint || !fingerprint.includes('|')) {
477
+ return false;
478
+ }
479
+ return !this.isSubsetFingerprint(fingerprint) && !this.isFilteredFingerprint(fingerprint);
480
+ }
470
481
  /**
471
482
  * Returns true if the slot was cached under an ORDER BY (fingerprint segment [2]).
472
483
  *
@@ -870,7 +881,7 @@ export class LocalCacheManager extends BaseSingleton {
870
881
  * @param fingerprint - The slot key
871
882
  * @param expiresAt - Absolute expiry (ms since epoch), when the slot has one
872
883
  */
873
- runViewWriteOptions(fingerprint, expiresAt) {
884
+ runViewWriteOptions(fingerprint, expiresAt, pubSubOverride) {
874
885
  const options = {};
875
886
  const entity = this.extractEntityFromFingerprint(fingerprint);
876
887
  if (entity) {
@@ -879,6 +890,10 @@ export class LocalCacheManager extends BaseSingleton {
879
890
  if (expiresAt) {
880
891
  options.TTLSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
881
892
  }
893
+ if (pubSubOverride) {
894
+ options.pubSubAction = pubSubOverride.pubSubAction;
895
+ options.pubSubData = pubSubOverride.pubSubData;
896
+ }
882
897
  return options;
883
898
  }
884
899
  /** Storage write options for any registry entry; only RunView slots are indexed. */
@@ -1018,6 +1033,21 @@ export class LocalCacheManager extends BaseSingleton {
1018
1033
  const baseEntity = entityEvent.baseEntity;
1019
1034
  if (!baseEntity?.EntityInfo?.Name)
1020
1035
  return;
1036
+ if (entityEvent.type === 'delete') {
1037
+ await this.invalidateCascadeDeletedRelatedEntities(baseEntity.EntityInfo);
1038
+ }
1039
+ if ((entityEvent.type === 'save' || entityEvent.type === 'delete') && baseEntity.EntityInfo.IsParentType) {
1040
+ const activeChainLower = new Set();
1041
+ let currChild = baseEntity.ISAChild;
1042
+ while (currChild) {
1043
+ const cName = currChild.EntityInfo?.Name?.trim().toLowerCase();
1044
+ if (cName) {
1045
+ activeChainLower.add(cName);
1046
+ }
1047
+ currChild = currChild.ISAChild;
1048
+ }
1049
+ await this.invalidateISADescendantEntities(baseEntity.EntityInfo, activeChainLower);
1050
+ }
1021
1051
  const entityName = baseEntity.EntityInfo.Name;
1022
1052
  // Short-circuit: if caching is disabled for this entity, skip the fingerprint scan
1023
1053
  if (!this.IsCachingEnabledForEntity(baseEntity.EntityInfo))
@@ -1318,14 +1348,99 @@ export class LocalCacheManager extends BaseSingleton {
1318
1348
  list.push(change);
1319
1349
  byEntity.set(change.EntityInfo.Name, list);
1320
1350
  }
1351
+ const touchedLower = new Set(Array.from(byEntity.keys(), k => k.trim().toLowerCase()));
1321
1352
  for (const [entityName, changes] of byEntity) {
1322
1353
  await this.applyEntityChanges(entityName, changes);
1354
+ const cascadeDelete = changes.find(c => c.Type === 'delete' && c.EntityInfo?.CascadeDeletes);
1355
+ if (cascadeDelete) {
1356
+ await this.invalidateCascadeDeletedRelatedEntities(cascadeDelete.EntityInfo);
1357
+ }
1358
+ const parentInfo = changes[0]?.EntityInfo;
1359
+ if (parentInfo?.IsParentType) {
1360
+ await this.invalidateISADescendantEntities(parentInfo, touchedLower);
1361
+ }
1323
1362
  }
1324
1363
  }
1325
1364
  /** Invalidates every entity a batch touched — for a batch that failed or was abandoned. */
1326
1365
  async invalidateBatchEntities(batch) {
1327
- for (const entityName of batch.TouchedEntities.keys()) {
1366
+ for (const [entityName, entityInfo] of batch.TouchedEntities.entries()) {
1328
1367
  await this.InvalidateEntityCaches(entityName);
1368
+ await this.invalidateCascadeDeletedRelatedEntities(entityInfo);
1369
+ await this.invalidateISADescendantEntities(entityInfo);
1370
+ }
1371
+ }
1372
+ /**
1373
+ * Invalidates cached RunView/dataset slots for IS-A child entities whose SQL views join
1374
+ * `entityInfo`'s table when a mutation on `entityInfo` was not accompanied by a row-level
1375
+ * update for those children:
1376
+ * - Overlapping subtypes (`AllowMultipleSubtypes === true`): sibling subtypes can share the
1377
+ * same parent PK, so any child not in `updatedEntityNamesLower` may hold stale inherited
1378
+ * parent columns and must be invalidated.
1379
+ * - Disjoint subtypes (`AllowMultipleSubtypes === false`): a parent PK belongs to at most one
1380
+ * child subtype. If one of `entityInfo.ChildEntities` was already updated in the same
1381
+ * operation (`updatedEntityNamesLower`), sibling child tables cannot contain that PK and stay
1382
+ * cached; if no child was part of the operation (e.g. a bare parent save/delete or a parent-only
1383
+ * remote invalidation), all child subtypes are invalidated.
1384
+ */
1385
+ async invalidateISADescendantEntities(entityInfo, updatedEntityNamesLower) {
1386
+ if (!entityInfo?.IsParentType || !Array.isArray(entityInfo.ChildEntities) || entityInfo.ChildEntities.length === 0) {
1387
+ return;
1388
+ }
1389
+ const directChildren = entityInfo.ChildEntities;
1390
+ if (!entityInfo.AllowMultipleSubtypes && updatedEntityNamesLower && updatedEntityNamesLower.size > 0) {
1391
+ const anyDirectChildUpdated = directChildren.some(c => c?.Name && updatedEntityNamesLower.has(c.Name.trim().toLowerCase()));
1392
+ if (anyDirectChildUpdated) {
1393
+ return;
1394
+ }
1395
+ }
1396
+ const visited = new Set([(entityInfo.Name ?? '').trim().toLowerCase()]);
1397
+ const queue = [...directChildren];
1398
+ while (queue.length > 0) {
1399
+ const child = queue.shift();
1400
+ const childName = child?.Name?.trim();
1401
+ if (!childName) {
1402
+ continue;
1403
+ }
1404
+ const childLower = childName.toLowerCase();
1405
+ if (visited.has(childLower)) {
1406
+ continue;
1407
+ }
1408
+ visited.add(childLower);
1409
+ if (!updatedEntityNamesLower?.has(childLower)) {
1410
+ await this.InvalidateEntityCaches(childName);
1411
+ }
1412
+ if (Array.isArray(child.ChildEntities) && child.ChildEntities.length > 0) {
1413
+ queue.push(...child.ChildEntities);
1414
+ }
1415
+ }
1416
+ }
1417
+ /**
1418
+ * When a parent entity with `CascadeDeletes = true` is deleted (e.g. via its generated
1419
+ * `spDelete*` stored procedure), the database deletes its `One To Many` child records directly
1420
+ * without raising per-child `BaseEntity.Delete()` events. Invalidate cached RunView/dataset
1421
+ * slots for those child entities so cascade-deleted rows do not linger in `LocalCacheManager`.
1422
+ */
1423
+ async invalidateCascadeDeletedRelatedEntities(entityInfo) {
1424
+ if (!entityInfo?.CascadeDeletes || !Array.isArray(entityInfo.RelatedEntities)) {
1425
+ return;
1426
+ }
1427
+ const parentLower = (entityInfo.Name ?? '').trim().toLowerCase();
1428
+ const invalidated = new Set();
1429
+ for (const rel of entityInfo.RelatedEntities) {
1430
+ const relatedName = rel?.RelatedEntity?.trim();
1431
+ if (!relatedName) {
1432
+ continue;
1433
+ }
1434
+ const relType = (rel.Type ?? 'One To Many').trim().toUpperCase();
1435
+ if (relType !== 'ONE TO MANY') {
1436
+ continue;
1437
+ }
1438
+ const relatedLower = relatedName.toLowerCase();
1439
+ if (relatedLower === parentLower || invalidated.has(relatedLower)) {
1440
+ continue;
1441
+ }
1442
+ invalidated.add(relatedLower);
1443
+ await this.InvalidateEntityCaches(relatedName);
1329
1444
  }
1330
1445
  }
1331
1446
  /** Applies one entity's buffered changes to every slot indexed for it, each slot once. */
@@ -1386,6 +1501,12 @@ export class LocalCacheManager extends BaseSingleton {
1386
1501
  // the Metadata helper itself proxies to the global provider with sensible fallbacks.
1387
1502
  const md = entityEvent.provider ?? new Metadata();
1388
1503
  const entityInfo = md.EntityByName(entityName);
1504
+ if (payload?.action === 'delete' && entityInfo) {
1505
+ await this.invalidateCascadeDeletedRelatedEntities(entityInfo);
1506
+ }
1507
+ if ((payload?.action === 'save' || payload?.action === 'delete') && entityInfo?.IsParentType) {
1508
+ await this.invalidateISADescendantEntities(entityInfo);
1509
+ }
1389
1510
  if (entityInfo && !this.IsCachingEnabledForEntity(entityInfo))
1390
1511
  return;
1391
1512
  const fingerprints = await this.resolveFingerprintsForEntity(entityName);
@@ -1457,7 +1578,8 @@ export class LocalCacheManager extends BaseSingleton {
1457
1578
  if (key.KeyValuePairs.some(kv => kv.Value == null))
1458
1579
  return;
1459
1580
  LogStatusVerbose(`LocalCacheManager: remote-invalidate (save) for "${entityName}" PK=${key.ToConcatenatedString()}, updating ${fingerprints.size} cached fingerprint(s)`);
1460
- const rowComplete = LocalCacheManager.RowHasEveryField(entityInfo, recordData); // per row, not per slot
1581
+ const rowComplete = LocalCacheManager.RowHasEveryField(entityInfo, recordData)
1582
+ && !LocalCacheManager.RowContainsPlaintextEncryptedField(entityInfo, recordData); // per row, not per slot
1461
1583
  for (const fingerprint of fingerprintSnapshot) {
1462
1584
  try {
1463
1585
  // Subset slot (MaxRows/StartRow): upserting would grow the slot past the
@@ -1581,9 +1703,12 @@ export class LocalCacheManager extends BaseSingleton {
1581
1703
  }
1582
1704
  else if (!this.isFilteredFingerprint(fingerprint)) {
1583
1705
  // Unfiltered cache: update the record in place, unless the saved row is missing columns
1706
+ // or carries decrypted plaintext on an Encrypt=true field (BaseEntity.GetAll() holds
1707
+ // in-memory plaintext after Save(), which must never be persisted to shared cache/Redis).
1584
1708
  const entityData = baseEntity.GetAll();
1585
- if (!LocalCacheManager.RowHasEveryField(baseEntity.EntityInfo, entityData)) {
1586
- LogStatusVerbose(`LocalCacheManager: Invalidating "${fingerprint.substring(0, 60)}" — the saved row is missing columns`);
1709
+ if (!LocalCacheManager.RowHasEveryField(baseEntity.EntityInfo, entityData) ||
1710
+ LocalCacheManager.RowContainsPlaintextEncryptedField(baseEntity.EntityInfo, entityData)) {
1711
+ LogStatusVerbose(`LocalCacheManager: Invalidating "${fingerprint.substring(0, 60)}" — the saved row is missing columns or holds decrypted plaintext`);
1587
1712
  await this.InvalidateRunViewResult(fingerprint);
1588
1713
  return;
1589
1714
  }
@@ -1631,14 +1756,48 @@ export class LocalCacheManager extends BaseSingleton {
1631
1756
  }
1632
1757
  return true;
1633
1758
  }
1634
- /** Whether every saved row among `changes` carries every column ({@link RowHasEveryField}). */
1759
+ /**
1760
+ * Returns `true` when `entity` declares one or more encrypted fields (`Encrypt && EncryptionKeyID`)
1761
+ * and `row` contains a non-empty string value for any such field that is neither ciphertext
1762
+ * (`$ENC$...`) nor `ENCRYPTED_SENTINEL`.
1763
+ *
1764
+ * Used as a defense-in-depth gate so decrypted in-memory entity values (e.g. from
1765
+ * `BaseEntity.GetAll()` during `save` events) are never written in plaintext to local or
1766
+ * distributed Redis caches.
1767
+ */
1768
+ static RowContainsPlaintextEncryptedField(entity, row) {
1769
+ if (!entity || !row || typeof row !== 'object') {
1770
+ return false;
1771
+ }
1772
+ const encryptedFields = Array.isArray(entity.EncryptedFields)
1773
+ ? entity.EncryptedFields
1774
+ : Array.isArray(entity.Fields)
1775
+ ? entity.Fields.filter(f => f.Encrypt && Boolean(f.EncryptionKeyID))
1776
+ : [];
1777
+ if (encryptedFields.length === 0) {
1778
+ return false;
1779
+ }
1780
+ const rowMap = row;
1781
+ for (const field of encryptedFields) {
1782
+ if (!field.EncryptionKeyID)
1783
+ continue;
1784
+ const val = rowMap[field.Name];
1785
+ if (typeof val === 'string' && val.length > 0 && val !== ENCRYPTED_SENTINEL && !IsValueEncrypted(val)) {
1786
+ return true;
1787
+ }
1788
+ }
1789
+ return false;
1790
+ }
1791
+ /** Whether every saved row among `changes` carries every column ({@link RowHasEveryField}) and no plaintext encrypted values. */
1635
1792
  static everySavedRowComplete(changes) {
1636
- return changes.every(c => c.Type !== 'save' || LocalCacheManager.RowHasEveryField(c.EntityInfo, c.Record));
1793
+ return changes.every(c => c.Type !== 'save' ||
1794
+ (LocalCacheManager.RowHasEveryField(c.EntityInfo, c.Record) &&
1795
+ !LocalCacheManager.RowContainsPlaintextEncryptedField(c.EntityInfo, c.Record)));
1637
1796
  }
1638
1797
  /**
1639
1798
  * True when a saved row may be written into `fingerprint`'s slot in place: the slot is neither
1640
1799
  * filtered (including row-security and field-security segments) nor a subset, and the row
1641
- * carries every column ({@link RowHasEveryField}). Otherwise the slot must be invalidated.
1800
+ * carries every column ({@link RowHasEveryField}) with no plaintext secrets. Otherwise the slot must be invalidated.
1642
1801
  *
1643
1802
  * @param fingerprint - The slot to maintain
1644
1803
  * @param entity - The saved record's entity
@@ -1647,7 +1806,8 @@ export class LocalCacheManager extends BaseSingleton {
1647
1806
  CanUpsertRowIntoSlot(fingerprint, entity, row) {
1648
1807
  return !this.isFilteredFingerprint(fingerprint)
1649
1808
  && !this.isSubsetFingerprint(fingerprint)
1650
- && LocalCacheManager.RowHasEveryField(entity, row);
1809
+ && LocalCacheManager.RowHasEveryField(entity, row)
1810
+ && !LocalCacheManager.RowContainsPlaintextEncryptedField(entity, row);
1651
1811
  }
1652
1812
  /**
1653
1813
  * Handles a save/delete that could not be applied to a slot this process indexed — in practice
@@ -1746,7 +1906,25 @@ export class LocalCacheManager extends BaseSingleton {
1746
1906
  }
1747
1907
  }
1748
1908
  else {
1749
- // For set/removed, dispatch only to callbacks for the specific fingerprint
1909
+ this._lastVerifiedAt.delete(event.CacheKey);
1910
+ if (event.Action === 'row_delta' && event.Data) {
1911
+ const existingEntry = this._registry.get(event.CacheKey);
1912
+ if (existingEntry) {
1913
+ try {
1914
+ const parsedDelta = JSON.parse(event.Data);
1915
+ if (typeof parsedDelta.rowCount === 'number') {
1916
+ existingEntry.rowCount = parsedDelta.rowCount;
1917
+ }
1918
+ if (typeof parsedDelta.maxUpdatedAt === 'string') {
1919
+ existingEntry.maxUpdatedAt = parsedDelta.maxUpdatedAt;
1920
+ }
1921
+ }
1922
+ catch {
1923
+ // Best-effort local registry metadata update; callbacks handle invalid payloads safely.
1924
+ }
1925
+ }
1926
+ }
1927
+ // For set/row_delta/removed, dispatch only to callbacks for the specific fingerprint
1750
1928
  const callbacks = this._changeCallbacks.get(event.CacheKey);
1751
1929
  if (callbacks) {
1752
1930
  for (const cb of callbacks) {
@@ -2408,6 +2586,17 @@ export class LocalCacheManager extends BaseSingleton {
2408
2586
  LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for external entity "${params.EntityName}" — no TTL provided (would never invalidate)`, verboseOnly: true });
2409
2587
  return;
2410
2588
  }
2589
+ // Defense-in-depth on server caches (TrustLocalCacheCompletely === true):
2590
+ // never persist decrypted plaintext values for Encrypt=true columns into shared/Redis cache.
2591
+ const cacheProvider = provider;
2592
+ if (entity &&
2593
+ cacheProvider?.TrustLocalCacheCompletely === true &&
2594
+ !options?.ProviderInternalScaffolding &&
2595
+ Array.isArray(results) &&
2596
+ results.some(r => typeof r === 'object' && r !== null && LocalCacheManager.RowContainsPlaintextEncryptedField(entity, r))) {
2597
+ LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping server cache write for "${params.EntityName}" — rows contain decrypted plaintext for an encrypted field`, verboseOnly: true });
2598
+ return;
2599
+ }
2411
2600
  }
2412
2601
  catch (err) {
2413
2602
  // fall through and write — fail-open is safer than fail-closed here
@@ -2948,11 +3137,23 @@ export class LocalCacheManager extends BaseSingleton {
2948
3137
  }
2949
3138
  const pkFieldNames = changes[0].Key.KeyValuePairs.map(kv => kv.FieldName);
2950
3139
  const resultMap = this.indexRowsByKey(cached.results, pkFieldNames);
2951
- if (!this.applyChangesToRowMap(resultMap, changes)) {
3140
+ const deltaChanges = this.applyChangesToRowMap(resultMap, changes);
3141
+ if (!deltaChanges.changed) {
2952
3142
  return true; // only removals of rows the slot does not hold
2953
3143
  }
2954
- return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
2955
- schemaStamp: cached.schemaStamp, providerInternalScaffolding: cached.providerInternalScaffolding });
3144
+ return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, {
3145
+ totalRowCount: cached.totalRowCount,
3146
+ rowCount: cached.results.length,
3147
+ schemaHash: cached.schemaHash,
3148
+ schemaStamp: cached.schemaStamp,
3149
+ providerInternalScaffolding: cached.providerInternalScaffolding,
3150
+ }, cached.providerInternalScaffolding
3151
+ ? undefined
3152
+ : {
3153
+ upsertedRows: deltaChanges.upsertedRows,
3154
+ deletedPrimaryKeys: deltaChanges.deletedPrimaryKeys,
3155
+ primaryKeyFields: pkFieldNames,
3156
+ });
2956
3157
  }
2957
3158
  catch (e) {
2958
3159
  LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
@@ -2975,20 +3176,38 @@ export class LocalCacheManager extends BaseSingleton {
2975
3176
  }
2976
3177
  return resultMap;
2977
3178
  }
2978
- /** Applies `changes` to `rows` in order. @returns True when anything changed. */
3179
+ /** Applies `changes` to `rows` in order and collects the collapsed net row delta for pub/sub. */
2979
3180
  applyChangesToRowMap(rows, changes) {
2980
3181
  let changed = false;
3182
+ const upsertsByKey = new Map();
3183
+ const deletesByKey = new Map();
2981
3184
  for (const change of changes) {
2982
3185
  const keyStr = this.cheapKeyFromCompositeKey(change.Key);
2983
3186
  if (change.Row) {
2984
3187
  rows.set(keyStr, change.Row);
3188
+ deletesByKey.delete(keyStr);
3189
+ upsertsByKey.set(keyStr, change.Row);
2985
3190
  changed = true;
2986
3191
  }
2987
3192
  else if (rows.delete(keyStr)) {
3193
+ upsertsByKey.delete(keyStr);
3194
+ const pkRecord = {};
3195
+ for (const kv of change.Key.KeyValuePairs) {
3196
+ const v = kv.Value;
3197
+ pkRecord[kv.FieldName] =
3198
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null
3199
+ ? v
3200
+ : String(v);
3201
+ }
3202
+ deletesByKey.set(keyStr, pkRecord);
2988
3203
  changed = true;
2989
3204
  }
2990
3205
  }
2991
- return changed;
3206
+ return {
3207
+ changed,
3208
+ upsertedRows: Array.from(upsertsByKey.values()),
3209
+ deletedPrimaryKeys: Array.from(deletesByKey.values()),
3210
+ };
2992
3211
  }
2993
3212
  /**
2994
3213
  * Stores updated results array back to the cache and updates the registry.
@@ -3004,7 +3223,7 @@ export class LocalCacheManager extends BaseSingleton {
3004
3223
  * full-dataset slot is unchanged (prior total == prior length) while a subset slot keeps a
3005
3224
  * correct total.
3006
3225
  */
3007
- async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior) {
3226
+ async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior, rowDelta) {
3008
3227
  // Stamp the slot the way the fill path does — the newest row's __mj_UpdatedAt — so the
3009
3228
  // stamp means the same thing whichever funnel wrote the slot and matches what the database
3010
3229
  // reports for the same rows (the client smart-cache check compares the two). The caller's
@@ -3058,7 +3277,23 @@ export class LocalCacheManager extends BaseSingleton {
3058
3277
  if (!prior?.providerInternalScaffolding) {
3059
3278
  this.freezeRowDataIfProviderSharesReferences(data);
3060
3279
  }
3061
- await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt));
3280
+ let pubSubOverride;
3281
+ if (rowDelta) {
3282
+ const deltaPayload = {
3283
+ upsertedRows: rowDelta.upsertedRows,
3284
+ deletedPrimaryKeys: rowDelta.deletedPrimaryKeys,
3285
+ primaryKeyFields: rowDelta.primaryKeyFields,
3286
+ maxUpdatedAt,
3287
+ rowCount: updatedResults.length,
3288
+ totalRowCount: data.totalRowCount ?? updatedResults.length,
3289
+ schemaHash: data.schemaHash,
3290
+ };
3291
+ pubSubOverride = {
3292
+ pubSubAction: 'row_delta',
3293
+ pubSubData: JSON.stringify(deltaPayload),
3294
+ };
3295
+ }
3296
+ await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt, pubSubOverride));
3062
3297
  this._lastVerifiedAt.set(fingerprint, now);
3063
3298
  if (existingEntry) {
3064
3299
  existingEntry.maxUpdatedAt = maxUpdatedAt;