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

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 (74) 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/baseEngine.d.ts +118 -8
  5. package/dist/generic/baseEngine.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.js +528 -82
  7. package/dist/generic/baseEngine.js.map +1 -1
  8. package/dist/generic/baseEntity.d.ts +63 -4
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +625 -55
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  13. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  14. package/dist/generic/baseRemotableOperation.js +112 -8
  15. package/dist/generic/baseRemotableOperation.js.map +1 -1
  16. package/dist/generic/databaseProviderBase.d.ts +37 -0
  17. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  18. package/dist/generic/databaseProviderBase.js +222 -40
  19. package/dist/generic/databaseProviderBase.js.map +1 -1
  20. package/dist/generic/entityInfo.d.ts +6 -0
  21. package/dist/generic/entityInfo.d.ts.map +1 -1
  22. package/dist/generic/entityInfo.js +18 -12
  23. package/dist/generic/entityInfo.js.map +1 -1
  24. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  25. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  26. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  27. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  28. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  29. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  30. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  31. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  32. package/dist/generic/interfaces.d.ts +50 -1
  33. package/dist/generic/interfaces.d.ts.map +1 -1
  34. package/dist/generic/interfaces.js.map +1 -1
  35. package/dist/generic/listMembershipFilter.d.ts +5 -0
  36. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  37. package/dist/generic/listMembershipFilter.js +23 -0
  38. package/dist/generic/listMembershipFilter.js.map +1 -0
  39. package/dist/generic/localCacheManager.d.ts +48 -5
  40. package/dist/generic/localCacheManager.d.ts.map +1 -1
  41. package/dist/generic/localCacheManager.js +146 -17
  42. package/dist/generic/localCacheManager.js.map +1 -1
  43. package/dist/generic/providerBase.d.ts +155 -2
  44. package/dist/generic/providerBase.d.ts.map +1 -1
  45. package/dist/generic/providerBase.js +362 -124
  46. package/dist/generic/providerBase.js.map +1 -1
  47. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  48. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  49. package/dist/generic/relatedRecordCollection.js +11 -2
  50. package/dist/generic/relatedRecordCollection.js.map +1 -1
  51. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  52. package/dist/generic/remoteOperationDispatch.js +14 -2
  53. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  54. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  55. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  56. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  57. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  58. package/dist/generic/transportError.d.ts +41 -0
  59. package/dist/generic/transportError.d.ts.map +1 -0
  60. package/dist/generic/transportError.js +167 -0
  61. package/dist/generic/transportError.js.map +1 -0
  62. package/dist/generic/util.d.ts +12 -0
  63. package/dist/generic/util.d.ts.map +1 -1
  64. package/dist/generic/util.js +52 -3
  65. package/dist/generic/util.js.map +1 -1
  66. package/dist/index.d.ts +2 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +2 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/views/runView.d.ts +23 -10
  71. package/dist/views/runView.d.ts.map +1 -1
  72. package/dist/views/runView.js +5 -1
  73. package/dist/views/runView.js.map +1 -1
  74. package/package.json +3 -3
@@ -8,16 +8,17 @@ 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';
21
22
  import { CompositeKey, KeyValuePair } from './compositeKey.js';
22
23
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
23
24
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
@@ -130,6 +131,18 @@ let EntityField = class EntityField {
130
131
  SetCreateSuppressed(suppressed) {
131
132
  this._createSuppressed = suppressed;
132
133
  }
134
+ /**
135
+ * Framework-internal: resets all mutable field state in place to match a freshly constructed
136
+ * `EntityField` instance without allocating a new object or invalidating `BaseEntity` field maps.
137
+ */
138
+ ResetForInPlaceHydration() {
139
+ this._oldValue = null;
140
+ this._value = null;
141
+ this._neverSet = true;
142
+ this._notLoaded = false;
143
+ this._writtenAfterNotLoaded = false;
144
+ this._createSuppressed = false;
145
+ }
133
146
  get Name() {
134
147
  return this._entityFieldInfo.Name;
135
148
  }
@@ -1077,6 +1090,7 @@ export class BaseEntity {
1077
1090
  * pay nothing for the feature, not even an empty Map per instance.
1078
1091
  */
1079
1092
  this._companions = null;
1093
+ this._finalizeGeneration = 0;
1080
1094
  /**
1081
1095
  * 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
1096
  *
@@ -2193,25 +2207,102 @@ export class BaseEntity {
2193
2207
  * (and potentially parent entities in the IS-A chain).
2194
2208
  */
2195
2209
  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
2210
  this.discardJSONFieldObjects();
2200
2211
  // Field security: denied values never enter this object (see narrowHydrationSource).
2201
2212
  data = this.narrowHydrationSource(data);
2202
2213
  // Recursively hydrate parent entities first (deepest ancestor resets first).
2203
- // After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
2204
2214
  if (this._parentEntity) {
2205
2215
  this._parentEntity.Hydrate(data);
2206
2216
  }
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);
2217
+ // Populate this entity's fields in place without destroying hydrated EntityField instances
2218
+ // or invalidating _fieldCache / _codeNameCache.
2219
+ this.resetAndApplyOwnedFieldsInPlace(data);
2220
+ }
2221
+ /**
2222
+ * Resets transient record state and applies `fieldData` to this entity's own fields in a
2223
+ * single pass over `this._fields`, reusing existing `EntityField` instances and preserving
2224
+ * `_fieldCache` / `_codeNameCache` when already hydrated.
2225
+ *
2226
+ * Equivalent to `init() + SetMany(ownedFieldsFrom(fieldData), true, true, true) + markFieldsOmittedBySourceAsNotLoaded(fieldData)`
2227
+ * with zero `EntityField` reallocations and zero intermediate `ownedFieldsFrom` object allocation.
2228
+ */
2229
+ resetAndApplyOwnedFieldsInPlace(fieldData) {
2230
+ this._finalizeGeneration++;
2231
+ this._compositeKey = null;
2232
+ this._resultHistory = [];
2233
+ this._recordLoaded = false;
2234
+ this._raw = null;
2235
+ this._rawConverted = null;
2236
+ this.hydrateFieldsIfNeeded();
2237
+ if (!fieldData || typeof fieldData !== 'object' || Array.isArray(fieldData)) {
2238
+ for (let i = 0; i < this._fields.length; i++) {
2239
+ const field = this._fields[i];
2240
+ field.ResetForInPlaceHydration();
2241
+ field.ClearAsNotLoaded();
2242
+ }
2243
+ return;
2244
+ }
2245
+ let normalizedMap = null;
2246
+ let sharedParentMirrors = null;
2247
+ const parentFieldNames = this._parentEntity ? this._parentEntityFieldNames : null;
2248
+ for (let i = 0; i < this._fields.length; i++) {
2249
+ const field = this._fields[i];
2250
+ const match = BaseEntity.findOwnedFieldValueInSource(field, fieldData, () => {
2251
+ if (normalizedMap === null) {
2252
+ normalizedMap = BaseEntity.buildNormalizedHydrationMap(fieldData);
2253
+ }
2254
+ return normalizedMap;
2255
+ });
2256
+ field.ResetForInPlaceHydration();
2257
+ if (match.found) {
2258
+ this.setFieldValue(field, match.val);
2259
+ field.ResetOldValue();
2260
+ if (parentFieldNames) {
2261
+ if (parentFieldNames.has(match.key)) {
2262
+ (sharedParentMirrors ??= {})[match.key] = match.val;
2263
+ }
2264
+ else if (parentFieldNames.has(field.Name)) {
2265
+ (sharedParentMirrors ??= {})[field.Name] = match.val;
2266
+ }
2267
+ }
2268
+ }
2269
+ else {
2270
+ field.ClearAsNotLoaded();
2271
+ }
2272
+ }
2273
+ if (sharedParentMirrors && this._parentEntity) {
2274
+ this._parentEntity.SetMany(sharedParentMirrors, true, true, true);
2275
+ }
2276
+ this.updateSavedStateFromPrimaryKeys();
2277
+ }
2278
+ static findOwnedFieldValueInSource(field, fieldData, getNormalizedMap) {
2279
+ if (BaseEntity.ownValuePresent(fieldData, field.Name)) {
2280
+ return { found: true, key: field.Name, val: fieldData[field.Name] };
2281
+ }
2282
+ if (field.CodeName !== field.Name && BaseEntity.ownValuePresent(fieldData, field.CodeName)) {
2283
+ return { found: true, key: field.CodeName, val: fieldData[field.CodeName] };
2284
+ }
2285
+ const normalizedMap = getNormalizedMap();
2286
+ const hit = normalizedMap.get(field.Name.trim().toLowerCase()) ??
2287
+ (field.CodeName !== field.Name
2288
+ ? normalizedMap.get(field.CodeName.trim().toLowerCase())
2289
+ : undefined);
2290
+ if (hit !== undefined) {
2291
+ return { found: true, key: hit.key, val: hit.val };
2292
+ }
2293
+ return { found: false, key: field.Name, val: undefined };
2294
+ }
2295
+ static buildNormalizedHydrationMap(fieldData) {
2296
+ const normalizedMap = new Map();
2297
+ const keys = Object.keys(fieldData);
2298
+ for (let kIdx = 0; kIdx < keys.length; kIdx++) {
2299
+ const k = keys[kIdx];
2300
+ const v = fieldData[k];
2301
+ if (v !== undefined) {
2302
+ normalizedMap.set(k.trim().toLowerCase(), { key: k, val: v });
2303
+ }
2304
+ }
2305
+ return normalizedMap;
2215
2306
  }
2216
2307
  /**
2217
2308
  * Columns on `data` that this entity actually defines (by field name or CodeName).
@@ -2906,6 +2997,7 @@ export class BaseEntity {
2906
2997
  entity: level,
2907
2998
  wasSaved: level._everSaved,
2908
2999
  wasLoaded: level._recordLoaded,
3000
+ finalizeGeneration: level._finalizeGeneration,
2909
3001
  resultHistory: [...level._resultHistory],
2910
3002
  fields: level.Fields.map(field => ({ field, state: field.GetState() })),
2911
3003
  });
@@ -2925,7 +3017,7 @@ export class BaseEntity {
2925
3017
  * Puts this record back as a snapshot captured it, after the unit of work that finalized it
2926
3018
  * rolled back.
2927
3019
  *
2928
- * A save's `finalizeSave()` rebuilds the record's fields from what its write returned, marks it
3020
+ * A save's `finalizeSave()` updates the record's fields from what its write returned, marks it
2929
3021
  * saved and empties its result history. The database undid the write, so the record gets back
2930
3022
  * its saved and loaded flags, its result history, and each field's value and tracking state. A
2931
3023
  * field edited since then keeps the edit, and is compared with its captured baseline again, since
@@ -2935,14 +3027,16 @@ export class BaseEntity {
2935
3027
  * The history matters to the caller: a save records its failure only when nothing else did,
2936
3028
  * judged by the history's length when it started, which an emptied history would make wrong.
2937
3029
  *
2938
- * A record still holding the captured field objects was never finalized, so nothing about it
2939
- * changed and it is left alone.
3030
+ * A record whose `_finalizeGeneration` (and field object identities) still matches the snapshot
3031
+ * was never finalized, so nothing about it changed and it is left alone.
2940
3032
  */
2941
3033
  restoreSnapshot(snapshot) {
2942
- const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
3034
+ const finalized = this._finalizeGeneration !== snapshot.finalizeGeneration ||
3035
+ snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
2943
3036
  if (!finalized) {
2944
3037
  return;
2945
3038
  }
3039
+ this._finalizeGeneration = snapshot.finalizeGeneration;
2946
3040
  this._everSaved = snapshot.wasSaved;
2947
3041
  this._recordLoaded = snapshot.wasLoaded;
2948
3042
  this._resultHistory = snapshot.resultHistory;
@@ -4949,19 +5043,11 @@ export class BaseEntity {
4949
5043
  // Record Changes payload from this entity's pre-save state, so narrowing the response
4950
5044
  // here costs the audit nothing; it only keeps denied values out of this object.
4951
5045
  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
- }
5046
+ // Reset transient record state and update owned EntityField values in place so
5047
+ // hydrated EntityField instances and _fieldCache/_codeNameCache are preserved across saves.
5048
+ // Deliberately do NOT call discardJSONFieldObjects() here so JSONType references held
5049
+ // across Save() remain attached and live.
5050
+ this.resetAndApplyOwnedFieldsInPlace(fieldData);
4965
5051
  this._everSaved = true; // Mark as saved after successful save
4966
5052
  const result = this.LatestResult;
4967
5053
  if (result)
@@ -5601,6 +5687,398 @@ export class BaseEntity {
5601
5687
  await this.deserializeCompanionsFromData(data);
5602
5688
  return true;
5603
5689
  }
5690
+ /**
5691
+ * Framework-internal batch IS-A parent hydration entry point used by
5692
+ * `TransformSimpleObjectToEntityObject` when the provider supports batched `RunViews`.
5693
+ *
5694
+ * Collapses $O(2N)$ per-record child-discovery and child-load queries into at most
5695
+ * 1 batched discovery query + 1 batched `RunViews` call per hierarchy depth level
5696
+ * (and 0 discovery queries when a `SubtypeSelector` / `EntitySubtypeResolver` resolves all rows).
5697
+ */
5698
+ static { this.DEFAULT_ISA_BATCH_CHUNK_SIZE = 500; }
5699
+ /**
5700
+ * Maximum number of primary keys included in a single `WHERE PK IN (...)` batch query
5701
+ * during IS-A child discovery or hydration.
5702
+ */
5703
+ static get ISABatchChunkSize() {
5704
+ return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
5705
+ }
5706
+ async LoadFromDataBatchAsISAParent(peers, md, contextUser) {
5707
+ const loadedParents = [];
5708
+ for (let i = 0; i < peers.length; i++) {
5709
+ const { entity, data } = peers[i];
5710
+ if (IsMemberOverridden(entity, 'LoadFromData', BaseEntity)) {
5711
+ await entity.LoadFromData(data);
5712
+ continue;
5713
+ }
5714
+ const allPksSet = await entity.hydrateSingleISAParentPeer(data);
5715
+ if (allPksSet) {
5716
+ loadedParents.push(entity);
5717
+ }
5718
+ }
5719
+ if (loadedParents.length > 0) {
5720
+ await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser);
5721
+ }
5722
+ }
5723
+ async hydrateSingleISAParentPeer(rawData) {
5724
+ this.discardJSONFieldObjects();
5725
+ const dataObj = rawData instanceof BaseEntity
5726
+ ? rawData.GetAll()
5727
+ : rawData;
5728
+ const narrowed = this.narrowHydrationSource(dataObj);
5729
+ if (this._parentEntity && narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed)) {
5730
+ this._parentEntity.Hydrate(narrowed);
5731
+ }
5732
+ const isPlain = Boolean(narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed) && !(narrowed instanceof Date));
5733
+ this.SetMany(narrowed, true, false, true);
5734
+ if (isPlain) {
5735
+ this.markFieldsOmittedBySourceAsNotLoaded(narrowed);
5736
+ }
5737
+ let allPksSet = false;
5738
+ if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
5739
+ allPksSet = true;
5740
+ for (const pkey of this.PrimaryKeys) {
5741
+ if (pkey.Value === null || pkey.Value === undefined) {
5742
+ allPksSet = false;
5743
+ break;
5744
+ }
5745
+ }
5746
+ this._recordLoaded = allPksSet;
5747
+ this._everSaved = allPksSet;
5748
+ if (allPksSet) {
5749
+ this.cacheRecordName();
5750
+ }
5751
+ }
5752
+ else {
5753
+ LogError(`BaseEntity.LoadFromDataBatchAsISAParent() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
5754
+ this._recordLoaded = false;
5755
+ this._everSaved = false;
5756
+ }
5757
+ await this.deserializeCompanionsFromData(narrowed);
5758
+ return allPksSet;
5759
+ }
5760
+ static async hydrateISAChildrenForLevel(levelEntities, md, contextUser) {
5761
+ const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
5762
+ if (activeParents.length === 0) {
5763
+ return;
5764
+ }
5765
+ const groupsByEntityId = new Map();
5766
+ for (const p of activeParents) {
5767
+ p._childEntityDiscoveryDone = true;
5768
+ let group = groupsByEntityId.get(p.EntityInfo.ID);
5769
+ if (!group) {
5770
+ group = [];
5771
+ groupsByEntityId.set(p.EntityInfo.ID, group);
5772
+ }
5773
+ group.push(p);
5774
+ }
5775
+ const nextLevelChildren = [];
5776
+ for (const [, groupParents] of groupsByEntityId) {
5777
+ const sampleParent = groupParents[0];
5778
+ if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
5779
+ await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, contextUser);
5780
+ }
5781
+ else {
5782
+ await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren);
5783
+ }
5784
+ }
5785
+ if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
5786
+ await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser);
5787
+ }
5788
+ }
5789
+ static async hydrateOverlappingChildrenForGroup(groupParents, contextUser) {
5790
+ const sampleParent = groupParents[0];
5791
+ const parentInfo = sampleParent.EntityInfo;
5792
+ const entityProvider = sampleParent.ProviderToUse;
5793
+ if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
5794
+ const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
5795
+ const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, contextUser ?? sampleParent._contextCurrentUser);
5796
+ const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5797
+ for (const p of groupParents) {
5798
+ const rawPk = String(p.PrimaryKey.Values());
5799
+ const pkKey = pkField ? pkField.FormatCanonicalValue(rawPk) : CanonicalPrimaryKeyValue(rawPk, false);
5800
+ const matched = batchMap.get(pkKey) ?? [];
5801
+ p._childEntities = matched.map(name => ({ entityName: name }));
5802
+ }
5803
+ }
5804
+ else {
5805
+ await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
5806
+ }
5807
+ }
5808
+ static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren) {
5809
+ const sampleParent = groupParents[0];
5810
+ const parentInfo = sampleParent.EntityInfo;
5811
+ const entityProvider = sampleParent.ProviderToUse;
5812
+ const rvProvider = sampleParent.RunViewProviderToUse;
5813
+ const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
5814
+ // 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
5817
+ 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);
5825
+ }
5826
+ needsDiscovery.push(unlinked);
5827
+ }
5828
+ }
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);
5834
+ }
5835
+ }
5836
+ }
5837
+ static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser) {
5838
+ const hintedByChild = new Map();
5839
+ const hintedNameByParent = new Map();
5840
+ const needsDiscovery = [];
5841
+ for (const p of groupParents) {
5842
+ const hintedName = await p.resolveSubtypeLoadHint();
5843
+ if (hintedName) {
5844
+ const childInfo = md.EntityByName?.(hintedName) ??
5845
+ parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
5846
+ const userForPerm = contextUser ?? p._contextCurrentUser ?? md.CurrentUser;
5847
+ const canRead = childInfo && userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
5848
+ if (childInfo && canRead) {
5849
+ BaseEntity.appendParentToChildGroup(hintedByChild, childInfo.Name, p);
5850
+ hintedNameByParent.set(p, childInfo.Name);
5851
+ }
5852
+ else {
5853
+ needsDiscovery.push(p);
5854
+ }
5855
+ }
5856
+ else {
5857
+ needsDiscovery.push(p);
5858
+ }
5859
+ }
5860
+ return { hintedByChild, hintedNameByParent, needsDiscovery };
5861
+ }
5862
+ static appendParentToChildGroup(grouped, childName, parent) {
5863
+ let bucket = grouped.get(childName);
5864
+ if (!bucket) {
5865
+ bucket = [];
5866
+ grouped.set(childName, bucket);
5867
+ }
5868
+ bucket.push(parent);
5869
+ }
5870
+ static buildChildPkInFilter(pkField, rawPkValues) {
5871
+ const formatted = rawPkValues.map(val => {
5872
+ if (!pkField.NeedsQuotes) {
5873
+ // Emit the digits as written: a bigint beyond 2^53 must not be rewritten by Number().
5874
+ const text = String(val).trim();
5875
+ if (/^-?\d+(\.\d+)?$/.test(text)) {
5876
+ return text;
5877
+ }
5878
+ }
5879
+ return `'${EscapeSQLString(String(val))}'`;
5880
+ });
5881
+ return `${pkField.Name} IN (${formatted.join(', ')})`;
5882
+ }
5883
+ static async batchDiscoverDisjointChildren(parentInfo, parents, entityProvider, rvProvider, contextUser) {
5884
+ const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5885
+ const pkIsUuid = Boolean(pkField?.IsUniqueIdentifier);
5886
+ const grouped = new Map();
5887
+ const pkValues = parents.map(p => String(p.PrimaryKey.Values()));
5888
+ if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
5889
+ const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pkValues, contextUser);
5890
+ for (const p of parents) {
5891
+ const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
5892
+ const firstMatch = batchMap.get(pkKey)?.[0];
5893
+ if (firstMatch) {
5894
+ BaseEntity.appendParentToChildGroup(grouped, firstMatch, p);
5895
+ }
5896
+ }
5897
+ return grouped;
5898
+ }
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
5903
+ if (typeof entityProvider?.FindISAChildEntity === 'function') {
5904
+ for (const p of parents) {
5905
+ const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
5906
+ if (found?.ChildEntityName) {
5907
+ BaseEntity.appendParentToChildGroup(grouped, found.ChildEntityName, p);
5908
+ }
5909
+ }
5910
+ }
5911
+ return grouped;
5912
+ }
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
+ }
5950
+ }
5951
+ }
5952
+ }
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);
5959
+ }
5960
+ }
5961
+ return grouped;
5962
+ }
5963
+ static async batchLoadAndLinkDisjointChildren(parentsByChildName, md, rvProvider, contextUser, outLinkedChildren) {
5964
+ const unlinkedParents = [];
5965
+ if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
5966
+ for (const [childName, parents] of parentsByChildName) {
5967
+ for (const p of parents) {
5968
+ const linked = await p.createAndLinkChildEntity(childName);
5969
+ if (!linked) {
5970
+ unlinkedParents.push(p);
5971
+ }
5972
+ }
5973
+ }
5974
+ return unlinkedParents;
5975
+ }
5976
+ const deniedChildLogged = new Set();
5977
+ const slices = BaseEntity.buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
5978
+ if (slices.length === 0) {
5979
+ return unlinkedParents;
5980
+ }
5981
+ const rv = new RunView(rvProvider);
5982
+ const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
5983
+ for (let sIdx = 0; sIdx < slices.length; sIdx++) {
5984
+ await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged);
5985
+ }
5986
+ return unlinkedParents;
5987
+ }
5988
+ static buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged) {
5989
+ const chunkSize = BaseEntity.ISABatchChunkSize;
5990
+ const slices = [];
5991
+ for (const [childName, parents] of parentsByChildName) {
5992
+ const childInfo = md.EntityByName?.(childName) ??
5993
+ parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
5994
+ const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5995
+ if (!childInfo || !pkField) {
5996
+ for (const p of parents)
5997
+ unlinkedParents.push(p);
5998
+ continue;
5999
+ }
6000
+ const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
6001
+ const canRead = userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
6002
+ if (!canRead) {
6003
+ if (!deniedChildLogged.has(childInfo.Name)) {
6004
+ deniedChildLogged.add(childInfo.Name);
6005
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${childInfo.Name}'; matching parent records are returned without their subtype`, 'IS-A');
6006
+ }
6007
+ for (const p of parents)
6008
+ unlinkedParents.push(p);
6009
+ continue;
6010
+ }
6011
+ for (let start = 0; start < parents.length; start += chunkSize) {
6012
+ const sliceParents = parents.slice(start, start + chunkSize);
6013
+ const pks = sliceParents.map(p => String(p.PrimaryKey.Values()));
6014
+ slices.push({
6015
+ childName: childInfo.Name,
6016
+ pkFieldName: pkField.Name,
6017
+ pkIsUuid: Boolean(pkField.IsUniqueIdentifier),
6018
+ parents: sliceParents,
6019
+ param: {
6020
+ EntityName: childInfo.Name,
6021
+ ExtraFilter: BaseEntity.buildChildPkInFilter(pkField, pks),
6022
+ ResultType: 'simple',
6023
+ // Internal hydration read: see batchDiscoverDisjointChildren — keep these
6024
+ // per-batch IN-list reads out of the shared RunView cache.
6025
+ BypassCache: true,
6026
+ },
6027
+ });
6028
+ }
6029
+ }
6030
+ return slices;
6031
+ }
6032
+ static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged) {
6033
+ if (!res?.Success || !Array.isArray(res.Results)) {
6034
+ // Not silent: the per-row path surfaced a failed child load; here the parents stay
6035
+ // unlinked (plain parent records) and the cause goes to the log.
6036
+ 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');
6037
+ for (const p of slice.parents)
6038
+ unlinkedParents.push(p);
6039
+ return;
6040
+ }
6041
+ const rowByPk = new Map();
6042
+ for (const row of res.Results) {
6043
+ const pkVal = row[slice.pkFieldName];
6044
+ if (pkVal != null) {
6045
+ rowByPk.set(CanonicalPrimaryKeyValue(String(pkVal), slice.pkIsUuid), row);
6046
+ }
6047
+ }
6048
+ for (const p of slice.parents) {
6049
+ const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), slice.pkIsUuid);
6050
+ const childRow = rowByPk.get(pkKey);
6051
+ if (!childRow) {
6052
+ unlinkedParents.push(p);
6053
+ continue;
6054
+ }
6055
+ const childEntity = await p.createChildEntityObject(slice.childName);
6056
+ if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
6057
+ // The per-row path threw here (InnerLoad → CheckPermissions(Read, true)). The batch
6058
+ // path degrades to a plain parent record instead, and says so once per slice.
6059
+ if (!deniedChildLogged.has(slice.childName)) {
6060
+ deniedChildLogged.add(slice.childName);
6061
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${slice.childName}'; matching parent records are returned without their subtype`, 'IS-A');
6062
+ }
6063
+ unlinkedParents.push(p);
6064
+ continue;
6065
+ }
6066
+ await p.linkHydratedChildFromBatchRow(childEntity, childRow);
6067
+ outLinkedChildren.push(childEntity);
6068
+ }
6069
+ }
6070
+ async linkHydratedChildFromBatchRow(childEntity, childRow) {
6071
+ this.replaceChildParentChain(childEntity);
6072
+ this._childEntity = childEntity;
6073
+ childEntity.discardJSONFieldObjects();
6074
+ const narrowed = childEntity.narrowHydrationSource(childRow);
6075
+ childEntity.resetAndApplyOwnedFieldsInPlace(narrowed);
6076
+ childEntity._recordLoaded = true;
6077
+ childEntity._everSaved = true;
6078
+ childEntity._compositeKey = this.PrimaryKey;
6079
+ childEntity.cacheRecordName();
6080
+ await childEntity.deserializeCompanionsFromData(narrowed);
6081
+ }
5604
6082
  /**
5605
6083
  * Extracts and applies a companion payload carried inside a plain data object, if present.
5606
6084
  *
@@ -6094,20 +6572,53 @@ export class BaseEntity {
6094
6572
  if (childEntities.length === 0) {
6095
6573
  return { HasChildren: false, ChildEntityName: '' };
6096
6574
  }
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
6575
  const pkValue = this.PrimaryKey.Values();
6101
- for (const childEntity of childEntities) {
6576
+ // Tier 1: Single UNION ALL SQL query on this entity's bound DatabaseProviderBase
6577
+ // (executes inside any active transaction on this.ProviderToUse in 1 round-trip instead of K).
6578
+ const entityProvider = this.ProviderToUse;
6579
+ if (typeof entityProvider?.FindISAChildEntity === 'function') {
6580
+ const found = await entityProvider.FindISAChildEntity(this.EntityInfo, String(pkValue), this._contextCurrentUser);
6581
+ if (found?.ChildEntityName) {
6582
+ return { HasChildren: true, ChildEntityName: found.ChildEntityName };
6583
+ }
6584
+ return { HasChildren: false, ChildEntityName: '' };
6585
+ }
6586
+ // Tier 2: Batched RunViews on this instance's RunView provider (GraphQLDataProvider or test mocks)
6587
+ const rvProvider = this.RunViewProviderToUse;
6588
+ const rv = new RunView(rvProvider);
6589
+ const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
6590
+ if (validChildren.length === 0) {
6591
+ return { HasChildren: false, ChildEntityName: '' };
6592
+ }
6593
+ if (rvProvider && typeof rvProvider.RunViews === 'function') {
6594
+ const viewParams = validChildren.map(childEntity => {
6595
+ const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6596
+ return {
6597
+ EntityName: childEntity.Name,
6598
+ ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
6599
+ ResultType: 'simple',
6600
+ Fields: [pkField.Name],
6601
+ MaxRows: 1,
6602
+ };
6603
+ });
6604
+ const results = await rv.RunViews(viewParams, this._contextCurrentUser);
6605
+ for (let i = 0; i < validChildren.length; i++) {
6606
+ const result = results[i];
6607
+ if (result?.Success && result.Results && result.Results.length > 0) {
6608
+ return { HasChildren: true, ChildEntityName: validChildren[i].Name };
6609
+ }
6610
+ }
6611
+ return { HasChildren: false, ChildEntityName: '' };
6612
+ }
6613
+ for (const childEntity of validChildren) {
6102
6614
  const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6103
- if (!pkField)
6104
- continue;
6105
6615
  const result = await rv.RunView({
6106
6616
  EntityName: childEntity.Name,
6107
- ExtraFilter: `${pkField.Name} = '${pkValue}'`,
6617
+ ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
6108
6618
  ResultType: 'simple',
6109
6619
  Fields: [pkField.Name],
6110
- MaxRows: 1
6620
+ MaxRows: 1,
6621
+ SkipTotalRowCount: true
6111
6622
  }, this._contextCurrentUser);
6112
6623
  if (result && result.Success && result.Results && result.Results.length > 0) {
6113
6624
  return { HasChildren: true, ChildEntityName: childEntity.Name };
@@ -6169,18 +6680,50 @@ export class BaseEntity {
6169
6680
  if (childEntities.length === 0) {
6170
6681
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
6171
6682
  }
6172
- const rv = new RunView((provider ?? BaseEntity.Provider));
6173
6683
  const pkValue = primaryKey.Values();
6174
- for (const child of childEntities) {
6684
+ const candidateEntityProvider = (provider ?? BaseEntity.Provider);
6685
+ if (typeof candidateEntityProvider?.FindISAChildEntity === 'function') {
6686
+ const found = await candidateEntityProvider.FindISAChildEntity(entityInfo, String(pkValue), contextUser);
6687
+ if (found?.ChildEntityName) {
6688
+ const matchedChild = childEntities.find(c => sameEntityName(c.Name, found.ChildEntityName));
6689
+ if (matchedChild) {
6690
+ return BaseEntity.resolveLeafEntityRecursive(matchedChild, primaryKey, contextUser, provider);
6691
+ }
6692
+ }
6693
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
6694
+ }
6695
+ const rvProvider = (provider ?? BaseEntity.Provider);
6696
+ const rv = new RunView(rvProvider);
6697
+ const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
6698
+ if (rvProvider && typeof rvProvider.RunViews === 'function' && validChildren.length > 0) {
6699
+ const viewParams = validChildren.map(child => {
6700
+ const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6701
+ return {
6702
+ EntityName: child.Name,
6703
+ ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
6704
+ ResultType: 'simple',
6705
+ Fields: [childPK.Name],
6706
+ MaxRows: 1,
6707
+ };
6708
+ });
6709
+ const results = await rv.RunViews(viewParams, contextUser);
6710
+ for (let i = 0; i < validChildren.length; i++) {
6711
+ const result = results[i];
6712
+ if (result?.Success && result.Results?.length > 0) {
6713
+ return BaseEntity.resolveLeafEntityRecursive(validChildren[i], primaryKey, contextUser, provider);
6714
+ }
6715
+ }
6716
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
6717
+ }
6718
+ for (const child of validChildren) {
6175
6719
  const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6176
- if (!childPK)
6177
- continue;
6178
6720
  const result = await rv.RunView({
6179
6721
  EntityName: child.Name,
6180
- ExtraFilter: `${childPK.Name} = '${pkValue}'`,
6722
+ ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
6181
6723
  ResultType: 'simple',
6182
6724
  Fields: [childPK.Name],
6183
- MaxRows: 1
6725
+ MaxRows: 1,
6726
+ SkipTotalRowCount: true
6184
6727
  }, contextUser);
6185
6728
  if (result?.Success && result.Results?.length > 0) {
6186
6729
  // Found a child — recurse to see if there's an even more specific leaf
@@ -6227,10 +6770,11 @@ export class BaseEntity {
6227
6770
  return;
6228
6771
  const viewParams = validSiblings.map(sibling => ({
6229
6772
  EntityName: sibling.Name,
6230
- ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
6773
+ ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${EscapeSQLString(String(pkValue))}'`, // first-pk-ok: IS-A shared key
6231
6774
  ResultType: 'simple',
6232
6775
  Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
6233
- MaxRows: 1
6776
+ MaxRows: 1,
6777
+ SkipTotalRowCount: true
6234
6778
  }));
6235
6779
  const results = await rv.RunViews(viewParams, this._contextCurrentUser);
6236
6780
  for (let i = 0; i < results.length; i++) {
@@ -6434,7 +6978,8 @@ export class BaseEntity {
6434
6978
  }
6435
6979
  /**
6436
6980
  * 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.
6981
+ * Only generates embeddings for new records, when the source field has changed, or when
6982
+ * {@link IsEmbeddingStale} reports the stored vector came from another model.
6438
6983
  * Stores the vector embedding, the model ID used to generate it and, when the entity has one, the
6439
6984
  * vector's binary companion.
6440
6985
  *
@@ -6452,33 +6997,46 @@ export class BaseEntity {
6452
6997
  * @returns Promise that resolves to true if embedding was generated successfully, false otherwise
6453
6998
  */
6454
6999
  async GenerateEmbedding(field, vectorField, modelField, binaryVectorField) {
7000
+ const clearVector = () => {
7001
+ vectorField.Value = null;
7002
+ if (binaryVectorField)
7003
+ binaryVectorField.Value = null;
7004
+ if (modelField)
7005
+ modelField.Value = null;
7006
+ };
7007
+ // After an edit to a saved record, its stored vector describes the old text, so a failed
7008
+ // re-embed clears it rather than leaving it searchable.
7009
+ const edited = this.IsSaved && field.Dirty;
6455
7010
  try {
6456
7011
  if (this._skipEmbeddings)
6457
7012
  return true;
6458
- if (!this.IsSaved || field.Dirty) {
7013
+ const stale = vectorField.Value != null && await this.IsEmbeddingStale(modelField?.Value ?? null);
7014
+ if (!this.IsSaved || field.Dirty || stale) {
6459
7015
  if (field.Value?.trim().length > 0) {
6460
7016
  // recalc vector
6461
7017
  const e = await this.EmbedTextLocal(field.Value);
6462
- if (e && e.vector) {
7018
+ // An empty vector is a failed embed (the BaseEmbeddings convention); don't persist it.
7019
+ if (e?.vector?.length) {
6463
7020
  vectorField.Value = JSON.stringify(e.vector);
6464
7021
  if (binaryVectorField)
6465
7022
  binaryVectorField.Value = Float32VectorToBase64(e.vector);
6466
7023
  if (modelField)
6467
7024
  modelField.Value = e.modelID;
6468
7025
  }
7026
+ else if (edited) {
7027
+ clearVector();
7028
+ }
6469
7029
  }
6470
7030
  else {
6471
- vectorField.Value = null;
6472
- if (binaryVectorField)
6473
- binaryVectorField.Value = null;
6474
- if (modelField)
6475
- modelField.Value = null;
7031
+ clearVector();
6476
7032
  }
6477
7033
  }
6478
7034
  return true;
6479
7035
  }
6480
7036
  catch (e) {
6481
7037
  console.error("Error generating embedding:", e);
7038
+ if (edited)
7039
+ clearVector();
6482
7040
  return false;
6483
7041
  }
6484
7042
  }
@@ -6492,6 +7050,18 @@ export class BaseEntity {
6492
7050
  async EmbedTextLocal(textToEmbed) {
6493
7051
  throw new Error("EmbedTextLocal not implemented in BaseEntity, sub-classes must implement this functionality to use it");
6494
7052
  }
7053
+ /**
7054
+ * Whether a stored vector is stale: it came from a model other than the one this entity embeds
7055
+ * with now, or from an unrecorded one. Asked only for a field that holds a vector. A stale vector
7056
+ * is regenerated on the next save even when its text is unchanged, which is how stored vectors
7057
+ * follow a change of embedding model. The base implementation returns false (only new or changed
7058
+ * text is embedded); server-side sub-classes that embed with the platform's default local model
7059
+ * override it.
7060
+ * @param embeddingModelID - The ID of the AI model that produced the stored vector, or null
7061
+ */
7062
+ async IsEmbeddingStale(embeddingModelID) {
7063
+ return false;
7064
+ }
6495
7065
  /**
6496
7066
  * Specifies if the current object supports the @see EmbedTextLocal method or not - useful to know before calling it for conditional
6497
7067
  * 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