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