@memberjunction/core 6.1.0-edge.1 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +6 -0
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +6 -0
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +120 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +31 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +75 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts +19 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js.map +1 -0
- package/dist/generic/authTypes.d.ts +69 -0
- package/dist/generic/authTypes.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +14 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +53 -13
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +150 -23
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +435 -66
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +16 -4
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +30 -0
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +97 -0
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/dataHooks.d.ts +5 -0
- package/dist/generic/dataHooks.d.ts.map +1 -1
- package/dist/generic/dataHooks.js +27 -3
- package/dist/generic/dataHooks.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +9 -5
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +123 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -0
- package/dist/generic/embeddedRecord.js +356 -0
- package/dist/generic/embeddedRecord.js.map +1 -0
- package/dist/generic/entityCompanion.d.ts +20 -2
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +17 -1
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +131 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -0
- package/dist/generic/entityConfiguration.js +346 -0
- package/dist/generic/entityConfiguration.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +274 -4
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +502 -14
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySavePlan.d.ts +40 -11
- package/dist/generic/entitySavePlan.d.ts.map +1 -1
- package/dist/generic/entitySavePlan.js +70 -2
- package/dist/generic/entitySavePlan.js.map +1 -1
- package/dist/generic/interfaces.d.ts +35 -24
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +6 -24
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +123 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +264 -9
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +118 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +311 -11
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +36 -4
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +70 -8
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +14 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +1 -1
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -5
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +58 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +20 -0
- package/dist/views/runView.js.map +1 -1
- package/package.json +5 -4
- package/readme.md +64 -1
|
@@ -13,7 +13,7 @@ import { LibraryInfo } from "./libraryInfo.js";
|
|
|
13
13
|
import { CompositeKey } from "./compositeKey.js";
|
|
14
14
|
import { ExplorerNavigationItem } from "./explorerNavigationItem.js";
|
|
15
15
|
import { Metadata } from "./metadata.js";
|
|
16
|
-
import { RunView } from "../views/runView.js";
|
|
16
|
+
import { RunView, IsMaterializedDataSource } from "../views/runView.js";
|
|
17
17
|
import { IsPlatformSQL } from "./platformSQL.js";
|
|
18
18
|
import { GetDataHooks } from "./dataHooks.js";
|
|
19
19
|
import { TransformSimpleObjectToEntityObject } from "./util.js";
|
|
@@ -144,7 +144,16 @@ export function ProjectRowsToFields(rows, requestedFields) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
if (allKept) {
|
|
147
|
-
|
|
147
|
+
// ...but only when handing the input back is safe. A `Fields` request is documented
|
|
148
|
+
// to yield a per-caller row set the caller may mutate, and full coverage is not a
|
|
149
|
+
// narrower promise than partial coverage — it just happens to project to the same
|
|
150
|
+
// shape. Frozen input means `rows` is the cache's shared array, so returning it here
|
|
151
|
+
// would quietly hand a Fields caller immutable rows and break that contract for the
|
|
152
|
+
// one field list that covers everything. Fall through to the copy path in that case;
|
|
153
|
+
// unfrozen input (the DB-miss path) keeps the allocation-free fast path.
|
|
154
|
+
if (!Object.isFrozen(rows)) {
|
|
155
|
+
return rows;
|
|
156
|
+
}
|
|
148
157
|
}
|
|
149
158
|
}
|
|
150
159
|
// Cache lowercase key→keep decisions across rows to avoid repeated allocations
|
|
@@ -536,6 +545,7 @@ export class ProviderBase {
|
|
|
536
545
|
// Cache hit — transform and return directly
|
|
537
546
|
LogStatusEx({ message: ` ✅ [Cache HIT] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${preResult.cachedResult.Results?.length ?? 0} rows from cache, no DB query`, verboseOnly: true });
|
|
538
547
|
await this.TransformSimpleObjectToEntityObject(params, preResult.cachedResult, contextUser);
|
|
548
|
+
await this.ApplyPostRunViewHooksToCacheHit(params, preResult.cachedResult, contextUser);
|
|
539
549
|
TelemetryManager.Instance.EndEvent(preResult.telemetryEventId, {
|
|
540
550
|
cacheHit: true,
|
|
541
551
|
cacheStatus: preResult.cacheStatus,
|
|
@@ -549,6 +559,8 @@ export class ProviderBase {
|
|
|
549
559
|
// Cache miss — execute query, then post-process (stores in cache)
|
|
550
560
|
LogStatusEx({ message: ` 🔍 [Cache MISS] RunView "${params.EntityName || params.ViewName || 'unknown'}" — querying database`, verboseOnly: true });
|
|
551
561
|
const result = await this.InternalRunView(params, contextUser);
|
|
562
|
+
// PostRunView copies any hook-supplied replacement onto `result` in place, so this
|
|
563
|
+
// reference reflects the hook chain's output.
|
|
552
564
|
await this.PostRunView(result, params, preResult, contextUser);
|
|
553
565
|
return result;
|
|
554
566
|
}
|
|
@@ -678,6 +690,12 @@ export class ProviderBase {
|
|
|
678
690
|
batchSize: params.length,
|
|
679
691
|
totalResultCount: totalResults
|
|
680
692
|
});
|
|
693
|
+
// allCached ⇒ every param produced a hit and was pushed in order (PreRunViews only
|
|
694
|
+
// pushes a null placeholder on the path that clears allCached), so index i of
|
|
695
|
+
// cachedResults corresponds to params[i].
|
|
696
|
+
for (let i = 0; i < preResult.cachedResults.length; i++) {
|
|
697
|
+
await this.ApplyPostRunViewHooksToCacheHit(params[i], preResult.cachedResults[i], contextUser);
|
|
698
|
+
}
|
|
681
699
|
return preResult.cachedResults;
|
|
682
700
|
}
|
|
683
701
|
// Execute the internal implementation for non-cached items
|
|
@@ -978,15 +996,51 @@ export class ProviderBase {
|
|
|
978
996
|
* the seek key, so caching a page would poison the entity+filter slot
|
|
979
997
|
* - `ResultType 'count_only'` — returns no rows; caching its empty Results under
|
|
980
998
|
* a fingerprint that excludes ResultType would poison row queries
|
|
999
|
+
* - `DataSource: 'Materialized'` — the snapshot is rebuilt OUT-OF-BAND by the scheduled refresh
|
|
1000
|
+
* (direct SQL, no BaseEntity save), so the entity's normal event-driven cache invalidation never
|
|
1001
|
+
* fires for it; a cached materialized result would be served indefinitely stale after a refresh.
|
|
1002
|
+
* Bypass caching entirely for materialized reads. (The `ds:materialized` fingerprint segment still
|
|
1003
|
+
* keeps the short-lived dedup/linger layer from cross-serving Live vs Materialized in-flight reads.)
|
|
981
1004
|
* - entities where server caching is disallowed
|
|
982
1005
|
*/
|
|
983
1006
|
runViewCacheEligible(param) {
|
|
984
1007
|
return !param.BypassCache &&
|
|
985
1008
|
!param.AfterKey &&
|
|
1009
|
+
!IsMaterializedDataSource(param.DataSource) &&
|
|
986
1010
|
param.ResultType !== 'count_only' &&
|
|
987
1011
|
(param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
|
|
988
1012
|
this.IsServerCacheAllowedForEntity(param);
|
|
989
1013
|
}
|
|
1014
|
+
/** True when the entity is a CodeGen materialized-query wrapper (materialized_vw*) whose snapshot is
|
|
1015
|
+
* refreshed out-of-band — the same one IsServerCacheAllowedForEntity excludes from the server cache. */
|
|
1016
|
+
isMaterializedWrapperEntity(param) {
|
|
1017
|
+
if (!param.EntityName) {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
const entity = this.EntityByName(param.EntityName);
|
|
1021
|
+
return !!(entity?.VirtualEntity && entity.BaseView && entity.BaseView.toLowerCase().startsWith('materialized_vw'));
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Write-side eligibility for the smart-cache-check (stamped) path. On the trusting SERVER this is
|
|
1025
|
+
* exactly runViewCacheEligible — the server cache is kept fresh by BaseEntity events, so a
|
|
1026
|
+
* server-cache-disallowed entity must never be slotted. On a CLIENT the slot is instead written with
|
|
1027
|
+
* a maxUpdatedAt stamp and DB-revalidated per request, so the server Trust/event gate does NOT apply:
|
|
1028
|
+
* a server-cache-disallowed entity (Trust=0 'MJ: Audit Logs', Record Changes, other caching-disabled)
|
|
1029
|
+
* is still safely client-cacheable when stamped — folding runViewCacheEligible's server gate onto this
|
|
1030
|
+
* path regressed that (integration check client-cache.C12). Materialized reads stay excluded on both
|
|
1031
|
+
* (the out-of-band snapshot swap the stamp can't observe).
|
|
1032
|
+
*/
|
|
1033
|
+
runViewCacheEligibleForWrite(param) {
|
|
1034
|
+
if (this.TrustLocalCacheCompletely) {
|
|
1035
|
+
return this.runViewCacheEligible(param);
|
|
1036
|
+
}
|
|
1037
|
+
return !param.BypassCache &&
|
|
1038
|
+
!param.AfterKey &&
|
|
1039
|
+
!IsMaterializedDataSource(param.DataSource) &&
|
|
1040
|
+
param.ResultType !== 'count_only' &&
|
|
1041
|
+
param.CacheLocal === true &&
|
|
1042
|
+
!this.isMaterializedWrapperEntity(param);
|
|
1043
|
+
}
|
|
990
1044
|
/**
|
|
991
1045
|
* SECURITY — decide whether the shared cache must be BYPASSED for a RunView that targets
|
|
992
1046
|
* a saved VIEW rather than a named entity (no `EntityName`), under a context user.
|
|
@@ -1497,6 +1551,13 @@ export class ProviderBase {
|
|
|
1497
1551
|
// outer CacheLocal layer would otherwise write a SECOND, divergent slot with no/foreign
|
|
1498
1552
|
// TTL (CacheLocalTTL) — a stale-forever hazard since external data can't be event-invalidated.
|
|
1499
1553
|
&& !this.IsExternalQuery(params)
|
|
1554
|
+
// Materialized query results are excluded for the SAME reason: the snapshot IS the cache
|
|
1555
|
+
// and its freshness is governed by the refresh cycle, not by BaseEntity events, so it can't
|
|
1556
|
+
// be event-invalidated. Layering the outer CacheLocal TTL slot on top would serve rows older
|
|
1557
|
+
// than the latest materialized refresh AND would risk a Live-vs-Materialized slot collision
|
|
1558
|
+
// (the fingerprint carries no DataSource segment). Keeping materialized out of this layer
|
|
1559
|
+
// means the only staleness is the refresh cadence the snapshot already advertises.
|
|
1560
|
+
&& !IsMaterializedDataSource(params.DataSource)
|
|
1500
1561
|
&& LocalCacheManager.Instance.IsInitialized;
|
|
1501
1562
|
let queryFingerprint;
|
|
1502
1563
|
if (queryCacheEngaged) {
|
|
@@ -1524,6 +1585,9 @@ export class ProviderBase {
|
|
|
1524
1585
|
QueryID: cached.queryId ?? params.QueryID ?? '',
|
|
1525
1586
|
QueryName: params.QueryName ?? '',
|
|
1526
1587
|
Success: true,
|
|
1588
|
+
// Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
|
|
1589
|
+
// rows) while the outbound Results is mutable — the runtime freeze is the
|
|
1590
|
+
// enforcement. Same cast as the RunView hit paths above.
|
|
1527
1591
|
Results: cached.results,
|
|
1528
1592
|
RowCount: cached.results.length,
|
|
1529
1593
|
TotalRowCount: cached.rowCount ?? cached.results.length,
|
|
@@ -1860,7 +1924,9 @@ export class ProviderBase {
|
|
|
1860
1924
|
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
|
|
1861
1925
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
1862
1926
|
if (cached) {
|
|
1863
|
-
//
|
|
1927
|
+
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
1928
|
+
// what stops a consumer from corrupting the cache. Anything that needs to
|
|
1929
|
+
// transform them must map onto copies.
|
|
1864
1930
|
let results = cached.results;
|
|
1865
1931
|
if (callerRequestedFields && params.ResultType !== 'entity_object') {
|
|
1866
1932
|
results = ProjectRowsToFields(results, callerRequestedFields);
|
|
@@ -2009,7 +2075,7 @@ export class ProviderBase {
|
|
|
2009
2075
|
fingerprintMap.set(i, fingerprint);
|
|
2010
2076
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2011
2077
|
if (cached) {
|
|
2012
|
-
//
|
|
2078
|
+
// Shared, deep-frozen cache rows — same contract as the single-view hit path.
|
|
2013
2079
|
let results = cached.results;
|
|
2014
2080
|
if (callerFields && param.ResultType !== 'entity_object') {
|
|
2015
2081
|
results = ProjectRowsToFields(results, callerFields);
|
|
@@ -2090,7 +2156,13 @@ export class ProviderBase {
|
|
|
2090
2156
|
// field list to `*` in the FINGERPRINT only, which cannot affect fetching.
|
|
2091
2157
|
param.Fields = entity.Fields.map(f => f.Name);
|
|
2092
2158
|
}
|
|
2093
|
-
|
|
2159
|
+
// Gate on runViewCacheEligible (NOT raw param.CacheLocal): the smart-cache-check path is a
|
|
2160
|
+
// second, independent cache transport, and gating it on CacheLocal alone re-admits the exact
|
|
2161
|
+
// params runViewCacheEligible excludes — DataSource:'Materialized' (out-of-band refreshed, no
|
|
2162
|
+
// BaseEntity event), count_only, AfterKey, BypassCache, cache-disallowed entities. On a client
|
|
2163
|
+
// (!TrustLocalCacheCompletely) runViewCacheEligible already implies CacheLocal===true, so this is
|
|
2164
|
+
// strictly a tightening — normal cacheable slots are unaffected.
|
|
2165
|
+
if (this.runViewCacheEligibleForWrite(param) && LocalCacheManager.Instance.IsInitialized) {
|
|
2094
2166
|
cacheable.push({ paramIndex: i, fingerprint: this.clientCacheFingerprint(param) });
|
|
2095
2167
|
}
|
|
2096
2168
|
}
|
|
@@ -2266,8 +2338,9 @@ export class ProviderBase {
|
|
|
2266
2338
|
// Get entity info for primary key field name
|
|
2267
2339
|
const entity = this.EntityByName(param.EntityName);
|
|
2268
2340
|
const primaryKeyFieldName = entity?.FirstPrimaryKey?.Name || 'ID';
|
|
2269
|
-
// Apply differential update to cache
|
|
2270
|
-
|
|
2341
|
+
// Apply differential update to cache (runViewCacheEligible, not raw CacheLocal — see the
|
|
2342
|
+
// cacheable-gate note in prepareSmartCacheCheckParams; keeps Materialized/count_only/etc. out).
|
|
2343
|
+
if (this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
|
|
2271
2344
|
const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs, primaryKeyFieldName, checkResult.maxUpdatedAt || new Date().toISOString(), checkResult.rowCount || 0, checkResult.aggregateResults, // Pass fresh aggregate results (can't be differentially computed)
|
|
2272
2345
|
this);
|
|
2273
2346
|
if (merged) {
|
|
@@ -2320,8 +2393,10 @@ export class ProviderBase {
|
|
|
2320
2393
|
UserViewRunID: '',
|
|
2321
2394
|
AggregateResults: checkResult.aggregateResults // Include fresh aggregate results
|
|
2322
2395
|
};
|
|
2323
|
-
// Update the local cache with fresh data (don't await - fire and forget for performance)
|
|
2324
|
-
|
|
2396
|
+
// Update the local cache with fresh data (don't await - fire and forget for performance).
|
|
2397
|
+
// runViewCacheEligible, not raw CacheLocal — see the cacheable-gate note; a first-time
|
|
2398
|
+
// Materialized read reaches this 'stale' branch with fresh data and would otherwise be cached.
|
|
2399
|
+
if (this.runViewCacheEligibleForWrite(param) && checkResult.maxUpdatedAt && LocalCacheManager.Instance.IsInitialized) {
|
|
2325
2400
|
const fingerprint = this.clientCacheFingerprint(param);
|
|
2326
2401
|
// Note: We don't await here to avoid blocking the response
|
|
2327
2402
|
// Cache update happens in background
|
|
@@ -2442,8 +2517,19 @@ export class ProviderBase {
|
|
|
2442
2517
|
}
|
|
2443
2518
|
// Transform the result set into BaseEntity-derived objects, if needed
|
|
2444
2519
|
await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
|
|
2445
|
-
// Run registered PostRunView hooks (e.g., data masking, audit logging)
|
|
2446
|
-
|
|
2520
|
+
// Run registered PostRunView hooks (e.g., data masking, audit logging).
|
|
2521
|
+
//
|
|
2522
|
+
// A hook may RETURN a replacement result rather than mutating the one it was handed —
|
|
2523
|
+
// that is what `PostRunViewHook`'s signature promises, and it is the only option left
|
|
2524
|
+
// now that cached rows are frozen. Reassigning the local `result` would drop it on the
|
|
2525
|
+
// floor, because RunView returns the reference IT holds. Copy the replacement's fields
|
|
2526
|
+
// onto that reference instead, so the caller observes the hook's changes without
|
|
2527
|
+
// PostRunView having to change its return type (which would break external
|
|
2528
|
+
// subclasses that override it).
|
|
2529
|
+
const hooked = await this.RunPostRunViewHooks(params, result, contextUser);
|
|
2530
|
+
if (hooked && hooked !== result) {
|
|
2531
|
+
Object.assign(result, hooked);
|
|
2532
|
+
}
|
|
2447
2533
|
// Register OnDataChanged callback if provided and we have a fingerprint
|
|
2448
2534
|
if (params.OnDataChanged && preResult.fingerprint) {
|
|
2449
2535
|
result.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
@@ -2588,6 +2674,43 @@ export class ProviderBase {
|
|
|
2588
2674
|
}
|
|
2589
2675
|
return result;
|
|
2590
2676
|
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Applies the PostRunView hook chain to a result that was served from cache, mutating
|
|
2679
|
+
* `result` in place so the caller's reference reflects the chain's output.
|
|
2680
|
+
*
|
|
2681
|
+
* ## Why cache hits must run the hooks
|
|
2682
|
+
* PostRunView is the OUTPUT half of the enforcement seam (data masking / audit). Hooks
|
|
2683
|
+
* receive `contextUser`, so masking is PER-USER, while the cache slot is shared across
|
|
2684
|
+
* users — there is no correct way to apply masking once at write time on behalf of a
|
|
2685
|
+
* reader who has not arrived yet. A hit that skips the chain therefore returns rows the
|
|
2686
|
+
* miss path would have masked.
|
|
2687
|
+
*
|
|
2688
|
+
* This previously appeared to work by accident: PostRunView writes the cache BEFORE
|
|
2689
|
+
* running the hooks, so a hook that masked rows in place was writing through into the
|
|
2690
|
+
* cached objects — which both made later hits look masked and baked one user's masking
|
|
2691
|
+
* decision into a shared slot. Freeze-on-write removes that write-through, which is what
|
|
2692
|
+
* makes running the chain here necessary rather than merely tidier.
|
|
2693
|
+
*
|
|
2694
|
+
* ## Why mutating `result` in place is safe
|
|
2695
|
+
* Cache-hit results are FRESH wrapper objects built per hit by PreRunView/PreRunViews —
|
|
2696
|
+
* only `.Results` points at shared cache state. A hook that returns a replacement (the
|
|
2697
|
+
* required pattern now that rows are frozen) is copied onto that per-hit wrapper, so it
|
|
2698
|
+
* can never write back into the cache.
|
|
2699
|
+
*
|
|
2700
|
+
* ## Why the guard
|
|
2701
|
+
* `GetDataHooks` is a memoized store read (~30ns), but `await`-ing the async chain costs
|
|
2702
|
+
* a microtask (~750ns) — comparable to the entire cache lookup this rides on. The
|
|
2703
|
+
* overwhelmingly common case is zero registered hooks, so check first and skip the await.
|
|
2704
|
+
*/
|
|
2705
|
+
async ApplyPostRunViewHooksToCacheHit(params, result, contextUser) {
|
|
2706
|
+
if (GetDataHooks('PostRunView').length === 0) {
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
const hooked = await this.RunPostRunViewHooks(params, result, contextUser);
|
|
2710
|
+
if (hooked && hooked !== result) {
|
|
2711
|
+
Object.assign(result, hooked);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2591
2714
|
/**
|
|
2592
2715
|
* Post-processing hook for RunQuery.
|
|
2593
2716
|
* Handles cache storage and telemetry end.
|
|
@@ -2679,6 +2802,16 @@ export class ProviderBase {
|
|
|
2679
2802
|
// set to true, we still skip caching for this entity.
|
|
2680
2803
|
if (entity.Name === 'MJ: Record Changes')
|
|
2681
2804
|
return false;
|
|
2805
|
+
// Same rationale for MATERIALIZED QUERY entities: their wrapper view (materialized_vw<CodeName>) is
|
|
2806
|
+
// rebuilt OUT-OF-BAND by the scheduled materialization refresh (a direct-SQL atomic table swap),
|
|
2807
|
+
// which fires no BaseEntity.Save event for this entity — so a cached read would be served the
|
|
2808
|
+
// pre-refresh snapshot indefinitely. Identify them by BOTH the CodeGen wrapper-view naming convention
|
|
2809
|
+
// AND the VirtualEntity flag (CodeGen mints these as virtual entities) — the conjunction avoids
|
|
2810
|
+
// over-matching a real, event-invalidated entity that merely happens to be named materialized_vw*.
|
|
2811
|
+
// (Base-view materializations reuse the SOURCE entity and are handled by the DataSource:'Materialized'
|
|
2812
|
+
// bypass in runViewCacheEligible; this covers the query-materialization Live-read path.)
|
|
2813
|
+
if (entity.VirtualEntity && entity.BaseView && entity.BaseView.toLowerCase().startsWith('materialized_vw'))
|
|
2814
|
+
return false;
|
|
2682
2815
|
return entity.TrustServerCacheCompletely !== false;
|
|
2683
2816
|
}
|
|
2684
2817
|
shouldAutoCache(params, result) {
|
|
@@ -2918,6 +3051,9 @@ export class ProviderBase {
|
|
|
2918
3051
|
* @param contextUser - The user context for permissions
|
|
2919
3052
|
*/
|
|
2920
3053
|
async TransformSimpleObjectToEntityObject(param, result, contextUser) {
|
|
3054
|
+
// Mutually exclusive with the entity branch below: entity objects get real types from
|
|
3055
|
+
// BaseEntity's Get/Set conversion, so normalization applies only to non-entity results.
|
|
3056
|
+
this.NormalizeSimpleRowTypes(param, result);
|
|
2921
3057
|
if (param.ResultType === 'entity_object' && result && result.Success && result.Results?.length > 0) {
|
|
2922
3058
|
result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser);
|
|
2923
3059
|
// Opt-in batched child loading: ONE query per named collection across the whole result
|
|
@@ -2929,6 +3065,167 @@ export class ProviderBase {
|
|
|
2929
3065
|
}
|
|
2930
3066
|
}
|
|
2931
3067
|
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Normalizes non-entity (`'simple'`) result rows so `Date` and numeric columns hold real
|
|
3070
|
+
* `Date`s and `number`s on EVERY tier, matching what the generated entity types declare.
|
|
3071
|
+
*
|
|
3072
|
+
* ## Why this is unconditional
|
|
3073
|
+
*
|
|
3074
|
+
* Before this existed, the value a simple read returned for a `DATETIME` column depended on
|
|
3075
|
+
* where the code happened to run: a fresh server-side query yields real `Date` objects (the
|
|
3076
|
+
* driver parses them and `AdjustDatetimeFields` timezone-adjusts them), a server-side Redis
|
|
3077
|
+
* cache hit yields ISO strings (`JSON.parse` with no reviver), and a browser client over
|
|
3078
|
+
* GraphQL yields ISO strings (rows are `JSON.stringify`'d on the wire). Same call, three
|
|
3079
|
+
* shapes. MJ's contract is a unified programming interface on both sides of the wire, so the
|
|
3080
|
+
* one representation the platform's own generated types declare — `Date` — is enforced here,
|
|
3081
|
+
* at the one choke point every provider's RunView pipeline flows through.
|
|
3082
|
+
*
|
|
3083
|
+
* ## What it does NOT do
|
|
3084
|
+
*
|
|
3085
|
+
* It makes date and number VALUES match the generated types; it does not make a caller's `T`
|
|
3086
|
+
* honest in general. A `Status` column typed as a closed union still holds whatever string the
|
|
3087
|
+
* database held, and plain rows never have entity methods. If you need the type to be fully
|
|
3088
|
+
* true, use `ResultType: 'entity_object'`.
|
|
3089
|
+
*
|
|
3090
|
+
* ## Cost and cache safety
|
|
3091
|
+
*
|
|
3092
|
+
* The field-key lists are computed once per view from `EntityInfo`, not per cell. Rows already
|
|
3093
|
+
* in the right shape — the common server-side case, where the driver returned `Date`s — are
|
|
3094
|
+
* detected and the ORIGINAL array is kept untouched: same array identity, same row objects,
|
|
3095
|
+
* zero copying. A row is shallow-copied only when a cell actually converts, and that copy is
|
|
3096
|
+
* load-bearing: on a cache hit the rows handed back can be the cache's OWN objects (the
|
|
3097
|
+
* in-memory server store holds them by reference), so converting in place would write `Date`s
|
|
3098
|
+
* into the cache entry itself and corrupt it for serialization and for later readers.
|
|
3099
|
+
*
|
|
3100
|
+
* Per-cell rules:
|
|
3101
|
+
* - `Date` instances pass through untouched, so the pass is idempotent on every path.
|
|
3102
|
+
* - `NULL`/`undefined` cells are left alone rather than becoming epoch-1970 dates.
|
|
3103
|
+
* - An unparseable value is left as-is rather than written as `Invalid Date`, which renders
|
|
3104
|
+
* as that literal string and destroys the evidence of what the database actually held.
|
|
3105
|
+
* - An integer string outside `Number.MAX_SAFE_INTEGER` stays a string: the PostgreSQL
|
|
3106
|
+
* provider deliberately returns unsafe-range BIGINTs as strings to avoid precision loss,
|
|
3107
|
+
* and `Number('9007199254740993')` "succeeds" while silently corrupting the value.
|
|
3108
|
+
*
|
|
3109
|
+
* View-based runs (`ViewID`/`ViewName` with neither `EntityName` nor a loaded `ViewEntity`)
|
|
3110
|
+
* skip normalization: resolving the entity would take an async User Views read this late in
|
|
3111
|
+
* the pipeline. Pass `EntityName` alongside the view identifier to get normalized rows.
|
|
3112
|
+
*/
|
|
3113
|
+
NormalizeSimpleRowTypes(param, result) {
|
|
3114
|
+
if (param.ResultType === 'entity_object' || param.ResultType === 'count_only') {
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
if (!result?.Success || !result.Results?.length) {
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
3120
|
+
const entity = this.resolveEntityForNormalization(param);
|
|
3121
|
+
if (!entity) {
|
|
3122
|
+
// An unresolvable entity name is already a failed query elsewhere; normalization is
|
|
3123
|
+
// not the place to raise it, and guessing field types would be worse than raw rows.
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
// Once per view, not once per cell. Each entry lists the row keys one field can appear
|
|
3127
|
+
// under: the batch transport keys rows by Name, the singular transport adds CodeName.
|
|
3128
|
+
const dateKeys = this.normalizationKeys(entity, EntityFieldTSType.Date);
|
|
3129
|
+
const numberKeys = this.normalizationKeys(entity, EntityFieldTSType.Number);
|
|
3130
|
+
if (!dateKeys.length && !numberKeys.length) {
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
let anyRowChanged = false;
|
|
3134
|
+
const normalized = result.Results.map(row => {
|
|
3135
|
+
const converted = this.normalizeSimpleRow(row, dateKeys, numberKeys);
|
|
3136
|
+
if (converted) {
|
|
3137
|
+
anyRowChanged = true;
|
|
3138
|
+
return converted;
|
|
3139
|
+
}
|
|
3140
|
+
return row;
|
|
3141
|
+
});
|
|
3142
|
+
if (anyRowChanged) {
|
|
3143
|
+
result.Results = normalized;
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Resolves the {@link EntityInfo} normalization should read field types from, using only
|
|
3148
|
+
* synchronously available information on the params.
|
|
3149
|
+
*/
|
|
3150
|
+
resolveEntityForNormalization(param) {
|
|
3151
|
+
if (param.EntityName) {
|
|
3152
|
+
return this.EntityByName(param.EntityName);
|
|
3153
|
+
}
|
|
3154
|
+
if (param.ViewEntity) {
|
|
3155
|
+
// Weak typing mirrors RunView.GetEntityNameFromRunViewParams: MJCore cannot import
|
|
3156
|
+
// the core-entities UserView subclass without creating a circular dependency.
|
|
3157
|
+
const entityID = param.ViewEntity.Get('EntityID');
|
|
3158
|
+
return entityID ? this.EntityByID(entityID) : undefined;
|
|
3159
|
+
}
|
|
3160
|
+
return undefined;
|
|
3161
|
+
}
|
|
3162
|
+
/**
|
|
3163
|
+
* The row keys each field of the given TSType can appear under, one entry per field.
|
|
3164
|
+
*/
|
|
3165
|
+
normalizationKeys(entity, tsType) {
|
|
3166
|
+
return entity.Fields
|
|
3167
|
+
.filter(f => f.TSType === tsType)
|
|
3168
|
+
.map(f => (f.CodeName && f.CodeName !== f.Name ? [f.Name, f.CodeName] : [f.Name]));
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Returns a converted shallow copy of the row, or null when no cell needed converting —
|
|
3172
|
+
* so untouched rows keep their identity and cached rows are never written to.
|
|
3173
|
+
*/
|
|
3174
|
+
normalizeSimpleRow(row, dateKeys, numberKeys) {
|
|
3175
|
+
if (!row || typeof row !== 'object') {
|
|
3176
|
+
return null;
|
|
3177
|
+
}
|
|
3178
|
+
let copy = null;
|
|
3179
|
+
for (const keys of dateKeys) {
|
|
3180
|
+
for (const key of keys) {
|
|
3181
|
+
const date = this.parseDateCell((copy ?? row)[key]);
|
|
3182
|
+
if (date) {
|
|
3183
|
+
copy = copy ?? { ...row };
|
|
3184
|
+
copy[key] = date;
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
for (const keys of numberKeys) {
|
|
3189
|
+
for (const key of keys) {
|
|
3190
|
+
const num = this.parseNumericCell((copy ?? row)[key]);
|
|
3191
|
+
if (num !== null) {
|
|
3192
|
+
copy = copy ?? { ...row };
|
|
3193
|
+
copy[key] = num;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
return copy;
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* A real Date for a convertible cell, or null to leave the cell untouched. Existing Date
|
|
3201
|
+
* instances, NULLs, and unparseable values all return null — see the per-cell rules on
|
|
3202
|
+
* {@link NormalizeSimpleRowTypes}.
|
|
3203
|
+
*/
|
|
3204
|
+
parseDateCell(value) {
|
|
3205
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
3206
|
+
return null;
|
|
3207
|
+
}
|
|
3208
|
+
const date = new Date(value);
|
|
3209
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* A number for a convertible string cell, or null to leave the cell untouched.
|
|
3213
|
+
*/
|
|
3214
|
+
parseNumericCell(value) {
|
|
3215
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
3216
|
+
return null;
|
|
3217
|
+
}
|
|
3218
|
+
const num = Number(value);
|
|
3219
|
+
if (!Number.isFinite(num)) {
|
|
3220
|
+
return null;
|
|
3221
|
+
}
|
|
3222
|
+
// An integer string beyond the safe range is a deliberate driver choice (PostgreSQL
|
|
3223
|
+
// returns unsafe BIGINTs as strings): converting would silently corrupt the value.
|
|
3224
|
+
if (Number.isInteger(num) && !Number.isSafeInteger(num)) {
|
|
3225
|
+
return null;
|
|
3226
|
+
}
|
|
3227
|
+
return num;
|
|
3228
|
+
}
|
|
2932
3229
|
/**
|
|
2933
3230
|
* Returns the currently loaded local metadata from within the instance
|
|
2934
3231
|
*/
|
|
@@ -3582,6 +3879,9 @@ export class ProviderBase {
|
|
|
3582
3879
|
await newObject.Config(actualContextUser);
|
|
3583
3880
|
// Initialize IS-A parent entity composition chain before any data operations
|
|
3584
3881
|
await newObject.InitializeParentEntity();
|
|
3882
|
+
// Construct declared embedded peers (no NewRecord/Load yet) so NewRecord()
|
|
3883
|
+
// can provision required FKs synchronously and Load() can fill them.
|
|
3884
|
+
await newObject.InitializeEmbeddedRecords();
|
|
3585
3885
|
if (actualLoadKey) {
|
|
3586
3886
|
// Load existing record
|
|
3587
3887
|
const loadResult = await newObject.InnerLoad(actualLoadKey);
|