@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.5

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 (73) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +7 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +22 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.d.ts +29 -5
  8. package/dist/generic/baseEngine.d.ts.map +1 -1
  9. package/dist/generic/baseEngine.js +117 -11
  10. package/dist/generic/baseEngine.js.map +1 -1
  11. package/dist/generic/baseEntity.d.ts +45 -1
  12. package/dist/generic/baseEntity.d.ts.map +1 -1
  13. package/dist/generic/baseEntity.js +308 -187
  14. package/dist/generic/baseEntity.js.map +1 -1
  15. package/dist/generic/baseInfo.d.ts +31 -3
  16. package/dist/generic/baseInfo.d.ts.map +1 -1
  17. package/dist/generic/baseInfo.js +138 -40
  18. package/dist/generic/baseInfo.js.map +1 -1
  19. package/dist/generic/databaseProviderBase.d.ts +37 -8
  20. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  21. package/dist/generic/databaseProviderBase.js +162 -65
  22. package/dist/generic/databaseProviderBase.js.map +1 -1
  23. package/dist/generic/entityConfiguration.d.ts +8 -2
  24. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  25. package/dist/generic/entityConfiguration.js +9 -0
  26. package/dist/generic/entityConfiguration.js.map +1 -1
  27. package/dist/generic/entityInfo.d.ts +78 -3
  28. package/dist/generic/entityInfo.d.ts.map +1 -1
  29. package/dist/generic/entityInfo.js +348 -55
  30. package/dist/generic/entityInfo.js.map +1 -1
  31. package/dist/generic/infoProviderResolution.d.ts +16 -0
  32. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  33. package/dist/generic/infoProviderResolution.js +17 -0
  34. package/dist/generic/infoProviderResolution.js.map +1 -0
  35. package/dist/generic/interfaces.d.ts +25 -1
  36. package/dist/generic/interfaces.d.ts.map +1 -1
  37. package/dist/generic/interfaces.js.map +1 -1
  38. package/dist/generic/localCacheManager.d.ts +238 -40
  39. package/dist/generic/localCacheManager.d.ts.map +1 -1
  40. package/dist/generic/localCacheManager.js +466 -71
  41. package/dist/generic/localCacheManager.js.map +1 -1
  42. package/dist/generic/mjapiUrl.d.ts +21 -0
  43. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  44. package/dist/generic/mjapiUrl.js +25 -0
  45. package/dist/generic/mjapiUrl.js.map +1 -0
  46. package/dist/generic/providerBase.d.ts +118 -3
  47. package/dist/generic/providerBase.d.ts.map +1 -1
  48. package/dist/generic/providerBase.js +398 -100
  49. package/dist/generic/providerBase.js.map +1 -1
  50. package/dist/generic/queryInfo.d.ts +10 -9
  51. package/dist/generic/queryInfo.d.ts.map +1 -1
  52. package/dist/generic/queryInfo.js +60 -33
  53. package/dist/generic/queryInfo.js.map +1 -1
  54. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  55. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  56. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  57. package/dist/generic/securityInfo.d.ts +6 -6
  58. package/dist/generic/securityInfo.d.ts.map +1 -1
  59. package/dist/generic/securityInfo.js +10 -10
  60. package/dist/generic/securityInfo.js.map +1 -1
  61. package/dist/generic/util.d.ts.map +1 -1
  62. package/dist/generic/util.js +3 -0
  63. package/dist/generic/util.js.map +1 -1
  64. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  65. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  66. package/dist/generic/wellKnownUserSource.js +18 -9
  67. package/dist/generic/wellKnownUserSource.js.map +1 -1
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -0
  71. package/dist/index.js.map +1 -1
  72. package/package.json +3 -3
  73. package/readme.md +29 -4
@@ -92,6 +92,18 @@ let EntityField = class EntityField {
92
92
  */
93
93
  MarkNotLoaded() {
94
94
  this._notLoaded = true;
95
+ this._writtenAfterNotLoaded = false;
96
+ }
97
+ /**
98
+ * Framework-internal, for the hydration paths: the source omitted this field, so it is
99
+ * not-loaded AND holds nothing. Unlike {@link MarkNotLoaded}, which only flags the field and keeps
100
+ * its in-memory value, this drops a default or a previous record's value that would otherwise
101
+ * read back through Get() as if it had been loaded.
102
+ */
103
+ ClearAsNotLoaded() {
104
+ this._value = null;
105
+ this._oldValue = null;
106
+ this.MarkNotLoaded();
95
107
  }
96
108
  /**
97
109
  * True when field-level security bars this user from supplying the field's value on INSERT,
@@ -198,6 +210,9 @@ let EntityField = class EntityField {
198
210
  // this field.
199
211
  const wasNotLoaded = this._notLoaded;
200
212
  this._notLoaded = false;
213
+ if (wasNotLoaded) {
214
+ this._writtenAfterNotLoaded = true;
215
+ }
201
216
  // in the below, we set the OldValue, but only if (a) we have never set the value before, or (b) the value or the old value is not null - which means that we are in a record setup scenario
202
217
  if (this._neverSet && !wasNotLoaded &&
203
218
  (value !== null || this._oldValue !== null)) {
@@ -215,6 +230,12 @@ let EntityField = class EntityField {
215
230
  ResetNeverSetFlag() {
216
231
  this._neverSet = true;
217
232
  }
233
+ /**
234
+ * True when the field has had no explicit write since construction or since {@link ResetNeverSetFlag}.
235
+ */
236
+ get NeverSet() {
237
+ return this._neverSet;
238
+ }
218
239
  /**
219
240
  * Returns true if the field is dirty, false otherwise. A field is considered dirty if the value is different from the old value. If the field is read only, it is never dirty.
220
241
  * A {@link NotLoaded} field is never dirty either — its in-memory state is a construction
@@ -223,9 +244,11 @@ let EntityField = class EntityField {
223
244
  get Dirty() {
224
245
  if (this.ReadOnly || this._notLoaded)
225
246
  return false;
247
+ else if (this._writtenAfterNotLoaded)
248
+ return true; // a write onto a value the record never loaded must reach the save, even null
226
249
  else {
227
250
  const oldNull = this._oldValue === null || this.OldValue === undefined || Number.isNaN(this.OldValue); // check for NaN because sometimes we have old values that are NaN and we need to account for that
228
- const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.OldValue);
251
+ const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.Value);
229
252
  if (oldNull && curNull)
230
253
  return false; // BOTH are null or undefined, not dirty
231
254
  else {
@@ -239,16 +262,16 @@ let EntityField = class EntityField {
239
262
  // and sometimes the new values are date objects, convert to UTC timestamp for comparison
240
263
  newCompare = this.Value.getTime();
241
264
  }
265
+ const tsType = this._entityFieldInfo.TSType;
242
266
  // Special handling for bit/Boolean types - treat truthy values as equivalent
243
- if (this._entityFieldInfo.TSType === EntityFieldTSType.Boolean ||
244
- this._entityFieldInfo.Type.toLowerCase() === 'bit') {
267
+ if (tsType === EntityFieldTSType.Boolean) {
245
268
  // Convert both values to boolean for comparison
246
269
  const oldBool = this.convertToBoolean(oldCompare);
247
270
  const newBool = this.convertToBoolean(newCompare);
248
271
  return oldBool !== newBool;
249
272
  }
250
273
  // Special handling for numeric types - treat numeric strings that convert to same value as equivalent
251
- if (this._entityFieldInfo.TSType === EntityFieldTSType.Number || this.isNumericType(this._entityFieldInfo.Type)) {
274
+ if (tsType === EntityFieldTSType.Number) {
252
275
  const oldNum = this.convertToNumber(oldCompare);
253
276
  const newNum = this.convertToNumber(newCompare);
254
277
  // Handle NaN cases - if both are NaN, they're equivalent
@@ -262,7 +285,7 @@ let EntityField = class EntityField {
262
285
  return oldNum !== newNum;
263
286
  }
264
287
  // for string types where the comparisons are not both strings
265
- if (this._entityFieldInfo.TSType === EntityFieldTSType.String) {
288
+ if (tsType === EntityFieldTSType.String) {
266
289
  if (typeof oldCompare === 'object') {
267
290
  // need to convert the object to a string for comparison
268
291
  oldCompare = JSON.stringify(oldCompare);
@@ -370,7 +393,7 @@ let EntityField = class EntityField {
370
393
  }
371
394
  else {
372
395
  // we do have a default value, but our current value is null. If we are in an EXISTING record, this is an error, check the OldValue to determine this
373
- if (this._oldValue !== null && this._oldValue !== undefined) {
396
+ if ((this._oldValue !== null && this._oldValue !== undefined) || this._writtenAfterNotLoaded) {
374
397
  result.Success = false;
375
398
  result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
376
399
  }
@@ -465,9 +488,16 @@ let EntityField = class EntityField {
465
488
  result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be longer than ${ef.MaxByteLength} bytes. Current value is ${byteLength} bytes`, this.Value));
466
489
  }
467
490
  }
468
- constructor(fieldInfo, Value) {
491
+ constructor(fieldInfo, Value, skipDefaultValue = false) {
469
492
  this._neverSet = true;
470
493
  this._notLoaded = false;
494
+ /**
495
+ * True once the field is explicitly set after being not-loaded: a write onto a value the record
496
+ * never loaded. The field is then dirty whatever it was set to — even null, which equals its
497
+ * cleared old value — so the write reaches the save, and Validate treats it as an existing
498
+ * record's value. Cleared when the old value is reset (load, save) or the field is not-loaded again.
499
+ */
500
+ this._writtenAfterNotLoaded = false;
471
501
  this._createSuppressed = false;
472
502
  // NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
473
503
  // column is physically part of the entity and the instance must exist. Active-status is
@@ -477,94 +507,21 @@ let EntityField = class EntityField {
477
507
  if (Value) {
478
508
  this.Value = Value;
479
509
  }
480
- else if (fieldInfo.DefaultValue) {
481
- if (fieldInfo.TSType === EntityFieldTSType.Boolean) {
482
- // special handling for booleans as we don't want a string passed into a boolean field, we want a true boolean
483
- if (typeof fieldInfo.DefaultValue === "string" && fieldInfo.DefaultValue.trim() === "1" || fieldInfo.DefaultValue.trim().toLowerCase() === "true")
484
- this.Value = true;
485
- else
486
- this.Value = false;
487
- }
488
- else if (fieldInfo.TSType === EntityFieldTSType.Number) {
489
- // special handling for numbers as we don't want a string passed into a value for a numeric field
490
- if (!isNaN(Number(fieldInfo.DefaultValue))) {
491
- this.Value = Number(fieldInfo.DefaultValue);
492
- }
493
- else if (fieldInfo.DefaultValue.trim().toLowerCase() === "null") {
494
- this.Value = null;
495
- }
496
- }
497
- else if (fieldInfo.IsUniqueIdentifier) {
498
- // special handling for GUIDs (SQL Server `uniqueidentifier` / PostgreSQL `uuid`),
499
- // we don't want to populate anything here because the server always sets the value, leave blank
500
- this.Value = null;
501
- }
502
- else if (fieldInfo.TSType === EntityFieldTSType.Date) {
503
- if (fieldInfo.DefaultValue.trim().length > 0) {
504
- // special handling for dates as we don't want to use getdate() type defaults as is, we want to convert them to a JS Date object
505
- try {
506
- // attempt to convert the default value to a date
507
- const temp = new Date(fieldInfo.DefaultValue);
508
- // if we get here, that means it worked, but we could have an invalid date, so check for that
509
- if (isNaN(temp.getTime())) {
510
- // this is an invalid date, so throw an error
511
- throw new Error(); // blank error because we just end up going to the catch block anyway and in there we have logic to handle this properly
512
- }
513
- else
514
- this.Value = temp;
515
- }
516
- catch (e) {
517
- // if we get here, that means the default value is not a valid date, so we need to check to see if it's a SQL current date function
518
- if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(fieldInfo.DefaultValue)) {
519
- // we have a SQL current date function default, leave the field alone if its a special date field as the server (i.e. database) will handle
520
- //setting the value, otherwise set the value to the current date
521
- if (fieldInfo.IsSpecialDateField) {
522
- this.Value = null;
523
- }
524
- else {
525
- this.Value = new Date();
526
- }
527
- }
528
- else {
529
- // we have a default value that is not a valid date and not a getdate() type default, so we need to throw an error
530
- throw new Error(`Invalid default value for ${fieldInfo.Entity}.${fieldInfo.Name} of ${fieldInfo.DefaultValue}. Default values for date fields must be a valid date or a getdate() type default`);
531
- }
532
- }
533
- }
510
+ else if (!skipDefaultValue && fieldInfo.DefaultValue) {
511
+ const parsed = typeof fieldInfo.GetParsedDefaultValue === 'function'
512
+ ? fieldInfo.GetParsedDefaultValue()
513
+ : EntityFieldInfo.ParseDefaultValue(fieldInfo);
514
+ if (parsed.isCurrentDateFunction) {
515
+ this.Value = new Date();
534
516
  }
535
- else {
536
- // For strings: strip PostgreSQL's typed-literal wrapper.
537
- //
538
- // PG's pg_get_expr() (used by vwSQLColumnsAndEntityFields →
539
- // EntityField.DefaultValue) renders a string default like:
540
- // 'Single'::character varying
541
- // 'pending'::text
542
- // SQL Server stores the same default as just `'Single'` or
543
- // `'pending'`. If we set the field value to the raw PG form,
544
- // the value is `'Single'::character varying` (27 chars), and
545
- // a MaxLength=20 constraint immediately fails validation —
546
- // even though the actual content is `Single` (6 chars).
547
- //
548
- // Unwrap a leading single-quoted string followed by a `::type`
549
- // suffix. Function-call defaults (`nextval('...')`,
550
- // `now() AT TIME ZONE 'UTC'`) deliberately don't match this
551
- // shape and are left untouched — the database evaluates them
552
- // server-side at INSERT time.
553
- const dv = fieldInfo.DefaultValue.trim();
554
- const pgTypedLiteral = /^'((?:[^']|'')*)'::[A-Za-z][\w "()\[\],]*$/;
555
- const m = dv.match(pgTypedLiteral);
556
- if (m) {
557
- // Replace the SQL-escaped doubled quotes back to a single quote.
558
- this.Value = m[1].replace(/''/g, "'");
559
- }
560
- else {
561
- this.Value = fieldInfo.DefaultValue;
562
- }
517
+ else if (parsed.hasValue) {
518
+ this.Value = parsed.value instanceof Date ? new Date(parsed.value.getTime()) : parsed.value;
563
519
  }
564
520
  this._neverSet = true; // set this back to true because we are setting the default value and we want to be able to set this ONCE from BaseEntity when we load
565
521
  }
566
522
  else {
567
- this.Value = null; // we need to set the value to null instead of being undefined as the value is defined, it is NULL
523
+ this._value = null; // we need to set the value to null instead of being undefined as the value is defined, it is NULL
524
+ this._oldValue = null;
568
525
  this._neverSet = true; // set this back to true because we are setting the value to null;
569
526
  }
570
527
  }
@@ -573,6 +530,23 @@ let EntityField = class EntityField {
573
530
  */
574
531
  ResetOldValue() {
575
532
  this._oldValue = this.Value;
533
+ this._writtenAfterNotLoaded = false;
534
+ }
535
+ /**
536
+ * Framework-internal, for {@link BaseEntity.Revert}: puts the field back to its last loaded or
537
+ * saved state. A not-loaded field has nothing to revert and stays not-loaded; a field written
538
+ * after being not-loaded goes back to not-loaded, so the revert neither turns an unloaded field
539
+ * into "loaded" data a later update would write, nor leaves the write's dirty flag behind.
540
+ */
541
+ RevertToOldValue() {
542
+ if (this._notLoaded) {
543
+ return;
544
+ }
545
+ if (this._writtenAfterNotLoaded) {
546
+ this.ClearAsNotLoaded();
547
+ return;
548
+ }
549
+ this.Value = this.OldValue;
576
550
  }
577
551
  /**
578
552
  * Restores the dirty-tracking baseline to a previously captured value.
@@ -585,7 +559,13 @@ let EntityField = class EntityField {
585
559
  * back can put them back with {@link RestoreState}.
586
560
  */
587
561
  GetState() {
588
- return { Value: this._value, OldValue: this._oldValue, NotLoaded: this._notLoaded, NeverSet: this._neverSet };
562
+ return {
563
+ Value: this._value,
564
+ OldValue: this._oldValue,
565
+ NotLoaded: this._notLoaded,
566
+ NeverSet: this._neverSet,
567
+ WrittenAfterNotLoaded: this._writtenAfterNotLoaded,
568
+ };
589
569
  }
590
570
  /**
591
571
  * Framework-internal: puts back a state {@link GetState} captured. It writes the state as it was,
@@ -596,6 +576,7 @@ let EntityField = class EntityField {
596
576
  this._oldValue = state.OldValue;
597
577
  this._notLoaded = state.NotLoaded;
598
578
  this._neverSet = state.NeverSet;
579
+ this._writtenAfterNotLoaded = state.WrittenAfterNotLoaded ?? false;
599
580
  }
600
581
  /**
601
582
  * @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
@@ -620,7 +601,7 @@ let EntityField = class EntityField {
620
601
  };
621
602
  EntityField = EntityField_1 = __decorate([
622
603
  OptionalKeyedSpecialization(),
623
- __metadata("design:paramtypes", [EntityFieldInfo, Object])
604
+ __metadata("design:paramtypes", [EntityFieldInfo, Object, Boolean])
624
605
  ], EntityField);
625
606
  export { EntityField };
626
607
  /**
@@ -905,6 +886,12 @@ export class BaseEntity {
905
886
  * commits atomically.
906
887
  */
907
888
  this._transactionGroup = null;
889
+ /**
890
+ * RxJS `Subject` that fires `BaseEntityEvent`s (save, delete, field-change,
891
+ * remote-invalidate). Exposed publicly via `Event$` so engines and UI components can react
892
+ * to entity lifecycle events.
893
+ */
894
+ this._eventSubject = null;
908
895
  /**
909
896
  * Bounded ring of `BaseEntityResult` objects from each Save/Delete attempt. The most
910
897
  * recent entry is exposed via `LatestResult` for error inspection after a failure.
@@ -1112,7 +1099,7 @@ export class BaseEntity {
1112
1099
  * however it is possible for the string in the map to be any unique key relative to the object so you could have vectors
1113
1100
  * that embed multiple fields if desired.
1114
1101
  */
1115
- this._vectors = new Map();
1102
+ this._vectors = null;
1116
1103
  /**
1117
1104
  * When true, GenerateEmbedding() returns immediately without computing vectors.
1118
1105
  * Set this before calling Save() in batch/sync contexts where embedding computation
@@ -1120,7 +1107,6 @@ export class BaseEntity {
1120
1107
  * per-process is expensive and vectors can be computed later by the API server).
1121
1108
  */
1122
1109
  this._skipEmbeddings = false;
1123
- this._eventSubject = new Subject();
1124
1110
  this._entityInfo = Entity;
1125
1111
  EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
1126
1112
  this._provider = Provider;
@@ -1662,7 +1648,24 @@ export class BaseEntity {
1662
1648
  static errorText(e) {
1663
1649
  return e instanceof Error ? e.message : String(e);
1664
1650
  }
1665
- static { this._subtypeLookupCache = new Map(); }
1651
+ /**
1652
+ * The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup. A memo
1653
+ * shared across providers would hand provider B a row loaded through provider A (another connection,
1654
+ * another user's context) or a `null` miss recorded against A's database. Keyed weakly so a
1655
+ * request-scoped provider's memo is collected with it.
1656
+ */
1657
+ static { this._subtypeLookupCache = new WeakMap(); }
1658
+ /** Partition key for lookups made by an entity that has no provider at all. */
1659
+ static { this._noProviderSubtypeKey = {}; }
1660
+ static subtypeLookupCacheFor(provider) {
1661
+ const partitionKey = provider ?? BaseEntity._noProviderSubtypeKey;
1662
+ let cache = BaseEntity._subtypeLookupCache.get(partitionKey);
1663
+ if (!cache) {
1664
+ cache = new Map();
1665
+ BaseEntity._subtypeLookupCache.set(partitionKey, cache);
1666
+ }
1667
+ return cache;
1668
+ }
1666
1669
  /** Per entity (lowercased name): the resolver asked for load hints, decided once per registration. */
1667
1670
  static { this._subtypeLoadHintResolvers = new Map(); }
1668
1671
  /** Indexes over engine-cached arrays, keyed by the live array itself; see {@link findEngineCachedRow}. */
@@ -1674,7 +1677,7 @@ export class BaseEntity {
1674
1677
  * reported again.
1675
1678
  */
1676
1679
  static ClearSubtypeLookupCache() {
1677
- BaseEntity._subtypeLookupCache.clear();
1680
+ BaseEntity._subtypeLookupCache = new WeakMap();
1678
1681
  BaseEntity._subtypeLoadHintResolvers.clear();
1679
1682
  BaseEntity._engineCachedRowIndexes = new WeakMap();
1680
1683
  BaseEntity._loggedSubtypeLoadHintMessages.clear();
@@ -1897,25 +1900,26 @@ export class BaseEntity {
1897
1900
  }
1898
1901
  async getSubtypePathTargetEntity(entityName, pkValue) {
1899
1902
  const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
1900
- if (BaseEntity._subtypeLookupCache.has(cacheKey)) {
1901
- return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
1903
+ const provider = this.ProviderToUse;
1904
+ const lookupCache = BaseEntity.subtypeLookupCacheFor(provider);
1905
+ if (lookupCache.has(cacheKey)) {
1906
+ return lookupCache.get(cacheKey) ?? null;
1902
1907
  }
1903
1908
  // 1. Check BaseEngineRegistry for loaded cached entities
1904
1909
  const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
1905
1910
  if (cached) {
1906
- BaseEntity._subtypeLookupCache.set(cacheKey, cached);
1911
+ lookupCache.set(cacheKey, cached);
1907
1912
  return cached;
1908
1913
  }
1909
1914
  // 2. Fall back to loading via provider
1910
- const provider = this.ProviderToUse;
1911
1915
  if (!provider?.GetEntityObject) {
1912
- BaseEntity._subtypeLookupCache.set(cacheKey, null);
1916
+ lookupCache.set(cacheKey, null);
1913
1917
  return null;
1914
1918
  }
1915
1919
  try {
1916
1920
  const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
1917
1921
  if (!targetObj) {
1918
- BaseEntity._subtypeLookupCache.set(cacheKey, null);
1922
+ lookupCache.set(cacheKey, null);
1919
1923
  return null;
1920
1924
  }
1921
1925
  let key;
@@ -1929,14 +1933,14 @@ export class BaseEntity {
1929
1933
  }
1930
1934
  const loaded = await targetObj.InnerLoad(key);
1931
1935
  if (loaded) {
1932
- BaseEntity._subtypeLookupCache.set(cacheKey, targetObj);
1936
+ lookupCache.set(cacheKey, targetObj);
1933
1937
  return targetObj;
1934
1938
  }
1935
1939
  }
1936
1940
  catch {
1937
1941
  // On load failure, return null
1938
1942
  }
1939
- BaseEntity._subtypeLookupCache.set(cacheKey, null);
1943
+ lookupCache.set(cacheKey, null);
1940
1944
  return null;
1941
1945
  }
1942
1946
  /**
@@ -3039,6 +3043,9 @@ export class BaseEntity {
3039
3043
  * @returns
3040
3044
  */
3041
3045
  RegisterEventHandler(callback) {
3046
+ if (!this._eventSubject) {
3047
+ this._eventSubject = new Subject();
3048
+ }
3042
3049
  return this._eventSubject.asObservable().subscribe(callback);
3043
3050
  }
3044
3051
  /**
@@ -3060,6 +3067,9 @@ export class BaseEntity {
3060
3067
  this.RaiseEvent('transaction_ready', null);
3061
3068
  }
3062
3069
  static { this._baseEventCode = 'BaseEntityEvent'; }
3070
+ static { this._globalEventTypes = new Set([
3071
+ 'save', 'delete', 'load_complete', 'save_started', 'delete_started', 'load_started'
3072
+ ]); }
3063
3073
  /**
3064
3074
  * When a BaseEntity class raises an event with MJGlobal, the eventCode property is set to this value. This is used to identify events that are raised by BaseEntity objects.
3065
3075
  * Any MJGlobal event that is raised by a BaseEntity class will use a BaseEntityEvent type as the args parameter
@@ -3081,11 +3091,10 @@ export class BaseEntity {
3081
3091
  const provider = this.ProviderToUse;
3082
3092
  // this is the local event handler that is specific to THIS instance of the entity object
3083
3093
  LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
3084
- this._eventSubject.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider });
3094
+ this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider });
3085
3095
  // this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
3086
3096
  // we broadcast save/delete/load events and their _started counterparts
3087
- const globalEventTypes = ['save', 'delete', 'load_complete', 'save_started', 'delete_started', 'load_started'];
3088
- if (globalEventTypes.includes(type)) {
3097
+ if (BaseEntity._globalEventTypes.has(type)) {
3089
3098
  const event = new BaseEntityEvent();
3090
3099
  event.baseEntity = this;
3091
3100
  event.payload = payload;
@@ -3272,6 +3281,10 @@ export class BaseEntity {
3272
3281
  }
3273
3282
  // Lookup needs an EntityField instance to return — hydrate.
3274
3283
  this.hydrateFieldsIfNeeded();
3284
+ if (this._entityInfo && typeof this._entityInfo.FieldIndexByName === 'function' && this._fields.length === this._entityInfo.Fields.length && this._fieldCache === null) {
3285
+ const idx = this._entityInfo.FieldIndexByName(fieldName);
3286
+ return idx >= 0 ? (this._fields[idx] ?? null) : null;
3287
+ }
3275
3288
  const lcase = fieldName.trim().toLowerCase(); // do this once as we will use it multiple times
3276
3289
  if (this._fieldCache === null) {
3277
3290
  this._fieldCache = new Map();
@@ -3315,6 +3328,9 @@ export class BaseEntity {
3315
3328
  * ```
3316
3329
  */
3317
3330
  FieldIsDirty(fieldName, ...more) {
3331
+ if (!this._fieldsHydrated && this._raw !== null && !this._parentEntity) {
3332
+ return false;
3333
+ }
3318
3334
  const names = more.length === 0 ? [fieldName] : [fieldName, ...more];
3319
3335
  for (const name of names) {
3320
3336
  if (this.GetFieldByName(name)?.Dirty === true) {
@@ -3334,6 +3350,10 @@ export class BaseEntity {
3334
3350
  }
3335
3351
  // Lookup needs an EntityField instance to return — hydrate.
3336
3352
  this.hydrateFieldsIfNeeded();
3353
+ if (this._entityInfo && typeof this._entityInfo.FieldIndexByCodeName === 'function' && this._fields.length === this._entityInfo.Fields.length && this._codeNameCache === null) {
3354
+ const idx = this._entityInfo.FieldIndexByCodeName(codeName);
3355
+ return idx >= 0 ? (this._fields[idx] ?? null) : null;
3356
+ }
3337
3357
  const lcase = codeName.trim().toLowerCase();
3338
3358
  if (this._codeNameCache === null) {
3339
3359
  this._codeNameCache = new Map();
@@ -3488,41 +3508,47 @@ export class BaseEntity {
3488
3508
  setLocal(FieldName, Value) {
3489
3509
  const field = this.GetFieldByName(FieldName);
3490
3510
  if (field != null) {
3491
- if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
3492
- field.Value = new Date(Value);
3511
+ this.setFieldValue(field, Value);
3512
+ }
3513
+ }
3514
+ setFieldValue(field, Value) {
3515
+ const tsType = field.EntityFieldInfo.TSType;
3516
+ if (tsType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
3517
+ field.Value = new Date(Value);
3518
+ }
3519
+ else if (tsType === EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
3520
+ const numericValue = Number(Value);
3521
+ if (!isNaN(numericValue)) {
3522
+ field.Value = numericValue;
3493
3523
  }
3494
- else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
3495
- const numericValue = Number(Value);
3496
- if (!isNaN(numericValue)) {
3497
- field.Value = numericValue;
3498
- }
3499
- else {
3500
- field.Value = Value;
3501
- }
3524
+ else {
3525
+ field.Value = Value;
3502
3526
  }
3503
- else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
3504
- if (typeof Value === 'string') {
3505
- if (Value.trim() === '1' || Value.trim().toLowerCase() === 'true') {
3506
- field.Value = true;
3507
- }
3508
- else if (Value.trim() === '0' || Value.trim().toLowerCase() === 'false') {
3509
- field.Value = false;
3510
- }
3511
- else {
3512
- field.Value = Value;
3513
- }
3527
+ }
3528
+ else if (tsType === EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
3529
+ if (typeof Value === 'string') {
3530
+ const trimmed = Value.trim();
3531
+ const lower = trimmed.toLowerCase();
3532
+ if (trimmed === '1' || lower === 'true') {
3533
+ field.Value = true;
3514
3534
  }
3515
- else if (typeof Value === 'number') {
3516
- field.Value = Value !== 0;
3535
+ else if (trimmed === '0' || lower === 'false') {
3536
+ field.Value = false;
3517
3537
  }
3518
3538
  else {
3519
3539
  field.Value = Value;
3520
3540
  }
3521
3541
  }
3542
+ else if (typeof Value === 'number') {
3543
+ field.Value = Value !== 0;
3544
+ }
3522
3545
  else {
3523
3546
  field.Value = Value;
3524
3547
  }
3525
3548
  }
3549
+ else {
3550
+ field.Value = Value;
3551
+ }
3526
3552
  }
3527
3553
  /**
3528
3554
  * Returns the value of the field with the given name. If the field is a date, and the value is a string, it will be converted to a date object.
@@ -3533,6 +3559,15 @@ export class BaseEntity {
3533
3559
  * @param FieldName
3534
3560
  * @returns
3535
3561
  */
3562
+ /**
3563
+ * A raw-mode row's value for a field: under its Name, or else its CodeName. Hydration accepts
3564
+ * both keys (as SetMany does), so every raw-mode read must too, or a CodeName-keyed row reads
3565
+ * back empty. `undefined` means the row omitted the field.
3566
+ */
3567
+ rawValueFor(raw, field) {
3568
+ const byName = raw[field.Name];
3569
+ return byName !== undefined || field.CodeName === field.Name ? byName : raw[field.CodeName];
3570
+ }
3536
3571
  /** Records a value derived from `_raw` so later reads skip the conversion. See {@link _rawConverted}. */
3537
3572
  memoizeRawConversion(fieldName, value) {
3538
3573
  (this._rawConverted ??= new Map()).set(fieldName, value);
@@ -3559,28 +3594,31 @@ export class BaseEntity {
3559
3594
  // hydration. Cache data uses exact SQL column names so no case-insensitive scan needed.
3560
3595
  if (!this._fieldsHydrated && this._raw) {
3561
3596
  let value = this._raw[FieldName];
3562
- if (value === undefined)
3563
- return null;
3564
3597
  // Conversions mirror the hydrated path, and memoize into `_rawConverted` rather than
3565
3598
  // back into `_raw` — the row may be shared, frozen cache state. Fields needing no
3566
3599
  // conversion (the vast majority) never touch the memo at all, so the fast path stays
3567
3600
  // a single property read.
3568
3601
  const fi = this._entityInfo?.FieldByName(FieldName);
3602
+ if (value === undefined && fi)
3603
+ value = this.rawValueFor(this._raw, fi);
3604
+ if (value === undefined)
3605
+ return null;
3606
+ const memoKey = fi ? fi.Name : FieldName;
3569
3607
  if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
3570
- const memo = this._rawConverted?.get(FieldName);
3608
+ const memo = this._rawConverted?.get(memoKey);
3571
3609
  if (memo !== undefined)
3572
3610
  return memo;
3573
3611
  const d = new Date(value);
3574
- this.memoizeRawConversion(FieldName, d);
3612
+ this.memoizeRawConversion(memoKey, d);
3575
3613
  return d;
3576
3614
  }
3577
3615
  // Mirror the EntityField.Value setter: rtrim padding for fixed-width string columns.
3578
3616
  if (typeof value === 'string' && fi?.FixedWidthColumn) {
3579
- const memo = this._rawConverted?.get(FieldName);
3617
+ const memo = this._rawConverted?.get(memoKey);
3580
3618
  if (memo !== undefined)
3581
3619
  return memo;
3582
3620
  value = trimTrailingSpaces(value);
3583
- this.memoizeRawConversion(FieldName, value);
3621
+ this.memoizeRawConversion(memoKey, value);
3584
3622
  }
3585
3623
  return value;
3586
3624
  }
@@ -3624,8 +3662,8 @@ export class BaseEntity {
3624
3662
  if (checkStatus) {
3625
3663
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
3626
3664
  }
3627
- // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
3628
- this.setLocal(key, object[key]);
3665
+ // Use setFieldValue directly so we don't repeat GetFieldByName(key)
3666
+ this.setFieldValue(field, object[key]);
3629
3667
  if (replaceOldValues) {
3630
3668
  field.ResetOldValue();
3631
3669
  }
@@ -3638,8 +3676,8 @@ export class BaseEntity {
3638
3676
  if (checkStatus) {
3639
3677
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
3640
3678
  }
3641
- // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
3642
- this.setLocal(field.Name, object[key]);
3679
+ // Use setFieldValue directly so we don't repeat GetFieldByName(field.Name)
3680
+ this.setFieldValue(field, object[key]);
3643
3681
  if (replaceOldValues) {
3644
3682
  field.ResetOldValue();
3645
3683
  }
@@ -3707,21 +3745,51 @@ export class BaseEntity {
3707
3745
  */
3708
3746
  GetAll(oldValues = false, onlyDirtyFields = false) {
3709
3747
  let obj = {};
3710
- for (let field of this.Fields) {
3711
- // NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
3712
- // construction artifact, and serializing it would launder a default/null into
3713
- // something downstream code treats as data — the exact masquerade the flag exists
3714
- // to prevent. Key-absence also propagates the flag naturally: hydrating another
3715
- // entity from this output re-marks the same fields not-loaded.
3716
- if (field.NotLoaded)
3717
- continue;
3718
- if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
3719
- // Reads field.Value directly — serialization is framework-internal, so it does not
3720
- // (and must not) assert active status. No suppression toggle needed: the assertion no
3721
- // longer lives on EntityField.Value (it moved to BaseEntity.Get/Set/SetMany).
3722
- obj[field.Name] = oldValues ? field.OldValue : field.Value;
3723
- if (field.EntityFieldInfo.TSType == EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
3724
- obj[field.Name] = new Date(obj[field.Name]); // a timestamp, convert to JS Date Object
3748
+ if (!this._fieldsHydrated && this._raw !== null) {
3749
+ if (!onlyDirtyFields) {
3750
+ for (const ef of this.EntityInfo.Fields) {
3751
+ const name = ef.Name;
3752
+ const rawVal = this.rawValueFor(this._raw, ef);
3753
+ // Key absence in _raw means NotLoaded (D-3 decision): omit from serialized output
3754
+ if (rawVal === undefined)
3755
+ continue;
3756
+ let val;
3757
+ const memo = this._rawConverted?.get(name);
3758
+ if (memo !== undefined) {
3759
+ val = memo;
3760
+ }
3761
+ else if (ef.TSType === EntityFieldTSType.Date && (typeof rawVal === 'string' || typeof rawVal === 'number')) {
3762
+ val = new Date(rawVal);
3763
+ this.memoizeRawConversion(name, val);
3764
+ }
3765
+ else if (typeof rawVal === 'string' && ef.FixedWidthColumn) {
3766
+ val = trimTrailingSpaces(rawVal);
3767
+ this.memoizeRawConversion(name, val);
3768
+ }
3769
+ else {
3770
+ val = rawVal;
3771
+ }
3772
+ obj[name] = val;
3773
+ }
3774
+ }
3775
+ }
3776
+ else {
3777
+ for (let field of this.Fields) {
3778
+ // NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
3779
+ // construction artifact, and serializing it would launder a default/null into
3780
+ // something downstream code treats as data — the exact masquerade the flag exists
3781
+ // to prevent. Key-absence also propagates the flag naturally: hydrating another
3782
+ // entity from this output re-marks the same fields not-loaded.
3783
+ if (field.NotLoaded)
3784
+ continue;
3785
+ if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
3786
+ // Reads field.Value directly — serialization is framework-internal, so it does not
3787
+ // (and must not) assert active status. No suppression toggle needed: the assertion no
3788
+ // longer lives on EntityField.Value (it moved to BaseEntity.Get/Set/SetMany).
3789
+ obj[field.Name] = oldValues ? field.OldValue : field.Value;
3790
+ if (field.EntityFieldInfo.TSType == EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
3791
+ obj[field.Name] = new Date(obj[field.Name]); // a timestamp, convert to JS Date Object
3792
+ }
3725
3793
  }
3726
3794
  }
3727
3795
  }
@@ -3921,26 +3989,33 @@ export class BaseEntity {
3921
3989
  this._fieldsHydrated = true;
3922
3990
  if (!this.EntityInfo)
3923
3991
  return;
3924
- // 1. Allocate one EntityField per column. Same construction the old `init()` used to do
3925
- // up-front — we just deferred it.
3926
- for (const rawField of this.EntityInfo.Fields) {
3927
- const key = this.EntityInfo.Name + '.' + rawField.Name;
3928
- const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
3929
- this._fields.push(newField);
3930
- }
3931
- // 2. If we have raw data from LoadFromData, populate Fields. The first set of each Field
3932
- // runs through the EntityField setter with `_NeverSet=true`, which simultaneously sets
3933
- // Value AND OldValue (so the field is not marked dirty). This matches the old
3934
- // SetMany(replaceOldValues=true) semantics on initial load.
3992
+ // 1. Allocate one EntityField per column. Check once whether any custom EntityField
3993
+ // subclasses are registered in ClassFactory; in standard deployments none are, so we
3994
+ // can instantiate EntityField directly without N string concatenations + factory lookups.
3995
+ const hasCustomEntityFieldRegistrations = MJGlobal.Instance.ClassFactory.GetAllRegistrations(EntityField).length > 0;
3996
+ const entityFields = this.EntityInfo.Fields;
3997
+ const skipDefaultValue = this._raw !== null;
3998
+ if (hasCustomEntityFieldRegistrations) {
3999
+ const prefix = this.EntityInfo.Name + '.';
4000
+ for (let i = 0; i < entityFields.length; i++) {
4001
+ const rawField = entityFields[i];
4002
+ const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, prefix + rawField.Name, rawField, undefined, skipDefaultValue);
4003
+ this._fields.push(newField);
4004
+ }
4005
+ }
4006
+ else {
4007
+ for (let i = 0; i < entityFields.length; i++) {
4008
+ this._fields.push(new EntityField(entityFields[i], undefined, skipDefaultValue));
4009
+ }
4010
+ }
4011
+ // 2. If we have raw data from LoadFromData, populate Fields and mark omitted fields as
4012
+ // NotLoaded in a single pass.
3935
4013
  if (this._raw) {
3936
- for (const field of this._fields) {
3937
- const value = this._raw[field.Name];
4014
+ const raw = this._raw;
4015
+ for (let i = 0; i < this._fields.length; i++) {
4016
+ const field = this._fields[i];
4017
+ const value = this.rawValueFor(raw, field.EntityFieldInfo);
3938
4018
  if (value !== undefined) {
3939
- // Populating fields from the loaded row writes EntityField.Value directly, which does
3940
- // not assert active status — so a deprecated column still present in the table emits
3941
- // no warning on load (hydration is not user use of the field). No suppression toggle
3942
- // is needed now that the assertion lives at BaseEntity.Get/Set/SetMany.
3943
- // Date conversion mirrors what SetLocal / Get does for raw string/number dates.
3944
4019
  if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
3945
4020
  field.Value = new Date(value);
3946
4021
  }
@@ -3948,10 +4023,10 @@ export class BaseEntity {
3948
4023
  field.Value = value;
3949
4024
  }
3950
4025
  }
4026
+ else {
4027
+ field.MarkNotLoaded();
4028
+ }
3951
4029
  }
3952
- // _raw IS a hydration source (LoadFromData fast path) — fields whose key it omitted
3953
- // are not-loaded, not defaulted. See EntityField.NotLoaded.
3954
- this.markFieldsOmittedBySourceAsNotLoaded(this._raw);
3955
4030
  // Raw data has been promoted into Fields — release the reference so we don't carry
3956
4031
  // duplicate state. Fields hold their own copies, so a frozen source no longer
3957
4032
  // constrains anything from here on.
@@ -3971,7 +4046,9 @@ export class BaseEntity {
3971
4046
  return;
3972
4047
  for (const field of this.Fields) {
3973
4048
  if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
3974
- field.MarkNotLoaded();
4049
+ // Cleared, not just flagged: otherwise a default from construction, or the previous
4050
+ // record's value on a reload, reads back through Get() as if it had been loaded.
4051
+ field.ClearAsNotLoaded();
3975
4052
  }
3976
4053
  }
3977
4054
  }
@@ -4479,7 +4556,7 @@ export class BaseEntity {
4479
4556
  const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
4480
4557
  if (!this.TransactionGroup) {
4481
4558
  // no transaction group, so we have our results here
4482
- const result = this.finalizeSave(data, saveSubType);
4559
+ const result = this.finalizeSave(data, saveSubType, _options);
4483
4560
  // Settle the scope this entity opened, if any (only the initiator holds
4484
4561
  // one). The provider can fail by RETURNING falsy data rather than
4485
4562
  // throwing — a validate-type entity action rejecting the save does
@@ -4500,7 +4577,7 @@ export class BaseEntity {
4500
4577
  this.TransactionGroup.TransactionNotifications$.subscribe(({ success, results, error }) => {
4501
4578
  const transItem = success && results ? results.find(r => r.Transaction.BaseEntity === this) : undefined;
4502
4579
  if (transItem) {
4503
- this.finalizeSave(transItem.Result, saveSubType); // we get the resulting data from the transaction result, not data above as that will be blank when in a TG
4580
+ this.finalizeSave(transItem.Result, saveSubType, _options); // we get the resulting data from the transaction result, not data above as that will be blank when in a TG
4504
4581
  }
4505
4582
  else {
4506
4583
  // The transaction group failed / rolled back, OR (should never happen) reported success
@@ -4602,10 +4679,37 @@ export class BaseEntity {
4602
4679
  // If no remaining children, safe to delete parent
4603
4680
  return remainingChildren.length === 0;
4604
4681
  }
4605
- finalizeSave(data, saveSubType) {
4682
+ finalizeSave(data, saveSubType, options) {
4606
4683
  if (data) {
4684
+ const effectiveReturnMode = options?.SaveReturnMode ?? this.EntityInfo.SaveReturnMode;
4685
+ const hasGetAll = (val) => val instanceof BaseEntity || typeof val.GetAll === 'function';
4686
+ const rawFieldData = hasGetAll(data) ? data.GetAll() : data;
4687
+ // In 'datestamps_only' mode, the database only returned PK + __mj_CreatedAt / __mj_UpdatedAt.
4688
+ // Merge those returned values onto the current in-memory values BEFORE init() wipes Fields,
4689
+ // so caller-set fields remain loaded and intact without a post-save BaseView SELECT.
4690
+ // When DatabaseProviderBase.Save already merged its pre-save snapshot (`__mj_preMerged`),
4691
+ // reuse `rawFieldData` directly to avoid a redundant second `GetAll()` call.
4692
+ let fieldData;
4693
+ if (effectiveReturnMode === 'datestamps_only' && typeof rawFieldData === 'object' && !Array.isArray(rawFieldData)) {
4694
+ if (rawFieldData.__mj_preMerged) {
4695
+ fieldData = rawFieldData;
4696
+ }
4697
+ else {
4698
+ const currentValues = this.GetAll();
4699
+ if (saveSubType === 'create' || !this.IsSaved) {
4700
+ for (const f of this.EntityInfo.Fields) {
4701
+ if (f.IsVirtual && !f.IsPrimaryKey && !(f.Name in rawFieldData) && (currentValues[f.Name] === null || currentValues[f.Name] === undefined)) {
4702
+ delete currentValues[f.Name];
4703
+ }
4704
+ }
4705
+ }
4706
+ fieldData = { ...currentValues, ...rawFieldData };
4707
+ }
4708
+ }
4709
+ else {
4710
+ fieldData = rawFieldData;
4711
+ }
4607
4712
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
4608
- const fieldData = (data instanceof BaseEntity || (data && typeof data.GetAll === 'function')) ? data.GetAll() : data;
4609
4713
  // IS-A GetAll() merges the parent, including parent virtuals this entity
4610
4714
  // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
4611
4715
  // this entity defines, and ignore anything leftover.
@@ -4903,7 +5007,7 @@ export class BaseEntity {
4903
5007
  this.discardJSONFieldObjects(); // unconditional: an un-flushed edit through a stale reference must go too
4904
5008
  if (this.Dirty) {
4905
5009
  for (let field of this.Fields) {
4906
- field.Value = field.OldValue;
5010
+ field.RevertToOldValue();
4907
5011
  }
4908
5012
  // IS-A composition: revert parent entity chain as well
4909
5013
  if (this._parentEntity) {
@@ -5108,12 +5212,26 @@ export class BaseEntity {
5108
5212
  // This is the dominant warm-load case (cache restore in TransformSimpleObjectToEntityObject
5109
5213
  // populating engine arrays of read-only data) and was previously the per-row bottleneck.
5110
5214
  const isPlainObject = data && typeof data === 'object' && !Array.isArray(data) && !(data instanceof Date);
5215
+ const isFirstOrUnhydratedLoad = this._raw === null &&
5216
+ !this._recordLoaded &&
5217
+ !this._everSaved &&
5218
+ ((!this._fieldsHydrated && this._fields.length === 0) || this._fields.every(f => f.NeverSet));
5219
+ const hasCompanionPayload = this.HasCompanions &&
5220
+ isPlainObject &&
5221
+ Array.isArray(data[COMPANION_PAYLOAD_KEY]);
5111
5222
  const canTakeFastPath = isPlainObject &&
5112
- !this._fieldsHydrated &&
5113
- this._fields.length === 0 &&
5223
+ isFirstOrUnhydratedLoad &&
5114
5224
  !this._parentEntity &&
5115
- !this.EntityInfo?.IsParentType;
5225
+ !this.EntityInfo?.IsParentType &&
5226
+ !hasCompanionPayload;
5116
5227
  if (canTakeFastPath) {
5228
+ if (this._fieldsHydrated || this._fields.length > 0) {
5229
+ this._fields = [];
5230
+ this._fieldsHydrated = false;
5231
+ this._fieldCache = null;
5232
+ this._codeNameCache = null;
5233
+ }
5234
+ this._compositeKey = null;
5117
5235
  this._raw = data;
5118
5236
  // Drop any conversions memoized from a previously-loaded row — they describe the old
5119
5237
  // `_raw`, not this one. No isFrozen probe is needed: `Get()` never writes to `_raw`,
@@ -5124,7 +5242,7 @@ export class BaseEntity {
5124
5242
  const pks = this.EntityInfo?.PrimaryKeys ?? [];
5125
5243
  let allPksPresent = pks.length > 0;
5126
5244
  for (const pkInfo of pks) {
5127
- const v = this._raw[pkInfo.Name];
5245
+ const v = this.rawValueFor(this._raw, pkInfo);
5128
5246
  if (v === null || v === undefined) {
5129
5247
  allPksPresent = false;
5130
5248
  break;
@@ -6091,13 +6209,16 @@ export class BaseEntity {
6091
6209
  * use this to track vectors associated with
6092
6210
  */
6093
6211
  get Vectors() {
6212
+ if (!this._vectors) {
6213
+ this._vectors = new Map();
6214
+ }
6094
6215
  return this._vectors;
6095
6216
  }
6096
6217
  /**
6097
6218
  * Resets the vector embeddings for this entity to an empty state.
6098
6219
  */
6099
6220
  ResetVectors() {
6100
- this._vectors.clear();
6221
+ this._vectors?.clear();
6101
6222
  }
6102
6223
  /**
6103
6224
  * Resolves the recursive foreign key field for this entity. If `parentFieldName` is provided,