@memberjunction/core 6.1.0-edge.0 → 6.1.0-edge.2
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/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/baseEngineRegistry.d.ts +14 -0
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +32 -0
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +366 -29
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +872 -96
- package/dist/generic/baseEntity.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 +44 -17
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +96 -52
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +218 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -0
- package/dist/generic/entityCompanion.js +170 -0
- package/dist/generic/entityCompanion.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +21 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +21 -0
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySavePlan.d.ts +199 -0
- package/dist/generic/entitySavePlan.d.ts.map +1 -0
- package/dist/generic/entitySavePlan.js +213 -0
- package/dist/generic/entitySavePlan.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +125 -0
- package/dist/generic/entityTransactionScope.d.ts.map +1 -0
- package/dist/generic/entityTransactionScope.js +115 -0
- package/dist/generic/entityTransactionScope.js.map +1 -0
- package/dist/generic/interfaces.d.ts +122 -35
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +27 -0
- 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 +254 -9
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +112 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +303 -9
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordBatchLoader.d.ts +39 -0
- package/dist/generic/relatedRecordBatchLoader.d.ts.map +1 -0
- package/dist/generic/relatedRecordBatchLoader.js +154 -0
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -0
- package/dist/generic/relatedRecordCollection.d.ts +588 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -0
- package/dist/generic/relatedRecordCollection.js +1020 -0
- package/dist/generic/relatedRecordCollection.js.map +1 -0
- package/dist/generic/saveEntityGraphOperation.d.ts +148 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -0
- package/dist/generic/saveEntityGraphOperation.js +157 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -0
- package/dist/generic/telemetryManager.d.ts +21 -1
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +21 -6
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +66 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +195 -1
- package/dist/generic/runReport.d.ts +0 -25
- package/dist/generic/runReport.d.ts.map +0 -1
- package/dist/generic/runReport.js +0 -38
- package/dist/generic/runReport.js.map +0 -1
|
@@ -17,6 +17,7 @@ import { RunView } 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";
|
|
20
|
+
import { LoadRelatedRecordsBatched } from "./relatedRecordBatchLoader.js";
|
|
20
21
|
/**
|
|
21
22
|
* Creates a new instance of AllMetadata from a simple object.
|
|
22
23
|
* Handles deserialization and proper instantiation of all metadata classes.
|
|
@@ -143,7 +144,16 @@ export function ProjectRowsToFields(rows, requestedFields) {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
if (allKept) {
|
|
146
|
-
|
|
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
|
+
}
|
|
147
157
|
}
|
|
148
158
|
}
|
|
149
159
|
// Cache lowercase key→keep decisions across rows to avoid repeated allocations
|
|
@@ -206,6 +216,21 @@ export class ProviderBase {
|
|
|
206
216
|
this._clientFingerprintMemo = new WeakMap();
|
|
207
217
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
208
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether this provider can execute a multi-record unit of work atomically, in-process.
|
|
221
|
+
*
|
|
222
|
+
* Defaults to `false` — the correct answer for every provider that is not talking directly to a
|
|
223
|
+
* database, most importantly the client-side `GraphQLDataProvider`. `DatabaseProviderBase`
|
|
224
|
+
* overrides this to `true` and supplies {@link DatabaseProviderBase.BeginEntityTransaction}.
|
|
225
|
+
*
|
|
226
|
+
* `BaseEntity` reads this to decide whether a multi-node save graph runs locally inside a
|
|
227
|
+
* transaction or is routed to the server as a single unit of work. Defaulting to `false` is the
|
|
228
|
+
* safe direction: a provider that has not opted in never has non-atomic work mistaken for
|
|
229
|
+
* atomic work.
|
|
230
|
+
*/
|
|
231
|
+
get SupportsEntityTransactions() {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
209
234
|
// ── Metadata Refresh Check Debounce ────────────────────────────────
|
|
210
235
|
/**
|
|
211
236
|
* Minimum interval (ms) between metadata refresh checks to prevent
|
|
@@ -520,6 +545,7 @@ export class ProviderBase {
|
|
|
520
545
|
// Cache hit — transform and return directly
|
|
521
546
|
LogStatusEx({ message: ` ✅ [Cache HIT] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${preResult.cachedResult.Results?.length ?? 0} rows from cache, no DB query`, verboseOnly: true });
|
|
522
547
|
await this.TransformSimpleObjectToEntityObject(params, preResult.cachedResult, contextUser);
|
|
548
|
+
await this.ApplyPostRunViewHooksToCacheHit(params, preResult.cachedResult, contextUser);
|
|
523
549
|
TelemetryManager.Instance.EndEvent(preResult.telemetryEventId, {
|
|
524
550
|
cacheHit: true,
|
|
525
551
|
cacheStatus: preResult.cacheStatus,
|
|
@@ -533,11 +559,20 @@ export class ProviderBase {
|
|
|
533
559
|
// Cache miss — execute query, then post-process (stores in cache)
|
|
534
560
|
LogStatusEx({ message: ` 🔍 [Cache MISS] RunView "${params.EntityName || params.ViewName || 'unknown'}" — querying database`, verboseOnly: true });
|
|
535
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.
|
|
536
564
|
await this.PostRunView(result, params, preResult, contextUser);
|
|
537
565
|
return result;
|
|
538
566
|
}
|
|
539
|
-
//
|
|
540
|
-
//
|
|
567
|
+
// Delegate to RunViews, which uses the smart cache check (lightweight maxUpdatedAt +
|
|
568
|
+
// rowCount validation against the server).
|
|
569
|
+
//
|
|
570
|
+
// NOT client-only: the guard above also sends SERVER reads down here whenever BypassCache
|
|
571
|
+
// or AfterKey is set — so every page of a keyset sweep arrives as a size-1 batch. Any
|
|
572
|
+
// "single vs batch RunView" reasoning must treat this branch as carrying single RunViews
|
|
573
|
+
// too: in particular, batch telemetry fingerprints must include per-view pagination
|
|
574
|
+
// cursors, or every page of a sweep collapses onto one fingerprint and falsely fires the
|
|
575
|
+
// Duplicate analyzer.
|
|
541
576
|
const results = await this.RunViews([params], contextUser);
|
|
542
577
|
return results[0];
|
|
543
578
|
}
|
|
@@ -655,6 +690,12 @@ export class ProviderBase {
|
|
|
655
690
|
batchSize: params.length,
|
|
656
691
|
totalResultCount: totalResults
|
|
657
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
|
+
}
|
|
658
699
|
return preResult.cachedResults;
|
|
659
700
|
}
|
|
660
701
|
// Execute the internal implementation for non-cached items
|
|
@@ -1501,6 +1542,9 @@ export class ProviderBase {
|
|
|
1501
1542
|
QueryID: cached.queryId ?? params.QueryID ?? '',
|
|
1502
1543
|
QueryName: params.QueryName ?? '',
|
|
1503
1544
|
Success: true,
|
|
1545
|
+
// Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
|
|
1546
|
+
// rows) while the outbound Results is mutable — the runtime freeze is the
|
|
1547
|
+
// enforcement. Same cast as the RunView hit paths above.
|
|
1504
1548
|
Results: cached.results,
|
|
1505
1549
|
RowCount: cached.results.length,
|
|
1506
1550
|
TotalRowCount: cached.rowCount ?? cached.results.length,
|
|
@@ -1837,7 +1881,9 @@ export class ProviderBase {
|
|
|
1837
1881
|
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
|
|
1838
1882
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
1839
1883
|
if (cached) {
|
|
1840
|
-
//
|
|
1884
|
+
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
1885
|
+
// what stops a consumer from corrupting the cache. Anything that needs to
|
|
1886
|
+
// transform them must map onto copies.
|
|
1841
1887
|
let results = cached.results;
|
|
1842
1888
|
if (callerRequestedFields && params.ResultType !== 'entity_object') {
|
|
1843
1889
|
results = ProjectRowsToFields(results, callerRequestedFields);
|
|
@@ -1899,11 +1945,34 @@ export class ProviderBase {
|
|
|
1899
1945
|
const fromEngine = params.some(p => p._fromEngine);
|
|
1900
1946
|
const telemetryEventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
|
|
1901
1947
|
BatchSize: params.length,
|
|
1902
|
-
|
|
1948
|
+
// '' placeholder (NOT .filter(Boolean)) for a view identified only by ViewEntity:
|
|
1949
|
+
// Entities must stay index-parallel to Filters/OrderBys/StartRows/AfterKeys or the
|
|
1950
|
+
// fingerprint attributes one view's filter/cursor to the next named entity.
|
|
1951
|
+
// generateRunViewFingerprint skips falsy entries without disturbing the indexes.
|
|
1952
|
+
Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID || ''),
|
|
1903
1953
|
// Per-view filter/orderBy parallel to Entities so the telemetry fingerprint can
|
|
1904
1954
|
// tell apart two batches over the same entity set but with different filters.
|
|
1905
1955
|
Filters: params.map(p => p.ExtraFilter),
|
|
1906
1956
|
OrderBys: params.map(p => p.OrderBy),
|
|
1957
|
+
// Per-view pagination cursors, also parallel to Entities. Every page of a sweep
|
|
1958
|
+
// shares the same entity+filter+orderBy and differs only here, so without these
|
|
1959
|
+
// the pages collapse onto one fingerprint and the Duplicate analyzer fires from
|
|
1960
|
+
// page 2 on. This path carries single RunViews too: RunView() delegates to
|
|
1961
|
+
// RunViews([params]) whenever BypassCache or AfterKey is set (and always on the
|
|
1962
|
+
// client), which is exactly what a keyset sweep does on every page.
|
|
1963
|
+
StartRows: params.map(p => p.StartRow),
|
|
1964
|
+
AfterKeys: params.map(p => p.AfterKey?.ToConcatenatedString()),
|
|
1965
|
+
// Exemption was threaded through the DEPRECATED batch twin but not this one, so
|
|
1966
|
+
// RunViewParams.Telemetry.Exempt was silently ineffective for every batch RunView —
|
|
1967
|
+
// and, because RunView() delegates here whenever BypassCache or AfterKey is set,
|
|
1968
|
+
// for those single reads too. A caller marking an intentional repeat got warned
|
|
1969
|
+
// anyway, with no indication their exemption had been dropped.
|
|
1970
|
+
//
|
|
1971
|
+
// Exempt only when EVERY view in the batch is exempt: a batch is one telemetry
|
|
1972
|
+
// event, so exempting it on the strength of one member would silently suppress
|
|
1973
|
+
// findings about the others.
|
|
1974
|
+
Exempt: params.length > 0 && params.every(p => p.Telemetry?.Exempt),
|
|
1975
|
+
ExemptReason: params.find(p => p.Telemetry?.Reason)?.Telemetry?.Reason,
|
|
1907
1976
|
_fromEngine: fromEngine
|
|
1908
1977
|
}, contextUser?.ID);
|
|
1909
1978
|
// Client-side providers route any CacheLocal params through smart-cache-check:
|
|
@@ -1963,7 +2032,7 @@ export class ProviderBase {
|
|
|
1963
2032
|
fingerprintMap.set(i, fingerprint);
|
|
1964
2033
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
1965
2034
|
if (cached) {
|
|
1966
|
-
//
|
|
2035
|
+
// Shared, deep-frozen cache rows — same contract as the single-view hit path.
|
|
1967
2036
|
let results = cached.results;
|
|
1968
2037
|
if (callerFields && param.ResultType !== 'entity_object') {
|
|
1969
2038
|
results = ProjectRowsToFields(results, callerFields);
|
|
@@ -2396,8 +2465,19 @@ export class ProviderBase {
|
|
|
2396
2465
|
}
|
|
2397
2466
|
// Transform the result set into BaseEntity-derived objects, if needed
|
|
2398
2467
|
await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
|
|
2399
|
-
// Run registered PostRunView hooks (e.g., data masking, audit logging)
|
|
2400
|
-
|
|
2468
|
+
// Run registered PostRunView hooks (e.g., data masking, audit logging).
|
|
2469
|
+
//
|
|
2470
|
+
// A hook may RETURN a replacement result rather than mutating the one it was handed —
|
|
2471
|
+
// that is what `PostRunViewHook`'s signature promises, and it is the only option left
|
|
2472
|
+
// now that cached rows are frozen. Reassigning the local `result` would drop it on the
|
|
2473
|
+
// floor, because RunView returns the reference IT holds. Copy the replacement's fields
|
|
2474
|
+
// onto that reference instead, so the caller observes the hook's changes without
|
|
2475
|
+
// PostRunView having to change its return type (which would break external
|
|
2476
|
+
// subclasses that override it).
|
|
2477
|
+
const hooked = await this.RunPostRunViewHooks(params, result, contextUser);
|
|
2478
|
+
if (hooked && hooked !== result) {
|
|
2479
|
+
Object.assign(result, hooked);
|
|
2480
|
+
}
|
|
2401
2481
|
// Register OnDataChanged callback if provided and we have a fingerprint
|
|
2402
2482
|
if (params.OnDataChanged && preResult.fingerprint) {
|
|
2403
2483
|
result.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
@@ -2542,6 +2622,43 @@ export class ProviderBase {
|
|
|
2542
2622
|
}
|
|
2543
2623
|
return result;
|
|
2544
2624
|
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Applies the PostRunView hook chain to a result that was served from cache, mutating
|
|
2627
|
+
* `result` in place so the caller's reference reflects the chain's output.
|
|
2628
|
+
*
|
|
2629
|
+
* ## Why cache hits must run the hooks
|
|
2630
|
+
* PostRunView is the OUTPUT half of the enforcement seam (data masking / audit). Hooks
|
|
2631
|
+
* receive `contextUser`, so masking is PER-USER, while the cache slot is shared across
|
|
2632
|
+
* users — there is no correct way to apply masking once at write time on behalf of a
|
|
2633
|
+
* reader who has not arrived yet. A hit that skips the chain therefore returns rows the
|
|
2634
|
+
* miss path would have masked.
|
|
2635
|
+
*
|
|
2636
|
+
* This previously appeared to work by accident: PostRunView writes the cache BEFORE
|
|
2637
|
+
* running the hooks, so a hook that masked rows in place was writing through into the
|
|
2638
|
+
* cached objects — which both made later hits look masked and baked one user's masking
|
|
2639
|
+
* decision into a shared slot. Freeze-on-write removes that write-through, which is what
|
|
2640
|
+
* makes running the chain here necessary rather than merely tidier.
|
|
2641
|
+
*
|
|
2642
|
+
* ## Why mutating `result` in place is safe
|
|
2643
|
+
* Cache-hit results are FRESH wrapper objects built per hit by PreRunView/PreRunViews —
|
|
2644
|
+
* only `.Results` points at shared cache state. A hook that returns a replacement (the
|
|
2645
|
+
* required pattern now that rows are frozen) is copied onto that per-hit wrapper, so it
|
|
2646
|
+
* can never write back into the cache.
|
|
2647
|
+
*
|
|
2648
|
+
* ## Why the guard
|
|
2649
|
+
* `GetDataHooks` is a memoized store read (~30ns), but `await`-ing the async chain costs
|
|
2650
|
+
* a microtask (~750ns) — comparable to the entire cache lookup this rides on. The
|
|
2651
|
+
* overwhelmingly common case is zero registered hooks, so check first and skip the await.
|
|
2652
|
+
*/
|
|
2653
|
+
async ApplyPostRunViewHooksToCacheHit(params, result, contextUser) {
|
|
2654
|
+
if (GetDataHooks('PostRunView').length === 0) {
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
const hooked = await this.RunPostRunViewHooks(params, result, contextUser);
|
|
2658
|
+
if (hooked && hooked !== result) {
|
|
2659
|
+
Object.assign(result, hooked);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2545
2662
|
/**
|
|
2546
2663
|
* Post-processing hook for RunQuery.
|
|
2547
2664
|
* Handles cache storage and telemetry end.
|
|
@@ -2806,11 +2923,17 @@ export class ProviderBase {
|
|
|
2806
2923
|
const batchExempt = params.length > 0 && params.every(p => p.Telemetry?.Exempt);
|
|
2807
2924
|
const eventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
|
|
2808
2925
|
BatchSize: params.length,
|
|
2809
|
-
|
|
2926
|
+
// '' placeholder (NOT .filter(Boolean)) — see PreRunViews: Entities must stay
|
|
2927
|
+
// index-parallel to Filters/OrderBys/StartRows/AfterKeys.
|
|
2928
|
+
Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID || ''),
|
|
2810
2929
|
// Per-view filter/orderBy parallel to Entities so the telemetry fingerprint can
|
|
2811
2930
|
// tell apart two batches over the same entity set but with different filters.
|
|
2812
2931
|
Filters: params.map(p => p.ExtraFilter),
|
|
2813
2932
|
OrderBys: params.map(p => p.OrderBy),
|
|
2933
|
+
// Per-view pagination cursors — see PreRunViews: keeps each page of a sweep a
|
|
2934
|
+
// distinct fingerprint instead of a false Duplicate RunView from page 2 onward.
|
|
2935
|
+
StartRows: params.map(p => p.StartRow),
|
|
2936
|
+
AfterKeys: params.map(p => p.AfterKey?.ToConcatenatedString()),
|
|
2814
2937
|
_fromEngine: fromEngine,
|
|
2815
2938
|
Exempt: batchExempt,
|
|
2816
2939
|
ExemptReason: params.find(p => p.Telemetry?.Reason)?.Telemetry?.Reason
|
|
@@ -2866,9 +2989,180 @@ export class ProviderBase {
|
|
|
2866
2989
|
* @param contextUser - The user context for permissions
|
|
2867
2990
|
*/
|
|
2868
2991
|
async TransformSimpleObjectToEntityObject(param, result, contextUser) {
|
|
2992
|
+
// Mutually exclusive with the entity branch below: entity objects get real types from
|
|
2993
|
+
// BaseEntity's Get/Set conversion, so normalization applies only to non-entity results.
|
|
2994
|
+
this.NormalizeSimpleRowTypes(param, result);
|
|
2869
2995
|
if (param.ResultType === 'entity_object' && result && result.Success && result.Results?.length > 0) {
|
|
2870
2996
|
result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser);
|
|
2997
|
+
// Opt-in batched child loading: ONE query per named collection across the whole result
|
|
2998
|
+
// set, not one per row. Companion eager loading is deliberately kept out of
|
|
2999
|
+
// LoadFromData() (which is the per-row path above) precisely so that populating children
|
|
3000
|
+
// for a view cannot degrade into N+1 — see relatedRecordBatchLoader.ts.
|
|
3001
|
+
if (param.IncludeRelatedRecords?.length) {
|
|
3002
|
+
await LoadRelatedRecordsBatched(result.Results, param.IncludeRelatedRecords, this, contextUser);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Normalizes non-entity (`'simple'`) result rows so `Date` and numeric columns hold real
|
|
3008
|
+
* `Date`s and `number`s on EVERY tier, matching what the generated entity types declare.
|
|
3009
|
+
*
|
|
3010
|
+
* ## Why this is unconditional
|
|
3011
|
+
*
|
|
3012
|
+
* Before this existed, the value a simple read returned for a `DATETIME` column depended on
|
|
3013
|
+
* where the code happened to run: a fresh server-side query yields real `Date` objects (the
|
|
3014
|
+
* driver parses them and `AdjustDatetimeFields` timezone-adjusts them), a server-side Redis
|
|
3015
|
+
* cache hit yields ISO strings (`JSON.parse` with no reviver), and a browser client over
|
|
3016
|
+
* GraphQL yields ISO strings (rows are `JSON.stringify`'d on the wire). Same call, three
|
|
3017
|
+
* shapes. MJ's contract is a unified programming interface on both sides of the wire, so the
|
|
3018
|
+
* one representation the platform's own generated types declare — `Date` — is enforced here,
|
|
3019
|
+
* at the one choke point every provider's RunView pipeline flows through.
|
|
3020
|
+
*
|
|
3021
|
+
* ## What it does NOT do
|
|
3022
|
+
*
|
|
3023
|
+
* It makes date and number VALUES match the generated types; it does not make a caller's `T`
|
|
3024
|
+
* honest in general. A `Status` column typed as a closed union still holds whatever string the
|
|
3025
|
+
* database held, and plain rows never have entity methods. If you need the type to be fully
|
|
3026
|
+
* true, use `ResultType: 'entity_object'`.
|
|
3027
|
+
*
|
|
3028
|
+
* ## Cost and cache safety
|
|
3029
|
+
*
|
|
3030
|
+
* The field-key lists are computed once per view from `EntityInfo`, not per cell. Rows already
|
|
3031
|
+
* in the right shape — the common server-side case, where the driver returned `Date`s — are
|
|
3032
|
+
* detected and the ORIGINAL array is kept untouched: same array identity, same row objects,
|
|
3033
|
+
* zero copying. A row is shallow-copied only when a cell actually converts, and that copy is
|
|
3034
|
+
* load-bearing: on a cache hit the rows handed back can be the cache's OWN objects (the
|
|
3035
|
+
* in-memory server store holds them by reference), so converting in place would write `Date`s
|
|
3036
|
+
* into the cache entry itself and corrupt it for serialization and for later readers.
|
|
3037
|
+
*
|
|
3038
|
+
* Per-cell rules:
|
|
3039
|
+
* - `Date` instances pass through untouched, so the pass is idempotent on every path.
|
|
3040
|
+
* - `NULL`/`undefined` cells are left alone rather than becoming epoch-1970 dates.
|
|
3041
|
+
* - An unparseable value is left as-is rather than written as `Invalid Date`, which renders
|
|
3042
|
+
* as that literal string and destroys the evidence of what the database actually held.
|
|
3043
|
+
* - An integer string outside `Number.MAX_SAFE_INTEGER` stays a string: the PostgreSQL
|
|
3044
|
+
* provider deliberately returns unsafe-range BIGINTs as strings to avoid precision loss,
|
|
3045
|
+
* and `Number('9007199254740993')` "succeeds" while silently corrupting the value.
|
|
3046
|
+
*
|
|
3047
|
+
* View-based runs (`ViewID`/`ViewName` with neither `EntityName` nor a loaded `ViewEntity`)
|
|
3048
|
+
* skip normalization: resolving the entity would take an async User Views read this late in
|
|
3049
|
+
* the pipeline. Pass `EntityName` alongside the view identifier to get normalized rows.
|
|
3050
|
+
*/
|
|
3051
|
+
NormalizeSimpleRowTypes(param, result) {
|
|
3052
|
+
if (param.ResultType === 'entity_object' || param.ResultType === 'count_only') {
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
if (!result?.Success || !result.Results?.length) {
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
const entity = this.resolveEntityForNormalization(param);
|
|
3059
|
+
if (!entity) {
|
|
3060
|
+
// An unresolvable entity name is already a failed query elsewhere; normalization is
|
|
3061
|
+
// not the place to raise it, and guessing field types would be worse than raw rows.
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
// Once per view, not once per cell. Each entry lists the row keys one field can appear
|
|
3065
|
+
// under: the batch transport keys rows by Name, the singular transport adds CodeName.
|
|
3066
|
+
const dateKeys = this.normalizationKeys(entity, EntityFieldTSType.Date);
|
|
3067
|
+
const numberKeys = this.normalizationKeys(entity, EntityFieldTSType.Number);
|
|
3068
|
+
if (!dateKeys.length && !numberKeys.length) {
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
let anyRowChanged = false;
|
|
3072
|
+
const normalized = result.Results.map(row => {
|
|
3073
|
+
const converted = this.normalizeSimpleRow(row, dateKeys, numberKeys);
|
|
3074
|
+
if (converted) {
|
|
3075
|
+
anyRowChanged = true;
|
|
3076
|
+
return converted;
|
|
3077
|
+
}
|
|
3078
|
+
return row;
|
|
3079
|
+
});
|
|
3080
|
+
if (anyRowChanged) {
|
|
3081
|
+
result.Results = normalized;
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Resolves the {@link EntityInfo} normalization should read field types from, using only
|
|
3086
|
+
* synchronously available information on the params.
|
|
3087
|
+
*/
|
|
3088
|
+
resolveEntityForNormalization(param) {
|
|
3089
|
+
if (param.EntityName) {
|
|
3090
|
+
return this.EntityByName(param.EntityName);
|
|
3091
|
+
}
|
|
3092
|
+
if (param.ViewEntity) {
|
|
3093
|
+
// Weak typing mirrors RunView.GetEntityNameFromRunViewParams: MJCore cannot import
|
|
3094
|
+
// the core-entities UserView subclass without creating a circular dependency.
|
|
3095
|
+
const entityID = param.ViewEntity.Get('EntityID');
|
|
3096
|
+
return entityID ? this.EntityByID(entityID) : undefined;
|
|
3097
|
+
}
|
|
3098
|
+
return undefined;
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* The row keys each field of the given TSType can appear under, one entry per field.
|
|
3102
|
+
*/
|
|
3103
|
+
normalizationKeys(entity, tsType) {
|
|
3104
|
+
return entity.Fields
|
|
3105
|
+
.filter(f => f.TSType === tsType)
|
|
3106
|
+
.map(f => (f.CodeName && f.CodeName !== f.Name ? [f.Name, f.CodeName] : [f.Name]));
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Returns a converted shallow copy of the row, or null when no cell needed converting —
|
|
3110
|
+
* so untouched rows keep their identity and cached rows are never written to.
|
|
3111
|
+
*/
|
|
3112
|
+
normalizeSimpleRow(row, dateKeys, numberKeys) {
|
|
3113
|
+
if (!row || typeof row !== 'object') {
|
|
3114
|
+
return null;
|
|
3115
|
+
}
|
|
3116
|
+
let copy = null;
|
|
3117
|
+
for (const keys of dateKeys) {
|
|
3118
|
+
for (const key of keys) {
|
|
3119
|
+
const date = this.parseDateCell((copy ?? row)[key]);
|
|
3120
|
+
if (date) {
|
|
3121
|
+
copy = copy ?? { ...row };
|
|
3122
|
+
copy[key] = date;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
for (const keys of numberKeys) {
|
|
3127
|
+
for (const key of keys) {
|
|
3128
|
+
const num = this.parseNumericCell((copy ?? row)[key]);
|
|
3129
|
+
if (num !== null) {
|
|
3130
|
+
copy = copy ?? { ...row };
|
|
3131
|
+
copy[key] = num;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
return copy;
|
|
3136
|
+
}
|
|
3137
|
+
/**
|
|
3138
|
+
* A real Date for a convertible cell, or null to leave the cell untouched. Existing Date
|
|
3139
|
+
* instances, NULLs, and unparseable values all return null — see the per-cell rules on
|
|
3140
|
+
* {@link NormalizeSimpleRowTypes}.
|
|
3141
|
+
*/
|
|
3142
|
+
parseDateCell(value) {
|
|
3143
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
3144
|
+
return null;
|
|
3145
|
+
}
|
|
3146
|
+
const date = new Date(value);
|
|
3147
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* A number for a convertible string cell, or null to leave the cell untouched.
|
|
3151
|
+
*/
|
|
3152
|
+
parseNumericCell(value) {
|
|
3153
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
3154
|
+
return null;
|
|
3155
|
+
}
|
|
3156
|
+
const num = Number(value);
|
|
3157
|
+
if (!Number.isFinite(num)) {
|
|
3158
|
+
return null;
|
|
3159
|
+
}
|
|
3160
|
+
// An integer string beyond the safe range is a deliberate driver choice (PostgreSQL
|
|
3161
|
+
// returns unsafe BIGINTs as strings): converting would silently corrupt the value.
|
|
3162
|
+
if (Number.isInteger(num) && !Number.isSafeInteger(num)) {
|
|
3163
|
+
return null;
|
|
2871
3164
|
}
|
|
3165
|
+
return num;
|
|
2872
3166
|
}
|
|
2873
3167
|
/**
|
|
2874
3168
|
* Returns the currently loaded local metadata from within the instance
|