@memberjunction/core 6.2.0-edge.6 → 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 (96) hide show
  1. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  2. package/dist/generic/EntityFieldRules.js +1 -0
  3. package/dist/generic/EntityFieldRules.js.map +1 -1
  4. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.d.ts +165 -12
  7. package/dist/generic/baseEngine.d.ts.map +1 -1
  8. package/dist/generic/baseEngine.js +622 -98
  9. package/dist/generic/baseEngine.js.map +1 -1
  10. package/dist/generic/baseEngineRegistry.d.ts +10 -2
  11. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  12. package/dist/generic/baseEngineRegistry.js +33 -5
  13. package/dist/generic/baseEngineRegistry.js.map +1 -1
  14. package/dist/generic/baseEntity.d.ts +121 -6
  15. package/dist/generic/baseEntity.d.ts.map +1 -1
  16. package/dist/generic/baseEntity.js +1144 -64
  17. package/dist/generic/baseEntity.js.map +1 -1
  18. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  19. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  20. package/dist/generic/baseRemotableOperation.js +112 -8
  21. package/dist/generic/baseRemotableOperation.js.map +1 -1
  22. package/dist/generic/databaseProviderBase.d.ts +37 -0
  23. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  24. package/dist/generic/databaseProviderBase.js +262 -46
  25. package/dist/generic/databaseProviderBase.js.map +1 -1
  26. package/dist/generic/entityInfo.d.ts +25 -1
  27. package/dist/generic/entityInfo.d.ts.map +1 -1
  28. package/dist/generic/entityInfo.js +70 -12
  29. package/dist/generic/entityInfo.js.map +1 -1
  30. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  31. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  32. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  33. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  34. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  35. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  36. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  37. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  38. package/dist/generic/interfaces.d.ts +50 -1
  39. package/dist/generic/interfaces.d.ts.map +1 -1
  40. package/dist/generic/interfaces.js.map +1 -1
  41. package/dist/generic/listMembershipFilter.d.ts +5 -0
  42. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  43. package/dist/generic/listMembershipFilter.js +23 -0
  44. package/dist/generic/listMembershipFilter.js.map +1 -0
  45. package/dist/generic/localCacheManager.d.ts +69 -5
  46. package/dist/generic/localCacheManager.d.ts.map +1 -1
  47. package/dist/generic/localCacheManager.js +253 -18
  48. package/dist/generic/localCacheManager.js.map +1 -1
  49. package/dist/generic/logging.d.ts +105 -38
  50. package/dist/generic/logging.d.ts.map +1 -1
  51. package/dist/generic/logging.js +196 -5
  52. package/dist/generic/logging.js.map +1 -1
  53. package/dist/generic/providerBase.d.ts +198 -3
  54. package/dist/generic/providerBase.d.ts.map +1 -1
  55. package/dist/generic/providerBase.js +721 -223
  56. package/dist/generic/providerBase.js.map +1 -1
  57. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  58. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  59. package/dist/generic/relatedRecordCollection.js +11 -2
  60. package/dist/generic/relatedRecordCollection.js.map +1 -1
  61. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  62. package/dist/generic/remoteOperationDispatch.js +14 -2
  63. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  64. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  65. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  66. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  67. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  68. package/dist/generic/sharedLoad.d.ts +42 -0
  69. package/dist/generic/sharedLoad.d.ts.map +1 -0
  70. package/dist/generic/sharedLoad.js +74 -0
  71. package/dist/generic/sharedLoad.js.map +1 -0
  72. package/dist/generic/telemetryManager.d.ts +31 -0
  73. package/dist/generic/telemetryManager.d.ts.map +1 -1
  74. package/dist/generic/telemetryManager.js +85 -9
  75. package/dist/generic/telemetryManager.js.map +1 -1
  76. package/dist/generic/transactionGroup.d.ts +19 -0
  77. package/dist/generic/transactionGroup.d.ts.map +1 -1
  78. package/dist/generic/transactionGroup.js +55 -7
  79. package/dist/generic/transactionGroup.js.map +1 -1
  80. package/dist/generic/transportError.d.ts +41 -0
  81. package/dist/generic/transportError.d.ts.map +1 -0
  82. package/dist/generic/transportError.js +167 -0
  83. package/dist/generic/transportError.js.map +1 -0
  84. package/dist/generic/util.d.ts +13 -1
  85. package/dist/generic/util.d.ts.map +1 -1
  86. package/dist/generic/util.js +53 -4
  87. package/dist/generic/util.js.map +1 -1
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/views/runView.d.ts +23 -10
  93. package/dist/views/runView.d.ts.map +1 -1
  94. package/dist/views/runView.js +5 -1
  95. package/dist/views/runView.js.map +1 -1
  96. package/package.json +3 -3
@@ -8,16 +8,18 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  var EntityField_1;
11
- import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, UserCacheKeySegment, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
11
+ import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, UserCacheKeySegment, WarningManager, ComputeContentHashAsync, EscapeSQLString } from '@memberjunction/global';
12
12
  import { GetDataHooks } from './dataHooks.js';
13
13
  import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
14
14
  import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
15
+ import { CanonicalPrimaryKeyValue } from './util.js';
15
16
  import { BaseEngineRegistry } from './baseEngineRegistry.js';
16
17
  import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
17
18
  import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
18
19
  import { Metadata } from './metadata.js';
19
20
  import { RunView } from '../views/runView.js';
20
- import { LogDebug, LogError, LogStatusEx } from './logging.js';
21
+ import { LogDebug, LogError, LogStatusEx, LogWarning } from './logging.js';
22
+ import { TelemetryManager } from './telemetryManager.js';
21
23
  import { CompositeKey, KeyValuePair } from './compositeKey.js';
22
24
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
23
25
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
@@ -130,6 +132,18 @@ let EntityField = class EntityField {
130
132
  SetCreateSuppressed(suppressed) {
131
133
  this._createSuppressed = suppressed;
132
134
  }
135
+ /**
136
+ * Framework-internal: resets all mutable field state in place to match a freshly constructed
137
+ * `EntityField` instance without allocating a new object or invalidating `BaseEntity` field maps.
138
+ */
139
+ ResetForInPlaceHydration() {
140
+ this._oldValue = null;
141
+ this._value = null;
142
+ this._neverSet = true;
143
+ this._notLoaded = false;
144
+ this._writtenAfterNotLoaded = false;
145
+ this._createSuppressed = false;
146
+ }
133
147
  get Name() {
134
148
  return this._entityFieldInfo.Name;
135
149
  }
@@ -1077,6 +1091,7 @@ export class BaseEntity {
1077
1091
  * pay nothing for the feature, not even an empty Map per instance.
1078
1092
  */
1079
1093
  this._companions = null;
1094
+ this._finalizeGeneration = 0;
1080
1095
  /**
1081
1096
  * Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
1082
1097
  *
@@ -1532,7 +1547,7 @@ export class BaseEntity {
1532
1547
  *
1533
1548
  * @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
1534
1549
  */
1535
- async resolveSubtypeLoadHint() {
1550
+ async resolveSubtypeLoadHint(batchUnfilteredCache) {
1536
1551
  if (this._skipSubtypeLoadHint) {
1537
1552
  return null;
1538
1553
  }
@@ -1543,7 +1558,7 @@ export class BaseEntity {
1543
1558
  }
1544
1559
  const selector = this.EntityInfo.SubtypeSelectorConfig;
1545
1560
  const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
1546
- return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
1561
+ return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath, batchUnfilteredCache) : null;
1547
1562
  }
1548
1563
  /**
1549
1564
  * The resolver to ask for load hints for an entity, decided once per registration and cached:
@@ -1605,9 +1620,9 @@ export class BaseEntity {
1605
1620
  * Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
1606
1621
  * this entity's metadata is logged once and gives no hint; the create path throws on it instead.
1607
1622
  */
1608
- async evaluateSelectorForLoadHint(path) {
1623
+ async evaluateSelectorForLoadHint(path, batchUnfilteredCache) {
1609
1624
  try {
1610
- 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));
1611
1626
  return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
1612
1627
  }
1613
1628
  catch (e) {
@@ -1996,15 +2011,16 @@ export class BaseEntity {
1996
2011
  }
1997
2012
  /**
1998
2013
  * Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
1999
- * `BaseEngine` caches already hold. Never queries, and never reads the create path's memo, whose
2000
- * 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.
2001
2017
  *
2002
2018
  * `pkValue` is one foreign-key column's value, matched against the first primary key of the
2003
2019
  * cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
2004
2020
  *
2005
2021
  * @returns The cached record, or null when no loaded engine holds it as an entity object.
2006
2022
  */
2007
- static findSubtypePathTargetInEngineCache(entityName, pkValue) {
2023
+ static findSubtypePathTargetInEngineCache(entityName, pkValue, batchUnfilteredCache) {
2008
2024
  const key = NormalizeUUID(String(pkValue));
2009
2025
  for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
2010
2026
  const found = BaseEntity.findEngineCachedRow(match.records, key);
@@ -2012,6 +2028,13 @@ export class BaseEntity {
2012
2028
  return found;
2013
2029
  }
2014
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
+ }
2015
2038
  return null;
2016
2039
  }
2017
2040
  /**
@@ -2193,25 +2216,102 @@ export class BaseEntity {
2193
2216
  * (and potentially parent entities in the IS-A chain).
2194
2217
  */
2195
2218
  Hydrate(data) {
2196
- // Reset this entity to pristine state: clears _compositeKey, _recordLoaded,
2197
- // _everSaved, and recreates all EntityField instances with _NeverSet = true
2198
- this.init();
2199
2219
  this.discardJSONFieldObjects();
2200
2220
  // Field security: denied values never enter this object (see narrowHydrationSource).
2201
2221
  data = this.narrowHydrationSource(data);
2202
2222
  // Recursively hydrate parent entities first (deepest ancestor resets first).
2203
- // After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
2204
2223
  if (this._parentEntity) {
2205
2224
  this._parentEntity.Hydrate(data);
2206
2225
  }
2207
- // Populate this entity's fields. SetMany also routes parent field values to
2208
- // _parentEntity via the IS-A routing block (which now includes PK fields).
2209
- // replaceOldValues=true ensures OldValue matches Value (no false dirty flags).
2210
- // ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
2211
- // dropped columns that belong to another level of the IS-A chain.
2212
- this.SetMany(this.ownedFieldsFrom(data), true, true, true);
2213
- // Hydrate is a hydration entry point: keys the source omitted are not-loaded.
2214
- this.markFieldsOmittedBySourceAsNotLoaded(data);
2226
+ // Populate this entity's fields in place without destroying hydrated EntityField instances
2227
+ // or invalidating _fieldCache / _codeNameCache.
2228
+ this.resetAndApplyOwnedFieldsInPlace(data);
2229
+ }
2230
+ /**
2231
+ * Resets transient record state and applies `fieldData` to this entity's own fields in a
2232
+ * single pass over `this._fields`, reusing existing `EntityField` instances and preserving
2233
+ * `_fieldCache` / `_codeNameCache` when already hydrated.
2234
+ *
2235
+ * Equivalent to `init() + SetMany(ownedFieldsFrom(fieldData), true, true, true) + markFieldsOmittedBySourceAsNotLoaded(fieldData)`
2236
+ * with zero `EntityField` reallocations and zero intermediate `ownedFieldsFrom` object allocation.
2237
+ */
2238
+ resetAndApplyOwnedFieldsInPlace(fieldData) {
2239
+ this._finalizeGeneration++;
2240
+ this._compositeKey = null;
2241
+ this._resultHistory = [];
2242
+ this._recordLoaded = false;
2243
+ this._raw = null;
2244
+ this._rawConverted = null;
2245
+ this.hydrateFieldsIfNeeded();
2246
+ if (!fieldData || typeof fieldData !== 'object' || Array.isArray(fieldData)) {
2247
+ for (let i = 0; i < this._fields.length; i++) {
2248
+ const field = this._fields[i];
2249
+ field.ResetForInPlaceHydration();
2250
+ field.ClearAsNotLoaded();
2251
+ }
2252
+ return;
2253
+ }
2254
+ let normalizedMap = null;
2255
+ let sharedParentMirrors = null;
2256
+ const parentFieldNames = this._parentEntity ? this._parentEntityFieldNames : null;
2257
+ for (let i = 0; i < this._fields.length; i++) {
2258
+ const field = this._fields[i];
2259
+ const match = BaseEntity.findOwnedFieldValueInSource(field, fieldData, () => {
2260
+ if (normalizedMap === null) {
2261
+ normalizedMap = BaseEntity.buildNormalizedHydrationMap(fieldData);
2262
+ }
2263
+ return normalizedMap;
2264
+ });
2265
+ field.ResetForInPlaceHydration();
2266
+ if (match.found) {
2267
+ this.setFieldValue(field, match.val);
2268
+ field.ResetOldValue();
2269
+ if (parentFieldNames) {
2270
+ if (parentFieldNames.has(match.key)) {
2271
+ (sharedParentMirrors ??= {})[match.key] = match.val;
2272
+ }
2273
+ else if (parentFieldNames.has(field.Name)) {
2274
+ (sharedParentMirrors ??= {})[field.Name] = match.val;
2275
+ }
2276
+ }
2277
+ }
2278
+ else {
2279
+ field.ClearAsNotLoaded();
2280
+ }
2281
+ }
2282
+ if (sharedParentMirrors && this._parentEntity) {
2283
+ this._parentEntity.SetMany(sharedParentMirrors, true, true, true);
2284
+ }
2285
+ this.updateSavedStateFromPrimaryKeys();
2286
+ }
2287
+ static findOwnedFieldValueInSource(field, fieldData, getNormalizedMap) {
2288
+ if (BaseEntity.ownValuePresent(fieldData, field.Name)) {
2289
+ return { found: true, key: field.Name, val: fieldData[field.Name] };
2290
+ }
2291
+ if (field.CodeName !== field.Name && BaseEntity.ownValuePresent(fieldData, field.CodeName)) {
2292
+ return { found: true, key: field.CodeName, val: fieldData[field.CodeName] };
2293
+ }
2294
+ const normalizedMap = getNormalizedMap();
2295
+ const hit = normalizedMap.get(field.Name.trim().toLowerCase()) ??
2296
+ (field.CodeName !== field.Name
2297
+ ? normalizedMap.get(field.CodeName.trim().toLowerCase())
2298
+ : undefined);
2299
+ if (hit !== undefined) {
2300
+ return { found: true, key: hit.key, val: hit.val };
2301
+ }
2302
+ return { found: false, key: field.Name, val: undefined };
2303
+ }
2304
+ static buildNormalizedHydrationMap(fieldData) {
2305
+ const normalizedMap = new Map();
2306
+ const keys = Object.keys(fieldData);
2307
+ for (let kIdx = 0; kIdx < keys.length; kIdx++) {
2308
+ const k = keys[kIdx];
2309
+ const v = fieldData[k];
2310
+ if (v !== undefined) {
2311
+ normalizedMap.set(k.trim().toLowerCase(), { key: k, val: v });
2312
+ }
2313
+ }
2314
+ return normalizedMap;
2215
2315
  }
2216
2316
  /**
2217
2317
  * Columns on `data` that this entity actually defines (by field name or CodeName).
@@ -2906,6 +3006,7 @@ export class BaseEntity {
2906
3006
  entity: level,
2907
3007
  wasSaved: level._everSaved,
2908
3008
  wasLoaded: level._recordLoaded,
3009
+ finalizeGeneration: level._finalizeGeneration,
2909
3010
  resultHistory: [...level._resultHistory],
2910
3011
  fields: level.Fields.map(field => ({ field, state: field.GetState() })),
2911
3012
  });
@@ -2925,7 +3026,7 @@ export class BaseEntity {
2925
3026
  * Puts this record back as a snapshot captured it, after the unit of work that finalized it
2926
3027
  * rolled back.
2927
3028
  *
2928
- * A save's `finalizeSave()` rebuilds the record's fields from what its write returned, marks it
3029
+ * A save's `finalizeSave()` updates the record's fields from what its write returned, marks it
2929
3030
  * saved and empties its result history. The database undid the write, so the record gets back
2930
3031
  * its saved and loaded flags, its result history, and each field's value and tracking state. A
2931
3032
  * field edited since then keeps the edit, and is compared with its captured baseline again, since
@@ -2935,14 +3036,16 @@ export class BaseEntity {
2935
3036
  * The history matters to the caller: a save records its failure only when nothing else did,
2936
3037
  * judged by the history's length when it started, which an emptied history would make wrong.
2937
3038
  *
2938
- * A record still holding the captured field objects was never finalized, so nothing about it
2939
- * changed and it is left alone.
3039
+ * A record whose `_finalizeGeneration` (and field object identities) still matches the snapshot
3040
+ * was never finalized, so nothing about it changed and it is left alone.
2940
3041
  */
2941
3042
  restoreSnapshot(snapshot) {
2942
- const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
3043
+ const finalized = this._finalizeGeneration !== snapshot.finalizeGeneration ||
3044
+ snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
2943
3045
  if (!finalized) {
2944
3046
  return;
2945
3047
  }
3048
+ this._finalizeGeneration = snapshot.finalizeGeneration;
2946
3049
  this._everSaved = snapshot.wasSaved;
2947
3050
  this._recordLoaded = snapshot.wasLoaded;
2948
3051
  this._resultHistory = snapshot.resultHistory;
@@ -4787,7 +4890,23 @@ export class BaseEntity {
4787
4890
  LogError(`EntitySaveOptions.OnValidated callback threw (ignored): ${cbErr?.message ?? String(cbErr)}`);
4788
4891
  }
4789
4892
  }
4790
- 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
+ }
4791
4910
  if (!this.TransactionGroup) {
4792
4911
  // no transaction group, so we have our results here
4793
4912
  const result = this.finalizeSave(data, saveSubType, _options);
@@ -4949,19 +5068,11 @@ export class BaseEntity {
4949
5068
  // Record Changes payload from this entity's pre-save state, so narrowing the response
4950
5069
  // here costs the audit nothing; it only keeps denied values out of this object.
4951
5070
  fieldData = this.narrowHydrationSource(fieldData);
4952
- this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
4953
- // IS-A GetAll() merges the parent, including parent virtuals this entity
4954
- // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
4955
- // this entity defines, and ignore anything leftover.
4956
- this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
4957
- // finalizeSave re-hydrates from the save RESPONSE — a hydration source. Keys it
4958
- // omitted (e.g. fields the server stripped for field security) must be marked
4959
- // not-loaded, or the defaults init() just produced would masquerade as confirmed
4960
- // values and be resent on the NEXT save (the create/update-response corner: a
4961
- // trigger/system adjustment or concurrent change would be silently stomped).
4962
- if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
4963
- this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
4964
- }
5071
+ // Reset transient record state and update owned EntityField values in place so
5072
+ // hydrated EntityField instances and _fieldCache/_codeNameCache are preserved across saves.
5073
+ // Deliberately do NOT call discardJSONFieldObjects() here so JSONType references held
5074
+ // across Save() remain attached and live.
5075
+ this.resetAndApplyOwnedFieldsInPlace(fieldData);
4965
5076
  this._everSaved = true; // Mark as saved after successful save
4966
5077
  const result = this.LatestResult;
4967
5078
  if (result)
@@ -5601,6 +5712,867 @@ export class BaseEntity {
5601
5712
  await this.deserializeCompanionsFromData(data);
5602
5713
  return true;
5603
5714
  }
5715
+ /**
5716
+ * Framework-internal batch IS-A parent hydration entry point used by
5717
+ * `TransformSimpleObjectToEntityObject` when the provider supports batched `RunViews`.
5718
+ *
5719
+ * Collapses $O(2N)$ per-record child-discovery and child-load queries into at most
5720
+ * 1 batched discovery query + 1 batched `RunViews` call per hierarchy depth level
5721
+ * (and 0 discovery queries when a `SubtypeSelector` / `EntitySubtypeResolver` resolves all rows).
5722
+ */
5723
+ static { this.DEFAULT_ISA_BATCH_CHUNK_SIZE = 500; }
5724
+ /**
5725
+ * Maximum number of primary keys included in a single `WHERE PK IN (...)` batch query
5726
+ * during IS-A child discovery or hydration.
5727
+ */
5728
+ static get ISABatchChunkSize() {
5729
+ return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
5730
+ }
5731
+ async LoadFromDataBatchAsISAParent(peers, md, contextUser, batchUnfilteredCache) {
5732
+ const loadedParents = [];
5733
+ for (let i = 0; i < peers.length; i++) {
5734
+ const { entity, data } = peers[i];
5735
+ if (IsMemberOverridden(entity, 'LoadFromData', BaseEntity)) {
5736
+ await entity.LoadFromData(data);
5737
+ continue;
5738
+ }
5739
+ const allPksSet = await entity.hydrateSingleISAParentPeer(data);
5740
+ if (allPksSet) {
5741
+ loadedParents.push(entity);
5742
+ }
5743
+ }
5744
+ if (loadedParents.length > 0) {
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;
5783
+ }
5784
+ await target.LoadFromData(source.GetAll());
5785
+ }
5786
+ async hydrateSingleISAParentPeer(rawData) {
5787
+ this.discardJSONFieldObjects();
5788
+ const dataObj = rawData instanceof BaseEntity
5789
+ ? rawData.GetAll()
5790
+ : rawData;
5791
+ const narrowed = this.narrowHydrationSource(dataObj);
5792
+ if (this._parentEntity && narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed)) {
5793
+ this._parentEntity.Hydrate(narrowed);
5794
+ }
5795
+ const isPlain = Boolean(narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed) && !(narrowed instanceof Date));
5796
+ this.SetMany(narrowed, true, false, true);
5797
+ if (isPlain) {
5798
+ this.markFieldsOmittedBySourceAsNotLoaded(narrowed);
5799
+ }
5800
+ let allPksSet = false;
5801
+ if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
5802
+ allPksSet = true;
5803
+ for (const pkey of this.PrimaryKeys) {
5804
+ if (pkey.Value === null || pkey.Value === undefined) {
5805
+ allPksSet = false;
5806
+ break;
5807
+ }
5808
+ }
5809
+ this._recordLoaded = allPksSet;
5810
+ this._everSaved = allPksSet;
5811
+ if (allPksSet) {
5812
+ this.cacheRecordName();
5813
+ }
5814
+ }
5815
+ else {
5816
+ LogError(`BaseEntity.LoadFromDataBatchAsISAParent() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
5817
+ this._recordLoaded = false;
5818
+ this._everSaved = false;
5819
+ }
5820
+ await this.deserializeCompanionsFromData(narrowed);
5821
+ return allPksSet;
5822
+ }
5823
+ static async hydrateISAChildrenForLevel(levelEntities, md, contextUser, batchUnfilteredCache) {
5824
+ const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
5825
+ if (activeParents.length === 0) {
5826
+ return;
5827
+ }
5828
+ const groupsByEntityId = new Map();
5829
+ for (const p of activeParents) {
5830
+ p._childEntityDiscoveryDone = true;
5831
+ let group = groupsByEntityId.get(p.EntityInfo.ID);
5832
+ if (!group) {
5833
+ group = [];
5834
+ groupsByEntityId.set(p.EntityInfo.ID, group);
5835
+ }
5836
+ group.push(p);
5837
+ }
5838
+ const nextLevelChildren = [];
5839
+ for (const [, groupParents] of groupsByEntityId) {
5840
+ const sampleParent = groupParents[0];
5841
+ if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
5842
+ await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache);
5843
+ }
5844
+ else {
5845
+ await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache);
5846
+ }
5847
+ }
5848
+ if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
5849
+ await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser, batchUnfilteredCache);
5850
+ }
5851
+ }
5852
+ static async hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache) {
5853
+ const sampleParent = groupParents[0];
5854
+ const parentInfo = sampleParent.EntityInfo;
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
+ }
5890
+ if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
5891
+ const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
5892
+ const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, effectiveUser);
5893
+ const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5894
+ for (const p of groupParents) {
5895
+ const rawPk = String(p.PrimaryKey.Values());
5896
+ const pkKey = pkField ? pkField.FormatCanonicalValue(rawPk) : CanonicalPrimaryKeyValue(rawPk, false);
5897
+ const matched = batchMap.get(pkKey) ?? [];
5898
+ p._childEntities = matched.map(name => ({ entityName: name }));
5899
+ }
5900
+ return;
5901
+ }
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;
5957
+ }
5958
+ await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
5959
+ }
5960
+ static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache) {
5961
+ const sampleParent = groupParents[0];
5962
+ const parentInfo = sampleParent.EntityInfo;
5963
+ const entityProvider = sampleParent.ProviderToUse;
5964
+ const rvProvider = sampleParent.RunViewProviderToUse;
5965
+ const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
5966
+ const deniedChildLogged = new Set();
5967
+ // 1) Evaluate in-memory SubtypeSelector / EntitySubtypeResolver load hints
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();
5974
+ if (hintedByChild.size > 0) {
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');
6030
+ }
6031
+ continue;
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
+ });
6042
+ }
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
+ }
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);
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;
6330
+ }
6331
+ static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser, batchUnfilteredCache) {
6332
+ const hintedByChild = new Map();
6333
+ const hintedNameByParent = new Map();
6334
+ const needsDiscovery = [];
6335
+ for (const p of groupParents) {
6336
+ const hintedName = await p.resolveSubtypeLoadHint(batchUnfilteredCache);
6337
+ if (hintedName) {
6338
+ const childInfo = md.EntityByName?.(hintedName) ??
6339
+ parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
6340
+ const userForPerm = contextUser ?? p._contextCurrentUser ?? md.CurrentUser;
6341
+ const canRead = childInfo && userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
6342
+ if (childInfo && canRead) {
6343
+ BaseEntity.appendParentToChildGroup(hintedByChild, childInfo.Name, p);
6344
+ hintedNameByParent.set(p, childInfo.Name);
6345
+ }
6346
+ else {
6347
+ needsDiscovery.push(p);
6348
+ }
6349
+ }
6350
+ else {
6351
+ needsDiscovery.push(p);
6352
+ }
6353
+ }
6354
+ return { hintedByChild, hintedNameByParent, needsDiscovery };
6355
+ }
6356
+ static appendParentToChildGroup(grouped, childName, parent) {
6357
+ let bucket = grouped.get(childName);
6358
+ if (!bucket) {
6359
+ bucket = [];
6360
+ grouped.set(childName, bucket);
6361
+ }
6362
+ bucket.push(parent);
6363
+ }
6364
+ static buildChildPkInFilter(pkField, rawPkValues) {
6365
+ const formatted = rawPkValues.map(val => {
6366
+ if (!pkField.NeedsQuotes) {
6367
+ // Emit the digits as written: a bigint beyond 2^53 must not be rewritten by Number().
6368
+ const text = String(val).trim();
6369
+ if (/^-?\d+(\.\d+)?$/.test(text)) {
6370
+ return text;
6371
+ }
6372
+ }
6373
+ return `'${EscapeSQLString(String(val))}'`;
6374
+ });
6375
+ return `${pkField.Name} IN (${formatted.join(', ')})`;
6376
+ }
6377
+ static async batchDiscoverDisjointChildren(parentInfo, parents, entityProvider, rvProvider, contextUser) {
6378
+ const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6379
+ const pkIsUuid = Boolean(pkField?.IsUniqueIdentifier);
6380
+ const grouped = new Map();
6381
+ const pkValues = parents.map(p => String(p.PrimaryKey.Values()));
6382
+ if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
6383
+ const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pkValues, contextUser);
6384
+ for (const p of parents) {
6385
+ const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
6386
+ const firstMatch = batchMap.get(pkKey)?.[0];
6387
+ if (firstMatch) {
6388
+ BaseEntity.appendParentToChildGroup(grouped, firstMatch, p);
6389
+ }
6390
+ }
6391
+ return grouped;
6392
+ }
6393
+ // Last-resort per-parent fallback when FindISAChildEntitiesBatch is absent
6394
+ if (typeof entityProvider?.FindISAChildEntity === 'function') {
6395
+ for (const p of parents) {
6396
+ const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
6397
+ if (found?.ChildEntityName) {
6398
+ BaseEntity.appendParentToChildGroup(grouped, found.ChildEntityName, p);
6399
+ }
6400
+ }
6401
+ }
6402
+ return grouped;
6403
+ }
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);
6442
+ }
6443
+ }
6444
+ if (cacheMisses.length > 0) {
6445
+ remainingByChildName.set(childName, cacheMisses);
6446
+ }
6447
+ }
6448
+ else {
6449
+ remainingByChildName.set(childName, parents);
6450
+ }
6451
+ }
6452
+ if (remainingByChildName.size === 0) {
6453
+ return unlinkedParents;
6454
+ }
6455
+ if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
6456
+ for (const [childName, parents] of remainingByChildName) {
6457
+ for (const p of parents) {
6458
+ const linked = await p.createAndLinkChildEntity(childName);
6459
+ if (!linked) {
6460
+ unlinkedParents.push(p);
6461
+ outNotFound.add(p);
6462
+ }
6463
+ }
6464
+ }
6465
+ return unlinkedParents;
6466
+ }
6467
+ const slices = BaseEntity.buildDisjointChildQuerySlices(remainingByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
6468
+ if (slices.length === 0) {
6469
+ return unlinkedParents;
6470
+ }
6471
+ const rv = new RunView(rvProvider);
6472
+ const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
6473
+ for (let sIdx = 0; sIdx < slices.length; sIdx++) {
6474
+ await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound);
6475
+ }
6476
+ return unlinkedParents;
6477
+ }
6478
+ static buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged) {
6479
+ const chunkSize = BaseEntity.ISABatchChunkSize;
6480
+ const slices = [];
6481
+ for (const [childName, parents] of parentsByChildName) {
6482
+ const childInfo = md.EntityByName?.(childName) ??
6483
+ parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
6484
+ const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6485
+ if (!childInfo || !pkField) {
6486
+ for (const p of parents)
6487
+ unlinkedParents.push(p);
6488
+ continue;
6489
+ }
6490
+ const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
6491
+ const canRead = userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
6492
+ if (!canRead) {
6493
+ if (!deniedChildLogged.has(childInfo.Name)) {
6494
+ deniedChildLogged.add(childInfo.Name);
6495
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${childInfo.Name}'; matching parent records are returned without their subtype`, 'IS-A');
6496
+ }
6497
+ for (const p of parents)
6498
+ unlinkedParents.push(p);
6499
+ continue;
6500
+ }
6501
+ for (let start = 0; start < parents.length; start += chunkSize) {
6502
+ const sliceParents = parents.slice(start, start + chunkSize);
6503
+ const pks = sliceParents.map(p => String(p.PrimaryKey.Values()));
6504
+ slices.push({
6505
+ childName: childInfo.Name,
6506
+ pkFieldName: pkField.Name,
6507
+ pkIsUuid: Boolean(pkField.IsUniqueIdentifier),
6508
+ parents: sliceParents,
6509
+ param: {
6510
+ EntityName: childInfo.Name,
6511
+ ExtraFilter: BaseEntity.buildChildPkInFilter(pkField, pks),
6512
+ ResultType: 'simple',
6513
+ // Internal hydration read: see batchDiscoverDisjointChildren — keep these
6514
+ // per-batch IN-list reads out of the shared RunView cache.
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,
6519
+ },
6520
+ });
6521
+ }
6522
+ }
6523
+ return slices;
6524
+ }
6525
+ static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound) {
6526
+ if (!res?.Success || !Array.isArray(res.Results)) {
6527
+ // Not silent: the per-row path surfaced a failed child load; here the parents stay
6528
+ // unlinked (plain parent records) and the cause goes to the log.
6529
+ LogWarning(`BaseEntity: batched IS-A child load of '${slice.childName}' failed (${res?.ErrorMessage || 'no result'}); ${slice.parents.length} parent record(s) continue without their subtype`, 'IS-A');
6530
+ for (const p of slice.parents)
6531
+ unlinkedParents.push(p);
6532
+ return;
6533
+ }
6534
+ const rowByPk = new Map();
6535
+ for (const row of res.Results) {
6536
+ const pkVal = row[slice.pkFieldName];
6537
+ if (pkVal != null) {
6538
+ rowByPk.set(CanonicalPrimaryKeyValue(String(pkVal), slice.pkIsUuid), row);
6539
+ }
6540
+ }
6541
+ for (const p of slice.parents) {
6542
+ const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), slice.pkIsUuid);
6543
+ const childRow = rowByPk.get(pkKey);
6544
+ if (!childRow) {
6545
+ unlinkedParents.push(p);
6546
+ outNotFound.add(p);
6547
+ continue;
6548
+ }
6549
+ const childEntity = await p.createChildEntityObject(slice.childName);
6550
+ if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
6551
+ // The per-row path threw here (InnerLoad → CheckPermissions(Read, true)). The batch
6552
+ // path degrades to a plain parent record instead, and says so once per slice.
6553
+ if (!deniedChildLogged.has(slice.childName)) {
6554
+ deniedChildLogged.add(slice.childName);
6555
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${slice.childName}'; matching parent records are returned without their subtype`, 'IS-A');
6556
+ }
6557
+ unlinkedParents.push(p);
6558
+ continue;
6559
+ }
6560
+ await p.linkHydratedChildFromBatchRow(childEntity, childRow);
6561
+ outLinkedChildren.push(childEntity);
6562
+ }
6563
+ }
6564
+ async linkHydratedChildFromBatchRow(childEntity, childRow) {
6565
+ this.replaceChildParentChain(childEntity);
6566
+ this._childEntity = childEntity;
6567
+ childEntity.discardJSONFieldObjects();
6568
+ const narrowed = childEntity.narrowHydrationSource(childRow);
6569
+ childEntity.resetAndApplyOwnedFieldsInPlace(narrowed);
6570
+ childEntity._recordLoaded = true;
6571
+ childEntity._everSaved = true;
6572
+ childEntity._compositeKey = this.PrimaryKey;
6573
+ childEntity.cacheRecordName();
6574
+ await childEntity.deserializeCompanionsFromData(narrowed);
6575
+ }
5604
6576
  /**
5605
6577
  * Extracts and applies a companion payload carried inside a plain data object, if present.
5606
6578
  *
@@ -5950,7 +6922,23 @@ export class BaseEntity {
5950
6922
  relatedEntityList: null
5951
6923
  });
5952
6924
  // Delete OWN row first (FK constraint: child must be deleted before parent)
5953
- 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) {
5954
6942
  ownRowDeleted = true;
5955
6943
  // IS-A: after own delete succeeds, cascade to parent chain
5956
6944
  if (hasParentChain) {
@@ -6094,20 +7082,53 @@ export class BaseEntity {
6094
7082
  if (childEntities.length === 0) {
6095
7083
  return { HasChildren: false, ChildEntityName: '' };
6096
7084
  }
6097
- // Use RunView on this instance's provider — a host RunView cannot see
6098
- // uncommitted child rows on a graph-scoped connection.
6099
- const rv = new RunView(this.RunViewProviderToUse);
6100
7085
  const pkValue = this.PrimaryKey.Values();
6101
- for (const childEntity of childEntities) {
7086
+ // Tier 1: Single UNION ALL SQL query on this entity's bound DatabaseProviderBase
7087
+ // (executes inside any active transaction on this.ProviderToUse in 1 round-trip instead of K).
7088
+ const entityProvider = this.ProviderToUse;
7089
+ if (typeof entityProvider?.FindISAChildEntity === 'function') {
7090
+ const found = await entityProvider.FindISAChildEntity(this.EntityInfo, String(pkValue), this._contextCurrentUser);
7091
+ if (found?.ChildEntityName) {
7092
+ return { HasChildren: true, ChildEntityName: found.ChildEntityName };
7093
+ }
7094
+ return { HasChildren: false, ChildEntityName: '' };
7095
+ }
7096
+ // Tier 2: Batched RunViews on this instance's RunView provider (GraphQLDataProvider or test mocks)
7097
+ const rvProvider = this.RunViewProviderToUse;
7098
+ const rv = new RunView(rvProvider);
7099
+ const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
7100
+ if (validChildren.length === 0) {
7101
+ return { HasChildren: false, ChildEntityName: '' };
7102
+ }
7103
+ if (rvProvider && typeof rvProvider.RunViews === 'function') {
7104
+ const viewParams = validChildren.map(childEntity => {
7105
+ const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
7106
+ return {
7107
+ EntityName: childEntity.Name,
7108
+ ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
7109
+ ResultType: 'simple',
7110
+ Fields: [pkField.Name],
7111
+ MaxRows: 1,
7112
+ };
7113
+ });
7114
+ const results = await rv.RunViews(viewParams, this._contextCurrentUser);
7115
+ for (let i = 0; i < validChildren.length; i++) {
7116
+ const result = results[i];
7117
+ if (result?.Success && result.Results && result.Results.length > 0) {
7118
+ return { HasChildren: true, ChildEntityName: validChildren[i].Name };
7119
+ }
7120
+ }
7121
+ return { HasChildren: false, ChildEntityName: '' };
7122
+ }
7123
+ for (const childEntity of validChildren) {
6102
7124
  const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6103
- if (!pkField)
6104
- continue;
6105
7125
  const result = await rv.RunView({
6106
7126
  EntityName: childEntity.Name,
6107
- ExtraFilter: `${pkField.Name} = '${pkValue}'`,
7127
+ ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
6108
7128
  ResultType: 'simple',
6109
7129
  Fields: [pkField.Name],
6110
- MaxRows: 1
7130
+ MaxRows: 1,
7131
+ SkipTotalRowCount: true
6111
7132
  }, this._contextCurrentUser);
6112
7133
  if (result && result.Success && result.Results && result.Results.length > 0) {
6113
7134
  return { HasChildren: true, ChildEntityName: childEntity.Name };
@@ -6169,18 +7190,50 @@ export class BaseEntity {
6169
7190
  if (childEntities.length === 0) {
6170
7191
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
6171
7192
  }
6172
- const rv = new RunView((provider ?? BaseEntity.Provider));
6173
7193
  const pkValue = primaryKey.Values();
6174
- for (const child of childEntities) {
7194
+ const candidateEntityProvider = (provider ?? BaseEntity.Provider);
7195
+ if (typeof candidateEntityProvider?.FindISAChildEntity === 'function') {
7196
+ const found = await candidateEntityProvider.FindISAChildEntity(entityInfo, String(pkValue), contextUser);
7197
+ if (found?.ChildEntityName) {
7198
+ const matchedChild = childEntities.find(c => sameEntityName(c.Name, found.ChildEntityName));
7199
+ if (matchedChild) {
7200
+ return BaseEntity.resolveLeafEntityRecursive(matchedChild, primaryKey, contextUser, provider);
7201
+ }
7202
+ }
7203
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
7204
+ }
7205
+ const rvProvider = (provider ?? BaseEntity.Provider);
7206
+ const rv = new RunView(rvProvider);
7207
+ const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
7208
+ if (rvProvider && typeof rvProvider.RunViews === 'function' && validChildren.length > 0) {
7209
+ const viewParams = validChildren.map(child => {
7210
+ const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
7211
+ return {
7212
+ EntityName: child.Name,
7213
+ ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
7214
+ ResultType: 'simple',
7215
+ Fields: [childPK.Name],
7216
+ MaxRows: 1,
7217
+ };
7218
+ });
7219
+ const results = await rv.RunViews(viewParams, contextUser);
7220
+ for (let i = 0; i < validChildren.length; i++) {
7221
+ const result = results[i];
7222
+ if (result?.Success && result.Results?.length > 0) {
7223
+ return BaseEntity.resolveLeafEntityRecursive(validChildren[i], primaryKey, contextUser, provider);
7224
+ }
7225
+ }
7226
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
7227
+ }
7228
+ for (const child of validChildren) {
6175
7229
  const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6176
- if (!childPK)
6177
- continue;
6178
7230
  const result = await rv.RunView({
6179
7231
  EntityName: child.Name,
6180
- ExtraFilter: `${childPK.Name} = '${pkValue}'`,
7232
+ ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
6181
7233
  ResultType: 'simple',
6182
7234
  Fields: [childPK.Name],
6183
- MaxRows: 1
7235
+ MaxRows: 1,
7236
+ SkipTotalRowCount: true
6184
7237
  }, contextUser);
6185
7238
  if (result?.Success && result.Results?.length > 0) {
6186
7239
  // Found a child — recurse to see if there's an even more specific leaf
@@ -6227,10 +7280,11 @@ export class BaseEntity {
6227
7280
  return;
6228
7281
  const viewParams = validSiblings.map(sibling => ({
6229
7282
  EntityName: sibling.Name,
6230
- ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
7283
+ ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${EscapeSQLString(String(pkValue))}'`, // first-pk-ok: IS-A shared key
6231
7284
  ResultType: 'simple',
6232
7285
  Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
6233
- MaxRows: 1
7286
+ MaxRows: 1,
7287
+ SkipTotalRowCount: true
6234
7288
  }));
6235
7289
  const results = await rv.RunViews(viewParams, this._contextCurrentUser);
6236
7290
  for (let i = 0; i < results.length; i++) {
@@ -6434,7 +7488,8 @@ export class BaseEntity {
6434
7488
  }
6435
7489
  /**
6436
7490
  * Generates a vector embedding for a single text field using AI engine.
6437
- * Only generates embeddings for new records or when the source field has changed.
7491
+ * Only generates embeddings for new records, when the source field has changed, or when
7492
+ * {@link IsEmbeddingStale} reports the stored vector came from another model.
6438
7493
  * Stores the vector embedding, the model ID used to generate it and, when the entity has one, the
6439
7494
  * vector's binary companion.
6440
7495
  *
@@ -6452,33 +7507,46 @@ export class BaseEntity {
6452
7507
  * @returns Promise that resolves to true if embedding was generated successfully, false otherwise
6453
7508
  */
6454
7509
  async GenerateEmbedding(field, vectorField, modelField, binaryVectorField) {
7510
+ const clearVector = () => {
7511
+ vectorField.Value = null;
7512
+ if (binaryVectorField)
7513
+ binaryVectorField.Value = null;
7514
+ if (modelField)
7515
+ modelField.Value = null;
7516
+ };
7517
+ // After an edit to a saved record, its stored vector describes the old text, so a failed
7518
+ // re-embed clears it rather than leaving it searchable.
7519
+ const edited = this.IsSaved && field.Dirty;
6455
7520
  try {
6456
7521
  if (this._skipEmbeddings)
6457
7522
  return true;
6458
- if (!this.IsSaved || field.Dirty) {
7523
+ const stale = vectorField.Value != null && await this.IsEmbeddingStale(modelField?.Value ?? null);
7524
+ if (!this.IsSaved || field.Dirty || stale) {
6459
7525
  if (field.Value?.trim().length > 0) {
6460
7526
  // recalc vector
6461
7527
  const e = await this.EmbedTextLocal(field.Value);
6462
- if (e && e.vector) {
7528
+ // An empty vector is a failed embed (the BaseEmbeddings convention); don't persist it.
7529
+ if (e?.vector?.length) {
6463
7530
  vectorField.Value = JSON.stringify(e.vector);
6464
7531
  if (binaryVectorField)
6465
7532
  binaryVectorField.Value = Float32VectorToBase64(e.vector);
6466
7533
  if (modelField)
6467
7534
  modelField.Value = e.modelID;
6468
7535
  }
7536
+ else if (edited) {
7537
+ clearVector();
7538
+ }
6469
7539
  }
6470
7540
  else {
6471
- vectorField.Value = null;
6472
- if (binaryVectorField)
6473
- binaryVectorField.Value = null;
6474
- if (modelField)
6475
- modelField.Value = null;
7541
+ clearVector();
6476
7542
  }
6477
7543
  }
6478
7544
  return true;
6479
7545
  }
6480
7546
  catch (e) {
6481
7547
  console.error("Error generating embedding:", e);
7548
+ if (edited)
7549
+ clearVector();
6482
7550
  return false;
6483
7551
  }
6484
7552
  }
@@ -6492,6 +7560,18 @@ export class BaseEntity {
6492
7560
  async EmbedTextLocal(textToEmbed) {
6493
7561
  throw new Error("EmbedTextLocal not implemented in BaseEntity, sub-classes must implement this functionality to use it");
6494
7562
  }
7563
+ /**
7564
+ * Whether a stored vector is stale: it came from a model other than the one this entity embeds
7565
+ * with now, or from an unrecorded one. Asked only for a field that holds a vector. A stale vector
7566
+ * is regenerated on the next save even when its text is unchanged, which is how stored vectors
7567
+ * follow a change of embedding model. The base implementation returns false (only new or changed
7568
+ * text is embedded); server-side sub-classes that embed with the platform's default local model
7569
+ * override it.
7570
+ * @param embeddingModelID - The ID of the AI model that produced the stored vector, or null
7571
+ */
7572
+ async IsEmbeddingStale(embeddingModelID) {
7573
+ return false;
7574
+ }
6495
7575
  /**
6496
7576
  * Specifies if the current object supports the @see EmbedTextLocal method or not - useful to know before calling it for conditional
6497
7577
  * code that has fallbacks as needed. BaseEntity does not implement this method but server-side sub-classes often do, but it is not mandatory for