@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,5 @@
|
|
|
1
1
|
import { BaseEntity } from "./baseEntity.js";
|
|
2
|
+
import { IsTransportError } from './transportError.js';
|
|
2
3
|
import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType, FieldSecurityDenialMessage, FieldSecurityError } from "./entityInfo.js";
|
|
3
4
|
import { GetEffectiveAlternateBaseViewName, IsExplicitBaseViewOverride } from "./alternateBaseViews.js";
|
|
4
5
|
import { ProviderType, AllMetadata } from "./interfaces.js";
|
|
@@ -272,6 +273,14 @@ export class ProviderBase {
|
|
|
272
273
|
this._clientFingerprintMemo = new WeakMap();
|
|
273
274
|
this._requestDeniedReadFields = new WeakMap();
|
|
274
275
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
276
|
+
/**
|
|
277
|
+
* Whether the most recent {@link RefreshRemoteMetadataTimestamps} actually REACHED the
|
|
278
|
+
* server, regardless of whether it came back with usable timestamps.
|
|
279
|
+
*
|
|
280
|
+
* Starts `true` so that nothing reads "unreachable" before a read has been attempted.
|
|
281
|
+
* See {@link ReadRemoteMetadataTimestamps} for why the distinction exists.
|
|
282
|
+
*/
|
|
283
|
+
this._lastRemoteTimestampReadReached = true;
|
|
275
284
|
/** Guard for {@link logMetadataSnapshotDisabledOnce}. */
|
|
276
285
|
this._metadataSnapshotSkipLogged = false;
|
|
277
286
|
}
|
|
@@ -1087,6 +1096,7 @@ export class ProviderBase {
|
|
|
1087
1096
|
if (params.OnDataChanged && preResult.fingerprint) {
|
|
1088
1097
|
preResult.cachedResult.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
1089
1098
|
}
|
|
1099
|
+
ProviderBase.clearSkippedTotalRowCount(params, preResult.cachedResult);
|
|
1090
1100
|
return preResult.cachedResult;
|
|
1091
1101
|
}
|
|
1092
1102
|
// Cache miss — execute query, then post-process (stores in cache)
|
|
@@ -1095,6 +1105,7 @@ export class ProviderBase {
|
|
|
1095
1105
|
// PostRunView copies any hook-supplied replacement onto `result` in place, so this
|
|
1096
1106
|
// reference reflects the hook chain's output.
|
|
1097
1107
|
await this.PostRunView(result, params, preResult, contextUser);
|
|
1108
|
+
ProviderBase.clearSkippedTotalRowCount(params, result);
|
|
1098
1109
|
return result;
|
|
1099
1110
|
}
|
|
1100
1111
|
// Delegate to RunViews, which uses the smart cache check (lightweight maxUpdatedAt +
|
|
@@ -1167,27 +1178,7 @@ export class ProviderBase {
|
|
|
1167
1178
|
// ── Fresh execution ──
|
|
1168
1179
|
const promise = this.executeRunViewsPipeline(params, contextUser)
|
|
1169
1180
|
.then(results => {
|
|
1170
|
-
|
|
1171
|
-
// extreme churn (hundreds of distinct keys resolving within one linger
|
|
1172
|
-
// window) skip lingering rather than hold more result arrays in memory —
|
|
1173
|
-
// the linger is a latency optimization, never a correctness requirement.
|
|
1174
|
-
const entry = this._inflightViews.get(key);
|
|
1175
|
-
if (entry && entry.promise === promise) {
|
|
1176
|
-
if (ProviderBase.DedupLingerMs > 0 && this._inflightViews.size <= ProviderBase.MaxLingerEntries) {
|
|
1177
|
-
entry.resolvedResults = results;
|
|
1178
|
-
entry.resolvedAt = Date.now();
|
|
1179
|
-
// Schedule cleanup after linger expires
|
|
1180
|
-
setTimeout(() => {
|
|
1181
|
-
const current = this._inflightViews.get(key);
|
|
1182
|
-
if (current && current.promise === promise) {
|
|
1183
|
-
this._inflightViews.delete(key);
|
|
1184
|
-
}
|
|
1185
|
-
}, ProviderBase.DedupLingerMs);
|
|
1186
|
-
}
|
|
1187
|
-
else {
|
|
1188
|
-
this._inflightViews.delete(key);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1181
|
+
this.stashInflightLingerResult(key, promise, results, true);
|
|
1191
1182
|
return results;
|
|
1192
1183
|
})
|
|
1193
1184
|
.catch(err => {
|
|
@@ -1204,6 +1195,36 @@ export class ProviderBase {
|
|
|
1204
1195
|
return results.map(r => this.shallowCopyResult(r));
|
|
1205
1196
|
}
|
|
1206
1197
|
// ── Dedup helpers ──────────────────────────────────────────────────
|
|
1198
|
+
/**
|
|
1199
|
+
* Stashes resolved RunViews results on `_inflightViews` for `DedupLingerMs` and schedules
|
|
1200
|
+
* cleanup via an isolated static `WeakRef` timer helper so the timer closure's V8 lexical
|
|
1201
|
+
* context never retains `this`, `promise`, or `results` on short-lived per-request providers.
|
|
1202
|
+
*/
|
|
1203
|
+
stashInflightLingerResult(key, promise, results, enforceMaxEntries) {
|
|
1204
|
+
const entry = this._inflightViews.get(key);
|
|
1205
|
+
if (!entry || entry.promise !== promise) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (ProviderBase.DedupLingerMs > 0 && (!enforceMaxEntries || this._inflightViews.size <= ProviderBase.MaxLingerEntries)) {
|
|
1209
|
+
const resolvedAt = Date.now();
|
|
1210
|
+
entry.resolvedResults = results;
|
|
1211
|
+
entry.resolvedAt = resolvedAt;
|
|
1212
|
+
ProviderBase.scheduleInflightLingerExpiry(new WeakRef(this), key, resolvedAt);
|
|
1213
|
+
}
|
|
1214
|
+
else {
|
|
1215
|
+
this._inflightViews.delete(key);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
static scheduleInflightLingerExpiry(selfRef, key, resolvedAt) {
|
|
1219
|
+
const timer = setTimeout(() => {
|
|
1220
|
+
const self = selfRef.deref();
|
|
1221
|
+
const current = self?._inflightViews.get(key);
|
|
1222
|
+
if (current && current.resolvedAt === resolvedAt) {
|
|
1223
|
+
self?._inflightViews.delete(key);
|
|
1224
|
+
}
|
|
1225
|
+
}, ProviderBase.DedupLingerMs);
|
|
1226
|
+
timer?.unref?.();
|
|
1227
|
+
}
|
|
1207
1228
|
/**
|
|
1208
1229
|
* The original RunViews execution pipeline (pre-processing, cache,
|
|
1209
1230
|
* internal execution, post-processing).
|
|
@@ -1234,12 +1255,32 @@ export class ProviderBase {
|
|
|
1234
1255
|
}
|
|
1235
1256
|
return merged;
|
|
1236
1257
|
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Keeps the `TotalRowCount` contract on every way out of the pipeline: a caller that passed
|
|
1260
|
+
* `SkipTotalRowCount` gets `undefined`, never a number. Cache hits, the smart-cache check and
|
|
1261
|
+
* the GraphQL wire each rebuild a result and each have a reason to fill in a row count, so the
|
|
1262
|
+
* contract is applied once here rather than at each of those sites.
|
|
1263
|
+
*/
|
|
1264
|
+
static clearSkippedTotalRowCount(params, result) {
|
|
1265
|
+
if (result && params.SkipTotalRowCount === true && params.ResultType !== 'count_only') {
|
|
1266
|
+
result.TotalRowCount = undefined;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Runs a RunViews batch of the views the field-security predicate gate admitted, and applies
|
|
1271
|
+
* the `TotalRowCount` contract to whichever stage produced each result.
|
|
1272
|
+
*/
|
|
1273
|
+
async runAdmittedViews(params, preResult, contextUser) {
|
|
1274
|
+
const results = await this.runAdmittedViewStages(params, preResult, contextUser);
|
|
1275
|
+
params.forEach((param, i) => ProviderBase.clearSkippedTotalRowCount(param, results[i]));
|
|
1276
|
+
return results;
|
|
1277
|
+
}
|
|
1237
1278
|
/**
|
|
1238
1279
|
* The cache, database and post-processing stages of a RunViews batch, for the views the
|
|
1239
1280
|
* field-security predicate gate admitted. `params` and every index map in `preResult`
|
|
1240
1281
|
* describe the same admitted views.
|
|
1241
1282
|
*/
|
|
1242
|
-
async
|
|
1283
|
+
async runAdmittedViewStages(params, preResult, contextUser) {
|
|
1243
1284
|
// Check for smart cache check mode
|
|
1244
1285
|
if (preResult.useSmartCacheCheck && preResult.smartCacheCheckParams) {
|
|
1245
1286
|
return this.executeSmartCacheCheck(params, preResult, contextUser);
|
|
@@ -1509,22 +1550,7 @@ export class ProviderBase {
|
|
|
1509
1550
|
// ── Fresh execution ──
|
|
1510
1551
|
const promise = this.executeRunViewsPipeline(params, contextUser)
|
|
1511
1552
|
.then(results => {
|
|
1512
|
-
|
|
1513
|
-
if (entry && entry.promise === promise) {
|
|
1514
|
-
entry.resolvedResults = results;
|
|
1515
|
-
entry.resolvedAt = Date.now();
|
|
1516
|
-
if (ProviderBase.DedupLingerMs > 0) {
|
|
1517
|
-
setTimeout(() => {
|
|
1518
|
-
const current = this._inflightViews.get(key);
|
|
1519
|
-
if (current && current.promise === promise) {
|
|
1520
|
-
this._inflightViews.delete(key);
|
|
1521
|
-
}
|
|
1522
|
-
}, ProviderBase.DedupLingerMs);
|
|
1523
|
-
}
|
|
1524
|
-
else {
|
|
1525
|
-
this._inflightViews.delete(key);
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1553
|
+
this.stashInflightLingerResult(key, promise, results, false);
|
|
1528
1554
|
return results;
|
|
1529
1555
|
})
|
|
1530
1556
|
.catch(err => {
|
|
@@ -1584,6 +1610,7 @@ export class ProviderBase {
|
|
|
1584
1610
|
return false;
|
|
1585
1611
|
}
|
|
1586
1612
|
return !param.BypassCache &&
|
|
1613
|
+
param.CacheLocal !== false &&
|
|
1587
1614
|
!param.AfterKey &&
|
|
1588
1615
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1589
1616
|
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
@@ -1593,6 +1620,27 @@ export class ProviderBase {
|
|
|
1593
1620
|
// Deliberately not user-dependent: field security changes which slot a request uses
|
|
1594
1621
|
// (the fls: segment), never whether it is cacheable.
|
|
1595
1622
|
}
|
|
1623
|
+
/**
|
|
1624
|
+
* True when the caller explicitly requested a non-empty `Fields` projection on a plain-object
|
|
1625
|
+
* (`ResultType !== 'entity_object'`) read.
|
|
1626
|
+
*/
|
|
1627
|
+
hasNarrowFieldsOverride(param) {
|
|
1628
|
+
return Boolean(param.Fields && param.Fields.length > 0 && param.ResultType !== 'entity_object');
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* True when a cache-miss read should widen `Fields` to the full entity superset AND populate
|
|
1632
|
+
* `LocalCacheManager` with that superset.
|
|
1633
|
+
*
|
|
1634
|
+
* On the server (`TrustLocalCacheCompletely === true`), cache-allowed entities (`AllowCaching === true`)
|
|
1635
|
+
* widen on miss by default so the Fields-agnostic superset slot serves any future `Fields` subset
|
|
1636
|
+
* (`server-cache` S1 -> S2 -> S3), UNLESS the caller explicitly opts out via `CacheLocal: false` or
|
|
1637
|
+
* `BypassCache: true` (in which case narrow SQL `SELECT` pushdown is preserved end-to-end without
|
|
1638
|
+
* polluting the cache). On the client (`TrustLocalCacheCompletely === false`), only `CacheLocal: true`
|
|
1639
|
+
* widens and caches.
|
|
1640
|
+
*/
|
|
1641
|
+
shouldWidenAndPopulateCache(param) {
|
|
1642
|
+
return this.runViewCacheEligible(param);
|
|
1643
|
+
}
|
|
1596
1644
|
/**
|
|
1597
1645
|
* True when the request targets an `AlternateBaseView` (explicitly on `param.AlternateBaseView`
|
|
1598
1646
|
* or pinned on `param.ViewEntity`) whose `CachePolicy` (or an ancestor's `CachePolicy` in its
|
|
@@ -1984,6 +2032,7 @@ export class ProviderBase {
|
|
|
1984
2032
|
ExtraFilter: filter,
|
|
1985
2033
|
ResultType: 'simple',
|
|
1986
2034
|
MaxRows: 1,
|
|
2035
|
+
SkipTotalRowCount: true,
|
|
1987
2036
|
}, contextUser);
|
|
1988
2037
|
if (!r.Success || (r.Results?.length ?? 0) === 0)
|
|
1989
2038
|
return null;
|
|
@@ -2092,6 +2141,7 @@ export class ProviderBase {
|
|
|
2092
2141
|
Fields: entity.PrimaryKeys.map(pk => pk.Name),
|
|
2093
2142
|
ResultType: 'simple',
|
|
2094
2143
|
MaxRows: ids.length,
|
|
2144
|
+
SkipTotalRowCount: true,
|
|
2095
2145
|
}, contextUser);
|
|
2096
2146
|
if (!r.Success)
|
|
2097
2147
|
return new Set();
|
|
@@ -2112,7 +2162,10 @@ export class ProviderBase {
|
|
|
2112
2162
|
// revoked grants stay visible through the linger window.
|
|
2113
2163
|
// SaveViewResults bypasses for the original reason: it creates DB
|
|
2114
2164
|
// records and must not be deduplicated.
|
|
2115
|
-
|
|
2165
|
+
// CacheLocal:false is the server-side opt-out from the RunView cache (read AND write). A
|
|
2166
|
+
// linger hit would hand back rows that were themselves served from that cache, so the
|
|
2167
|
+
// opt-out has to cover the dedup window as well.
|
|
2168
|
+
return params.some(p => p.SaveViewResults === true || p.BypassCache === true || p.CacheLocal === false);
|
|
2116
2169
|
}
|
|
2117
2170
|
/**
|
|
2118
2171
|
* Returns a shallow copy of a RunViewResult: the Results array is a
|
|
@@ -2263,7 +2316,8 @@ export class ProviderBase {
|
|
|
2263
2316
|
...(params.Parameters ?? {}),
|
|
2264
2317
|
__maxRows: params.MaxRows ?? -1,
|
|
2265
2318
|
__startRow: params.StartRow ?? 0,
|
|
2266
|
-
...serverSegment
|
|
2319
|
+
...serverSegment,
|
|
2320
|
+
...(queryAuth.fieldSecurityKey ? { __fieldSecurity: queryAuth.fieldSecurityKey } : {})
|
|
2267
2321
|
};
|
|
2268
2322
|
if (serverSegment !== null) {
|
|
2269
2323
|
queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
|
|
@@ -3056,41 +3110,29 @@ export class ProviderBase {
|
|
|
3056
3110
|
await this.EntityStatusCheck(params, 'PreRunView', contextUser);
|
|
3057
3111
|
const entityCheckTime = performance.now() - entityCheckStart;
|
|
3058
3112
|
// Save the caller's original Fields request for post-cache filtering.
|
|
3059
|
-
//
|
|
3060
|
-
//
|
|
3061
|
-
//
|
|
3062
|
-
//
|
|
3113
|
+
// When widening happens (full-row read, a cache-eligible read on the server, explicit
|
|
3114
|
+
// CacheLocal: true, or entity_object), we fetch every field this user's permission class
|
|
3115
|
+
// may read so the cache entry is a superset for that class, and project the result back to
|
|
3116
|
+
// the caller's Fields. A narrow Fields read first checks any already-warm superset slot
|
|
3117
|
+
// (0-SQL hit). Only CacheLocal: false or BypassCache: true keep the caller's narrow SQL
|
|
3118
|
+
// projection on a miss, and then nothing is written to the cache.
|
|
3063
3119
|
const entityLookupStart = performance.now();
|
|
3064
|
-
|
|
3120
|
+
const rawCallerRequestedFields = params.Fields && params.Fields.length > 0
|
|
3065
3121
|
? params.Fields.map(f => f.trim().toLowerCase())
|
|
3066
3122
|
: null; // null = caller wants all fields
|
|
3067
|
-
// Only override Fields to the widened fetch set when caching will actually happen
|
|
3068
|
-
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
3069
|
-
// end-to-end — there's no cache-coherence concern to preserve. The widened set is
|
|
3070
|
-
// ALL entity fields; a database provider then leaves out the columns this user may
|
|
3071
|
-
// not read, and the slot is keyed by the user's permission class (fls: segment).
|
|
3072
3123
|
const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
|
|
3073
|
-
const willCache = this.runViewCacheEligible(params);
|
|
3074
|
-
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
3075
|
-
// every column it is entitled to, or it round-trips partial state into a save. This
|
|
3076
|
-
// must happen here rather than only in the server's PreProcessRunView, which is not on
|
|
3077
|
-
// the client's path — a client entity_object request without CacheLocal would otherwise
|
|
3078
|
-
// ship the caller's narrow Fields over the wire and materialize partial entities. What a
|
|
3079
|
-
// field-restricted user RECEIVES is still their allowed set: the server strips denied
|
|
3080
|
-
// columns on the wire, and the missing keys mark those fields not-loaded.
|
|
3081
|
-
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
3082
3124
|
if (entity)
|
|
3083
3125
|
this.ResolveIncludeBinaryFields(params, entity);
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3126
|
+
const projectedCallerFields = (rawCallerRequestedFields && entity)
|
|
3127
|
+
? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerRequestedFields, entity)
|
|
3128
|
+
: rawCallerRequestedFields;
|
|
3129
|
+
const canReadFromCache = this.runViewCacheEligible(params);
|
|
3130
|
+
const willPopulateCache = this.shouldWidenAndPopulateCache(params) && (!this.hasNarrowFieldsOverride(params) || Boolean(entity));
|
|
3131
|
+
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
3132
|
+
// every column it is entitled to, or it round-trips partial state into a save.
|
|
3133
|
+
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
3092
3134
|
const entityLookupTime = performance.now() - entityLookupStart;
|
|
3093
|
-
// Check local cache if enabled
|
|
3135
|
+
// Check local cache FIRST if enabled (before any cache-miss Fields widening)
|
|
3094
3136
|
const cacheCheckStart = performance.now();
|
|
3095
3137
|
let cacheStatus = 'disabled';
|
|
3096
3138
|
let cachedResult;
|
|
@@ -3106,16 +3148,17 @@ export class ProviderBase {
|
|
|
3106
3148
|
// fails closed (or resolves ViewEntity synchronously) to plug that hole.
|
|
3107
3149
|
const cacheReadDenied = (!!entity && !!contextUser && !(entity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
3108
3150
|
this.cacheDeniedForViewOnlyRequest(params, contextUser);
|
|
3109
|
-
if (
|
|
3151
|
+
if (canReadFromCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
3110
3152
|
fingerprint = this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
3111
3153
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
3112
3154
|
if (cached) {
|
|
3113
3155
|
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
3114
3156
|
// what stops a consumer from corrupting the cache. Anything that needs to
|
|
3115
3157
|
// transform them must map onto copies.
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3158
|
+
// Materialize any encrypted fields stored as ciphertext at rest before projection.
|
|
3159
|
+
let results = await this.MaterializeCachedRowsForCaller(cached.results, entity, contextUser, Boolean(params.PreserveRestrictedApiCiphertext));
|
|
3160
|
+
if (projectedCallerFields && params.ResultType !== 'entity_object') {
|
|
3161
|
+
results = ProjectRowsToFields(results, projectedCallerFields);
|
|
3119
3162
|
}
|
|
3120
3163
|
// Field-level security MUST be applied here, not only in PostRunView: this path
|
|
3121
3164
|
// RETURNS (see RunView) without ever calling PostRunView, and it is the exact
|
|
@@ -3149,6 +3192,15 @@ export class ProviderBase {
|
|
|
3149
3192
|
cacheStatus = 'miss';
|
|
3150
3193
|
}
|
|
3151
3194
|
}
|
|
3195
|
+
// On cache MISS (or uninitialized cache), widen Fields ONLY when populating a superset
|
|
3196
|
+
// cache slot or materializing full BaseEntity objects.
|
|
3197
|
+
let callerRequestedFieldsForPost = null;
|
|
3198
|
+
if (cacheStatus !== 'hit' && entity && (willPopulateCache || widenForEntityObject)) {
|
|
3199
|
+
params.Fields = this.ComputeRunViewFetchFields(entity, params);
|
|
3200
|
+
if (willPopulateCache && projectedCallerFields) {
|
|
3201
|
+
callerRequestedFieldsForPost = projectedCallerFields;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3152
3204
|
const cacheCheckTime = performance.now() - cacheCheckStart;
|
|
3153
3205
|
const totalPreTime = performance.now() - preViewStart;
|
|
3154
3206
|
if (totalPreTime > 50) {
|
|
@@ -3159,9 +3211,10 @@ export class ProviderBase {
|
|
|
3159
3211
|
cacheStatus,
|
|
3160
3212
|
cachedResult,
|
|
3161
3213
|
fingerprint,
|
|
3162
|
-
// Only non-null when params.Fields was actually widened
|
|
3214
|
+
// Only non-null when params.Fields was actually widened for cache population — tells
|
|
3163
3215
|
// PostRunView to project cache-miss DB results back to the caller's shape
|
|
3164
|
-
callerRequestedFields:
|
|
3216
|
+
callerRequestedFields: callerRequestedFieldsForPost,
|
|
3217
|
+
populateCache: willPopulateCache,
|
|
3165
3218
|
};
|
|
3166
3219
|
}
|
|
3167
3220
|
/**
|
|
@@ -3283,6 +3336,7 @@ export class ProviderBase {
|
|
|
3283
3336
|
const cacheStatusMap = new Map();
|
|
3284
3337
|
const callerFieldsMap = new Map();
|
|
3285
3338
|
const fingerprintMap = new Map();
|
|
3339
|
+
const populateCacheSet = new Set();
|
|
3286
3340
|
const uncachedParams = [];
|
|
3287
3341
|
const cachedResults = [];
|
|
3288
3342
|
let allCached = true;
|
|
@@ -3290,31 +3344,22 @@ export class ProviderBase {
|
|
|
3290
3344
|
const param = params[i];
|
|
3291
3345
|
// Entity status check
|
|
3292
3346
|
await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
|
|
3293
|
-
// Save caller's original Fields
|
|
3294
|
-
//
|
|
3295
|
-
|
|
3347
|
+
// Save caller's original Fields. Consult warm superset cache slots first (0-SQL hit),
|
|
3348
|
+
// and on cache miss widen Fields ONLY when populating a superset cache slot or
|
|
3349
|
+
// materializing BaseEntity instances.
|
|
3350
|
+
const rawCallerFields = param.Fields && param.Fields.length > 0
|
|
3296
3351
|
? param.Fields.map(f => f.trim().toLowerCase())
|
|
3297
3352
|
: null;
|
|
3298
|
-
// Only override Fields to the widened fetch set when caching will actually happen
|
|
3299
|
-
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
3300
|
-
// end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
|
|
3301
|
-
// fields (see PreRunView).
|
|
3302
3353
|
const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
|
|
3303
|
-
const batchWillCache = this.runViewCacheEligible(param);
|
|
3304
|
-
// Same entity_object-always-widens rule as the single-view path above.
|
|
3305
|
-
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
3306
3354
|
if (batchEntity)
|
|
3307
3355
|
this.ResolveIncludeBinaryFields(param, batchEntity);
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
callerFieldsMap.set(i, callerFields);
|
|
3316
|
-
}
|
|
3317
|
-
}
|
|
3356
|
+
const projectedCallerFields = (rawCallerFields && batchEntity)
|
|
3357
|
+
? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerFields, batchEntity)
|
|
3358
|
+
: rawCallerFields;
|
|
3359
|
+
const batchCanReadCache = this.runViewCacheEligible(param);
|
|
3360
|
+
const batchWillPopulateCache = this.shouldWidenAndPopulateCache(param) && (!this.hasNarrowFieldsOverride(param) || Boolean(batchEntity));
|
|
3361
|
+
// Same entity_object-always-widens rule as the single-view path above.
|
|
3362
|
+
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
3318
3363
|
// Check local cache if enabled or if server trusts its cache completely
|
|
3319
3364
|
// BypassCache skips cache entirely — used by maintenance actions querying for
|
|
3320
3365
|
// records that were inserted via direct SQL (bypassing BaseEntity.Save())
|
|
@@ -3324,15 +3369,15 @@ export class ProviderBase {
|
|
|
3324
3369
|
// view-only fail-closed gate for ViewID/ViewName-only requests (no resolvable entity).
|
|
3325
3370
|
const batchCacheReadDenied = (!!batchEntity && !!contextUser && !(batchEntity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
3326
3371
|
this.cacheDeniedForViewOnlyRequest(param, contextUser);
|
|
3327
|
-
if (
|
|
3372
|
+
if (batchCanReadCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
3328
3373
|
const fingerprint = this.ComputeRunViewCacheFingerprint(param, contextUser);
|
|
3329
3374
|
fingerprintMap.set(i, fingerprint);
|
|
3330
3375
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
3331
3376
|
if (cached) {
|
|
3332
3377
|
// Shared, deep-frozen cache rows — same contract as the single-view hit path.
|
|
3333
|
-
let results = cached.results;
|
|
3334
|
-
if (
|
|
3335
|
-
results = ProjectRowsToFields(results,
|
|
3378
|
+
let results = await this.MaterializeCachedRowsForCaller(cached.results, batchEntity, contextUser, Boolean(param.PreserveRestrictedApiCiphertext));
|
|
3379
|
+
if (projectedCallerFields && param.ResultType !== 'entity_object') {
|
|
3380
|
+
results = ProjectRowsToFields(results, projectedCallerFields);
|
|
3336
3381
|
}
|
|
3337
3382
|
// Field-level security on the batch cache-hit path — same reasoning as the
|
|
3338
3383
|
// single-view path in PreRunView: this result is returned without
|
|
@@ -3368,6 +3413,15 @@ export class ProviderBase {
|
|
|
3368
3413
|
else {
|
|
3369
3414
|
cacheStatusMap.set(i, { status: 'disabled' });
|
|
3370
3415
|
}
|
|
3416
|
+
if (batchWillPopulateCache) {
|
|
3417
|
+
populateCacheSet.add(i);
|
|
3418
|
+
}
|
|
3419
|
+
if (batchEntity && (batchWillPopulateCache || batchWidenForEntityObject)) {
|
|
3420
|
+
param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
|
|
3421
|
+
if (batchWillPopulateCache && projectedCallerFields) {
|
|
3422
|
+
callerFieldsMap.set(i, projectedCallerFields);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3371
3425
|
allCached = false;
|
|
3372
3426
|
uncachedParams.push(param);
|
|
3373
3427
|
cachedResults.push(null); // Placeholder for uncached
|
|
@@ -3382,7 +3436,8 @@ export class ProviderBase {
|
|
|
3382
3436
|
uncachedParams: allCached ? undefined : uncachedParams,
|
|
3383
3437
|
cacheStatusMap,
|
|
3384
3438
|
callerFieldsMap: callerFieldsMap.size > 0 ? callerFieldsMap : undefined,
|
|
3385
|
-
fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined
|
|
3439
|
+
fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined,
|
|
3440
|
+
populateCacheSet,
|
|
3386
3441
|
};
|
|
3387
3442
|
}
|
|
3388
3443
|
/**
|
|
@@ -3762,18 +3817,39 @@ export class ProviderBase {
|
|
|
3762
3817
|
* @param preResult - The pre-processing result
|
|
3763
3818
|
* @param contextUser - Optional user context
|
|
3764
3819
|
*/
|
|
3820
|
+
/**
|
|
3821
|
+
* Hook allowing server database providers to substitute pre-decryption (ciphertext-preserving)
|
|
3822
|
+
* rows before storing view results in {@link LocalCacheManager} / Redis, ensuring decrypted
|
|
3823
|
+
* plaintext secrets are never persisted in local or shared caches.
|
|
3824
|
+
*
|
|
3825
|
+
* Client-side providers inherit this default identity implementation.
|
|
3826
|
+
*/
|
|
3827
|
+
PrepareRowsForCacheStorage(rows, _entityInfo) {
|
|
3828
|
+
return rows;
|
|
3829
|
+
}
|
|
3830
|
+
/**
|
|
3831
|
+
* Hook allowing server database providers to decrypt ciphertext-at-rest rows retrieved from
|
|
3832
|
+
* {@link LocalCacheManager} for in-process callers (or selectively preserve restricted
|
|
3833
|
+
* ciphertext when `preserveRestrictedApiCiphertext` is `true`).
|
|
3834
|
+
*
|
|
3835
|
+
* Client-side providers inherit this default identity implementation.
|
|
3836
|
+
*/
|
|
3837
|
+
async MaterializeCachedRowsForCaller(rows, _entityInfo, _contextUser, _preserveRestrictedApiCiphertext) {
|
|
3838
|
+
return rows;
|
|
3839
|
+
}
|
|
3765
3840
|
async PostRunView(result, params, preResult, contextUser) {
|
|
3766
3841
|
// Store in local cache BEFORE entity transformation — the cache needs
|
|
3767
3842
|
// plain JSON-serializable objects. BaseEntity objects contain RxJS Subjects
|
|
3768
3843
|
// with circular subscriber references that break JSON.stringify.
|
|
3769
3844
|
// On cache read, TransformSimpleObjectToEntityObject is called to restore
|
|
3770
3845
|
// entity objects when ResultType === 'entity_object'.
|
|
3771
|
-
//
|
|
3772
|
-
//
|
|
3773
|
-
|
|
3774
|
-
|
|
3846
|
+
// preResult.populateCache records PreRunView's widening decision so narrow Fields
|
|
3847
|
+
// queries (without CacheLocal: true) never poison the Fields-agnostic superset slot.
|
|
3848
|
+
const resolvedEntity = this.resolveEntityForRunViewParams(params);
|
|
3849
|
+
const shouldPopulateSlot = preResult.populateCache ?? this.shouldWidenAndPopulateCache(params);
|
|
3850
|
+
if (shouldPopulateSlot && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
|
|
3775
3851
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3776
|
-
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3852
|
+
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3777
3853
|
}
|
|
3778
3854
|
else if (this.shouldAutoCache(params, result, contextUser)) {
|
|
3779
3855
|
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
@@ -3781,7 +3857,7 @@ export class ProviderBase {
|
|
|
3781
3857
|
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
3782
3858
|
const fingerprint = preResult.fingerprint || this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
3783
3859
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3784
|
-
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3860
|
+
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3785
3861
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3786
3862
|
}
|
|
3787
3863
|
// Project cache-miss DB results back down to the caller's originally requested
|
|
@@ -3857,17 +3933,20 @@ export class ProviderBase {
|
|
|
3857
3933
|
// was disabled for them but auto-cache/OnDataChanged may still need it).
|
|
3858
3934
|
const fingerprint = preResult.fingerprintMap?.get(i)
|
|
3859
3935
|
?? this.ComputeRunViewCacheFingerprint(params[i], contextUser);
|
|
3860
|
-
// CRITICAL: must
|
|
3936
|
+
// CRITICAL: must reflect the SAME widening predicate PreRunViews used to decide
|
|
3861
3937
|
// whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
|
|
3862
|
-
// result here poisons the Fields-agnostic superset slot
|
|
3863
|
-
|
|
3864
|
-
|
|
3938
|
+
// result here poisons the Fields-agnostic superset slot.
|
|
3939
|
+
const itemEntity = this.resolveEntityForRunViewParams(params[i]);
|
|
3940
|
+
const shouldPopulateBatchSlot = preResult.populateCacheSet
|
|
3941
|
+
? preResult.populateCacheSet.has(i)
|
|
3942
|
+
: this.shouldWidenAndPopulateCache(params[i]);
|
|
3943
|
+
if (shouldPopulateBatchSlot && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
|
|
3865
3944
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
3866
|
-
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3945
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3867
3946
|
}
|
|
3868
3947
|
else if (this.shouldAutoCache(params[i], results[i], contextUser)) {
|
|
3869
3948
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
3870
|
-
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3949
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3871
3950
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3872
3951
|
}
|
|
3873
3952
|
// Register OnDataChanged callback if provided
|
|
@@ -4138,6 +4217,8 @@ export class ProviderBase {
|
|
|
4138
4217
|
// main-path variants of those bugs.
|
|
4139
4218
|
if (!this.runViewCacheEligible(params))
|
|
4140
4219
|
return false;
|
|
4220
|
+
if (this.hasNarrowFieldsOverride(params))
|
|
4221
|
+
return false;
|
|
4141
4222
|
if (!LocalCacheManager.Instance.IsInitialized)
|
|
4142
4223
|
return false;
|
|
4143
4224
|
if (!result.Success)
|
|
@@ -4453,6 +4534,9 @@ export class ProviderBase {
|
|
|
4453
4534
|
* Resolves the {@link EntityInfo} normalization should read field types from, using only
|
|
4454
4535
|
* synchronously available information on the params.
|
|
4455
4536
|
*/
|
|
4537
|
+
resolveEntityForRunViewParams(param) {
|
|
4538
|
+
return this.resolveEntityForNormalization(param);
|
|
4539
|
+
}
|
|
4456
4540
|
resolveEntityForNormalization(param) {
|
|
4457
4541
|
if (param.EntityName) {
|
|
4458
4542
|
return this.EntityByName(param.EntityName);
|
|
@@ -5155,6 +5239,13 @@ export class ProviderBase {
|
|
|
5155
5239
|
return false; // if we fail to copy the metadata, we will return false
|
|
5156
5240
|
}
|
|
5157
5241
|
}
|
|
5242
|
+
/**
|
|
5243
|
+
* Re-adopts the immutable read-only metadata catalog shell and lookup maps from
|
|
5244
|
+
* `Metadata.Provider` after the global provider completes a metadata refresh.
|
|
5245
|
+
*/
|
|
5246
|
+
SyncMetadataFromGlobalProvider() {
|
|
5247
|
+
return this.CopyMetadataFromGlobalProvider();
|
|
5248
|
+
}
|
|
5158
5249
|
/**
|
|
5159
5250
|
* Builds dataset filters based on the provider configuration.
|
|
5160
5251
|
* Ensures MJ Core schema is always included and never excluded.
|
|
@@ -5584,6 +5675,27 @@ export class ProviderBase {
|
|
|
5584
5675
|
* RefreshIfNeeded load outside Config's reload single-flight). For the same reason it gets
|
|
5585
5676
|
* `false`, not the owner's rejection, when the owner's check throws.
|
|
5586
5677
|
* @returns True if refresh is needed, false otherwise
|
|
5678
|
+
*
|
|
5679
|
+
* @remarks
|
|
5680
|
+
* This is a **freshness question, not a load**, so it never throws on an unreachable
|
|
5681
|
+
* server. {@link ReadRemoteMetadataTimestamps} reports whether the server was reached at
|
|
5682
|
+
* all, and when it was NOT the honest answer here depends on whether we hold anything to
|
|
5683
|
+
* serve:
|
|
5684
|
+
*
|
|
5685
|
+
* - **We hold local metadata** → answer `false` ("do not tear this down"). The cached
|
|
5686
|
+
* snapshot may be a few seconds stale; a rejected status query is no reason to throw
|
|
5687
|
+
* it away, and every caller of this method treats `false` as "carry on with what you
|
|
5688
|
+
* have".
|
|
5689
|
+
* - **We hold nothing** → answer `true`, so the caller attempts a real load against the
|
|
5690
|
+
* actual metadata fetch. That load logs its own failure and leaves the graph empty;
|
|
5691
|
+
* `SetupGraphQLClient` turns an empty graph into an error carrying it, while a caller
|
|
5692
|
+
* that only checks {@link Config}'s return value sees `true`.
|
|
5693
|
+
*
|
|
5694
|
+
* Before this, an unreachable status query propagated an exception out of
|
|
5695
|
+
* {@link Config} and the Explorer's single boot catch turned it into a non-retryable
|
|
5696
|
+
* "System Error" — the whole app dead over a freshness check it did not need an answer
|
|
5697
|
+
* to. The two warm callers ({@link backgroundValidateAndRefresh},
|
|
5698
|
+
* {@link preValidateAndRefresh}) already caught it; {@link Config} did not.
|
|
5587
5699
|
*/
|
|
5588
5700
|
async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
|
|
5589
5701
|
if (!this.AllowRefresh)
|
|
@@ -5622,10 +5734,29 @@ export class ProviderBase {
|
|
|
5622
5734
|
*/
|
|
5623
5735
|
async runRefreshCheck(providerToUse) {
|
|
5624
5736
|
const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
5737
|
+
// Read the reachability flag in the SAME synchronous turn the await above resumed in —
|
|
5738
|
+
// before the next await can let a concurrent check overwrite it.
|
|
5739
|
+
const serverReached = this._lastRemoteTimestampReadReached;
|
|
5625
5740
|
if (gotTimestamps) {
|
|
5626
5741
|
this._lastRefreshCheckAt = Date.now();
|
|
5627
5742
|
}
|
|
5628
5743
|
await this.syncLocalMetadataFromStorage();
|
|
5744
|
+
if (!gotTimestamps && !serverReached) {
|
|
5745
|
+
// Could not ask the server. `LocalMetadataObsolete()` would answer `true` here
|
|
5746
|
+
// purely because the remote timestamp list is empty — a "yes, reload everything"
|
|
5747
|
+
// derived from the absence of an answer rather than from any comparison.
|
|
5748
|
+
const haveLocal = (this._localMetadata?.AllEntities?.length ?? 0) > 0;
|
|
5749
|
+
if (haveLocal) {
|
|
5750
|
+
LogError(`[RefreshCheck] Could not read metadata timestamps from the server — serving the ` +
|
|
5751
|
+
`cached metadata snapshot (${this._localMetadata.AllEntities.length} entities) and ` +
|
|
5752
|
+
`deferring the staleness check to the next call.`);
|
|
5753
|
+
return false;
|
|
5754
|
+
}
|
|
5755
|
+
LogError(`[RefreshCheck] Could not read metadata timestamps from the server and there is no ` +
|
|
5756
|
+
`cached snapshot to fall back on — reporting a refresh as needed so the caller loads ` +
|
|
5757
|
+
`metadata; that load reports its own failure.`);
|
|
5758
|
+
return true;
|
|
5759
|
+
}
|
|
5629
5760
|
return this.LocalMetadataObsolete();
|
|
5630
5761
|
}
|
|
5631
5762
|
/**
|
|
@@ -5817,8 +5948,30 @@ export class ProviderBase {
|
|
|
5817
5948
|
if (cachedDataset && cachedDateStr) {
|
|
5818
5949
|
// We have a candidate cache entry — confirm freshness with the server.
|
|
5819
5950
|
const localDate = new Date(cachedDateStr);
|
|
5820
|
-
|
|
5821
|
-
|
|
5951
|
+
let status;
|
|
5952
|
+
try {
|
|
5953
|
+
status = await this.GetDatasetStatusByName(datasetName, itemFilters);
|
|
5954
|
+
}
|
|
5955
|
+
catch (e) {
|
|
5956
|
+
// Only the TRANSPORT failing earns the fallback. A server that answered "no"
|
|
5957
|
+
// (an identity without the scope, a validation rejection) gave a definite
|
|
5958
|
+
// answer that waiting will not change, and the cache would hand the caller
|
|
5959
|
+
// exactly what the server just declined to — so that propagates as it always did.
|
|
5960
|
+
if (!IsTransportError(e)) {
|
|
5961
|
+
throw e;
|
|
5962
|
+
}
|
|
5963
|
+
// We are HOLDING a complete cached dataset and only asked whether it is still
|
|
5964
|
+
// current. A failed freshness question is not grounds for throwing the answer
|
|
5965
|
+
// away and propagating: the line below re-fetches the whole dataset from the
|
|
5966
|
+
// same unreachable server, so the alternative to serving this cache is an
|
|
5967
|
+
// exception, not fresher data. Serve it and say so.
|
|
5968
|
+
LogError(`GetAndCacheDatasetByName('${datasetName}'): the freshness check failed — ` +
|
|
5969
|
+
`${e instanceof Error ? e.message : String(e)}. Serving the cached dataset ` +
|
|
5970
|
+
`(cached ${cachedDateStr}), which may be stale.`);
|
|
5971
|
+
return cachedDataset;
|
|
5972
|
+
}
|
|
5973
|
+
const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
|
|
5974
|
+
if (status?.Success && serverDate && localDate.getTime() >= serverDate.getTime()) {
|
|
5822
5975
|
// Timestamps suggest cache is fresh; verify per-entity row counts to
|
|
5823
5976
|
// catch deleted rows (timestamp comparison alone misses pure deletes).
|
|
5824
5977
|
// Shared with IsDatasetCacheUpToDate so the two cannot drift again.
|
|
@@ -5827,9 +5980,11 @@ export class ProviderBase {
|
|
|
5827
5980
|
}
|
|
5828
5981
|
}
|
|
5829
5982
|
}
|
|
5830
|
-
// Cold path or stale: fetch from server and cache.
|
|
5983
|
+
// Cold path or stale: fetch from server and cache only when the load succeeded.
|
|
5831
5984
|
const dataset = await this.GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse);
|
|
5832
|
-
|
|
5985
|
+
if (dataset?.Success) {
|
|
5986
|
+
await this.CacheDataset(datasetName, itemFilters, dataset);
|
|
5987
|
+
}
|
|
5833
5988
|
return dataset;
|
|
5834
5989
|
}
|
|
5835
5990
|
/**
|
|
@@ -5860,8 +6015,9 @@ export class ProviderBase {
|
|
|
5860
6015
|
if (localDate) {
|
|
5861
6016
|
// we have a local cached timestamp, so compare it to the server timestamp
|
|
5862
6017
|
const status = await this.GetDatasetStatusByName(datasetName, itemFilters);
|
|
5863
|
-
|
|
5864
|
-
|
|
6018
|
+
const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
|
|
6019
|
+
if (status?.Success && serverDate) {
|
|
6020
|
+
const serverTimestamp = serverDate.getTime();
|
|
5865
6021
|
if (localDate.getTime() >= serverTimestamp) {
|
|
5866
6022
|
// this situation means our local cache timestamp is >= the server timestamp, so we're most likely up to date
|
|
5867
6023
|
// in this situation, the last thing we check is for each entity, if the rowcount is the same as the server, if it is, we're good
|
|
@@ -5884,6 +6040,17 @@ export class ProviderBase {
|
|
|
5884
6040
|
return false;
|
|
5885
6041
|
}
|
|
5886
6042
|
}
|
|
6043
|
+
/**
|
|
6044
|
+
* Normalizes a dataset `LatestUpdateDate` to a valid `Date`, or `null` when absent or invalid
|
|
6045
|
+
* (for example, when a dataset or dataset-status call fails and returns `LatestUpdateDate: null`).
|
|
6046
|
+
*/
|
|
6047
|
+
validDatasetDate(value) {
|
|
6048
|
+
if (value == null) {
|
|
6049
|
+
return null;
|
|
6050
|
+
}
|
|
6051
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
6052
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
6053
|
+
}
|
|
5887
6054
|
/**
|
|
5888
6055
|
* Compares the per-entity row counts of a cached dataset against the server's status.
|
|
5889
6056
|
*
|
|
@@ -5942,6 +6109,12 @@ export class ProviderBase {
|
|
|
5942
6109
|
* @param dataset
|
|
5943
6110
|
*/
|
|
5944
6111
|
async CacheDataset(datasetName, itemFilters, dataset) {
|
|
6112
|
+
const latestUpdateDate = this.validDatasetDate(dataset?.LatestUpdateDate);
|
|
6113
|
+
if (!dataset?.Success || !latestUpdateDate) {
|
|
6114
|
+
LogError(`CacheDataset("${datasetName}"): refusing to cache failed or undated dataset result ` +
|
|
6115
|
+
`(Success=${dataset?.Success}, Status=${dataset?.Status ?? 'unknown'})`);
|
|
6116
|
+
return;
|
|
6117
|
+
}
|
|
5945
6118
|
const ls = this.LocalStorageProvider;
|
|
5946
6119
|
if (ls) {
|
|
5947
6120
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
@@ -5949,7 +6122,7 @@ export class ProviderBase {
|
|
|
5949
6122
|
await ls.SetItem(key, dataset, ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetCacheTTLSeconds });
|
|
5950
6123
|
// Date is stored as ISO string for forward-compatibility across providers
|
|
5951
6124
|
// (Redis can't natively round-trip Date; localStorage requires string).
|
|
5952
|
-
await ls.SetItem(key + '_date',
|
|
6125
|
+
await ls.SetItem(key + '_date', latestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
|
|
5953
6126
|
}
|
|
5954
6127
|
}
|
|
5955
6128
|
/**
|
|
@@ -6056,11 +6229,70 @@ export class ProviderBase {
|
|
|
6056
6229
|
}
|
|
6057
6230
|
/**
|
|
6058
6231
|
* Retrieves the latest metadata update timestamps from the server.
|
|
6059
|
-
* @returns Array of metadata update information
|
|
6232
|
+
* @returns Array of metadata update information, or `undefined` when the server could not
|
|
6233
|
+
* be asked (transport failure, timeout, gateway error) or answered unsuccessfully.
|
|
6234
|
+
*
|
|
6235
|
+
* @remarks
|
|
6236
|
+
* This is the ONE network call behind every staleness check, and it is a question about
|
|
6237
|
+
* freshness — never the metadata itself. A rejected status query therefore returns
|
|
6238
|
+
* `undefined` (an absent answer) rather than propagating: its callers already treat a
|
|
6239
|
+
* falsy result as "could not tell", while an exception out of here travelled unguarded
|
|
6240
|
+
* through {@link RefreshRemoteMetadataTimestamps} → {@link CheckToSeeIfRefreshNeeded} →
|
|
6241
|
+
* {@link Config} and killed the caller's whole bootstrap.
|
|
6242
|
+
*
|
|
6243
|
+
* The failure is logged at error level, not swallowed quietly: "we are running on a
|
|
6244
|
+
* cached snapshot because the server did not answer" is something an operator needs to
|
|
6245
|
+
* see in the console.
|
|
6060
6246
|
*/
|
|
6061
6247
|
async GetLatestMetadataUpdates(providerToUse) {
|
|
6062
|
-
|
|
6063
|
-
|
|
6248
|
+
return (await this.ReadRemoteMetadataTimestamps(providerToUse)).Timestamps;
|
|
6249
|
+
}
|
|
6250
|
+
/**
|
|
6251
|
+
* The metadata-status read, with **why** it failed preserved.
|
|
6252
|
+
*
|
|
6253
|
+
* Two failures look the same to {@link GetLatestMetadataUpdates} — both give `undefined` —
|
|
6254
|
+
* but they call for opposite responses, so callers that can act on the difference read it
|
|
6255
|
+
* here instead:
|
|
6256
|
+
*
|
|
6257
|
+
* - `Reached: false` — the request never got an answer (transport failure, timeout, 504).
|
|
6258
|
+
* Transient. Nothing can be concluded about freshness, and a full metadata load would hit
|
|
6259
|
+
* the same wall, so the right move is to keep serving what we have.
|
|
6260
|
+
* - `Reached: true` with no `Timestamps` — the server answered `Success: false`. A
|
|
6261
|
+
* *permanent* condition (the dataset is missing or misconfigured), not a network blip.
|
|
6262
|
+
* Degrading here would serve a stale cache forever; the historical behaviour of treating
|
|
6263
|
+
* it as "obsolete" and attempting the real load is correct, because `GetDatasetByName`
|
|
6264
|
+
* may well still work.
|
|
6265
|
+
* - A rejection that is NOT a transport failure — an authorization denial ("this API key
|
|
6266
|
+
* requires the dataset:read scope"), a validation rejection, a programming error — is
|
|
6267
|
+
* the server (or our own code) answering, not the wire failing. It propagates exactly
|
|
6268
|
+
* as it did before this method existed. {@link IsTransportError} draws the line. The
|
|
6269
|
+
* integration tier's FLS fixture depends on this: it builds a reader whose scope rule
|
|
6270
|
+
* has not propagated yet and retries `Config()` until the denial stops — a denial
|
|
6271
|
+
* swallowed into `Reached: false` left that reader with an empty snapshot for good.
|
|
6272
|
+
*
|
|
6273
|
+
* Conflating these is how a broken status endpoint — or a refused identity — would become
|
|
6274
|
+
* permanently invisible.
|
|
6275
|
+
*/
|
|
6276
|
+
async ReadRemoteMetadataTimestamps(providerToUse) {
|
|
6277
|
+
let d;
|
|
6278
|
+
try {
|
|
6279
|
+
// No schema filters for metadata — see comment in GetAllMetadata
|
|
6280
|
+
d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
|
|
6281
|
+
}
|
|
6282
|
+
catch (e) {
|
|
6283
|
+
if (!IsTransportError(e)) {
|
|
6284
|
+
// The server answered — with a refusal. Not ours to soften.
|
|
6285
|
+
throw e;
|
|
6286
|
+
}
|
|
6287
|
+
LogError(`ReadRemoteMetadataTimestamps: the metadata status query failed — ` +
|
|
6288
|
+
`${e instanceof Error ? e.message : String(e)}. Reporting the server as unreachable; ` +
|
|
6289
|
+
`callers fall back to the cached metadata snapshot.`);
|
|
6290
|
+
return { Reached: false, Timestamps: undefined };
|
|
6291
|
+
}
|
|
6292
|
+
return { Reached: true, Timestamps: this.mapDatasetStatusToTimestamps(d) };
|
|
6293
|
+
}
|
|
6294
|
+
/** Shape a dataset-status answer into the timestamp list, or `undefined` if it failed. */
|
|
6295
|
+
mapDatasetStatusToTimestamps(d) {
|
|
6064
6296
|
if (d && d.Success) {
|
|
6065
6297
|
const ret = d.EntityUpdateDates.map(e => {
|
|
6066
6298
|
return {
|
|
@@ -6079,6 +6311,7 @@ export class ProviderBase {
|
|
|
6079
6311
|
});
|
|
6080
6312
|
return ret;
|
|
6081
6313
|
}
|
|
6314
|
+
return undefined;
|
|
6082
6315
|
}
|
|
6083
6316
|
/**
|
|
6084
6317
|
* Refreshes the remote metadata timestamps from the server.
|
|
@@ -6086,9 +6319,14 @@ export class ProviderBase {
|
|
|
6086
6319
|
* @returns True if timestamps were successfully refreshed
|
|
6087
6320
|
*/
|
|
6088
6321
|
async RefreshRemoteMetadataTimestamps(providerToUse) {
|
|
6089
|
-
const
|
|
6090
|
-
|
|
6091
|
-
|
|
6322
|
+
const read = await this.ReadRemoteMetadataTimestamps(providerToUse);
|
|
6323
|
+
// Recorded rather than returned: this method's `boolean` contract is "did the timestamps
|
|
6324
|
+
// refresh", and widening it would change a public signature that callers outside this
|
|
6325
|
+
// class already consume. {@link CheckToSeeIfRefreshNeeded} reads the flag in the same
|
|
6326
|
+
// synchronous turn this call resolves in, so no interleaving call can overwrite it first.
|
|
6327
|
+
this._lastRemoteTimestampReadReached = read.Reached;
|
|
6328
|
+
if (read.Timestamps) {
|
|
6329
|
+
this._latestRemoteMetadataTimestamps = read.Timestamps;
|
|
6092
6330
|
return true;
|
|
6093
6331
|
}
|
|
6094
6332
|
else
|