@memberjunction/core 6.2.0-edge.7 → 6.2.0-edge.8

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
@@ -19,6 +19,7 @@ import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfac
19
19
  import { Metadata } from './metadata.js';
20
20
  import { RunView } from '../views/runView.js';
21
21
  import { LogDebug, LogError, LogStatusEx, LogWarning } from './logging.js';
22
+ import { TelemetryManager } from './telemetryManager.js';
22
23
  import { CompositeKey, KeyValuePair } from './compositeKey.js';
23
24
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
24
25
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
@@ -1546,7 +1547,7 @@ export class BaseEntity {
1546
1547
  *
1547
1548
  * @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
1548
1549
  */
1549
- async resolveSubtypeLoadHint() {
1550
+ async resolveSubtypeLoadHint(batchUnfilteredCache) {
1550
1551
  if (this._skipSubtypeLoadHint) {
1551
1552
  return null;
1552
1553
  }
@@ -1557,7 +1558,7 @@ export class BaseEntity {
1557
1558
  }
1558
1559
  const selector = this.EntityInfo.SubtypeSelectorConfig;
1559
1560
  const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
1560
- return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
1561
+ return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath, batchUnfilteredCache) : null;
1561
1562
  }
1562
1563
  /**
1563
1564
  * The resolver to ask for load hints for an entity, decided once per registration and cached:
@@ -1619,9 +1620,9 @@ export class BaseEntity {
1619
1620
  * Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
1620
1621
  * this entity's metadata is logged once and gives no hint; the create path throws on it instead.
1621
1622
  */
1622
- async evaluateSelectorForLoadHint(path) {
1623
+ async evaluateSelectorForLoadHint(path, batchUnfilteredCache) {
1623
1624
  try {
1624
- const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue));
1625
+ const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue, batchUnfilteredCache));
1625
1626
  return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
1626
1627
  }
1627
1628
  catch (e) {
@@ -2010,15 +2011,16 @@ export class BaseEntity {
2010
2011
  }
2011
2012
  /**
2012
2013
  * Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
2013
- * `BaseEngine` caches already hold. Never queries, and never reads the create path's memo, whose
2014
- * entries can outlive a change to the row: engine caches are kept current by entity events.
2014
+ * `BaseEngine` caches (or the active `RunViews` batch) already hold. Never queries, and never
2015
+ * reads the create path's memo, whose entries can outlive a change to the row: engine caches
2016
+ * are kept current by entity events.
2015
2017
  *
2016
2018
  * `pkValue` is one foreign-key column's value, matched against the first primary key of the
2017
2019
  * cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
2018
2020
  *
2019
2021
  * @returns The cached record, or null when no loaded engine holds it as an entity object.
2020
2022
  */
2021
- static findSubtypePathTargetInEngineCache(entityName, pkValue) {
2023
+ static findSubtypePathTargetInEngineCache(entityName, pkValue, batchUnfilteredCache) {
2022
2024
  const key = NormalizeUUID(String(pkValue));
2023
2025
  for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
2024
2026
  const found = BaseEntity.findEngineCachedRow(match.records, key);
@@ -2026,6 +2028,13 @@ export class BaseEntity {
2026
2028
  return found;
2027
2029
  }
2028
2030
  }
2031
+ const batchRecords = batchUnfilteredCache?.get(entityName.trim().toLowerCase());
2032
+ if (batchRecords) {
2033
+ const found = BaseEntity.findEngineCachedRow(batchRecords, key);
2034
+ if (found) {
2035
+ return found;
2036
+ }
2037
+ }
2029
2038
  return null;
2030
2039
  }
2031
2040
  /**
@@ -4881,7 +4890,23 @@ export class BaseEntity {
4881
4890
  LogError(`EntitySaveOptions.OnValidated callback threw (ignored): ${cbErr?.message ?? String(cbErr)}`);
4882
4891
  }
4883
4892
  }
4884
- const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
4893
+ const saveTelemetryId = TelemetryManager.Instance.StartEvent('Custom', 'BaseEntity.Save', {
4894
+ operationName: 'BaseEntity.Save',
4895
+ EntityName: this.EntityInfo?.Name,
4896
+ SaveSubType: saveSubType,
4897
+ }, this.ActiveUser?.ID);
4898
+ let data;
4899
+ try {
4900
+ data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
4901
+ TelemetryManager.Instance.EndEvent(saveTelemetryId, { success: !!data });
4902
+ }
4903
+ catch (saveErr) {
4904
+ TelemetryManager.Instance.EndEvent(saveTelemetryId, {
4905
+ success: false,
4906
+ error: saveErr instanceof Error ? saveErr.message : String(saveErr),
4907
+ });
4908
+ throw saveErr;
4909
+ }
4885
4910
  if (!this.TransactionGroup) {
4886
4911
  // no transaction group, so we have our results here
4887
4912
  const result = this.finalizeSave(data, saveSubType, _options);
@@ -5703,7 +5728,7 @@ export class BaseEntity {
5703
5728
  static get ISABatchChunkSize() {
5704
5729
  return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
5705
5730
  }
5706
- async LoadFromDataBatchAsISAParent(peers, md, contextUser) {
5731
+ async LoadFromDataBatchAsISAParent(peers, md, contextUser, batchUnfilteredCache) {
5707
5732
  const loadedParents = [];
5708
5733
  for (let i = 0; i < peers.length; i++) {
5709
5734
  const { entity, data } = peers[i];
@@ -5717,8 +5742,46 @@ export class BaseEntity {
5717
5742
  }
5718
5743
  }
5719
5744
  if (loadedParents.length > 0) {
5720
- await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser);
5745
+ await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser, batchUnfilteredCache);
5746
+ }
5747
+ }
5748
+ /**
5749
+ * Clones `source` into `target` (already instantiated and bound to the destination provider/user)
5750
+ * while preserving an existing in-memory `ISAChild` chain without issuing redundant database
5751
+ * discovery or load queries. When `source` is a disjoint IS-A parent without an attached
5752
+ * `ISAChild`, delegates child resolution to `hydrateISAChildrenForLevel` so `BaseEngine`
5753
+ * caches are consulted before falling back to the provider.
5754
+ */
5755
+ static async CloneEntityWithISAChain(source, target, md, contextUser) {
5756
+ if (source.EntityInfo?.IsParentType &&
5757
+ !source.EntityInfo.AllowMultipleSubtypes &&
5758
+ !IsMemberOverridden(target, 'LoadFromData', BaseEntity)) {
5759
+ const allPksSet = await target.hydrateSingleISAParentPeer(source);
5760
+ if (!allPksSet) {
5761
+ return;
5762
+ }
5763
+ if (source.ISAChild) {
5764
+ const deniedChildLogged = new Set();
5765
+ let currParent = target;
5766
+ let currSourceChild = source.ISAChild;
5767
+ while (currSourceChild) {
5768
+ const linked = [];
5769
+ const ok = await BaseEntity.linkSingleParentFromRawOrEntityRecord(currParent, currSourceChild.EntityInfo.Name, currSourceChild, linked, deniedChildLogged);
5770
+ currParent._childEntityDiscoveryDone = true;
5771
+ if (!ok || linked.length === 0) {
5772
+ break;
5773
+ }
5774
+ currParent = linked[0];
5775
+ currSourceChild = currSourceChild.ISAChild;
5776
+ }
5777
+ currParent._childEntityDiscoveryDone = true;
5778
+ }
5779
+ else {
5780
+ await BaseEntity.hydrateISAChildrenForLevel([target], md, contextUser);
5781
+ }
5782
+ return;
5721
5783
  }
5784
+ await target.LoadFromData(source.GetAll());
5722
5785
  }
5723
5786
  async hydrateSingleISAParentPeer(rawData) {
5724
5787
  this.discardJSONFieldObjects();
@@ -5757,7 +5820,7 @@ export class BaseEntity {
5757
5820
  await this.deserializeCompanionsFromData(narrowed);
5758
5821
  return allPksSet;
5759
5822
  }
5760
- static async hydrateISAChildrenForLevel(levelEntities, md, contextUser) {
5823
+ static async hydrateISAChildrenForLevel(levelEntities, md, contextUser, batchUnfilteredCache) {
5761
5824
  const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
5762
5825
  if (activeParents.length === 0) {
5763
5826
  return;
@@ -5776,23 +5839,57 @@ export class BaseEntity {
5776
5839
  for (const [, groupParents] of groupsByEntityId) {
5777
5840
  const sampleParent = groupParents[0];
5778
5841
  if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
5779
- await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, contextUser);
5842
+ await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache);
5780
5843
  }
5781
5844
  else {
5782
- await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren);
5845
+ await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache);
5783
5846
  }
5784
5847
  }
5785
5848
  if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
5786
- await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser);
5849
+ await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser, batchUnfilteredCache);
5787
5850
  }
5788
5851
  }
5789
- static async hydrateOverlappingChildrenForGroup(groupParents, contextUser) {
5852
+ static async hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache) {
5790
5853
  const sampleParent = groupParents[0];
5791
5854
  const parentInfo = sampleParent.EntityInfo;
5792
5855
  const entityProvider = sampleParent.ProviderToUse;
5856
+ const rvProvider = sampleParent.RunViewProviderToUse;
5857
+ const effectiveUser = contextUser ?? sampleParent._contextCurrentUser ?? md.CurrentUser;
5858
+ const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
5859
+ if (validChildren.length === 0) {
5860
+ for (const p of groupParents) {
5861
+ p._childEntities = [];
5862
+ }
5863
+ return;
5864
+ }
5865
+ const childLookups = validChildren.map(childInfo => {
5866
+ const pkField = childInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5867
+ const pkIsUuid = Boolean(pkField.IsUniqueIdentifier);
5868
+ const cachedRecords = BaseEntity.findUnfilteredInMemoryChildRecords(childInfo, entityProvider, md, effectiveUser, batchUnfilteredCache);
5869
+ return {
5870
+ childInfo,
5871
+ pkField,
5872
+ pkIsUuid,
5873
+ cachedByPk: cachedRecords ? BaseEntity.buildInMemoryChildLookupMap(cachedRecords, pkField.Name, pkIsUuid) : null,
5874
+ };
5875
+ });
5876
+ if (childLookups.every(c => c.cachedByPk !== null)) {
5877
+ for (const p of groupParents) {
5878
+ const rawPk = String(p.PrimaryKey.Values());
5879
+ const matched = [];
5880
+ for (const c of childLookups) {
5881
+ const pkKey = CanonicalPrimaryKeyValue(rawPk, c.pkIsUuid);
5882
+ if (c.cachedByPk?.has(pkKey)) {
5883
+ matched.push({ entityName: c.childInfo.Name });
5884
+ }
5885
+ }
5886
+ p._childEntities = matched;
5887
+ }
5888
+ return;
5889
+ }
5793
5890
  if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
5794
5891
  const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
5795
- const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, contextUser ?? sampleParent._contextCurrentUser);
5892
+ const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, effectiveUser);
5796
5893
  const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5797
5894
  for (const p of groupParents) {
5798
5895
  const rawPk = String(p.PrimaryKey.Values());
@@ -5800,46 +5897,443 @@ export class BaseEntity {
5800
5897
  const matched = batchMap.get(pkKey) ?? [];
5801
5898
  p._childEntities = matched.map(name => ({ entityName: name }));
5802
5899
  }
5900
+ return;
5803
5901
  }
5804
- else {
5805
- await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
5902
+ if (rvProvider && typeof rvProvider.RunViews === 'function') {
5903
+ const chunkSize = BaseEntity.ISABatchChunkSize;
5904
+ const rawPks = Array.from(new Set(groupParents.map(p => String(p.PrimaryKey.Values()))));
5905
+ const uncachedLookups = childLookups.filter(c => c.cachedByPk === null);
5906
+ const params = [];
5907
+ const meta = [];
5908
+ for (const c of uncachedLookups) {
5909
+ for (let i = 0; i < rawPks.length; i += chunkSize) {
5910
+ const chunk = rawPks.slice(i, i + chunkSize);
5911
+ params.push({
5912
+ EntityName: c.childInfo.Name,
5913
+ ExtraFilter: BaseEntity.buildChildPkInFilter(c.pkField, chunk),
5914
+ Fields: [c.pkField.Name],
5915
+ ResultType: 'simple',
5916
+ BypassCache: true,
5917
+ // Key-bounded (at most one row per PK in the chunk): the entity's
5918
+ // UserViewMaxRows cap protects nothing here and would drop subtypes.
5919
+ IgnoreMaxRows: true,
5920
+ });
5921
+ meta.push(c);
5922
+ }
5923
+ }
5924
+ for (const c of uncachedLookups) {
5925
+ c.cachedByPk = new Map();
5926
+ }
5927
+ if (params.length > 0) {
5928
+ const rv = new RunView(rvProvider);
5929
+ const results = await rv.RunViews(params, effectiveUser);
5930
+ for (let i = 0; i < results.length; i++) {
5931
+ const res = results[i];
5932
+ const c = meta[i];
5933
+ if (!res?.Success || !Array.isArray(res.Results)) {
5934
+ LogWarning(`BaseEntity: batched IS-A child load of '${c.childInfo.Name}' failed (${res?.ErrorMessage || 'no result'}); matching parent record(s) continue without their subtype`, 'IS-A');
5935
+ continue;
5936
+ }
5937
+ for (const row of res.Results) {
5938
+ const pkVal = row[c.pkField.Name];
5939
+ if (pkVal != null) {
5940
+ c.cachedByPk?.set(CanonicalPrimaryKeyValue(String(pkVal), c.pkIsUuid), row);
5941
+ }
5942
+ }
5943
+ }
5944
+ }
5945
+ for (const p of groupParents) {
5946
+ const rawPk = String(p.PrimaryKey.Values());
5947
+ const matched = [];
5948
+ for (const c of childLookups) {
5949
+ const pkKey = CanonicalPrimaryKeyValue(rawPk, c.pkIsUuid);
5950
+ if (c.cachedByPk?.has(pkKey)) {
5951
+ matched.push({ entityName: c.childInfo.Name });
5952
+ }
5953
+ }
5954
+ p._childEntities = matched;
5955
+ }
5956
+ return;
5806
5957
  }
5958
+ await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
5807
5959
  }
5808
- static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren) {
5960
+ static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache) {
5809
5961
  const sampleParent = groupParents[0];
5810
5962
  const parentInfo = sampleParent.EntityInfo;
5811
5963
  const entityProvider = sampleParent.ProviderToUse;
5812
5964
  const rvProvider = sampleParent.RunViewProviderToUse;
5813
5965
  const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
5966
+ const deniedChildLogged = new Set();
5814
5967
  // 1) Evaluate in-memory SubtypeSelector / EntitySubtypeResolver load hints
5815
- const { hintedByChild, hintedNameByParent, needsDiscovery } = await BaseEntity.partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser);
5816
- // 2) Batch-load hinted child rows in 1 RunViews call; any missing/failed row falls back to discovery
5968
+ const { hintedByChild, hintedNameByParent, needsDiscovery } = await BaseEntity.partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser, batchUnfilteredCache);
5969
+ // 2) Batch-load hinted child rows (from unfiltered memory caches first, then at most 1 RunViews call);
5970
+ // any missing/failed row falls back to discovery
5971
+ // Hinted parents whose child read succeeded and had no row (as opposed to a read that
5972
+ // failed or was not tried): only those are hint misses, as in the per-row path.
5973
+ const hintNotFound = new Set();
5817
5974
  if (hintedByChild.size > 0) {
5818
- const unlinkedFromHints = await BaseEntity.batchLoadAndLinkDisjointChildren(hintedByChild, md, rvProvider, effectiveUser, nextLevelChildren);
5819
- for (const unlinked of unlinkedFromHints) {
5820
- // Same diagnostic the per-row path emits when a SubtypeSelector hint names a
5821
- // child that has no row: operators need the signal that a selector is wrong.
5822
- const hinted = hintedNameByParent.get(unlinked);
5823
- if (hinted) {
5824
- BaseEntity.logLoadFoundNoRow(hinted, unlinked.PrimaryKey);
5975
+ const unlinkedFromHints = await BaseEntity.batchLoadAndLinkDisjointChildren(hintedByChild, md, entityProvider, rvProvider, effectiveUser, nextLevelChildren, deniedChildLogged, batchUnfilteredCache, hintNotFound);
5976
+ needsDiscovery.push(...unlinkedFromHints);
5977
+ }
5978
+ // 3) Discover and hydrate remaining unresolved parents (consulting unfiltered memory caches first,
5979
+ // then 1 single-pass RunViews call when FindISAChildEntitiesBatch is absent)
5980
+ if (needsDiscovery.length > 0) {
5981
+ const discoveredByParent = await BaseEntity.discoverAndHydrateDisjointChildren(parentInfo, needsDiscovery, md, entityProvider, rvProvider, effectiveUser, nextLevelChildren, deniedChildLogged, batchUnfilteredCache, new Map(Array.from(hintNotFound, p => [p, hintedNameByParent.get(p) ?? ''])));
5982
+ for (const p of hintNotFound) {
5983
+ BaseEntity.reportBatchSubtypeLoadHintMiss(p, hintedNameByParent.get(p), discoveredByParent.get(p) ?? null);
5984
+ }
5985
+ }
5986
+ }
5987
+ /**
5988
+ * The batch twin of the per-row path's diagnostics in {@link initializeDisjointChildEntity}, for
5989
+ * a parent whose hinted child had no row, run once discovery has had its say. When discovery
5990
+ * found the hinted child but could not link it, the row exists and its load came back empty, so
5991
+ * the empty load is logged, as the discovery-only path logs it. Anything else goes to
5992
+ * {@link reportSubtypeLoadHintMiss}: quiet (verbose, once) when no subtype row exists at all,
5993
+ * which is a normal state, and an error once per pair when the row is in a different child.
5994
+ */
5995
+ static reportBatchSubtypeLoadHintMiss(parent, hintedName, discoveredName) {
5996
+ if (!hintedName) {
5997
+ return;
5998
+ }
5999
+ if (discoveredName && sameEntityName(discoveredName, hintedName)) {
6000
+ if (!parent._childEntity) {
6001
+ BaseEntity.logLoadFoundNoRow(hintedName, parent.PrimaryKey);
6002
+ }
6003
+ return;
6004
+ }
6005
+ parent.reportSubtypeLoadHintMiss(hintedName, discoveredName);
6006
+ }
6007
+ /**
6008
+ * Discovers and links the child of each disjoint parent.
6009
+ *
6010
+ * @param readFoundNoRow - The child each parent's hinted read already found no row in. When
6011
+ * discovery names that same child, its row exists but reads back empty (a row filter), so it
6012
+ * is not read again; the per-row path stops there too.
6013
+ * @returns The child each parent was found to have, whether or not it could then be linked.
6014
+ * A parent with no child, or whose child could not be determined, is absent.
6015
+ */
6016
+ static async discoverAndHydrateDisjointChildren(parentInfo, parents, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged, batchUnfilteredCache, readFoundNoRow = new Map()) {
6017
+ const discoveredByParent = new Map();
6018
+ const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
6019
+ if (validChildren.length === 0) {
6020
+ return discoveredByParent;
6021
+ }
6022
+ const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
6023
+ const readableChildren = [];
6024
+ for (const child of validChildren) {
6025
+ const canRead = userForPerm ? child.GetUserPermisions(userForPerm).CanRead : true;
6026
+ if (!canRead) {
6027
+ if (!deniedChildLogged.has(child.Name)) {
6028
+ deniedChildLogged.add(child.Name);
6029
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${child.Name}'; matching parent records are returned without their subtype`, 'IS-A');
5825
6030
  }
5826
- needsDiscovery.push(unlinked);
6031
+ continue;
5827
6032
  }
6033
+ const pkField = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6034
+ const pkIsUuid = Boolean(pkField.IsUniqueIdentifier);
6035
+ const cachedRecords = BaseEntity.findUnfilteredInMemoryChildRecords(child, entityProvider, md, userForPerm, batchUnfilteredCache);
6036
+ readableChildren.push({
6037
+ childInfo: child,
6038
+ pkField,
6039
+ pkIsUuid,
6040
+ cachedByPk: cachedRecords ? BaseEntity.buildInMemoryChildLookupMap(cachedRecords, pkField.Name, pkIsUuid) : null,
6041
+ });
5828
6042
  }
5829
- // 3) Batch-discover remaining unresolved parents in 1 query, then batch-load their child rows in 1 RunViews call
5830
- if (needsDiscovery.length > 0) {
5831
- const discoveredByChild = await BaseEntity.batchDiscoverDisjointChildren(parentInfo, needsDiscovery, entityProvider, rvProvider, effectiveUser);
5832
- if (discoveredByChild.size > 0) {
5833
- await BaseEntity.batchLoadAndLinkDisjointChildren(discoveredByChild, md, rvProvider, effectiveUser, nextLevelChildren);
6043
+ if (readableChildren.length === 0) {
6044
+ return discoveredByParent;
6045
+ }
6046
+ // In a disjoint IS-A hierarchy (`AllowMultipleSubtypes === false`), each parent record
6047
+ // belongs to at most one child subtype across all `ChildEntities`. Resolve any parent whose
6048
+ // PK matches an unfiltered in-memory child cache (from BaseEngineRegistry or the active
6049
+ // RunViews batch) immediately with 0 queries, and only probe uncached child entities for
6050
+ // the remaining unresolved parents.
6051
+ const cachedChildren = readableChildren.filter(rc => rc.cachedByPk !== null);
6052
+ const uncachedChildren = readableChildren.filter(rc => rc.cachedByPk === null);
6053
+ let remainingParents = [];
6054
+ if (cachedChildren.length > 0) {
6055
+ for (const p of parents) {
6056
+ const rawPk = String(p.PrimaryKey.Values());
6057
+ let linkedFromMemory = false;
6058
+ for (const rc of cachedChildren) {
6059
+ const pkKey = CanonicalPrimaryKeyValue(rawPk, rc.pkIsUuid);
6060
+ const matched = rc.cachedByPk?.get(pkKey);
6061
+ if (matched) {
6062
+ discoveredByParent.set(p, rc.childInfo.Name);
6063
+ await BaseEntity.linkSingleParentFromRawOrEntityRecord(p, rc.childInfo.Name, matched, outLinkedChildren, deniedChildLogged);
6064
+ linkedFromMemory = true;
6065
+ break;
6066
+ }
6067
+ }
6068
+ if (!linkedFromMemory) {
6069
+ remainingParents.push(p);
6070
+ }
6071
+ }
6072
+ }
6073
+ else {
6074
+ remainingParents = Array.from(parents);
6075
+ }
6076
+ if (remainingParents.length === 0 || uncachedChildren.length === 0) {
6077
+ return discoveredByParent;
6078
+ }
6079
+ // Server-side batch UNION ALL discovery when available
6080
+ if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
6081
+ const discoveredByChild = await BaseEntity.batchDiscoverDisjointChildren(parentInfo, remainingParents, entityProvider, rvProvider, contextUser);
6082
+ BaseEntity.recordDiscoveredChildren(discoveredByChild, discoveredByParent);
6083
+ const toRead = BaseEntity.withoutChildrenAlreadyRead(discoveredByChild, readFoundNoRow);
6084
+ if (toRead.size > 0) {
6085
+ await BaseEntity.batchLoadAndLinkDisjointChildren(toRead, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged);
6086
+ }
6087
+ return discoveredByParent;
6088
+ }
6089
+ // Single-pass RunViews discovery + hydration when FindISAChildEntitiesBatch is absent (e.g. GraphQLDataProvider)
6090
+ if (rvProvider && typeof rvProvider.RunViews === 'function') {
6091
+ await BaseEntity.discoverAndLoadDisjointChildrenViaRunViews(readableChildren, remainingParents, rvProvider, contextUser, outLinkedChildren, deniedChildLogged);
6092
+ // Here discovery and the load are one read, so a child is known only where it linked.
6093
+ for (const p of remainingParents) {
6094
+ if (p._childEntity) {
6095
+ discoveredByParent.set(p, p._childEntity.EntityInfo.Name);
6096
+ }
6097
+ }
6098
+ return discoveredByParent;
6099
+ }
6100
+ // Last-resort per-parent fallback when neither FindISAChildEntitiesBatch nor RunViews exists
6101
+ if (typeof entityProvider?.FindISAChildEntity === 'function') {
6102
+ const discoveredByChild = new Map();
6103
+ for (const p of remainingParents) {
6104
+ const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
6105
+ if (found?.ChildEntityName) {
6106
+ BaseEntity.appendParentToChildGroup(discoveredByChild, found.ChildEntityName, p);
6107
+ }
5834
6108
  }
6109
+ BaseEntity.recordDiscoveredChildren(discoveredByChild, discoveredByParent);
6110
+ const toRead = BaseEntity.withoutChildrenAlreadyRead(discoveredByChild, readFoundNoRow);
6111
+ if (toRead.size > 0) {
6112
+ await BaseEntity.batchLoadAndLinkDisjointChildren(toRead, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged);
6113
+ }
6114
+ }
6115
+ return discoveredByParent;
6116
+ }
6117
+ /** `byChild` without each parent whose read of that same child already found no row. */
6118
+ static withoutChildrenAlreadyRead(byChild, readFoundNoRow) {
6119
+ const out = new Map();
6120
+ for (const [childName, parents] of byChild) {
6121
+ const toRead = parents.filter(p => {
6122
+ const alreadyRead = readFoundNoRow.get(p);
6123
+ return !alreadyRead || !sameEntityName(alreadyRead, childName);
6124
+ });
6125
+ if (toRead.length > 0) {
6126
+ out.set(childName, toRead);
6127
+ }
6128
+ }
6129
+ return out;
6130
+ }
6131
+ /** Copies a child-name → parents grouping into a parent → child-name map. */
6132
+ static recordDiscoveredChildren(byChild, out) {
6133
+ for (const [childName, parents] of byChild) {
6134
+ for (const p of parents) {
6135
+ out.set(p, childName);
6136
+ }
6137
+ }
6138
+ }
6139
+ /**
6140
+ * Single-pass `RunViews` child discovery and hydration for providers without `FindISAChildEntitiesBatch`
6141
+ * (such as `GraphQLDataProvider`). Queries only the readable child entities not already present in an
6142
+ * unfiltered memory cache, fetches full child rows (`SELECT *`, no `Fields` narrowing) in 1 `RunViews`
6143
+ * call per chunk, and links the highest-priority matching child row per parent immediately.
6144
+ */
6145
+ static async discoverAndLoadDisjointChildrenViaRunViews(readableChildren, parents, rvProvider, contextUser, outLinkedChildren, deniedChildLogged) {
6146
+ const uncachedChildren = readableChildren.filter(rc => rc.cachedByPk === null);
6147
+ const chunkSize = BaseEntity.ISABatchChunkSize;
6148
+ const rv = new RunView(rvProvider);
6149
+ for (let start = 0; start < parents.length; start += chunkSize) {
6150
+ const chunkParents = parents.slice(start, start + chunkSize);
6151
+ const chunkPks = chunkParents.map(p => String(p.PrimaryKey.Values()));
6152
+ const queriedRowsByChildName = new Map();
6153
+ if (uncachedChildren.length > 0) {
6154
+ const params = uncachedChildren.map(rc => ({
6155
+ EntityName: rc.childInfo.Name,
6156
+ ExtraFilter: BaseEntity.buildChildPkInFilter(rc.pkField, chunkPks),
6157
+ ResultType: 'simple',
6158
+ // Internal hydration read: keep per-batch IN-list reads out of the shared RunView cache.
6159
+ BypassCache: true,
6160
+ // Key-bounded (at most one row per PK in the chunk): the entity's
6161
+ // UserViewMaxRows cap protects nothing here and would drop subtypes.
6162
+ IgnoreMaxRows: true,
6163
+ }));
6164
+ const results = await rv.RunViews(params, contextUser);
6165
+ for (let cIdx = 0; cIdx < uncachedChildren.length; cIdx++) {
6166
+ const rc = uncachedChildren[cIdx];
6167
+ const res = results[cIdx];
6168
+ const rowByPk = new Map();
6169
+ queriedRowsByChildName.set(rc.childInfo.Name, rowByPk);
6170
+ if (!res?.Success || !Array.isArray(res.Results)) {
6171
+ LogWarning(`BaseEntity: batched IS-A child load of '${rc.childInfo.Name}' failed (${res?.ErrorMessage || 'no result'}); matching parent record(s) continue without their subtype`, 'IS-A');
6172
+ continue;
6173
+ }
6174
+ for (const row of res.Results) {
6175
+ const rawVal = row[rc.pkField.Name];
6176
+ if (rawVal != null) {
6177
+ const norm = CanonicalPrimaryKeyValue(String(rawVal), rc.pkIsUuid);
6178
+ if (!rowByPk.has(norm)) {
6179
+ rowByPk.set(norm, row);
6180
+ }
6181
+ }
6182
+ }
6183
+ }
6184
+ }
6185
+ for (const p of chunkParents) {
6186
+ const rawPk = String(p.PrimaryKey.Values());
6187
+ for (const rc of readableChildren) {
6188
+ const norm = CanonicalPrimaryKeyValue(rawPk, rc.pkIsUuid);
6189
+ const matched = rc.cachedByPk
6190
+ ? rc.cachedByPk.get(norm)
6191
+ : queriedRowsByChildName.get(rc.childInfo.Name)?.get(norm);
6192
+ if (matched) {
6193
+ await BaseEntity.linkSingleParentFromRawOrEntityRecord(p, rc.childInfo.Name, matched, outLinkedChildren, deniedChildLogged);
6194
+ break;
6195
+ }
6196
+ }
6197
+ }
6198
+ }
6199
+ }
6200
+ static findUnfilteredInMemoryChildRecords(childInfo, entityProvider, md, userForPerm, batchUnfilteredCache) {
6201
+ if (userForPerm && !childInfo.GetUserPermisions(userForPerm).CanRead) {
6202
+ return null;
6203
+ }
6204
+ // Check unfiltered views in the caller's own RunViews batch first (fetched in the same call for the same user)
6205
+ const batchMatch = batchUnfilteredCache?.get(childInfo.Name.trim().toLowerCase());
6206
+ if (batchMatch) {
6207
+ return batchMatch;
6208
+ }
6209
+ // Server-side BaseEngines load as the system user; do not serve from a shared engine cache when
6210
+ // the calling user has Row-Level Security filters on this child entity.
6211
+ const providerAsMd = entityProvider ?? md;
6212
+ const isDatabaseProvider = providerAsMd?.ProviderType === ProviderType.Database;
6213
+ if (isDatabaseProvider &&
6214
+ userForPerm &&
6215
+ childInfo.GetEffectiveRowFilterWhereClause(userForPerm, EntityPermissionType.Read, '').length > 0) {
6216
+ return null;
6217
+ }
6218
+ const providerConn = providerAsMd?.InstanceConnectionString;
6219
+ for (const match of BaseEngineRegistry.Instance.FindCachedEntity(childInfo.Name, { unfilteredOnly: true })) {
6220
+ const engineObj = match.engine;
6221
+ const engineConn = engineObj?.ProviderToUse?.InstanceConnectionString;
6222
+ if (providerConn && engineConn && providerConn !== engineConn) {
6223
+ continue;
6224
+ }
6225
+ return match.records;
6226
+ }
6227
+ return null;
6228
+ }
6229
+ static buildInMemoryChildLookupMap(records, pkFieldName, pkIsUuid) {
6230
+ const byPk = new Map();
6231
+ for (const rec of records) {
6232
+ if (!rec || typeof rec !== 'object')
6233
+ continue;
6234
+ const rawPk = typeof rec.Get === 'function'
6235
+ ? rec.Get(pkFieldName)
6236
+ : rec[pkFieldName];
6237
+ if (rawPk != null) {
6238
+ const norm = CanonicalPrimaryKeyValue(String(rawPk), pkIsUuid);
6239
+ if (!byPk.has(norm)) {
6240
+ byPk.set(norm, rec);
6241
+ }
6242
+ }
6243
+ }
6244
+ return byPk;
6245
+ }
6246
+ static async linkSingleParentFromRawOrEntityRecord(parent, childName, matchedRecord, outLinkedChildren, deniedChildLogged) {
6247
+ const childEntity = await parent.createChildEntityObject(childName);
6248
+ if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
6249
+ if (!deniedChildLogged.has(childName)) {
6250
+ deniedChildLogged.add(childName);
6251
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${childName}'; matching parent records are returned without their subtype`, 'IS-A');
6252
+ }
6253
+ return false;
6254
+ }
6255
+ const rawChildRow = (typeof matchedRecord.GetAll === 'function'
6256
+ ? matchedRecord.GetAll()
6257
+ : matchedRecord);
6258
+ const childRow = BaseEntity.inheritedFieldsFromParent(parent, childEntity, rawChildRow);
6259
+ await parent.linkHydratedChildFromBatchRow(childEntity, childRow);
6260
+ outLinkedChildren.push(childEntity);
6261
+ return true;
6262
+ }
6263
+ /**
6264
+ * Replaces the fields `parent` (or its IS-A ancestors) owns in an in-memory child row with
6265
+ * `parent`'s own loaded values, before linking the row downward onto `parent`. Keeps the
6266
+ * freshly loaded parent row authoritative for its own columns even when an engine cache holds
6267
+ * an older snapshot of the child view's joined parent columns, and still hydrates the child's
6268
+ * mirror fields: a mirror left NotLoaded would have no old value, so a later `child.Set()` of
6269
+ * an inherited field would report it as changing from null in `GetAll(true)` (and so in
6270
+ * Record Changes).
6271
+ *
6272
+ * "Owned by an ancestor" means the mirrored columns (`ParentEntityFieldNames`) and the
6273
+ * ancestors' virtual columns, which the child view also carries. The shared key and `__mj_`
6274
+ * timestamps are not: the child view's timestamps are its own table's. A replaced field the
6275
+ * parent did not load stays out of the row.
6276
+ */
6277
+ static inheritedFieldsFromParent(parent, childEntity, childRow) {
6278
+ if (!childRow || typeof childRow !== 'object') {
6279
+ return childRow;
6280
+ }
6281
+ const pkNamesLower = new Set();
6282
+ for (const pk of [...childEntity.EntityInfo.PrimaryKeys, ...parent.EntityInfo.PrimaryKeys]) {
6283
+ pkNamesLower.add(pk.Name.trim().toLowerCase());
6284
+ if (pk.CodeName)
6285
+ pkNamesLower.add(pk.CodeName.trim().toLowerCase());
6286
+ }
6287
+ // Canonical field name → its lowercased name and code name, for every replaced field.
6288
+ const replaced = new Map();
6289
+ const addReplaced = (name, codeName) => {
6290
+ const lower = name.trim().toLowerCase();
6291
+ if (pkNamesLower.has(lower) || lower.startsWith('__mj_')) {
6292
+ return;
6293
+ }
6294
+ replaced.set(name, codeName ? [lower, codeName.trim().toLowerCase()] : [lower]);
6295
+ };
6296
+ for (const name of childEntity.EntityInfo.ParentEntityFieldNames) {
6297
+ addReplaced(name, childEntity.EntityInfo.Fields.find(f => f.Name === name)?.CodeName);
6298
+ }
6299
+ for (const ancestorInfo of childEntity.EntityInfo.ParentChain) {
6300
+ for (const f of ancestorInfo.Fields) {
6301
+ if (f.IsVirtual) {
6302
+ addReplaced(f.Name, f.CodeName);
6303
+ }
6304
+ }
6305
+ }
6306
+ if (replaced.size === 0) {
6307
+ return childRow;
6308
+ }
6309
+ const replacedLower = new Set();
6310
+ for (const keys of replaced.values()) {
6311
+ for (const k of keys)
6312
+ replacedLower.add(k);
5835
6313
  }
6314
+ const copy = {};
6315
+ for (const key of Object.keys(childRow)) {
6316
+ if (!replacedLower.has(key.trim().toLowerCase())) {
6317
+ copy[key] = childRow[key];
6318
+ }
6319
+ }
6320
+ // GetAll() holds every field the parent loaded (NotLoaded ones are omitted), keyed by
6321
+ // field name, with its current values. The parent was loaded in this same batch, so its
6322
+ // current values are its loaded values.
6323
+ const parentValues = parent.GetAll();
6324
+ for (const name of replaced.keys()) {
6325
+ if (Object.prototype.hasOwnProperty.call(parentValues, name)) {
6326
+ copy[name] = parentValues[name];
6327
+ }
6328
+ }
6329
+ return copy;
5836
6330
  }
5837
- static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser) {
6331
+ static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser, batchUnfilteredCache) {
5838
6332
  const hintedByChild = new Map();
5839
6333
  const hintedNameByParent = new Map();
5840
6334
  const needsDiscovery = [];
5841
6335
  for (const p of groupParents) {
5842
- const hintedName = await p.resolveSubtypeLoadHint();
6336
+ const hintedName = await p.resolveSubtypeLoadHint(batchUnfilteredCache);
5843
6337
  if (hintedName) {
5844
6338
  const childInfo = md.EntityByName?.(hintedName) ??
5845
6339
  parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
@@ -5896,10 +6390,7 @@ export class BaseEntity {
5896
6390
  }
5897
6391
  return grouped;
5898
6392
  }
5899
- if (rvProvider && typeof rvProvider.RunViews === 'function') {
5900
- return BaseEntity.discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser);
5901
- }
5902
- // Last-resort per-parent fallback when neither FindISAChildEntitiesBatch nor RunViews exists
6393
+ // Last-resort per-parent fallback when FindISAChildEntitiesBatch is absent
5903
6394
  if (typeof entityProvider?.FindISAChildEntity === 'function') {
5904
6395
  for (const p of parents) {
5905
6396
  const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
@@ -5910,78 +6401,77 @@ export class BaseEntity {
5910
6401
  }
5911
6402
  return grouped;
5912
6403
  }
5913
- static async discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser) {
5914
- const grouped = new Map();
5915
- const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
5916
- if (validChildren.length === 0)
5917
- return grouped;
5918
- const chunkSize = BaseEntity.ISABatchChunkSize;
5919
- const winnerByPk = new Map();
5920
- const rv = new RunView(rvProvider);
5921
- for (let start = 0; start < pkValues.length; start += chunkSize) {
5922
- const chunkPks = pkValues.slice(start, start + chunkSize);
5923
- const probeParams = validChildren.map(child => {
5924
- const childPk = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5925
- return {
5926
- EntityName: child.Name,
5927
- ExtraFilter: BaseEntity.buildChildPkInFilter(childPk, chunkPks),
5928
- Fields: [childPk.Name],
5929
- ResultType: 'simple',
5930
- // Internal probe: the pre-batch path (InnerLoad) never touched the RunView
5931
- // cache. Every distinct IN-list would otherwise become its own filtered
5932
- // cache slot that is invalidated (never maintained) on each child save.
5933
- BypassCache: true,
5934
- };
5935
- });
5936
- const probeResults = await rv.RunViews(probeParams, contextUser);
5937
- for (let cIdx = 0; cIdx < validChildren.length; cIdx++) {
5938
- const child = validChildren[cIdx];
5939
- const pkName = child.FirstPrimaryKey.Name; // first-pk-ok: IS-A children share the parent's single key
5940
- const res = probeResults[cIdx];
5941
- if (!res?.Success || !Array.isArray(res.Results))
5942
- continue;
5943
- for (const row of res.Results) {
5944
- const rawVal = row[pkName];
5945
- if (rawVal != null) {
5946
- const norm = CanonicalPrimaryKeyValue(String(rawVal), pkIsUuid);
5947
- if (!winnerByPk.has(norm)) {
5948
- winnerByPk.set(norm, child.Name);
5949
- }
6404
+ static async batchLoadAndLinkDisjointChildren(parentsByChildName, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged = new Set(), batchUnfilteredCache, outNotFound = new Set()) {
6405
+ const unlinkedParents = [];
6406
+ // Every parent here already has a known subtype (a load hint, or discovery against the
6407
+ // DB), so a miss in a BaseEngine cache means the cache is behind the DB (the engine's event
6408
+ // debounce window, another server node, an out-of-band write), not that the row is absent.
6409
+ // Those parents are read from the DB with the uncached children below. A miss in the
6410
+ // caller's own RunViews batch is final: that read ran in this same call, for this user.
6411
+ // Unhinted discovery is different: there a complete cache does prove absence, and
6412
+ // discoverAndHydrateDisjointChildren never sends such parents here.
6413
+ const remainingByChildName = new Map();
6414
+ for (const [childName, parents] of parentsByChildName) {
6415
+ const childInfo = md.EntityByName?.(childName) ??
6416
+ parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
6417
+ const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6418
+ const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
6419
+ const cachedRecords = childInfo && pkField
6420
+ ? BaseEntity.findUnfilteredInMemoryChildRecords(childInfo, entityProvider, md, userForPerm, batchUnfilteredCache)
6421
+ : null;
6422
+ if (childInfo && pkField && cachedRecords) {
6423
+ const pkIsUuid = Boolean(pkField.IsUniqueIdentifier);
6424
+ const byPk = BaseEntity.buildInMemoryChildLookupMap(cachedRecords, pkField.Name, pkIsUuid);
6425
+ const fromCallerBatch = batchUnfilteredCache?.has(childInfo.Name.trim().toLowerCase()) === true;
6426
+ const cacheMisses = [];
6427
+ for (const p of parents) {
6428
+ const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
6429
+ const matched = byPk.get(pkKey);
6430
+ if (!matched && fromCallerBatch) {
6431
+ unlinkedParents.push(p);
6432
+ outNotFound.add(p);
6433
+ continue;
6434
+ }
6435
+ if (!matched) {
6436
+ cacheMisses.push(p);
6437
+ continue;
6438
+ }
6439
+ const linked = await BaseEntity.linkSingleParentFromRawOrEntityRecord(p, childInfo.Name, matched, outLinkedChildren, deniedChildLogged);
6440
+ if (!linked) {
6441
+ unlinkedParents.push(p);
5950
6442
  }
5951
6443
  }
6444
+ if (cacheMisses.length > 0) {
6445
+ remainingByChildName.set(childName, cacheMisses);
6446
+ }
5952
6447
  }
5953
- }
5954
- for (const p of parents) {
5955
- const norm = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
5956
- const childName = winnerByPk.get(norm);
5957
- if (childName) {
5958
- BaseEntity.appendParentToChildGroup(grouped, childName, p);
6448
+ else {
6449
+ remainingByChildName.set(childName, parents);
5959
6450
  }
5960
6451
  }
5961
- return grouped;
5962
- }
5963
- static async batchLoadAndLinkDisjointChildren(parentsByChildName, md, rvProvider, contextUser, outLinkedChildren) {
5964
- const unlinkedParents = [];
6452
+ if (remainingByChildName.size === 0) {
6453
+ return unlinkedParents;
6454
+ }
5965
6455
  if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
5966
- for (const [childName, parents] of parentsByChildName) {
6456
+ for (const [childName, parents] of remainingByChildName) {
5967
6457
  for (const p of parents) {
5968
6458
  const linked = await p.createAndLinkChildEntity(childName);
5969
6459
  if (!linked) {
5970
6460
  unlinkedParents.push(p);
6461
+ outNotFound.add(p);
5971
6462
  }
5972
6463
  }
5973
6464
  }
5974
6465
  return unlinkedParents;
5975
6466
  }
5976
- const deniedChildLogged = new Set();
5977
- const slices = BaseEntity.buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
6467
+ const slices = BaseEntity.buildDisjointChildQuerySlices(remainingByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
5978
6468
  if (slices.length === 0) {
5979
6469
  return unlinkedParents;
5980
6470
  }
5981
6471
  const rv = new RunView(rvProvider);
5982
6472
  const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
5983
6473
  for (let sIdx = 0; sIdx < slices.length; sIdx++) {
5984
- await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged);
6474
+ await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound);
5985
6475
  }
5986
6476
  return unlinkedParents;
5987
6477
  }
@@ -6023,13 +6513,16 @@ export class BaseEntity {
6023
6513
  // Internal hydration read: see batchDiscoverDisjointChildren — keep these
6024
6514
  // per-batch IN-list reads out of the shared RunView cache.
6025
6515
  BypassCache: true,
6516
+ // Key-bounded (at most one row per PK in the slice): the entity's
6517
+ // UserViewMaxRows cap protects nothing here and would drop subtypes.
6518
+ IgnoreMaxRows: true,
6026
6519
  },
6027
6520
  });
6028
6521
  }
6029
6522
  }
6030
6523
  return slices;
6031
6524
  }
6032
- static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged) {
6525
+ static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound) {
6033
6526
  if (!res?.Success || !Array.isArray(res.Results)) {
6034
6527
  // Not silent: the per-row path surfaced a failed child load; here the parents stay
6035
6528
  // unlinked (plain parent records) and the cause goes to the log.
@@ -6050,6 +6543,7 @@ export class BaseEntity {
6050
6543
  const childRow = rowByPk.get(pkKey);
6051
6544
  if (!childRow) {
6052
6545
  unlinkedParents.push(p);
6546
+ outNotFound.add(p);
6053
6547
  continue;
6054
6548
  }
6055
6549
  const childEntity = await p.createChildEntityObject(slice.childName);
@@ -6428,7 +6922,23 @@ export class BaseEntity {
6428
6922
  relatedEntityList: null
6429
6923
  });
6430
6924
  // Delete OWN row first (FK constraint: child must be deleted before parent)
6431
- if (await this.ProviderToUse.Delete(this, _options, this.ActiveUser)) {
6925
+ const deleteTelemetryId = TelemetryManager.Instance.StartEvent('Custom', 'BaseEntity.Delete', {
6926
+ operationName: 'BaseEntity.Delete',
6927
+ EntityName: this.EntityInfo?.Name,
6928
+ }, this.ActiveUser?.ID);
6929
+ let deletedOwnRow = false;
6930
+ try {
6931
+ deletedOwnRow = await this.ProviderToUse.Delete(this, _options, this.ActiveUser);
6932
+ TelemetryManager.Instance.EndEvent(deleteTelemetryId, { success: deletedOwnRow });
6933
+ }
6934
+ catch (delErr) {
6935
+ TelemetryManager.Instance.EndEvent(deleteTelemetryId, {
6936
+ success: false,
6937
+ error: delErr instanceof Error ? delErr.message : String(delErr),
6938
+ });
6939
+ throw delErr;
6940
+ }
6941
+ if (deletedOwnRow) {
6432
6942
  ownRowDeleted = true;
6433
6943
  // IS-A: after own delete succeeds, cascade to parent chain
6434
6944
  if (hasParentChain) {