@memberjunction/core 6.2.0-edge.7 → 6.2.0

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 (54) hide show
  1. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
  2. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.d.ts +47 -4
  4. package/dist/generic/baseEngine.d.ts.map +1 -1
  5. package/dist/generic/baseEngine.js +96 -18
  6. package/dist/generic/baseEngine.js.map +1 -1
  7. package/dist/generic/baseEngineRegistry.d.ts +10 -2
  8. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  9. package/dist/generic/baseEngineRegistry.js +33 -5
  10. package/dist/generic/baseEngineRegistry.js.map +1 -1
  11. package/dist/generic/baseEntity.d.ts +60 -4
  12. package/dist/generic/baseEntity.d.ts.map +1 -1
  13. package/dist/generic/baseEntity.js +603 -93
  14. package/dist/generic/baseEntity.js.map +1 -1
  15. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  16. package/dist/generic/databaseProviderBase.js +43 -9
  17. package/dist/generic/databaseProviderBase.js.map +1 -1
  18. package/dist/generic/entityInfo.d.ts +19 -1
  19. package/dist/generic/entityInfo.d.ts.map +1 -1
  20. package/dist/generic/entityInfo.js +52 -0
  21. package/dist/generic/entityInfo.js.map +1 -1
  22. package/dist/generic/localCacheManager.d.ts +21 -0
  23. package/dist/generic/localCacheManager.d.ts.map +1 -1
  24. package/dist/generic/localCacheManager.js +107 -1
  25. package/dist/generic/localCacheManager.js.map +1 -1
  26. package/dist/generic/logging.d.ts +105 -38
  27. package/dist/generic/logging.d.ts.map +1 -1
  28. package/dist/generic/logging.js +196 -5
  29. package/dist/generic/logging.js.map +1 -1
  30. package/dist/generic/providerBase.d.ts +43 -1
  31. package/dist/generic/providerBase.d.ts.map +1 -1
  32. package/dist/generic/providerBase.js +359 -99
  33. package/dist/generic/providerBase.js.map +1 -1
  34. package/dist/generic/sharedLoad.d.ts +42 -0
  35. package/dist/generic/sharedLoad.d.ts.map +1 -0
  36. package/dist/generic/sharedLoad.js +74 -0
  37. package/dist/generic/sharedLoad.js.map +1 -0
  38. package/dist/generic/telemetryManager.d.ts +31 -0
  39. package/dist/generic/telemetryManager.d.ts.map +1 -1
  40. package/dist/generic/telemetryManager.js +85 -9
  41. package/dist/generic/telemetryManager.js.map +1 -1
  42. package/dist/generic/transactionGroup.d.ts +19 -0
  43. package/dist/generic/transactionGroup.d.ts.map +1 -1
  44. package/dist/generic/transactionGroup.js +55 -7
  45. package/dist/generic/transactionGroup.js.map +1 -1
  46. package/dist/generic/util.d.ts +1 -1
  47. package/dist/generic/util.d.ts.map +1 -1
  48. package/dist/generic/util.js +2 -2
  49. package/dist/generic/util.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/package.json +3 -3
@@ -238,6 +238,21 @@ export class ProviderBase {
238
238
  */
239
239
  this._inflightViews = new Map();
240
240
  this._lingerInvalidationWired = false;
241
+ /**
242
+ * Per-entity monotonic mutation counter incremented whenever an entity receives a
243
+ * BaseEntity save/delete/remote-invalidate or an explicit {@link InvalidateRunViewDedupCache}
244
+ * call. Used by dataset reads (`GetDatasetByName`) to detect that an entity was mutated while
245
+ * SQL execution or post-processing was in flight and avoid writing stale pre-mutation rows
246
+ * back into `LocalCacheManager`.
247
+ */
248
+ this._entityMutationVersions = new Map();
249
+ /**
250
+ * Monotonic counter incremented whenever any metadata-member entity is mutated against this
251
+ * provider's backend. Checked by `GetAllMetadata()` before caching `MJ_Metadata` into
252
+ * `DatasetCache` so an in-flight background reload does not overwrite `DatasetCache` with a
253
+ * pre-mutation snapshot after a save/delete has already landed.
254
+ */
255
+ this._metadataMemberMutationSeq = 0;
241
256
  /**
242
257
  * Lowercased names of the entities whose rows COMPOSE this provider's metadata — the
243
258
  * MJ_Metadata dataset's item entities, recorded each time the dataset is loaded (and restored
@@ -427,6 +442,23 @@ export class ProviderBase {
427
442
  if (!entityName) {
428
443
  return;
429
444
  }
445
+ const cascadeRelatedEntities = [];
446
+ const isaRelatedEntities = [];
447
+ const entityInfo = entityEvent.baseEntity?.EntityInfo;
448
+ if (entityEvent.type === 'delete' && entityInfo?.CascadeDeletes && Array.isArray(entityInfo.RelatedEntities)) {
449
+ for (const rel of entityInfo.RelatedEntities) {
450
+ const relName = rel?.RelatedEntity?.trim().toLowerCase();
451
+ const relType = (rel?.Type ?? 'One To Many').trim().toUpperCase();
452
+ if (relName && relName !== entityName && relType === 'ONE TO MANY' && !cascadeRelatedEntities.includes(relName)) {
453
+ cascadeRelatedEntities.push(relName);
454
+ }
455
+ }
456
+ }
457
+ if ((entityEvent.type === 'save' || entityEvent.type === 'delete') && entityInfo) {
458
+ for (const isaLower of ProviderBase.collectISAHierarchyEntityNamesLower(entityInfo, entityName)) {
459
+ isaRelatedEntities.push(isaLower);
460
+ }
461
+ }
430
462
  for (const ref of ProviderBase._invalidationTargets) {
431
463
  const instance = ref.deref();
432
464
  if (!instance) {
@@ -437,9 +469,50 @@ export class ProviderBase {
437
469
  }
438
470
  instance.invalidateInflightViewsForEntity(entityName);
439
471
  instance.handleMetadataMemberEntityEvent(entityName, entityEvent);
472
+ for (const relLower of cascadeRelatedEntities) {
473
+ instance.invalidateInflightViewsForEntity(relLower);
474
+ instance.handleMetadataMemberEntityEvent(relLower, entityEvent);
475
+ }
476
+ for (const isaLower of isaRelatedEntities) {
477
+ instance.invalidateInflightViewsForEntity(isaLower);
478
+ instance.handleMetadataMemberEntityEvent(isaLower, entityEvent);
479
+ }
440
480
  }
441
481
  });
442
482
  }
483
+ /**
484
+ * Collects all lowercased entity names in the same IS-A hierarchy (`ParentEntityInfo` ancestors
485
+ * and `ChildEntities` descendants) as `entityInfo`, excluding `excludeLower`.
486
+ */
487
+ static collectISAHierarchyEntityNamesLower(entityInfo, excludeLower) {
488
+ if (!entityInfo.IsChildType && !entityInfo.IsParentType) {
489
+ return [];
490
+ }
491
+ let root = entityInfo;
492
+ const seenAncestors = new Set();
493
+ while (root.ParentEntityInfo && !seenAncestors.has(root.ParentEntityInfo)) {
494
+ seenAncestors.add(root);
495
+ root = root.ParentEntityInfo;
496
+ }
497
+ const results = [];
498
+ const visited = new Set();
499
+ const queue = [root];
500
+ while (queue.length > 0) {
501
+ const current = queue.shift();
502
+ const lower = current?.Name?.trim().toLowerCase();
503
+ if (!lower || visited.has(lower)) {
504
+ continue;
505
+ }
506
+ visited.add(lower);
507
+ if (lower !== excludeLower) {
508
+ results.push(lower);
509
+ }
510
+ if (Array.isArray(current.ChildEntities) && current.ChildEntities.length > 0) {
511
+ queue.push(...current.ChildEntities);
512
+ }
513
+ }
514
+ return results;
515
+ }
443
516
  /**
444
517
  * Lazily registers this instance (once per provider instance) for write-invalidation
445
518
  * of in-flight/lingered RunView entries touching a written entity. Deleting an IN-FLIGHT
@@ -460,6 +533,17 @@ export class ProviderBase {
460
533
  this._lingerInvalidationWired = true;
461
534
  ProviderBase._invalidationTargets.add(new WeakRef(this));
462
535
  }
536
+ /**
537
+ * Returns the current monotonic mutation version for `entityName` (case-insensitive).
538
+ */
539
+ GetEntityMutationVersion(entityName) {
540
+ this.ensureInflightViewInvalidation();
541
+ const lower = entityName?.trim().toLowerCase();
542
+ if (!lower) {
543
+ return 0;
544
+ }
545
+ return this._entityMutationVersions.get(lower) ?? 0;
546
+ }
463
547
  /**
464
548
  * Drops this provider's in-flight/lingered RunView dedup entries so the next identical
465
549
  * RunView goes to the server instead of replaying a result from the last
@@ -482,6 +566,9 @@ export class ProviderBase {
482
566
  const names = (entityNames ?? []).map(n => n?.trim().toLowerCase()).filter((n) => !!n);
483
567
  if (names.length === 0) {
484
568
  this._inflightViews.clear();
569
+ for (const [k, v] of this._entityMutationVersions) {
570
+ this._entityMutationVersions.set(k, v + 1);
571
+ }
485
572
  return;
486
573
  }
487
574
  for (const n of names) {
@@ -493,6 +580,7 @@ export class ProviderBase {
493
580
  * entity (lowercased name). Called on BaseEntity save/delete/remote-invalidate.
494
581
  */
495
582
  invalidateInflightViewsForEntity(lowerEntityName) {
583
+ this._entityMutationVersions.set(lowerEntityName, (this._entityMutationVersions.get(lowerEntityName) ?? 0) + 1);
496
584
  for (const [key, entry] of this._inflightViews) {
497
585
  if (entry.entityNames?.has(lowerEntityName)) {
498
586
  this._inflightViews.delete(key);
@@ -672,6 +760,7 @@ export class ProviderBase {
672
760
  if (!this.eventTargetsThisProviderBackend(entityEvent)) {
673
761
  return;
674
762
  }
763
+ this._metadataMemberMutationSeq++;
675
764
  this.scheduleMetadataMemberRefresh();
676
765
  }
677
766
  /**
@@ -890,6 +979,14 @@ export class ProviderBase {
890
979
  return true;
891
980
  }
892
981
  this._metadataMemberRefreshWaits = 0;
982
+ if (this._metadataReloadInFlight) {
983
+ // A metadata reload is already in flight. Do not call RefreshRemoteMetadataTimestamps()
984
+ // concurrently here, because that mutates _latestRemoteMetadataTimestamps out from under
985
+ // the in-flight pass before it runs adoptServerMetadata(). Instead, delegate directly to
986
+ // ReloadMetadata, which sets _metadataReloadQueued = true and re-reads authoritative
987
+ // remote timestamps at the start of the queued follow-up pass.
988
+ return this.ReloadMetadata(true, undefined, true);
989
+ }
893
990
  // The writer holds positive evidence, so the timestamps are re-read from the authoritative
894
991
  // status query. When they attribute the change entirely to the saved-query family (see
895
992
  // PartialRefreshMetadataItemCodes) only that family is reloaded, cache layers bypassed as a
@@ -1299,6 +1396,7 @@ export class ProviderBase {
1299
1396
  // allCached ⇒ every param produced a hit and was pushed in order (PreRunViews only
1300
1397
  // pushes a null placeholder on the path that clears allCached), so index i of
1301
1398
  // cachedResults corresponds to params[i].
1399
+ await this.TransformBatchRunViewResults(params, preResult.cachedResults, undefined, preResult.callerFieldsMap, contextUser);
1302
1400
  for (let i = 0; i < preResult.cachedResults.length; i++) {
1303
1401
  await this.ApplyPostRunViewHooksToCacheHit(params[i], preResult.cachedResults[i], contextUser);
1304
1402
  }
@@ -2334,22 +2432,24 @@ export class ProviderBase {
2334
2432
  queryDefinition = queryAuth.definition;
2335
2433
  // TTL-enforced, and a miss when the slot was written under another query definition.
2336
2434
  const cached = queryFingerprint ? await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition) : null;
2337
- if (cached && queryFingerprint) {
2338
- const serveFromSlot = () => ({
2339
- QueryID: cached.queryId ?? params.QueryID ?? '',
2340
- QueryName: params.QueryName ?? '',
2341
- Success: true,
2342
- // Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
2343
- // rows) while the outbound Results is mutable — the runtime freeze is the
2344
- // enforcement. Same cast as the RunView hit paths above.
2345
- Results: cached.results,
2346
- RowCount: cached.results.length,
2347
- TotalRowCount: cached.rowCount ?? cached.results.length,
2348
- ExecutionTime: 0,
2349
- ErrorMessage: '',
2350
- CacheHit: true,
2351
- CacheKey: queryFingerprint
2352
- });
2435
+ if (queryFingerprint) {
2436
+ const serveFromSlot = cached
2437
+ ? () => ({
2438
+ QueryID: cached.queryId ?? params.QueryID ?? '',
2439
+ QueryName: params.QueryName ?? '',
2440
+ Success: true,
2441
+ // Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
2442
+ // rows) while the outbound Results is mutable — the runtime freeze is the
2443
+ // enforcement. Same cast as the RunView hit paths above.
2444
+ Results: cached.results,
2445
+ RowCount: cached.results.length,
2446
+ TotalRowCount: cached.rowCount ?? cached.results.length,
2447
+ ExecutionTime: 0,
2448
+ ErrorMessage: '',
2449
+ CacheHit: true,
2450
+ CacheKey: queryFingerprint
2451
+ })
2452
+ : undefined;
2353
2453
  const checker = this.RunQueriesWithCacheCheck?.bind(this);
2354
2454
  if (!checker || this.TrustLocalCacheCompletely) {
2355
2455
  // TTL mode (server providers / no validation transport).
@@ -2366,69 +2466,71 @@ export class ProviderBase {
2366
2466
  // authorizes with the proper error message. Query metadata missing from the
2367
2467
  // provider cache ⇒ ALSO fall through (never serve rows we cannot authorize);
2368
2468
  // that costs one query run, not correctness.
2369
- if (contextUser) {
2370
- if (queryAuth.resolvable) {
2371
- // Metadata can answer — enforce it. `authorized` comes from the
2372
- // provider's STRONGEST available check (B45): the base enforces
2373
- // roles-only QueryInfo.UserCanRun; GenericDatabaseProvider's
2374
- // override enforces the full MJQueryEntityExtended.UserCanRun
2375
- // (roles + entity CanRead + recursive composition) — the identical
2376
- // check ValidateQueryForExecution applies on the miss path, so a
2377
- // hit is never easier to read than a miss.
2378
- if (queryAuth.authorized) {
2469
+ if (cached && serveFromSlot) {
2470
+ if (contextUser) {
2471
+ if (queryAuth.resolvable) {
2472
+ // Metadata can answer — enforce it. `authorized` comes from the
2473
+ // provider's STRONGEST available check (B45): the base enforces
2474
+ // roles-only QueryInfo.UserCanRun; GenericDatabaseProvider's
2475
+ // override enforces the full MJQueryEntityExtended.UserCanRun
2476
+ // (roles + entity CanRead + recursive composition) — the identical
2477
+ // check ValidateQueryForExecution applies on the miss path, so a
2478
+ // hit is never easier to read than a miss.
2479
+ if (queryAuth.authorized) {
2480
+ return serveFromSlot();
2481
+ }
2482
+ LogStatusEx({ message: `RunQuery cache: user '${contextUser.Email}' lacks run permission on '${queryAuth.queryName ?? params.QueryName ?? params.QueryID}' — falling through to authorized execution.`, verboseOnly: true });
2483
+ }
2484
+ else if (cached.warmedForUserID && UUIDsEqual(cached.warmedForUserID, contextUser.ID)) {
2485
+ // Metadata CANNOT answer — runtime-created queries never appear in the
2486
+ // provider's Queries cache (it does not refresh in-process; verified:
2487
+ // a saved Query stays invisible, 21 -> 21). But a slot only exists
2488
+ // because its WARMER executed the fully-authorized miss path — so the
2489
+ // warmer's own permission is already proven. Serve the warmer;
2490
+ // anyone ELSE falls through and pays one authorized execution.
2491
+ //
2492
+ // The first version of this gate failed closed on unresolvable
2493
+ // metadata, which silently disabled TTL caching for every
2494
+ // runtime-created query — caught by Q2/Q5/Q10 going red.
2379
2495
  return serveFromSlot();
2380
2496
  }
2381
- LogStatusEx({ message: `RunQuery cache: user '${contextUser.Email}' lacks run permission on '${queryAuth.queryName ?? params.QueryName ?? params.QueryID}' — falling through to authorized execution.`, verboseOnly: true });
2382
- }
2383
- else if (cached.warmedForUserID && UUIDsEqual(cached.warmedForUserID, contextUser.ID)) {
2384
- // Metadata CANNOT answer — runtime-created queries never appear in the
2385
- // provider's Queries cache (it does not refresh in-process; verified:
2386
- // a saved Query stays invisible, 21 -> 21). But a slot only exists
2387
- // because its WARMER executed the fully-authorized miss path — so the
2388
- // warmer's own permission is already proven. Serve the warmer;
2389
- // anyone ELSE falls through and pays one authorized execution.
2390
- //
2391
- // The first version of this gate failed closed on unresolvable
2392
- // metadata, which silently disabled TTL caching for every
2393
- // runtime-created query — caught by Q2/Q5/Q10 going red.
2394
- return serveFromSlot();
2497
+ else {
2498
+ LogStatusEx({ message: `RunQuery cache: '${params.QueryID ?? params.QueryName}' not in cached metadata and requester is not the slot's warmer — falling through to authorized execution.`, verboseOnly: true });
2499
+ }
2395
2500
  }
2396
2501
  else {
2397
- LogStatusEx({ message: `RunQuery cache: '${params.QueryID ?? params.QueryName}' not in cached metadata and requester is not the slot's warmer — falling through to authorized execution.`, verboseOnly: true });
2502
+ return serveFromSlot();
2398
2503
  }
2399
2504
  }
2400
- else {
2401
- return serveFromSlot();
2402
- }
2403
- }
2404
- // Client smart validation round trip.
2405
- //
2406
- // Guarded on `checker` (R4-2): the block above is entered when `!checker ||
2407
- // TrustLocalCacheCompletely`. If we got here via `!checker` AND the B43 permission
2408
- // gate DECLINED to serve (denied user / non-warmer), execution used to fall
2409
- // straight into `await checker(...)` = `await undefined(...)` — a TypeError instead
2410
- // of the promised "fall through to authorized execution". Every in-tree provider
2411
- // implements the optional `RunQueriesWithCacheCheck`, so this was latent, but the
2412
- // pre-B43 code handled `!checker` and this must too: skip the round trip and let
2413
- // the normal PreRunQuery/InternalRunQuery path below authorize and execute.
2414
- if (!checker) {
2415
- // fall through to normal execution below
2416
2505
  }
2417
2506
  else {
2507
+ // Client smart validation round trip (both cold first-fetch and warm revalidation).
2508
+ // Routing cold CacheLocal requests through RunQueriesWithCacheCheck with no cacheStatus
2509
+ // engages the server's firstFetchWithValidation path so the initial client slot is
2510
+ // stamped with maxUpdatedAt, rowCount, and definitionHash — allowing the very first
2511
+ // subsequent refresh (F5) to validate as 'current' instead of missing.
2418
2512
  const response = await checker([{
2419
2513
  params,
2420
- // '' for a slot written without a hash: the server answers `stale` and returns
2421
- // its hash, so the rewrite below stamps the slot.
2422
- cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount, definitionHash: cached.definitionHash ?? '' }
2514
+ ...(cached
2515
+ ? {
2516
+ // '' for a slot written without a hash: the server answers `stale` and returns
2517
+ // its hash, so the rewrite below stamps the slot.
2518
+ cacheStatus: {
2519
+ maxUpdatedAt: cached.maxUpdatedAt,
2520
+ rowCount: cached.rowCount,
2521
+ definitionHash: cached.definitionHash ?? ''
2522
+ }
2523
+ }
2524
+ : {})
2423
2525
  }], contextUser);
2424
2526
  const check = response.results?.[0];
2425
2527
  if (response.success && check) {
2426
- if (check.status === 'current') {
2528
+ if (check.status === 'current' && serveFromSlot) {
2427
2529
  return serveFromSlot();
2428
2530
  }
2429
2531
  if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
2430
2532
  const freshRows = check.results;
2431
- // Fire-and-forget slot rewrite — same pattern as the RunView client path
2533
+ // Fire-and-forget slot write/rewrite — same pattern as the RunView client path
2432
2534
  LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID, check.definitionHash ? { QueryID: check.queryId, Hash: check.definitionHash } : undefined).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
2433
2535
  return {
2434
2536
  QueryID: check.queryId ?? params.QueryID ?? '',
@@ -2438,12 +2540,25 @@ export class ProviderBase {
2438
2540
  RowCount: freshRows.length,
2439
2541
  TotalRowCount: check.rowCount ?? freshRows.length,
2440
2542
  ExecutionTime: 0,
2441
- ErrorMessage: ''
2543
+ ErrorMessage: '',
2544
+ CacheKey: queryFingerprint
2545
+ };
2546
+ }
2547
+ if (check.status === 'error') {
2548
+ return {
2549
+ QueryID: check.queryId ?? params.QueryID ?? '',
2550
+ QueryName: params.QueryName ?? '',
2551
+ Success: false,
2552
+ Results: [],
2553
+ RowCount: 0,
2554
+ TotalRowCount: 0,
2555
+ ExecutionTime: 0,
2556
+ ErrorMessage: check.errorMessage ?? response.errorMessage ?? 'Query execution failed'
2442
2557
  };
2443
2558
  }
2444
2559
  }
2560
+ // validation transport failed — fall through to a normal execution
2445
2561
  }
2446
- // validation transport failed — fall through to a normal execution
2447
2562
  }
2448
2563
  }
2449
2564
  // Pre-processing: telemetry, cache check
@@ -3378,6 +3493,7 @@ export class ProviderBase {
3378
3493
  let results = await this.MaterializeCachedRowsForCaller(cached.results, batchEntity, contextUser, Boolean(param.PreserveRestrictedApiCiphertext));
3379
3494
  if (projectedCallerFields && param.ResultType !== 'entity_object') {
3380
3495
  results = ProjectRowsToFields(results, projectedCallerFields);
3496
+ callerFieldsMap.set(i, projectedCallerFields);
3381
3497
  }
3382
3498
  // Field-level security on the batch cache-hit path — same reasoning as the
3383
3499
  // single-view path in PreRunView: this result is returned without
@@ -3396,8 +3512,9 @@ export class ProviderBase {
3396
3512
  // different Aggregates[] order — remap to THIS caller's requested order.
3397
3513
  AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, param.Aggregates)
3398
3514
  };
3399
- // if needed this will transform each result into an entity object
3400
- await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
3515
+ // Entity transformation (`ResultType: 'entity_object'`) is deferred to
3516
+ // TransformBatchRunViewResults in runAdmittedViewStages / PostRunViews so
3517
+ // cache hits and DB misses participate together in two-phase sibling IS-A hydration.
3401
3518
  this.logDeprecatedAlternateBaseViewIfAny(param, 'PreRunViews');
3402
3519
  if (!param.CacheLocal && this.TrustLocalCacheCompletely) {
3403
3520
  LogStatusEx({ message: ` ✅ [Server Cache HIT] RunViews "${param.EntityName || param.ViewName || 'unknown'}" — ${cached.results.length} rows served from server cache (no DB query)`, verboseOnly: true });
@@ -3573,6 +3690,14 @@ export class ProviderBase {
3573
3690
  // pre-resolved map instead of issuing their own GetRunViewResult calls.
3574
3691
  const processingPromises = params.map((param, i) => this.processSingleSmartCacheResult(param, i, serverResultsByViewIndex.get(i), preResolvedCache, contextUser));
3575
3692
  const processedResults = await Promise.all(processingPromises);
3693
+ const batchResults = processedResults.map(r => r.result);
3694
+ const alreadyTransformedIndices = new Set();
3695
+ for (let i = 0; i < processedResults.length; i++) {
3696
+ if (processedResults[i].alreadyTransformed) {
3697
+ alreadyTransformedIndices.add(i);
3698
+ }
3699
+ }
3700
+ await this.TransformBatchRunViewResults(params, batchResults, alreadyTransformedIndices, undefined, contextUser);
3576
3701
  // Aggregate telemetry stats
3577
3702
  let cacheHits = 0;
3578
3703
  let cacheMisses = 0;
@@ -3590,7 +3715,7 @@ export class ProviderBase {
3590
3715
  cacheMisses,
3591
3716
  batchSize: params.length
3592
3717
  });
3593
- return processedResults.map(r => r.result);
3718
+ return batchResults;
3594
3719
  }
3595
3720
  /**
3596
3721
  * Processes a single smart cache check result.
@@ -3636,8 +3761,6 @@ export class ProviderBase {
3636
3761
  // different Aggregates[] order — remap to THIS caller's requested order.
3637
3762
  AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, param.Aggregates)
3638
3763
  };
3639
- // Transform to entity objects if needed
3640
- await this.TransformSimpleObjectToEntityObject(param, cachedResult, contextUser);
3641
3764
  return { result: cachedResult, cacheHit: true, cacheMiss: false };
3642
3765
  }
3643
3766
  else {
@@ -3688,8 +3811,6 @@ export class ProviderBase {
3688
3811
  // Include aggregate results - either fresh from server or from merged cache
3689
3812
  AggregateResults: checkResult.aggregateResults || merged.aggregateResults
3690
3813
  };
3691
- // Transform to entity objects if needed
3692
- await this.TransformSimpleObjectToEntityObject(param, mergedResult, contextUser);
3693
3814
  return { result: mergedResult, cacheHit: true, cacheMiss: false };
3694
3815
  }
3695
3816
  }
@@ -3704,14 +3825,23 @@ export class ProviderBase {
3704
3825
  // `r.result` straight to the caller, so null became the caller's RESULT.
3705
3826
  //
3706
3827
  // The only correct remedy is what a cache miss does anyway: fetch in full. The
3707
- // declined slot was already invalidated by ApplyDifferentialUpdate, so the refetch
3708
- // repopulates it. CacheLocal is stripped and BypassCache set so this single call
3709
- // takes the plain path — it cannot re-enter the smart-cache transport, which is what
3710
- // makes the recursion impossible rather than merely unlikely.
3828
+ // declined slot was already invalidated by ApplyDifferentialUpdate, and because
3829
+ // fallbackParam sets CacheLocal:false + BypassCache:true (so the refetch takes the
3830
+ // plain path and cannot re-enter the smart-cache transport), PostRunViews does not
3831
+ // write to LocalCacheManager — so we repopulate the slot explicitly here.
3711
3832
  LogStatusEx({ message: `Differential merge declined for '${param.EntityName}' — refetching in full.`, verboseOnly: true });
3712
3833
  const fallbackParam = { ...param, CacheLocal: false, BypassCache: true };
3713
3834
  const freshFetch = await this.RunView(fallbackParam, contextUser);
3714
- return { result: freshFetch, cacheHit: false, cacheMiss: true };
3835
+ if (this.runViewCacheEligibleForWrite(param) && freshFetch.Success && LocalCacheManager.Instance.IsInitialized) {
3836
+ const plainRows = (freshFetch.Results || []).map(row => row && typeof row.GetAll === 'function'
3837
+ ? row.GetAll()
3838
+ : row);
3839
+ const maxUpdatedAt = checkResult.maxUpdatedAt || this.extractMaxUpdatedAt(plainRows) || '';
3840
+ if (maxUpdatedAt) {
3841
+ LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, plainRows, maxUpdatedAt, freshFetch.AggregateResults ?? checkResult.aggregateResults, freshFetch.TotalRowCount ?? checkResult.rowCount, this).catch(e => LogError(`Failed to update cache after differential fallback: ${e}`));
3842
+ }
3843
+ }
3844
+ return { result: freshFetch, cacheHit: false, cacheMiss: true, alreadyTransformed: true };
3715
3845
  }
3716
3846
  else if (checkResult.status === 'stale') {
3717
3847
  // Cache is stale - use fresh data and update cache (entity doesn't support differential)
@@ -3736,8 +3866,6 @@ export class ProviderBase {
3736
3866
  LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, checkResult.results || [], checkResult.maxUpdatedAt, checkResult.aggregateResults, // Include aggregate results in cache
3737
3867
  checkResult.rowCount, this).catch(e => LogError(`Failed to update cache: ${e}`));
3738
3868
  }
3739
- // Transform to entity objects if needed
3740
- await this.TransformSimpleObjectToEntityObject(param, freshResult, contextUser);
3741
3869
  return { result: freshResult, cacheHit: false, cacheMiss: true };
3742
3870
  }
3743
3871
  else {
@@ -3986,17 +4114,8 @@ export class ProviderBase {
3986
4114
  results[i].Results = this.ApplyFieldSecurityProjection(results[i].Results, params[i], contextUser);
3987
4115
  results[i].Results = this.ApplyRecordChangeFieldSecurityProjection(results[i].Results, params[i], contextUser);
3988
4116
  }
3989
- // Transform results to entity objects AFTER caching plain objects.
3990
- // Skip results that came from cache hits — they're already entity objects.
3991
- const transformPromises = [];
3992
- for (let i = 0; i < results.length; i++) {
3993
- const cacheInfo = preResult.cacheStatusMap?.get(i);
3994
- if (cacheInfo?.status === 'hit') {
3995
- continue;
3996
- }
3997
- transformPromises.push(this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser));
3998
- }
3999
- await Promise.all(transformPromises);
4117
+ // Transform results (both cache hits and DB misses) to entity objects AFTER caching plain objects.
4118
+ await this.TransformBatchRunViewResults(params, results, undefined, preResult.callerFieldsMap, contextUser);
4000
4119
  // Run registered PostRunView hooks on each result in the batch
4001
4120
  for (let i = 0; i < results.length; i++) {
4002
4121
  results[i] = await this.RunPostRunViewHooks(params[i], results[i], contextUser);
@@ -4417,12 +4536,7 @@ export class ProviderBase {
4417
4536
  */
4418
4537
  async PostProcessRunViews(results, params, contextUser) {
4419
4538
  if (params && params.length > 0) {
4420
- const promises = [];
4421
- for (let i = 0; i < results.length; i++) {
4422
- promises.push(this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser));
4423
- }
4424
- // await the promises for all transformations
4425
- await Promise.all(promises);
4539
+ await this.TransformBatchRunViewResults(params, results, undefined, undefined, contextUser);
4426
4540
  // End telemetry tracking for batch operation
4427
4541
  const eventId = params[0]._telemetryBatchEventId;
4428
4542
  if (eventId) {
@@ -4431,18 +4545,113 @@ export class ProviderBase {
4431
4545
  }
4432
4546
  }
4433
4547
  }
4548
+ /**
4549
+ * Two-phase batch entity transformation for `RunViews`:
4550
+ * 1. Transforms all non-IS-A-parent views in parallel first so lookup tables (`SubtypeSelector`)
4551
+ * and IS-A child tables in the same batch are hydrated before parent views run.
4552
+ * 2. Registers all unfiltered full-entity views from this batch on `BaseEntity` for the duration
4553
+ * of Phase 2, then transforms IS-A parent views so they can resolve child records and
4554
+ * `SubtypeSelector` targets directly from memory with 0 extra queries.
4555
+ */
4556
+ async TransformBatchRunViewResults(params, results, skipTransformIndices, callerFieldsMap, contextUser) {
4557
+ if (!params || params.length === 0) {
4558
+ return;
4559
+ }
4560
+ const nonParentIndices = [];
4561
+ const isaParentIndices = [];
4562
+ for (let i = 0; i < results.length; i++) {
4563
+ if (skipTransformIndices?.has(i)) {
4564
+ continue;
4565
+ }
4566
+ const firstRow = results[i]?.Results?.[0];
4567
+ if (firstRow instanceof BaseEntity) {
4568
+ continue;
4569
+ }
4570
+ const param = params[i];
4571
+ if (!param) {
4572
+ continue;
4573
+ }
4574
+ const entity = param.EntityName ? this.EntityByName(param.EntityName) : undefined;
4575
+ if (param.ResultType === 'entity_object' && entity?.IsParentType && entity.ChildEntities.length > 0) {
4576
+ isaParentIndices.push(i);
4577
+ }
4578
+ else {
4579
+ nonParentIndices.push(i);
4580
+ }
4581
+ }
4582
+ if (nonParentIndices.length > 0) {
4583
+ await Promise.all(nonParentIndices.map(i => this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser)));
4584
+ }
4585
+ if (isaParentIndices.length === 0) {
4586
+ return;
4587
+ }
4588
+ const batchUnfilteredCache = new Map();
4589
+ for (let i = 0; i < results.length; i++) {
4590
+ const param = params[i];
4591
+ if (param && this.isUnfilteredFullEntityBatchView(param, results[i], callerFieldsMap, i)) {
4592
+ const key = param.EntityName.trim().toLowerCase();
4593
+ if (!batchUnfilteredCache.has(key)) {
4594
+ batchUnfilteredCache.set(key, results[i].Results);
4595
+ }
4596
+ }
4597
+ }
4598
+ const batchCache = batchUnfilteredCache.size > 0 ? batchUnfilteredCache : undefined;
4599
+ await Promise.all(isaParentIndices.map(i => this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser, batchCache)));
4600
+ }
4601
+ isUnfilteredFullEntityBatchView(param, result, callerFieldsMap, viewIndex) {
4602
+ if (!param.EntityName || !result?.Success || !Array.isArray(result.Results)) {
4603
+ return false;
4604
+ }
4605
+ if (param.ResultType === 'count_only') {
4606
+ return false;
4607
+ }
4608
+ const filter = typeof param.ExtraFilter === 'string' ? param.ExtraFilter.trim() : '';
4609
+ const search = typeof param.UserSearchString === 'string' ? param.UserSearchString.trim() : '';
4610
+ if (filter.length > 0 || search.length > 0) {
4611
+ return false;
4612
+ }
4613
+ if (param.ViewID || param.ViewName || param.ViewEntity) {
4614
+ return false;
4615
+ }
4616
+ const alternateBaseView = param.AlternateBaseView;
4617
+ if (alternateBaseView && alternateBaseView.trim().length > 0) {
4618
+ return false;
4619
+ }
4620
+ if (param.MaxRows || param.StartRow || param.AfterKey) {
4621
+ return false;
4622
+ }
4623
+ if (typeof result.TotalRowCount === 'number' &&
4624
+ result.TotalRowCount > result.Results.length) {
4625
+ return false;
4626
+ }
4627
+ if (param.IgnoreMaxRows !== true) {
4628
+ const entity = this.EntityByName(param.EntityName);
4629
+ const maxRowsCap = entity?.UserViewMaxRows ?? 0;
4630
+ if (maxRowsCap > 0 && result.Results.length >= maxRowsCap) {
4631
+ return false;
4632
+ }
4633
+ }
4634
+ if (viewIndex !== undefined && callerFieldsMap?.has(viewIndex)) {
4635
+ return false;
4636
+ }
4637
+ if (param.Fields && param.Fields.length > 0 && !this.isFullCoverageFieldList(param)) {
4638
+ return false;
4639
+ }
4640
+ return true;
4641
+ }
4434
4642
  /**
4435
4643
  * Transforms the result set from simple objects to entity objects if needed.
4436
4644
  * @param param - The RunViewParams used for the request
4437
4645
  * @param result - The RunViewResult returned from the request
4438
4646
  * @param contextUser - The user context for permissions
4647
+ * @param batchUnfilteredCache - Optional map of unfiltered full-entity sibling views from the same RunViews call
4439
4648
  */
4440
- async TransformSimpleObjectToEntityObject(param, result, contextUser) {
4649
+ async TransformSimpleObjectToEntityObject(param, result, contextUser, batchUnfilteredCache) {
4441
4650
  // Mutually exclusive with the entity branch below: entity objects get real types from
4442
4651
  // BaseEntity's Get/Set conversion, so normalization applies only to non-entity results.
4443
4652
  this.NormalizeSimpleRowTypes(param, result);
4444
4653
  if (param.ResultType === 'entity_object' && result && result.Success && result.Results?.length > 0) {
4445
- result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser);
4654
+ result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser, batchUnfilteredCache);
4446
4655
  // Opt-in batched child loading: ONE query per named collection across the whole result
4447
4656
  // set, not one per row. Companion eager loading is deliberately kept out of
4448
4657
  // LoadFromData() (which is the per-row path above) precisely so that populating children
@@ -4870,6 +5079,9 @@ export class ProviderBase {
4870
5079
  LogWarning(`[Metadata Cache] Row count mismatch when adopting server metadata for "${remoteEntry.Type}": ` +
4871
5080
  `received ${receivedRowCount} rows but remote status reported ${remoteEntry.RowCount} rows. ` +
4872
5081
  `Not advancing local timestamp for "${remoteEntry.Type}" so the next check self-heals.`, 'Metadata');
5082
+ if (LocalCacheManager.Instance.IsInitialized && remoteEntry.Type) {
5083
+ LocalCacheManager.Instance.InvalidateEntityCaches(remoteEntry.Type).catch(() => { });
5084
+ }
4873
5085
  const priorEntry = prevLocal.find(m => norm(m.Type) === norm(remoteEntry.Type));
4874
5086
  if (priorEntry) {
4875
5087
  reconciled.push({
@@ -4897,6 +5109,7 @@ export class ProviderBase {
4897
5109
  if (!hasMismatch) {
4898
5110
  return remote;
4899
5111
  }
5112
+ this.ClearDatasetCache(ProviderBase._mjMetadataDatasetName).catch(() => { });
4900
5113
  const remoteRollup = remote.find(r => norm(r.Type) === rollup);
4901
5114
  if (remoteRollup) {
4902
5115
  const priorRollup = prevLocal.find(m => norm(m.Type) === rollup);
@@ -5282,6 +5495,40 @@ export class ProviderBase {
5282
5495
  return f;
5283
5496
  }
5284
5497
  static { this._mjMetadataDatasetName = 'MJ_Metadata'; }
5498
+ /**
5499
+ * Checks whether any item in a freshly fetched `MJ_Metadata` dataset has a row count that
5500
+ * disagrees with `_latestRemoteMetadataTimestamps` (when remote timestamps are populated).
5501
+ * Used to prevent `GetAllMetadata()` from caching a dataset snapshot into `DatasetCache`
5502
+ * whose row counts are already stale relative to remote status.
5503
+ */
5504
+ hasDatasetRemoteRowCountMismatch(dataset) {
5505
+ const remote = this._latestRemoteMetadataTimestamps;
5506
+ if (!remote || remote.length === 0 || !Array.isArray(dataset?.Results)) {
5507
+ return false;
5508
+ }
5509
+ const norm = (v) => (v ?? '').trim().toLowerCase();
5510
+ const rollup = norm(ProviderBase._allMetadataTypeName);
5511
+ const nonRollupRemote = remote.filter(r => norm(r.Type) !== rollup);
5512
+ const remoteByID = IndexByUUID(nonRollupRemote.filter(r => Boolean(r.ID)), r => r.ID);
5513
+ const remoteByType = new Map();
5514
+ for (const r of nonRollupRemote) {
5515
+ if (r.Type) {
5516
+ remoteByType.set(norm(r.Type), r);
5517
+ }
5518
+ }
5519
+ for (const item of dataset.Results) {
5520
+ if (!Array.isArray(item.Results)) {
5521
+ continue;
5522
+ }
5523
+ const remoteEntry = (item.EntityID ? remoteByID.Get(item.EntityID) : undefined)
5524
+ ?? remoteByType.get(norm(item.EntityName))
5525
+ ?? remoteByType.get(norm(item.Code));
5526
+ if (remoteEntry && remoteEntry.RowCount !== undefined && item.Results.length !== remoteEntry.RowCount) {
5527
+ return true;
5528
+ }
5529
+ }
5530
+ return false;
5531
+ }
5285
5532
  /**
5286
5533
  * Retrieves all metadata from the server and constructs typed instances.
5287
5534
  * Uses the MJ_Metadata dataset for efficient bulk loading.
@@ -5292,6 +5539,7 @@ export class ProviderBase {
5292
5539
  // we are now using datasets instead of the custom metadata to GraphQL to simplify GraphQL's work as it was very slow preivously
5293
5540
  // NOTE: Schema filters (IncludeSchemas/ExcludeSchemas) are for CodeGen only, not for runtime
5294
5541
  // metadata loading. We always load all schemas — there are no sys/staging entities in metadata anyway.
5542
+ const mutationSeqAtStart = this._metadataMemberMutationSeq;
5295
5543
  // Get the dataset and cache it for anyone else who wants to use it
5296
5544
  // When forceRefresh is true (from a hard Refresh() call), bypass LocalCacheManager
5297
5545
  const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse, forceRefresh);
@@ -5306,12 +5554,24 @@ export class ProviderBase {
5306
5554
  this.recordMetadataLoadFailure(new Error(`GetAllMetadata() - the ${ProviderBase._mjMetadataDatasetName} dataset returned no entities; keeping the metadata already loaded`));
5307
5555
  }
5308
5556
  else if (d && d.Success) {
5309
- // cache the dataset for anyone who wants to use it
5310
- await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
5311
5557
  // Record which entities this metadata is built from, so a write to any of them
5312
5558
  // schedules a debounced refresh of this provider's metadata (see
5313
5559
  // registerMetadataDatasetMembership — membership is the dataset definition, not a list).
5314
5560
  this.registerMetadataDatasetMembership(d);
5561
+ // Only cache the dataset in DatasetCache when no metadata-member mutation occurred
5562
+ // while GetDatasetByName was in flight, no follow-up reload is already queued, and
5563
+ // item row counts match _latestRemoteMetadataTimestamps. Otherwise a slow background
5564
+ // GetAllMetadata() that started before a delete can overwrite DatasetCache with
5565
+ // pre-delete rows right after the delete has already completed.
5566
+ const mutatedDuringLoad = this._metadataReloadQueued ||
5567
+ this._metadataMemberMutationSeq !== mutationSeqAtStart ||
5568
+ this.hasDatasetRemoteRowCountMismatch(d);
5569
+ if (!mutatedDuringLoad) {
5570
+ await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
5571
+ }
5572
+ else {
5573
+ await this.ClearDatasetCache(ProviderBase._mjMetadataDatasetName);
5574
+ }
5315
5575
  // got the results, let's build our response in the format we need
5316
5576
  const simpleMetadata = {};
5317
5577
  for (let r of d.Results) {