@memberjunction/core 6.1.0-edge.1 → 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.
Files changed (32) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +6 -0
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  3. package/dist/generic/InMemoryLocalStorageProvider.js +6 -0
  4. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +37 -3
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +84 -17
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/dataHooks.d.ts +5 -0
  10. package/dist/generic/dataHooks.d.ts.map +1 -1
  11. package/dist/generic/dataHooks.js +27 -3
  12. package/dist/generic/dataHooks.js.map +1 -1
  13. package/dist/generic/interfaces.d.ts +29 -0
  14. package/dist/generic/interfaces.d.ts.map +1 -1
  15. package/dist/generic/interfaces.js.map +1 -1
  16. package/dist/generic/localCacheManager.d.ts +123 -5
  17. package/dist/generic/localCacheManager.d.ts.map +1 -1
  18. package/dist/generic/localCacheManager.js +254 -9
  19. package/dist/generic/localCacheManager.js.map +1 -1
  20. package/dist/generic/providerBase.d.ts +99 -0
  21. package/dist/generic/providerBase.d.ts.map +1 -1
  22. package/dist/generic/providerBase.js +240 -5
  23. package/dist/generic/providerBase.js.map +1 -1
  24. package/dist/generic/relatedRecordCollection.d.ts +11 -1
  25. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  26. package/dist/generic/relatedRecordCollection.js +17 -1
  27. package/dist/generic/relatedRecordCollection.js.map +1 -1
  28. package/dist/views/runView.d.ts +35 -0
  29. package/dist/views/runView.d.ts.map +1 -1
  30. package/dist/views/runView.js.map +1 -1
  31. package/package.json +3 -3
  32. package/readme.md +44 -0
@@ -144,7 +144,16 @@ export function ProjectRowsToFields(rows, requestedFields) {
144
144
  }
145
145
  }
146
146
  if (allKept) {
147
- return rows;
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
@@ -1524,6 +1542,9 @@ export class ProviderBase {
1524
1542
  QueryID: cached.queryId ?? params.QueryID ?? '',
1525
1543
  QueryName: params.QueryName ?? '',
1526
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.
1527
1548
  Results: cached.results,
1528
1549
  RowCount: cached.results.length,
1529
1550
  TotalRowCount: cached.rowCount ?? cached.results.length,
@@ -1860,7 +1881,9 @@ export class ProviderBase {
1860
1881
  fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
1861
1882
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1862
1883
  if (cached) {
1863
- // Filter cached results to only the caller's requested fields (if specified)
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.
1864
1887
  let results = cached.results;
1865
1888
  if (callerRequestedFields && params.ResultType !== 'entity_object') {
1866
1889
  results = ProjectRowsToFields(results, callerRequestedFields);
@@ -2009,7 +2032,7 @@ export class ProviderBase {
2009
2032
  fingerprintMap.set(i, fingerprint);
2010
2033
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
2011
2034
  if (cached) {
2012
- // Filter cached results to caller's requested fields (if specified and not entity_object)
2035
+ // Shared, deep-frozen cache rows same contract as the single-view hit path.
2013
2036
  let results = cached.results;
2014
2037
  if (callerFields && param.ResultType !== 'entity_object') {
2015
2038
  results = ProjectRowsToFields(results, callerFields);
@@ -2442,8 +2465,19 @@ export class ProviderBase {
2442
2465
  }
2443
2466
  // Transform the result set into BaseEntity-derived objects, if needed
2444
2467
  await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
2445
- // Run registered PostRunView hooks (e.g., data masking, audit logging)
2446
- result = await this.RunPostRunViewHooks(params, result, contextUser);
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
+ }
2447
2481
  // Register OnDataChanged callback if provided and we have a fingerprint
2448
2482
  if (params.OnDataChanged && preResult.fingerprint) {
2449
2483
  result.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
@@ -2588,6 +2622,43 @@ export class ProviderBase {
2588
2622
  }
2589
2623
  return result;
2590
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
+ }
2591
2662
  /**
2592
2663
  * Post-processing hook for RunQuery.
2593
2664
  * Handles cache storage and telemetry end.
@@ -2918,6 +2989,9 @@ export class ProviderBase {
2918
2989
  * @param contextUser - The user context for permissions
2919
2990
  */
2920
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);
2921
2995
  if (param.ResultType === 'entity_object' && result && result.Success && result.Results?.length > 0) {
2922
2996
  result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser);
2923
2997
  // Opt-in batched child loading: ONE query per named collection across the whole result
@@ -2929,6 +3003,167 @@ export class ProviderBase {
2929
3003
  }
2930
3004
  }
2931
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;
3164
+ }
3165
+ return num;
3166
+ }
2932
3167
  /**
2933
3168
  * Returns the currently loaded local metadata from within the instance
2934
3169
  */