@memberjunction/core 6.2.0-edge.5 → 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 (148) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +15 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +21 -0
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  6. package/dist/generic/EntityFieldRules.js +1 -0
  7. package/dist/generic/EntityFieldRules.js.map +1 -1
  8. package/dist/generic/alternateBaseViews.d.ts +119 -0
  9. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  10. package/dist/generic/alternateBaseViews.js +223 -0
  11. package/dist/generic/alternateBaseViews.js.map +1 -0
  12. package/dist/generic/baseEngine.d.ts +171 -17
  13. package/dist/generic/baseEngine.d.ts.map +1 -1
  14. package/dist/generic/baseEngine.js +722 -117
  15. package/dist/generic/baseEngine.js.map +1 -1
  16. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  17. package/dist/generic/baseEngineRegistry.js +2 -1
  18. package/dist/generic/baseEngineRegistry.js.map +1 -1
  19. package/dist/generic/baseEntity.d.ts +216 -21
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +969 -100
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  24. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  25. package/dist/generic/baseRemotableOperation.js +112 -8
  26. package/dist/generic/baseRemotableOperation.js.map +1 -1
  27. package/dist/generic/contextTokens.d.ts +136 -0
  28. package/dist/generic/contextTokens.d.ts.map +1 -0
  29. package/dist/generic/contextTokens.js +319 -0
  30. package/dist/generic/contextTokens.js.map +1 -0
  31. package/dist/generic/databaseProviderBase.d.ts +103 -5
  32. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  33. package/dist/generic/databaseProviderBase.js +381 -52
  34. package/dist/generic/databaseProviderBase.js.map +1 -1
  35. package/dist/generic/embeddedRecord.d.ts +6 -0
  36. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  37. package/dist/generic/embeddedRecord.js +15 -1
  38. package/dist/generic/embeddedRecord.js.map +1 -1
  39. package/dist/generic/entityCompanion.d.ts +7 -0
  40. package/dist/generic/entityCompanion.d.ts.map +1 -1
  41. package/dist/generic/entityCompanion.js +8 -0
  42. package/dist/generic/entityCompanion.js.map +1 -1
  43. package/dist/generic/entityInfo.d.ts +234 -9
  44. package/dist/generic/entityInfo.d.ts.map +1 -1
  45. package/dist/generic/entityInfo.js +592 -77
  46. package/dist/generic/entityInfo.js.map +1 -1
  47. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  48. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  49. package/dist/generic/entityValueFieldSecurity.js +250 -0
  50. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  51. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  52. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  53. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  54. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  55. package/dist/generic/fieldSecuritySQLGate.d.ts +359 -0
  56. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  57. package/dist/generic/fieldSecuritySQLGate.js +902 -0
  58. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  59. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  60. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  61. package/dist/generic/indexRestrictedFields.js +129 -0
  62. package/dist/generic/indexRestrictedFields.js.map +1 -0
  63. package/dist/generic/interfaces.d.ts +64 -2
  64. package/dist/generic/interfaces.d.ts.map +1 -1
  65. package/dist/generic/interfaces.js.map +1 -1
  66. package/dist/generic/listMembershipFilter.d.ts +5 -0
  67. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  68. package/dist/generic/listMembershipFilter.js +23 -0
  69. package/dist/generic/listMembershipFilter.js.map +1 -0
  70. package/dist/generic/localCacheManager.d.ts +126 -14
  71. package/dist/generic/localCacheManager.d.ts.map +1 -1
  72. package/dist/generic/localCacheManager.js +348 -48
  73. package/dist/generic/localCacheManager.js.map +1 -1
  74. package/dist/generic/logging.d.ts.map +1 -1
  75. package/dist/generic/logging.js +7 -1
  76. package/dist/generic/logging.js.map +1 -1
  77. package/dist/generic/metadata.d.ts +3 -2
  78. package/dist/generic/metadata.d.ts.map +1 -1
  79. package/dist/generic/metadata.js +5 -2
  80. package/dist/generic/metadata.js.map +1 -1
  81. package/dist/generic/platformSQL.d.ts +54 -0
  82. package/dist/generic/platformSQL.d.ts.map +1 -1
  83. package/dist/generic/platformSQL.js +55 -0
  84. package/dist/generic/platformSQL.js.map +1 -1
  85. package/dist/generic/providerBase.d.ts +408 -57
  86. package/dist/generic/providerBase.d.ts.map +1 -1
  87. package/dist/generic/providerBase.js +1051 -310
  88. package/dist/generic/providerBase.js.map +1 -1
  89. package/dist/generic/queryInfo.d.ts +13 -0
  90. package/dist/generic/queryInfo.d.ts.map +1 -1
  91. package/dist/generic/queryInfo.js +13 -0
  92. package/dist/generic/queryInfo.js.map +1 -1
  93. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  94. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  95. package/dist/generic/queryTemplateContext.d.ts +49 -0
  96. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  97. package/dist/generic/queryTemplateContext.js +113 -0
  98. package/dist/generic/queryTemplateContext.js.map +1 -0
  99. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  100. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  101. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  102. package/dist/generic/relatedRecordCollection.d.ts +18 -0
  103. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  104. package/dist/generic/relatedRecordCollection.js +55 -7
  105. package/dist/generic/relatedRecordCollection.js.map +1 -1
  106. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  107. package/dist/generic/remoteOperationDispatch.js +14 -2
  108. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  109. package/dist/generic/runQuery.d.ts +42 -0
  110. package/dist/generic/runQuery.d.ts.map +1 -1
  111. package/dist/generic/runQuery.js +14 -0
  112. package/dist/generic/runQuery.js.map +1 -1
  113. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  114. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  115. package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
  116. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  118. package/dist/generic/saveEntityGraphOperation.js +3 -0
  119. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  120. package/dist/generic/securityInfo.d.ts +11 -0
  121. package/dist/generic/securityInfo.d.ts.map +1 -1
  122. package/dist/generic/securityInfo.js +15 -0
  123. package/dist/generic/securityInfo.js.map +1 -1
  124. package/dist/generic/telemetryManager.d.ts +9 -0
  125. package/dist/generic/telemetryManager.d.ts.map +1 -1
  126. package/dist/generic/telemetryManager.js +5 -1
  127. package/dist/generic/telemetryManager.js.map +1 -1
  128. package/dist/generic/transportError.d.ts +41 -0
  129. package/dist/generic/transportError.d.ts.map +1 -0
  130. package/dist/generic/transportError.js +167 -0
  131. package/dist/generic/transportError.js.map +1 -0
  132. package/dist/generic/util.d.ts +12 -0
  133. package/dist/generic/util.d.ts.map +1 -1
  134. package/dist/generic/util.js +52 -3
  135. package/dist/generic/util.js.map +1 -1
  136. package/dist/index.d.ts +9 -0
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +9 -0
  139. package/dist/index.js.map +1 -1
  140. package/dist/views/runView.d.ts +102 -0
  141. package/dist/views/runView.d.ts.map +1 -1
  142. package/dist/views/runView.js +40 -0
  143. package/dist/views/runView.js.map +1 -1
  144. package/dist/views/viewProperties.d.ts +55 -0
  145. package/dist/views/viewProperties.d.ts.map +1 -0
  146. package/dist/views/viewProperties.js +258 -0
  147. package/dist/views/viewProperties.js.map +1 -0
  148. package/package.json +3 -3
@@ -1,5 +1,7 @@
1
- import { BaseSingleton, MJGlobal, MJEventType } from "@memberjunction/global";
2
- import { IsMaterializedDataSource } from "../views/runView.js";
1
+ import { BaseSingleton, MJGlobal, MJEventType, IsValueEncrypted, ENCRYPTED_SENTINEL } from "@memberjunction/global";
2
+ import { IsMaterializedDataSource, NormalizeAlternateBaseViewName } from "../views/runView.js";
3
+ import { GetEffectiveAlternateBaseViewName } from "./alternateBaseViews.js";
4
+ import { ViewPropertyValuesKey } from "../views/viewProperties.js";
3
5
  import { LogError, LogStatusEx, LogWarning } from "./logging.js";
4
6
  import { BaseEntity } from "./baseEntity.js";
5
7
  import { Metadata } from "./metadata.js";
@@ -156,6 +158,7 @@ export class LocalCacheManager extends BaseSingleton {
156
158
  */
157
159
  this._sharesReferences = false;
158
160
  this._registry = new Map();
161
+ this._lastVerifiedAt = new Map();
159
162
  this._initialized = false;
160
163
  this._initializePromise = null;
161
164
  this._stats = { hits: 0, misses: 0 };
@@ -464,6 +467,17 @@ export class LocalCacheManager extends BaseSingleton {
464
467
  || this.hasNarrowingSegment(parts)
465
468
  || this.hasAggregates(parts);
466
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
+ }
467
481
  /**
468
482
  * Returns true if the slot was cached under an ORDER BY (fingerprint segment [2]).
469
483
  *
@@ -567,6 +581,9 @@ export class LocalCacheManager extends BaseSingleton {
567
581
  * segment is built. Same misclassification, worse consequence: a save by
568
582
  * user A was upserted into user B's RLS-scoped slot, injecting a row B's
569
583
  * predicate excludes. That is an RLS bypass.
584
+ * - `fls:<h>` — the slot holds only the columns one permission class may read. Upserting a
585
+ * saver's row would put columns into it that its readers may not see (or
586
+ * take away ones they may), so it is invalidated like the two above.
570
587
  *
571
588
  * ## Why it is written as a DENY-by-default allowlist
572
589
  * Enumerating the narrowing segments would repeat the original mistake: the next segment
@@ -590,6 +607,9 @@ export class LocalCacheManager extends BaseSingleton {
590
607
  if (!seg) {
591
608
  continue;
592
609
  }
610
+ if (seg.startsWith('abv:')) {
611
+ return true; // alternate base view applies a canonical WHERE/JOIN — never maintain in place on save
612
+ }
593
613
  if (seg.startsWith('imr:')) {
594
614
  continue; // widens the set — safe to maintain
595
615
  }
@@ -861,7 +881,7 @@ export class LocalCacheManager extends BaseSingleton {
861
881
  * @param fingerprint - The slot key
862
882
  * @param expiresAt - Absolute expiry (ms since epoch), when the slot has one
863
883
  */
864
- runViewWriteOptions(fingerprint, expiresAt) {
884
+ runViewWriteOptions(fingerprint, expiresAt, pubSubOverride) {
865
885
  const options = {};
866
886
  const entity = this.extractEntityFromFingerprint(fingerprint);
867
887
  if (entity) {
@@ -870,6 +890,10 @@ export class LocalCacheManager extends BaseSingleton {
870
890
  if (expiresAt) {
871
891
  options.TTLSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
872
892
  }
893
+ if (pubSubOverride) {
894
+ options.pubSubAction = pubSubOverride.pubSubAction;
895
+ options.pubSubData = pubSubOverride.pubSubData;
896
+ }
873
897
  return options;
874
898
  }
875
899
  /** Storage write options for any registry entry; only RunView slots are indexed. */
@@ -1327,11 +1351,12 @@ export class LocalCacheManager extends BaseSingleton {
1327
1351
  }
1328
1352
  const snapshot = [...fingerprints];
1329
1353
  const nowISO = new Date().toISOString();
1354
+ const savesComplete = LocalCacheManager.everySavedRowComplete(changes); // per change, not per slot
1330
1355
  const BATCH_SIZE = 8;
1331
1356
  for (let i = 0; i < snapshot.length; i += BATCH_SIZE) {
1332
1357
  await Promise.all(snapshot.slice(i, i + BATCH_SIZE).map(async (fingerprint) => {
1333
1358
  try {
1334
- await this.applyEntityChangesToSlot(fingerprint, changes, nowISO);
1359
+ await this.applyEntityChangesToSlot(fingerprint, changes, nowISO, savesComplete);
1335
1360
  }
1336
1361
  catch (err) {
1337
1362
  LogError(`LocalCacheManager: failed to apply batched changes to "${fingerprint}": ${err.message}`);
@@ -1343,12 +1368,14 @@ export class LocalCacheManager extends BaseSingleton {
1343
1368
  * One slot, several changes: the same decisions {@link processEntityEventForFingerprint} makes
1344
1369
  * per event, made once for the whole list.
1345
1370
  */
1346
- async applyEntityChangesToSlot(fingerprint, changes, nowISO) {
1371
+ async applyEntityChangesToSlot(fingerprint, changes, nowISO, savesComplete = LocalCacheManager.everySavedRowComplete(changes)) {
1347
1372
  const hasSave = changes.some(c => c.Type === 'save');
1348
1373
  // Subset and aggregate slots cannot be maintained in place; filtered slots cannot take an
1349
1374
  // upsert (the new row may not match the filter) but can drop deleted rows.
1375
+ // A saved row missing columns cannot replace a full-width row either (see RowHasEveryField).
1350
1376
  if (this.isSubsetFingerprint(fingerprint) || this.hasAggregates(fingerprint.split('|'))
1351
- || (hasSave && this.isFilteredFingerprint(fingerprint))) {
1377
+ || (hasSave && this.isFilteredFingerprint(fingerprint))
1378
+ || !savesComplete) {
1352
1379
  await this.InvalidateRunViewResult(fingerprint);
1353
1380
  return;
1354
1381
  }
@@ -1445,11 +1472,13 @@ export class LocalCacheManager extends BaseSingleton {
1445
1472
  if (key.KeyValuePairs.some(kv => kv.Value == null))
1446
1473
  return;
1447
1474
  LogStatusVerbose(`LocalCacheManager: remote-invalidate (save) for "${entityName}" PK=${key.ToConcatenatedString()}, updating ${fingerprints.size} cached fingerprint(s)`);
1475
+ const rowComplete = LocalCacheManager.RowHasEveryField(entityInfo, recordData)
1476
+ && !LocalCacheManager.RowContainsPlaintextEncryptedField(entityInfo, recordData); // per row, not per slot
1448
1477
  for (const fingerprint of fingerprintSnapshot) {
1449
1478
  try {
1450
1479
  // Subset slot (MaxRows/StartRow): upserting would grow the slot past the
1451
1480
  // caller's own row limit. Invalidate, same as a filtered slot.
1452
- if (!this.isFilteredFingerprint(fingerprint) && !this.isSubsetFingerprint(fingerprint)) {
1481
+ if (rowComplete && !this.isFilteredFingerprint(fingerprint) && !this.isSubsetFingerprint(fingerprint)) {
1453
1482
  await this.UpsertSingleEntity(fingerprint, recordData, key, nowISO);
1454
1483
  }
1455
1484
  else {
@@ -1567,9 +1596,17 @@ export class LocalCacheManager extends BaseSingleton {
1567
1596
  await this.invalidateIfNotMaintained(fingerprint, maintained);
1568
1597
  }
1569
1598
  else if (!this.isFilteredFingerprint(fingerprint)) {
1570
- // Unfiltered cache: update the record in place
1571
- LogStatusVerbose(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
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).
1572
1602
  const entityData = baseEntity.GetAll();
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`);
1606
+ await this.InvalidateRunViewResult(fingerprint);
1607
+ return;
1608
+ }
1609
+ LogStatusVerbose(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
1573
1610
  const maintained = await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
1574
1611
  await this.invalidateIfNotMaintained(fingerprint, maintained);
1575
1612
  }
@@ -1579,6 +1616,93 @@ export class LocalCacheManager extends BaseSingleton {
1579
1616
  await this.InvalidateRunViewResult(fingerprint);
1580
1617
  }
1581
1618
  }
1619
+ /**
1620
+ * True when `row` has a key for every non-binary field of `entity`, so it may replace the
1621
+ * cached copy of its record in place.
1622
+ *
1623
+ * A save event carries the saver's own values (`GetAll()`), and an object leaves out every
1624
+ * field it does not hold: one its user may not read, or one that was never loaded. Upserting
1625
+ * such a row into a full-width slot would replace a complete cached row with a partial one,
1626
+ * and every later reader of that slot, including users entitled to the missing columns, would
1627
+ * be served the row without them. A partial row therefore clears the slot instead, and the
1628
+ * next read refills it from the database. Binary fields are not required, because slots
1629
+ * leave them out unless asked for.
1630
+ *
1631
+ * A row is judged against the entity's field list, so without one (no entity metadata on
1632
+ * the event, which a real entity always carries) there is nothing to compare and the row is
1633
+ * accepted, as before this check existed.
1634
+ *
1635
+ * @param entity - The saved record's entity
1636
+ * @param row - The values the event carries
1637
+ */
1638
+ static RowHasEveryField(entity, row) {
1639
+ if (!row || typeof row !== 'object') {
1640
+ return false;
1641
+ }
1642
+ const fields = entity?.Fields;
1643
+ if (!Array.isArray(fields) || fields.length === 0) {
1644
+ return true;
1645
+ }
1646
+ for (const field of fields) {
1647
+ if (!field.IsBinaryFieldType && !Object.prototype.hasOwnProperty.call(row, field.Name)) {
1648
+ return false;
1649
+ }
1650
+ }
1651
+ return true;
1652
+ }
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. */
1686
+ static everySavedRowComplete(changes) {
1687
+ return changes.every(c => c.Type !== 'save' ||
1688
+ (LocalCacheManager.RowHasEveryField(c.EntityInfo, c.Record) &&
1689
+ !LocalCacheManager.RowContainsPlaintextEncryptedField(c.EntityInfo, c.Record)));
1690
+ }
1691
+ /**
1692
+ * True when a saved row may be written into `fingerprint`'s slot in place: the slot is neither
1693
+ * filtered (including row-security and field-security segments) nor a subset, and the row
1694
+ * carries every column ({@link RowHasEveryField}) with no plaintext secrets. Otherwise the slot must be invalidated.
1695
+ *
1696
+ * @param fingerprint - The slot to maintain
1697
+ * @param entity - The saved record's entity
1698
+ * @param row - The values the save carries
1699
+ */
1700
+ CanUpsertRowIntoSlot(fingerprint, entity, row) {
1701
+ return !this.isFilteredFingerprint(fingerprint)
1702
+ && !this.isSubsetFingerprint(fingerprint)
1703
+ && LocalCacheManager.RowHasEveryField(entity, row)
1704
+ && !LocalCacheManager.RowContainsPlaintextEncryptedField(entity, row);
1705
+ }
1582
1706
  /**
1583
1707
  * Handles a save/delete that could not be applied to a slot this process indexed — in practice
1584
1708
  * because the slot expired or was removed by someone else.
@@ -1676,7 +1800,25 @@ export class LocalCacheManager extends BaseSingleton {
1676
1800
  }
1677
1801
  }
1678
1802
  else {
1679
- // 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
1680
1822
  const callbacks = this._changeCallbacks.get(event.CacheKey);
1681
1823
  if (callbacks) {
1682
1824
  for (const cb of callbacks) {
@@ -1867,16 +2009,12 @@ export class LocalCacheManager extends BaseSingleton {
1867
2009
  * what goes in it, and `ProviderBase.ComputeRunViewFLSFingerprintKey` is the single place
1868
2010
  * that decision lives so the two tiers cannot drift.
1869
2011
  *
1870
- * **CLIENT-ONLY in practice.** The client passes its ALLOWED set
1871
- * (`ProviderBase.ComputeClientFLSAllowedKey`); the SERVER passes nothing. Server slots are
1872
- * full-width and shared by every user, with narrowing applied at read time by
1873
- * `ApplyFieldSecurityProjection` on both the hit and miss paths — a segment there would
1874
- * fragment one shared slot into one per permission class and protect nothing. Client slots
1875
- * are stored exactly as the server returned them (already narrowed on the wire) and are
1876
- * never projected on read, so the field set has to be part of slot identity: without it a
1877
- * user whose access is tightened keeps being served their persisted IndexedDB slot, because
1878
- * the currency check compares only `maxUpdatedAt` and `rowCount` and neither notices a
1879
- * column.
2012
+ * **Both tiers pass it.** Each slot holds the rows of one permission class: the provider
2013
+ * fetches only the columns the reading user may see, so the field set has to be part of slot
2014
+ * identity on the server exactly as it is in the browser, where rows are stored as the server
2015
+ * returned them and never projected on read. Users with the same allowed set share a slot;
2016
+ * users with different sets never do. That also keeps a user search's row set apart, since
2017
+ * which rows match depends on which columns the reader may search.
1880
2018
  *
1881
2019
  * Keyed on the ALLOWED set rather than the denied one deliberately: once client metadata is
1882
2020
  * filtered for restricted users ([#3485](https://github.com/MemberJunction/MJ/issues/3485))
@@ -1887,7 +2025,7 @@ export class LocalCacheManager extends BaseSingleton {
1887
2025
  * Empty for unrestricted users and non-FLS entities, which appends no segment at all — so
1888
2026
  * their fingerprints stay byte-identical and keep sharing slots (the `rls:` rule). A
1889
2027
  * permission change produces a new hash → fresh slot; slots keyed to the old hash strand
1890
- * until eviction (memory cost, not a leak).
2028
+ * until eviction (memory cost, not a leak). Hashed with {@link HashSecuritySegment}.
1891
2029
  * @param datasetSegment - Namespace for dataset-item slots (see the `ds:` append below): keeps
1892
2030
  * `GetDatasetByName` item caching from colliding with a plain unfiltered read of the same
1893
2031
  * entity. Appended only when supplied, so ordinary reads keep their pre-existing key.
@@ -1952,6 +2090,14 @@ export class LocalCacheManager extends BaseSingleton {
1952
2090
  if (params.IncludeBinaryFields === true) {
1953
2091
  parts.push('bin:1');
1954
2092
  }
2093
+ // SkipTotalRowCount segment. A call that skips the fallback COUNT stores a result whose
2094
+ // TotalRowCount was never counted, so it must not share a slot with the otherwise
2095
+ // identical counted call (each would be served the other's TotalRowCount). Appended only
2096
+ // when true, so every existing fingerprint stays byte-for-byte identical. Not allowlisted
2097
+ // in hasNarrowingSegment, so such a slot is invalidated on save rather than maintained.
2098
+ if (params.SkipTotalRowCount === true) {
2099
+ parts.push('strc:1');
2100
+ }
1955
2101
  // DataSource segment. RunViewParams.DataSource:'Materialized' routes the read to the entity's
1956
2102
  // materialized snapshot view (GetEffectiveBaseView), a DIFFERENT physical source than the default
1957
2103
  // live base view — so a Live read and a Materialized read of the same entity/filter/orderBy MUST
@@ -1961,6 +2107,15 @@ export class LocalCacheManager extends BaseSingleton {
1961
2107
  if (IsMaterializedDataSource(params.DataSource)) {
1962
2108
  parts.push('ds:materialized');
1963
2109
  }
2110
+ // AlternateBaseView segment. Routing a read through an alternate base view (explicitly via
2111
+ // params.AlternateBaseView or pinned inside a loaded params.ViewEntity's DisplayState JSON)
2112
+ // targets a different physical SQL view with its own canonical WHERE/JOIN, so it MUST NOT
2113
+ // share a cache slot with the entity's canonical BaseView or another alternate view.
2114
+ // Appended only when non-empty so canonical reads keep their exact pre-existing fingerprint.
2115
+ const effectiveAbv = NormalizeAlternateBaseViewName(GetEffectiveAlternateBaseViewName(params));
2116
+ if (effectiveAbv.length > 0) {
2117
+ parts.push(`abv:${effectiveAbv}`);
2118
+ }
1964
2119
  // Keyset (AfterKey) seek cursor MUST be part of the fingerprint. Each keyset page
1965
2120
  // sends a different AfterKey but otherwise-identical params; without this, sequential
1966
2121
  // pages collide on the same fingerprint and the dedup/linger layer hands page N+1 the
@@ -1978,25 +2133,24 @@ export class LocalCacheManager extends BaseSingleton {
1978
2133
  // substitutions) hash differently and therefore never collide.
1979
2134
  const rls = (rlsWhereClause ?? '').trim();
1980
2135
  if (rls.length > 0) {
1981
- parts.push(`rls:${this.simpleHash(rls)}`);
2136
+ parts.push(`rls:${this.HashSecuritySegment(rls)}`);
1982
2137
  }
1983
- // Field-Level-Security segment — the column counterpart of `rls:`, and CLIENT-ONLY.
2138
+ // Field-Level-Security segment — the column counterpart of `rls:`.
1984
2139
  //
1985
- // A browser stores rows exactly as the server returned them (already narrowed to the
1986
- // user's allowed columns) and never projects on read, so the field set has to be part
1987
- // of slot identity. Without it, a user whose access is tightened keeps being served
1988
- // their persisted IndexedDB slot: the currency check compares maxUpdatedAt and rowCount
1989
- // only, neither of which notices a column.
1990
- //
1991
- // The server passes nothing here — its slots are full-width and shared, with narrowing
1992
- // applied at read time by ApplyFieldSecurityProjection.
1993
- // ProviderBase.ComputeRunViewFLSFingerprintKey is where that split is decided.
2140
+ // A slot holds the columns one permission class may read, on both tiers: the server
2141
+ // fetches only the reader's allowed columns, and a browser stores rows exactly as the
2142
+ // server returned them. So the field set is part of slot identity, and a user is only
2143
+ // ever served a slot filled for the same allowed set. Without it, a user whose access is
2144
+ // tightened keeps being served a slot with the column in it (the currency check compares
2145
+ // maxUpdatedAt and rowCount only, neither of which notices a column), and a restricted
2146
+ // reader's narrow rows would be served to an unrestricted one.
2147
+ // ProviderBase.ComputeRunViewFLSFingerprintKey decides what the key contains.
1994
2148
  //
1995
2149
  // Appended ONLY when non-empty, so unrestricted users and non-FLS entities keep
1996
2150
  // byte-identical fingerprints and shared slots (the rls: rule).
1997
2151
  const fls = (flsFieldsKey ?? '').trim();
1998
2152
  if (fls.length > 0) {
1999
- parts.push(`fls:${this.simpleHash(fls)}`);
2153
+ parts.push(`fls:${this.HashSecuritySegment(fls)}`);
2000
2154
  }
2001
2155
  // Stored-view identity. A saved view's WhereClause/OrderBy live on the view, not in
2002
2156
  // params.ExtraFilter, so a view run and a plain entity read (or a different view) can
@@ -2009,6 +2163,17 @@ export class LocalCacheManager extends BaseSingleton {
2009
2163
  if (viewKey.length > 0) {
2010
2164
  parts.push(`vw:${viewKey}`);
2011
2165
  }
2166
+ // View-property segment. A stored view's property values (RunViewParams.Properties and
2167
+ // ServerProperties) each add a predicate the provider builds, so one chapter's run must
2168
+ // never be served another chapter's slot. On the server the provider has already replaced
2169
+ // both with the resolved values (token defaults included) before this runs. The values go
2170
+ // in as canonical JSON rather than a hash: a 32-bit hash of values a client partly chooses
2171
+ // could be steered onto another run's slot. Appended ONLY when a value is present, so every
2172
+ // run without properties keeps its exact fingerprint.
2173
+ const propertyKey = ViewPropertyValuesKey(params.Properties, params.ServerProperties);
2174
+ if (propertyKey.length > 0) {
2175
+ parts.push(`vp:${propertyKey}`);
2176
+ }
2012
2177
  // Dataset namespace. `GetDatasetByName` caches each dataset ITEM's rows through this same
2013
2178
  // builder, supplying only entity + the item's WhereClause — and every shipped item has a
2014
2179
  // NULL WhereClause, so without this segment a dataset item emits the identical fingerprint
@@ -2091,6 +2256,39 @@ export class LocalCacheManager extends BaseSingleton {
2091
2256
  }
2092
2257
  return reordered;
2093
2258
  }
2259
+ /**
2260
+ * A 128-bit hash, as 32 hex characters, of a security key that becomes part of a slot's
2261
+ * identity: the `rls:` and `fls:` fingerprint segments, and the field-security key a browser
2262
+ * sends with its cache status.
2263
+ *
2264
+ * These keys decide which users may share a slot, so two different keys must not land on the
2265
+ * same value. The 32-bit {@link simpleHash} used for the other segments is too short for that:
2266
+ * at a few tens of thousands of distinct row filters a collision becomes likely, and a
2267
+ * collision here serves one user's rows to another. Four independent 32-bit multiply-xor lanes
2268
+ * keep it synchronous, deterministic and identical in Node and the browser.
2269
+ *
2270
+ * @param str - The security key
2271
+ * @returns 32 lowercase hex characters
2272
+ */
2273
+ HashSecuritySegment(str) {
2274
+ let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762;
2275
+ for (let i = 0; i < str.length; i++) {
2276
+ const k = str.charCodeAt(i);
2277
+ h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
2278
+ h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
2279
+ h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
2280
+ h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
2281
+ }
2282
+ h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
2283
+ h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
2284
+ h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
2285
+ h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
2286
+ h1 ^= (h2 ^ h3 ^ h4);
2287
+ h2 ^= h1;
2288
+ h3 ^= h1;
2289
+ h4 ^= h1;
2290
+ return [h1, h2, h3, h4].map(h => (h >>> 0).toString(16).padStart(8, '0')).join('');
2291
+ }
2094
2292
  /**
2095
2293
  * Simple hash function for creating short fingerprints from strings.
2096
2294
  * Not cryptographic, just for deduplication/fingerprinting purposes.
@@ -2282,6 +2480,17 @@ export class LocalCacheManager extends BaseSingleton {
2282
2480
  LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for external entity "${params.EntityName}" — no TTL provided (would never invalidate)`, verboseOnly: true });
2283
2481
  return;
2284
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
+ }
2285
2494
  }
2286
2495
  catch (err) {
2287
2496
  // fall through and write — fail-open is safer than fail-closed here
@@ -2295,7 +2504,8 @@ export class LocalCacheManager extends BaseSingleton {
2295
2504
  maxUpdatedAt = coerced;
2296
2505
  }
2297
2506
  // Persist results, maxUpdatedAt, aggregateResults, totalRowCount, and schemaHash
2298
- const data = { results, maxUpdatedAt };
2507
+ const initialNow = Date.now();
2508
+ const data = { results, maxUpdatedAt, cachedAt: initialNow, lastVerifiedAt: initialNow };
2299
2509
  if (options?.ProviderInternalScaffolding) {
2300
2510
  // Persisted so event-driven in-place maintenance carries the exemption forward.
2301
2511
  data.providerInternalScaffolding = true;
@@ -2352,8 +2562,10 @@ export class LocalCacheManager extends BaseSingleton {
2352
2562
  await this.evictIfNeeded(sizeBytes);
2353
2563
  try {
2354
2564
  // Native object storage — IDB structured-clones, localStorage / Redis serialize internally.
2355
- const expiresAt = ttlMs ? Date.now() + ttlMs : undefined;
2565
+ const writeNow = Date.now();
2566
+ const expiresAt = ttlMs ? writeNow + ttlMs : undefined;
2356
2567
  await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, expiresAt));
2568
+ this._lastVerifiedAt.set(fingerprint, writeNow);
2357
2569
  this.registerEntry({
2358
2570
  key: fingerprint,
2359
2571
  type: 'runview',
@@ -2367,8 +2579,9 @@ export class LocalCacheManager extends BaseSingleton {
2367
2579
  MaxRows: params.MaxRows,
2368
2580
  HasAggregates: (params.Aggregates?.length ?? 0) > 0
2369
2581
  },
2370
- cachedAt: Date.now(),
2371
- lastAccessedAt: Date.now(),
2582
+ cachedAt: writeNow,
2583
+ lastAccessedAt: writeNow,
2584
+ lastVerifiedAt: writeNow,
2372
2585
  accessCount: 1,
2373
2586
  sizeBytes,
2374
2587
  maxUpdatedAt,
@@ -2493,6 +2706,11 @@ export class LocalCacheManager extends BaseSingleton {
2493
2706
  this.indexSlotThisProcessDidNotWrite(fingerprint);
2494
2707
  this._stats.hits++;
2495
2708
  const results = parsed.results || [];
2709
+ const cachedAt = registryEntry?.cachedAt ?? parsed.cachedAt;
2710
+ const lastVerifiedAt = this._lastVerifiedAt.get(fingerprint)
2711
+ ?? registryEntry?.lastVerifiedAt
2712
+ ?? parsed.lastVerifiedAt
2713
+ ?? cachedAt;
2496
2714
  const result = {
2497
2715
  results,
2498
2716
  maxUpdatedAt: parsed.maxUpdatedAt,
@@ -2503,12 +2721,31 @@ export class LocalCacheManager extends BaseSingleton {
2503
2721
  schemaStamp: parsed.schemaStamp,
2504
2722
  // Same carry-forward rationale: a maintained scaffolding slot must stay exempt.
2505
2723
  providerInternalScaffolding: parsed.providerInternalScaffolding,
2724
+ cachedAt,
2725
+ lastVerifiedAt,
2506
2726
  };
2507
2727
  if (parsed.aggregateResults) {
2508
2728
  result.aggregateResults = parsed.aggregateResults;
2509
2729
  }
2510
2730
  return result;
2511
2731
  }
2732
+ /**
2733
+ * Updates the `lastVerifiedAt` timestamp for a cached RunView slot in O(1) time
2734
+ * without re-cloning, re-freezing, or rewriting its stored row array.
2735
+ *
2736
+ * Used by server-side smart-cache verification after a lightweight SQL status probe
2737
+ * (`MAX(__mj_UpdatedAt)`, `COUNT(*)`) confirms that a cached slot is still current.
2738
+ *
2739
+ * @param fingerprint - The cache fingerprint to touch
2740
+ * @param timestamp - Verification timestamp in epoch ms (defaults to `Date.now()`)
2741
+ */
2742
+ TouchRunViewResultVerifiedAt(fingerprint, timestamp = Date.now()) {
2743
+ this._lastVerifiedAt.set(fingerprint, timestamp);
2744
+ const entry = this._registry.get(fingerprint);
2745
+ if (entry) {
2746
+ entry.lastVerifiedAt = timestamp;
2747
+ }
2748
+ }
2512
2749
  /**
2513
2750
  * Records a slot this process READ, so a later save can still find it once it has expired.
2514
2751
  *
@@ -2669,11 +2906,16 @@ export class LocalCacheManager extends BaseSingleton {
2669
2906
  // Note: If aggregateResults not provided, cached aggregates are cleared (they'd be stale)
2670
2907
  await this.SetRunViewResult(fingerprint, params, mergedResults, newMaxUpdatedAt, aggregateResults, mergedTotalRowCount, provider);
2671
2908
  // Return with rowCount derived from merged results and aggregates if provided
2909
+ const verifiedNow = this._lastVerifiedAt.get(fingerprint) ?? Date.now();
2910
+ this._lastVerifiedAt.set(fingerprint, verifiedNow);
2911
+ const registryEntry = this._registry.get(fingerprint);
2672
2912
  const result = {
2673
2913
  results: mergedResults,
2674
2914
  maxUpdatedAt: newMaxUpdatedAt,
2675
2915
  rowCount: mergedResults.length,
2676
- totalRowCount: mergedTotalRowCount
2916
+ totalRowCount: mergedTotalRowCount,
2917
+ cachedAt: registryEntry?.cachedAt ?? verifiedNow,
2918
+ lastVerifiedAt: verifiedNow
2677
2919
  };
2678
2920
  if (aggregateResults) {
2679
2921
  result.aggregateResults = aggregateResults;
@@ -2789,11 +3031,23 @@ export class LocalCacheManager extends BaseSingleton {
2789
3031
  }
2790
3032
  const pkFieldNames = changes[0].Key.KeyValuePairs.map(kv => kv.FieldName);
2791
3033
  const resultMap = this.indexRowsByKey(cached.results, pkFieldNames);
2792
- if (!this.applyChangesToRowMap(resultMap, changes)) {
3034
+ const deltaChanges = this.applyChangesToRowMap(resultMap, changes);
3035
+ if (!deltaChanges.changed) {
2793
3036
  return true; // only removals of rows the slot does not hold
2794
3037
  }
2795
- return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
2796
- 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
+ });
2797
3051
  }
2798
3052
  catch (e) {
2799
3053
  LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
@@ -2816,20 +3070,38 @@ export class LocalCacheManager extends BaseSingleton {
2816
3070
  }
2817
3071
  return resultMap;
2818
3072
  }
2819
- /** 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. */
2820
3074
  applyChangesToRowMap(rows, changes) {
2821
3075
  let changed = false;
3076
+ const upsertsByKey = new Map();
3077
+ const deletesByKey = new Map();
2822
3078
  for (const change of changes) {
2823
3079
  const keyStr = this.cheapKeyFromCompositeKey(change.Key);
2824
3080
  if (change.Row) {
2825
3081
  rows.set(keyStr, change.Row);
3082
+ deletesByKey.delete(keyStr);
3083
+ upsertsByKey.set(keyStr, change.Row);
2826
3084
  changed = true;
2827
3085
  }
2828
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);
2829
3097
  changed = true;
2830
3098
  }
2831
3099
  }
2832
- return changed;
3100
+ return {
3101
+ changed,
3102
+ upsertedRows: Array.from(upsertsByKey.values()),
3103
+ deletedPrimaryKeys: Array.from(deletesByKey.values()),
3104
+ };
2833
3105
  }
2834
3106
  /**
2835
3107
  * Stores updated results array back to the cache and updates the registry.
@@ -2845,15 +3117,19 @@ export class LocalCacheManager extends BaseSingleton {
2845
3117
  * full-dataset slot is unchanged (prior total == prior length) while a subset slot keeps a
2846
3118
  * correct total.
2847
3119
  */
2848
- async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior) {
3120
+ async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior, rowDelta) {
2849
3121
  // Stamp the slot the way the fill path does — the newest row's __mj_UpdatedAt — so the
2850
3122
  // stamp means the same thing whichever funnel wrote the slot and matches what the database
2851
3123
  // reports for the same rows (the client smart-cache check compares the two). The caller's
2852
3124
  // value (the event time) is used only when the rows carry no timestamp column.
2853
3125
  const maxUpdatedAt = LocalCacheManager.MaxUpdatedAtOfRows(updatedResults) ?? newMaxUpdatedAt;
3126
+ const now = Date.now();
3127
+ const existingEntry = this._registry.get(fingerprint);
2854
3128
  const data = {
2855
3129
  results: updatedResults,
2856
- maxUpdatedAt
3130
+ maxUpdatedAt,
3131
+ cachedAt: existingEntry?.cachedAt ?? now,
3132
+ lastVerifiedAt: now
2857
3133
  };
2858
3134
  // Carry the schemaHash FORWARD — never recompute it here (B38).
2859
3135
  //
@@ -2895,13 +3171,30 @@ export class LocalCacheManager extends BaseSingleton {
2895
3171
  if (!prior?.providerInternalScaffolding) {
2896
3172
  this.freezeRowDataIfProviderSharesReferences(data);
2897
3173
  }
2898
- const existingEntry = this._registry.get(fingerprint);
2899
- 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));
3191
+ this._lastVerifiedAt.set(fingerprint, now);
2900
3192
  if (existingEntry) {
2901
3193
  existingEntry.maxUpdatedAt = maxUpdatedAt;
2902
3194
  existingEntry.rowCount = updatedResults.length;
2903
3195
  existingEntry.sizeBytes = sizeBytes;
2904
- existingEntry.lastAccessedAt = Date.now();
3196
+ existingEntry.lastAccessedAt = now;
3197
+ existingEntry.lastVerifiedAt = now;
2905
3198
  this.debouncedPersistRegistry();
2906
3199
  }
2907
3200
  return true;
@@ -2924,6 +3217,7 @@ export class LocalCacheManager extends BaseSingleton {
2924
3217
  try {
2925
3218
  await this._storageProvider.Remove(key, CacheCategory.RunViewCache);
2926
3219
  this._registry.delete(key);
3220
+ this._lastVerifiedAt.delete(key);
2927
3221
  this.removeFromEntityIndex(key);
2928
3222
  }
2929
3223
  catch (e) {
@@ -3338,6 +3632,7 @@ export class LocalCacheManager extends BaseSingleton {
3338
3632
  await this._storageProvider.Remove(entry.key + '_date', category);
3339
3633
  }
3340
3634
  this._registry.delete(entry.key);
3635
+ this._lastVerifiedAt.delete(entry.key);
3341
3636
  }
3342
3637
  catch (e) {
3343
3638
  LogError(`LocalCacheManager.ClearByType failed for key ${entry.key}: ${e}`);
@@ -3368,6 +3663,7 @@ export class LocalCacheManager extends BaseSingleton {
3368
3663
  }
3369
3664
  }
3370
3665
  this._registry.clear();
3666
+ this._lastVerifiedAt.clear();
3371
3667
  this._stats = { hits: 0, misses: 0 };
3372
3668
  await this.persistRegistry();
3373
3669
  return count;
@@ -3418,6 +3714,7 @@ export class LocalCacheManager extends BaseSingleton {
3418
3714
  */
3419
3715
  unregisterEntry(key) {
3420
3716
  this._registry.delete(key);
3717
+ this._lastVerifiedAt.delete(key);
3421
3718
  this.debouncedPersistRegistry();
3422
3719
  }
3423
3720
  /**
@@ -3675,6 +3972,7 @@ export class LocalCacheManager extends BaseSingleton {
3675
3972
  this.removeFromEntityIndex(entry.fingerprint);
3676
3973
  }
3677
3974
  this._registry.delete(key);
3975
+ this._lastVerifiedAt.delete(key);
3678
3976
  }
3679
3977
  catch (e) {
3680
3978
  // Continue evicting other entries
@@ -3727,6 +4025,7 @@ export class LocalCacheManager extends BaseSingleton {
3727
4025
  this.removeFromEntityIndex(entry.key);
3728
4026
  bytesToFree -= entry.sizeBytes;
3729
4027
  this._registry.delete(entry.key);
4028
+ this._lastVerifiedAt.delete(entry.key);
3730
4029
  }
3731
4030
  catch {
3732
4031
  // Continue evicting
@@ -3831,6 +4130,7 @@ export class LocalCacheManager extends BaseSingleton {
3831
4130
  this.removeFromEntityIndex(entry.fingerprint);
3832
4131
  }
3833
4132
  this._registry.delete(key);
4133
+ this._lastVerifiedAt.delete(key);
3834
4134
  }
3835
4135
  }
3836
4136
  //# sourceMappingURL=localCacheManager.js.map