@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.
- package/dist/generic/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +165 -12
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +622 -98
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +10 -2
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +33 -5
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +121 -6
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +1144 -64
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseRemotableOperation.d.ts +95 -6
- package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
- package/dist/generic/baseRemotableOperation.js +112 -8
- package/dist/generic/baseRemotableOperation.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +37 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +262 -46
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +25 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +70 -12
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.js +12 -0
- package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.js +6 -2
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
- package/dist/generic/interfaces.d.ts +50 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/listMembershipFilter.d.ts +5 -0
- package/dist/generic/listMembershipFilter.d.ts.map +1 -0
- package/dist/generic/listMembershipFilter.js +23 -0
- package/dist/generic/listMembershipFilter.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +69 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +253 -18
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +105 -38
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +196 -5
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/providerBase.d.ts +198 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +721 -223
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +5 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +11 -2
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
- package/dist/generic/remoteOperationDispatch.js +14 -2
- package/dist/generic/remoteOperationDispatch.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/sharedLoad.d.ts +42 -0
- package/dist/generic/sharedLoad.d.ts.map +1 -0
- package/dist/generic/sharedLoad.js +74 -0
- package/dist/generic/sharedLoad.js.map +1 -0
- package/dist/generic/telemetryManager.d.ts +31 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +85 -9
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +19 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +55 -7
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +13 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +53 -4
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +23 -10
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +5 -1
- package/dist/views/runView.js.map +1 -1
- 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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
/**
|
|
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' ||
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
2955
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|