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

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 (74) 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/baseEngine.d.ts +118 -8
  5. package/dist/generic/baseEngine.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.js +528 -82
  7. package/dist/generic/baseEngine.js.map +1 -1
  8. package/dist/generic/baseEntity.d.ts +63 -4
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +625 -55
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  13. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  14. package/dist/generic/baseRemotableOperation.js +112 -8
  15. package/dist/generic/baseRemotableOperation.js.map +1 -1
  16. package/dist/generic/databaseProviderBase.d.ts +37 -0
  17. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  18. package/dist/generic/databaseProviderBase.js +222 -40
  19. package/dist/generic/databaseProviderBase.js.map +1 -1
  20. package/dist/generic/entityInfo.d.ts +6 -0
  21. package/dist/generic/entityInfo.d.ts.map +1 -1
  22. package/dist/generic/entityInfo.js +18 -12
  23. package/dist/generic/entityInfo.js.map +1 -1
  24. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  25. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  26. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  27. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  28. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  29. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  30. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  31. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  32. package/dist/generic/interfaces.d.ts +50 -1
  33. package/dist/generic/interfaces.d.ts.map +1 -1
  34. package/dist/generic/interfaces.js.map +1 -1
  35. package/dist/generic/listMembershipFilter.d.ts +5 -0
  36. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  37. package/dist/generic/listMembershipFilter.js +23 -0
  38. package/dist/generic/listMembershipFilter.js.map +1 -0
  39. package/dist/generic/localCacheManager.d.ts +48 -5
  40. package/dist/generic/localCacheManager.d.ts.map +1 -1
  41. package/dist/generic/localCacheManager.js +146 -17
  42. package/dist/generic/localCacheManager.js.map +1 -1
  43. package/dist/generic/providerBase.d.ts +155 -2
  44. package/dist/generic/providerBase.d.ts.map +1 -1
  45. package/dist/generic/providerBase.js +362 -124
  46. package/dist/generic/providerBase.js.map +1 -1
  47. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  48. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  49. package/dist/generic/relatedRecordCollection.js +11 -2
  50. package/dist/generic/relatedRecordCollection.js.map +1 -1
  51. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  52. package/dist/generic/remoteOperationDispatch.js +14 -2
  53. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  54. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  55. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  56. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  57. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  58. package/dist/generic/transportError.d.ts +41 -0
  59. package/dist/generic/transportError.d.ts.map +1 -0
  60. package/dist/generic/transportError.js +167 -0
  61. package/dist/generic/transportError.js.map +1 -0
  62. package/dist/generic/util.d.ts +12 -0
  63. package/dist/generic/util.d.ts.map +1 -1
  64. package/dist/generic/util.js +52 -3
  65. package/dist/generic/util.js.map +1 -1
  66. package/dist/index.d.ts +2 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +2 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/views/runView.d.ts +23 -10
  71. package/dist/views/runView.d.ts.map +1 -1
  72. package/dist/views/runView.js +5 -1
  73. package/dist/views/runView.js.map +1 -1
  74. 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. */
@@ -1457,7 +1472,8 @@ export class LocalCacheManager extends BaseSingleton {
1457
1472
  if (key.KeyValuePairs.some(kv => kv.Value == null))
1458
1473
  return;
1459
1474
  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
1475
+ const rowComplete = LocalCacheManager.RowHasEveryField(entityInfo, recordData)
1476
+ && !LocalCacheManager.RowContainsPlaintextEncryptedField(entityInfo, recordData); // per row, not per slot
1461
1477
  for (const fingerprint of fingerprintSnapshot) {
1462
1478
  try {
1463
1479
  // Subset slot (MaxRows/StartRow): upserting would grow the slot past the
@@ -1581,9 +1597,12 @@ export class LocalCacheManager extends BaseSingleton {
1581
1597
  }
1582
1598
  else if (!this.isFilteredFingerprint(fingerprint)) {
1583
1599
  // Unfiltered cache: update the record in place, unless the saved row is missing columns
1600
+ // or carries decrypted plaintext on an Encrypt=true field (BaseEntity.GetAll() holds
1601
+ // in-memory plaintext after Save(), which must never be persisted to shared cache/Redis).
1584
1602
  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`);
1603
+ if (!LocalCacheManager.RowHasEveryField(baseEntity.EntityInfo, entityData) ||
1604
+ LocalCacheManager.RowContainsPlaintextEncryptedField(baseEntity.EntityInfo, entityData)) {
1605
+ LogStatusVerbose(`LocalCacheManager: Invalidating "${fingerprint.substring(0, 60)}" — the saved row is missing columns or holds decrypted plaintext`);
1587
1606
  await this.InvalidateRunViewResult(fingerprint);
1588
1607
  return;
1589
1608
  }
@@ -1631,14 +1650,48 @@ export class LocalCacheManager extends BaseSingleton {
1631
1650
  }
1632
1651
  return true;
1633
1652
  }
1634
- /** Whether every saved row among `changes` carries every column ({@link RowHasEveryField}). */
1653
+ /**
1654
+ * Returns `true` when `entity` declares one or more encrypted fields (`Encrypt && EncryptionKeyID`)
1655
+ * and `row` contains a non-empty string value for any such field that is neither ciphertext
1656
+ * (`$ENC$...`) nor `ENCRYPTED_SENTINEL`.
1657
+ *
1658
+ * Used as a defense-in-depth gate so decrypted in-memory entity values (e.g. from
1659
+ * `BaseEntity.GetAll()` during `save` events) are never written in plaintext to local or
1660
+ * distributed Redis caches.
1661
+ */
1662
+ static RowContainsPlaintextEncryptedField(entity, row) {
1663
+ if (!entity || !row || typeof row !== 'object') {
1664
+ return false;
1665
+ }
1666
+ const encryptedFields = Array.isArray(entity.EncryptedFields)
1667
+ ? entity.EncryptedFields
1668
+ : Array.isArray(entity.Fields)
1669
+ ? entity.Fields.filter(f => f.Encrypt && Boolean(f.EncryptionKeyID))
1670
+ : [];
1671
+ if (encryptedFields.length === 0) {
1672
+ return false;
1673
+ }
1674
+ const rowMap = row;
1675
+ for (const field of encryptedFields) {
1676
+ if (!field.EncryptionKeyID)
1677
+ continue;
1678
+ const val = rowMap[field.Name];
1679
+ if (typeof val === 'string' && val.length > 0 && val !== ENCRYPTED_SENTINEL && !IsValueEncrypted(val)) {
1680
+ return true;
1681
+ }
1682
+ }
1683
+ return false;
1684
+ }
1685
+ /** Whether every saved row among `changes` carries every column ({@link RowHasEveryField}) and no plaintext encrypted values. */
1635
1686
  static everySavedRowComplete(changes) {
1636
- return changes.every(c => c.Type !== 'save' || LocalCacheManager.RowHasEveryField(c.EntityInfo, c.Record));
1687
+ return changes.every(c => c.Type !== 'save' ||
1688
+ (LocalCacheManager.RowHasEveryField(c.EntityInfo, c.Record) &&
1689
+ !LocalCacheManager.RowContainsPlaintextEncryptedField(c.EntityInfo, c.Record)));
1637
1690
  }
1638
1691
  /**
1639
1692
  * True when a saved row may be written into `fingerprint`'s slot in place: the slot is neither
1640
1693
  * 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.
1694
+ * carries every column ({@link RowHasEveryField}) with no plaintext secrets. Otherwise the slot must be invalidated.
1642
1695
  *
1643
1696
  * @param fingerprint - The slot to maintain
1644
1697
  * @param entity - The saved record's entity
@@ -1647,7 +1700,8 @@ export class LocalCacheManager extends BaseSingleton {
1647
1700
  CanUpsertRowIntoSlot(fingerprint, entity, row) {
1648
1701
  return !this.isFilteredFingerprint(fingerprint)
1649
1702
  && !this.isSubsetFingerprint(fingerprint)
1650
- && LocalCacheManager.RowHasEveryField(entity, row);
1703
+ && LocalCacheManager.RowHasEveryField(entity, row)
1704
+ && !LocalCacheManager.RowContainsPlaintextEncryptedField(entity, row);
1651
1705
  }
1652
1706
  /**
1653
1707
  * Handles a save/delete that could not be applied to a slot this process indexed — in practice
@@ -1746,7 +1800,25 @@ export class LocalCacheManager extends BaseSingleton {
1746
1800
  }
1747
1801
  }
1748
1802
  else {
1749
- // For set/removed, dispatch only to callbacks for the specific fingerprint
1803
+ this._lastVerifiedAt.delete(event.CacheKey);
1804
+ if (event.Action === 'row_delta' && event.Data) {
1805
+ const existingEntry = this._registry.get(event.CacheKey);
1806
+ if (existingEntry) {
1807
+ try {
1808
+ const parsedDelta = JSON.parse(event.Data);
1809
+ if (typeof parsedDelta.rowCount === 'number') {
1810
+ existingEntry.rowCount = parsedDelta.rowCount;
1811
+ }
1812
+ if (typeof parsedDelta.maxUpdatedAt === 'string') {
1813
+ existingEntry.maxUpdatedAt = parsedDelta.maxUpdatedAt;
1814
+ }
1815
+ }
1816
+ catch {
1817
+ // Best-effort local registry metadata update; callbacks handle invalid payloads safely.
1818
+ }
1819
+ }
1820
+ }
1821
+ // For set/row_delta/removed, dispatch only to callbacks for the specific fingerprint
1750
1822
  const callbacks = this._changeCallbacks.get(event.CacheKey);
1751
1823
  if (callbacks) {
1752
1824
  for (const cb of callbacks) {
@@ -2408,6 +2480,17 @@ export class LocalCacheManager extends BaseSingleton {
2408
2480
  LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for external entity "${params.EntityName}" — no TTL provided (would never invalidate)`, verboseOnly: true });
2409
2481
  return;
2410
2482
  }
2483
+ // Defense-in-depth on server caches (TrustLocalCacheCompletely === true):
2484
+ // never persist decrypted plaintext values for Encrypt=true columns into shared/Redis cache.
2485
+ const cacheProvider = provider;
2486
+ if (entity &&
2487
+ cacheProvider?.TrustLocalCacheCompletely === true &&
2488
+ !options?.ProviderInternalScaffolding &&
2489
+ Array.isArray(results) &&
2490
+ results.some(r => typeof r === 'object' && r !== null && LocalCacheManager.RowContainsPlaintextEncryptedField(entity, r))) {
2491
+ LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping server cache write for "${params.EntityName}" — rows contain decrypted plaintext for an encrypted field`, verboseOnly: true });
2492
+ return;
2493
+ }
2411
2494
  }
2412
2495
  catch (err) {
2413
2496
  // fall through and write — fail-open is safer than fail-closed here
@@ -2948,11 +3031,23 @@ export class LocalCacheManager extends BaseSingleton {
2948
3031
  }
2949
3032
  const pkFieldNames = changes[0].Key.KeyValuePairs.map(kv => kv.FieldName);
2950
3033
  const resultMap = this.indexRowsByKey(cached.results, pkFieldNames);
2951
- if (!this.applyChangesToRowMap(resultMap, changes)) {
3034
+ const deltaChanges = this.applyChangesToRowMap(resultMap, changes);
3035
+ if (!deltaChanges.changed) {
2952
3036
  return true; // only removals of rows the slot does not hold
2953
3037
  }
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 });
3038
+ return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, {
3039
+ totalRowCount: cached.totalRowCount,
3040
+ rowCount: cached.results.length,
3041
+ schemaHash: cached.schemaHash,
3042
+ schemaStamp: cached.schemaStamp,
3043
+ providerInternalScaffolding: cached.providerInternalScaffolding,
3044
+ }, cached.providerInternalScaffolding
3045
+ ? undefined
3046
+ : {
3047
+ upsertedRows: deltaChanges.upsertedRows,
3048
+ deletedPrimaryKeys: deltaChanges.deletedPrimaryKeys,
3049
+ primaryKeyFields: pkFieldNames,
3050
+ });
2956
3051
  }
2957
3052
  catch (e) {
2958
3053
  LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
@@ -2975,20 +3070,38 @@ export class LocalCacheManager extends BaseSingleton {
2975
3070
  }
2976
3071
  return resultMap;
2977
3072
  }
2978
- /** Applies `changes` to `rows` in order. @returns True when anything changed. */
3073
+ /** Applies `changes` to `rows` in order and collects the collapsed net row delta for pub/sub. */
2979
3074
  applyChangesToRowMap(rows, changes) {
2980
3075
  let changed = false;
3076
+ const upsertsByKey = new Map();
3077
+ const deletesByKey = new Map();
2981
3078
  for (const change of changes) {
2982
3079
  const keyStr = this.cheapKeyFromCompositeKey(change.Key);
2983
3080
  if (change.Row) {
2984
3081
  rows.set(keyStr, change.Row);
3082
+ deletesByKey.delete(keyStr);
3083
+ upsertsByKey.set(keyStr, change.Row);
2985
3084
  changed = true;
2986
3085
  }
2987
3086
  else if (rows.delete(keyStr)) {
3087
+ upsertsByKey.delete(keyStr);
3088
+ const pkRecord = {};
3089
+ for (const kv of change.Key.KeyValuePairs) {
3090
+ const v = kv.Value;
3091
+ pkRecord[kv.FieldName] =
3092
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null
3093
+ ? v
3094
+ : String(v);
3095
+ }
3096
+ deletesByKey.set(keyStr, pkRecord);
2988
3097
  changed = true;
2989
3098
  }
2990
3099
  }
2991
- return changed;
3100
+ return {
3101
+ changed,
3102
+ upsertedRows: Array.from(upsertsByKey.values()),
3103
+ deletedPrimaryKeys: Array.from(deletesByKey.values()),
3104
+ };
2992
3105
  }
2993
3106
  /**
2994
3107
  * Stores updated results array back to the cache and updates the registry.
@@ -3004,7 +3117,7 @@ export class LocalCacheManager extends BaseSingleton {
3004
3117
  * full-dataset slot is unchanged (prior total == prior length) while a subset slot keeps a
3005
3118
  * correct total.
3006
3119
  */
3007
- async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior) {
3120
+ async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior, rowDelta) {
3008
3121
  // Stamp the slot the way the fill path does — the newest row's __mj_UpdatedAt — so the
3009
3122
  // stamp means the same thing whichever funnel wrote the slot and matches what the database
3010
3123
  // reports for the same rows (the client smart-cache check compares the two). The caller's
@@ -3058,7 +3171,23 @@ export class LocalCacheManager extends BaseSingleton {
3058
3171
  if (!prior?.providerInternalScaffolding) {
3059
3172
  this.freezeRowDataIfProviderSharesReferences(data);
3060
3173
  }
3061
- await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt));
3174
+ let pubSubOverride;
3175
+ if (rowDelta) {
3176
+ const deltaPayload = {
3177
+ upsertedRows: rowDelta.upsertedRows,
3178
+ deletedPrimaryKeys: rowDelta.deletedPrimaryKeys,
3179
+ primaryKeyFields: rowDelta.primaryKeyFields,
3180
+ maxUpdatedAt,
3181
+ rowCount: updatedResults.length,
3182
+ totalRowCount: data.totalRowCount ?? updatedResults.length,
3183
+ schemaHash: data.schemaHash,
3184
+ };
3185
+ pubSubOverride = {
3186
+ pubSubAction: 'row_delta',
3187
+ pubSubData: JSON.stringify(deltaPayload),
3188
+ };
3189
+ }
3190
+ await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt, pubSubOverride));
3062
3191
  this._lastVerifiedAt.set(fingerprint, now);
3063
3192
  if (existingEntry) {
3064
3193
  existingEntry.maxUpdatedAt = maxUpdatedAt;