@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.
- package/dist/generic/BaseEntitySaveQueue.d.ts +15 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +21 -0
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- 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/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +171 -17
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +722 -117
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +216 -21
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +969 -100
- 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/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +103 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +381 -52
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +234 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +592 -77
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- 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 +359 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +902 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +64 -2
- 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 +126 -14
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +348 -48
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +408 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1051 -310
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +13 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +13 -0
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +18 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +55 -7
- 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/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.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 +119 -29
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.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 +12 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +52 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +102 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +40 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
* **
|
|
1871
|
-
*
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1874
|
-
*
|
|
1875
|
-
*
|
|
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.
|
|
2136
|
+
parts.push(`rls:${this.HashSecuritySegment(rls)}`);
|
|
1982
2137
|
}
|
|
1983
|
-
// Field-Level-Security segment — the column counterpart of `rls
|
|
2138
|
+
// Field-Level-Security segment — the column counterpart of `rls:`.
|
|
1984
2139
|
//
|
|
1985
|
-
// A
|
|
1986
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
1990
|
-
//
|
|
1991
|
-
//
|
|
1992
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
2371
|
-
lastAccessedAt:
|
|
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
|
-
|
|
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, {
|
|
2796
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2899
|
-
|
|
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 =
|
|
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
|