@memberjunction/core 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/generic/baseEntity.d.ts +166 -0
  2. package/dist/generic/baseEntity.d.ts.map +1 -1
  3. package/dist/generic/baseEntity.js +549 -9
  4. package/dist/generic/baseEntity.js.map +1 -1
  5. package/dist/generic/entityInfo.d.ts +79 -0
  6. package/dist/generic/entityInfo.d.ts.map +1 -1
  7. package/dist/generic/entityInfo.js +149 -2
  8. package/dist/generic/entityInfo.js.map +1 -1
  9. package/dist/generic/interfaces.d.ts +53 -2
  10. package/dist/generic/interfaces.d.ts.map +1 -1
  11. package/dist/generic/interfaces.js +13 -0
  12. package/dist/generic/interfaces.js.map +1 -1
  13. package/dist/generic/metadata.d.ts +2 -2
  14. package/dist/generic/metadata.d.ts.map +1 -1
  15. package/dist/generic/metadata.js +4 -4
  16. package/dist/generic/metadata.js.map +1 -1
  17. package/dist/generic/providerBase.d.ts +45 -4
  18. package/dist/generic/providerBase.d.ts.map +1 -1
  19. package/dist/generic/providerBase.js +125 -2
  20. package/dist/generic/providerBase.js.map +1 -1
  21. package/package.json +2 -2
  22. package/readme.md +871 -1271
  23. package/dist/__tests__/mocks/TestMetadataProvider.d.ts +0 -45
  24. package/dist/__tests__/mocks/TestMetadataProvider.d.ts.map +0 -1
  25. package/dist/__tests__/mocks/TestMetadataProvider.js +0 -217
  26. package/dist/__tests__/mocks/TestMetadataProvider.js.map +0 -1
  27. package/dist/__tests__/providerBase.concurrency.test.d.ts +0 -10
  28. package/dist/__tests__/providerBase.concurrency.test.d.ts.map +0 -1
  29. package/dist/__tests__/providerBase.concurrency.test.js +0 -253
  30. package/dist/__tests__/providerBase.concurrency.test.js.map +0 -1
  31. package/dist/__tests__/providerBase.refresh.test.d.ts +0 -10
  32. package/dist/__tests__/providerBase.refresh.test.d.ts.map +0 -1
  33. package/dist/__tests__/providerBase.refresh.test.js +0 -161
  34. package/dist/__tests__/providerBase.refresh.test.js.map +0 -1
  35. package/dist/__tests__/setup.d.ts +0 -5
  36. package/dist/__tests__/setup.d.ts.map +0 -1
  37. package/dist/__tests__/setup.js +0 -17
  38. package/dist/__tests__/setup.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { MJEventType, MJGlobal, uuidv4, WarningManager } from '@memberjunction/global';
2
2
  import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
3
- import { EntitySaveOptions } from './interfaces.js';
3
+ import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
4
4
  import { Metadata } from './metadata.js';
5
5
  import { RunView } from '../views/runView.js';
6
6
  import { LogDebug, LogError } from './logging.js';
@@ -466,6 +466,22 @@ export class BaseEntityEvent {
466
466
  * Base class used for all entity objects. This class is abstract and is sub-classes for each particular entity using the CodeGen tool. This class provides the basic functionality for loading, saving, and validating entity objects.
467
467
  */
468
468
  export class BaseEntity {
469
+ /**
470
+ * Gets the provider transaction handle for IS-A chain orchestration.
471
+ */
472
+ get ProviderTransaction() { return this._providerTransaction; }
473
+ /**
474
+ * Sets the provider transaction handle. Used during IS-A save/delete to share
475
+ * a single database transaction across the entire parent chain.
476
+ */
477
+ set ProviderTransaction(value) { this._providerTransaction = value; }
478
+ /**
479
+ * Returns the parent entity in the IS-A composition chain, or null if this
480
+ * entity is not an IS-A child type. Read-only public access for inspection.
481
+ * Named ISAParentEntity to avoid collision with generated Entity.ParentEntity
482
+ * string column (which contains the parent entity's name from the view).
483
+ */
484
+ get ISAParentEntity() { return this._parentEntity; }
469
485
  constructor(Entity, Provider = null) {
470
486
  this._Fields = [];
471
487
  this._recordLoaded = false;
@@ -476,6 +492,33 @@ export class BaseEntity {
476
492
  this._everSaved = false;
477
493
  this._isLoading = false;
478
494
  this._pendingDelete$ = null;
495
+ /**************************************************************************
496
+ * IS-A Type Relationship — Parent Entity Composition
497
+ *
498
+ * For IS-A child entities, _parentEntity holds a persistent live reference
499
+ * to the parent entity instance. All data routing (Set/Get), dirty tracking,
500
+ * validation, and save/delete orchestration flow through this composition chain.
501
+ **************************************************************************/
502
+ /**
503
+ * Persistent reference to the parent entity in the IS-A chain.
504
+ * For example, if MeetingEntity IS-A ProductEntity, the MeetingEntity instance
505
+ * holds a reference to a ProductEntity instance here. This is null for entities
506
+ * that are not IS-A child types.
507
+ */
508
+ this._parentEntity = null;
509
+ /**
510
+ * Cached set of field names that belong to parent entities — used for
511
+ * efficient O(1) lookup during Set/Get routing to determine if a field
512
+ * should be forwarded to _parentEntity.
513
+ */
514
+ this._parentEntityFieldNames = null;
515
+ /**
516
+ * Opaque provider-level transaction handle. Used by IS-A save/delete orchestration
517
+ * to share a single SQL transaction across the parent chain.
518
+ * On client (GraphQLDataProvider), this remains null.
519
+ * On server (SQLServerDataProvider), this holds a sql.Transaction.
520
+ */
521
+ this._providerTransaction = null;
479
522
  this._compositeKey = null;
480
523
  // Holds the current pending save observable (if any)
481
524
  this._pendingSave$ = null;
@@ -491,6 +534,76 @@ export class BaseEntity {
491
534
  this._provider = Provider;
492
535
  this.init();
493
536
  }
537
+ /**
538
+ * Initializes the IS-A parent entity composition chain. For child type entities,
539
+ * this creates the parent entity instance (and recursively its parent, etc.) and
540
+ * caches the parent field name set for routing.
541
+ *
542
+ * Must be called AFTER EntityInfo is available but BEFORE any Load/NewRecord/Set/Get.
543
+ * This is called by Metadata.GetEntityObject() after constructing the entity.
544
+ */
545
+ async InitializeParentEntity() {
546
+ if (!this.EntityInfo?.IsChildType)
547
+ return;
548
+ const md = new Metadata();
549
+ const parentEntityInfo = this.EntityInfo.ParentEntityInfo;
550
+ if (!parentEntityInfo)
551
+ return;
552
+ // Create the parent entity via Metadata to ensure proper class factory resolution
553
+ this._parentEntity = await md.GetEntityObject(parentEntityInfo.Name, this._contextCurrentUser);
554
+ // Recursive: the parent's InitializeParentEntity() was called by GetEntityObject()
555
+ // Cache the parent field names for O(1) routing lookups
556
+ this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
557
+ }
558
+ /**
559
+ * Resets this entity to a pristine state and populates it from the provided data object.
560
+ *
561
+ * Unlike {@link SetMany}, which incrementally updates existing field values, `Hydrate()`
562
+ * first resets ALL internal state — fields, composite key cache, loaded/saved flags —
563
+ * then populates from the provided data as if loading a fresh record from the database.
564
+ *
565
+ * This is critical for IS-A (table-per-type) inheritance: when a child entity loads
566
+ * its record, parent entities in the chain must be fully reset and re-populated from
567
+ * the child's view data, including the shared primary key. After `init()`, each
568
+ * EntityField's `_NeverSet` flag is `true`, allowing even ReadOnly PK fields to be
569
+ * set exactly once via `SetMany`.
570
+ *
571
+ * After population, entities are automatically marked as saved/loaded when all PK
572
+ * values are present (via {@link UpdateSavedStateFromPrimaryKeys}).
573
+ *
574
+ * The parent chain is handled recursively: if this entity has an IS-A parent, the
575
+ * parent is hydrated first (deepest ancestor first) via SetMany's built-in routing.
576
+ *
577
+ * @param data - A plain object whose properties map to field names on this entity
578
+ * (and potentially parent entities in the IS-A chain).
579
+ */
580
+ Hydrate(data) {
581
+ // Reset this entity to pristine state: clears _compositeKey, _recordLoaded,
582
+ // _everSaved, and recreates all EntityField instances with _NeverSet = true
583
+ this.init();
584
+ // Recursively hydrate parent entities first (deepest ancestor resets first).
585
+ // After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
586
+ if (this._parentEntity) {
587
+ this._parentEntity.Hydrate(data);
588
+ }
589
+ // Populate this entity's fields. SetMany also routes parent field values to
590
+ // _parentEntity via the IS-A routing block (which now includes PK fields).
591
+ // replaceOldValues=true ensures OldValue matches Value (no false dirty flags).
592
+ // ignoreNonExistentFields=true because data may contain fields from other
593
+ // entities in the chain that don't exist on this entity.
594
+ this.SetMany(data, true, true, true);
595
+ }
596
+ /**
597
+ * Propagates the ProviderTransaction handle down the IS-A parent chain so all
598
+ * entities in the chain execute on the same database transaction.
599
+ */
600
+ PropagateTransactionToParents() {
601
+ let current = this._parentEntity;
602
+ while (current) {
603
+ current.ProviderTransaction = this._providerTransaction;
604
+ current = current._parentEntity;
605
+ }
606
+ }
494
607
  /**
495
608
  * Returns this provider to be used for a given instance of a BaseEntity derived subclass. If the provider is not set, the BaseEntity.Provider is returned.
496
609
  */
@@ -750,7 +863,9 @@ export class BaseEntity {
750
863
  * 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.
751
864
  */
752
865
  get Dirty() {
753
- return !this.IsSaved || this.Fields.some(f => f.Dirty);
866
+ return !this.IsSaved ||
867
+ this.Fields.some(f => f.Dirty) ||
868
+ (this._parentEntity?.Dirty ?? false);
754
869
  }
755
870
  /**
756
871
  * Returns an array of all primary key fields for the entity. If the entity has a composite primary key, this method will return an array of all primary key fields.
@@ -785,10 +900,30 @@ export class BaseEntity {
785
900
  /**
786
901
  * Sets the value of a given field. If the field doesn't exist, nothing happens.
787
902
  * The field's type is used to convert the value to the appropriate type.
903
+ *
904
+ * For IS-A child entities, parent fields are routed to `_parentEntity.Set()` (recursive
905
+ * for N-level chains). The value is also mirrored on the child's own virtual EntityField
906
+ * so that code iterating `entity.Fields` still sees it. The authoritative state for parent
907
+ * fields lives on `_parentEntity`.
908
+ *
788
909
  * @param FieldName
789
910
  * @param Value
790
911
  */
791
912
  Set(FieldName, Value) {
913
+ // IS-A routing: if this field belongs to a parent entity, route to parent
914
+ if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
915
+ this._parentEntity.Set(FieldName, Value); // recursive for N-level chains
916
+ // Also mirror the value on our own virtual EntityField for UI compatibility
917
+ this.SetLocal(FieldName, Value);
918
+ return;
919
+ }
920
+ this.SetLocal(FieldName, Value);
921
+ }
922
+ /**
923
+ * Internal helper that sets a field value directly on THIS entity's own Fields array
924
+ * without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
925
+ */
926
+ SetLocal(FieldName, Value) {
792
927
  const field = this.GetFieldByName(FieldName);
793
928
  if (field != null) {
794
929
  if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
@@ -829,10 +964,18 @@ export class BaseEntity {
829
964
  }
830
965
  /**
831
966
  * 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.
967
+ *
968
+ * For IS-A child entities, parent fields return the authoritative value from `_parentEntity.Get()`
969
+ * (recursive for N-level chains), NOT the mirrored value on the child's own Fields array.
970
+ *
832
971
  * @param FieldName
833
972
  * @returns
834
973
  */
835
974
  Get(FieldName) {
975
+ // IS-A routing: return the authoritative value from the parent entity
976
+ if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
977
+ return this._parentEntity.Get(FieldName); // recursive for N-level chains
978
+ }
836
979
  const field = this.GetFieldByName(FieldName);
837
980
  if (field != null) {
838
981
  // if the field is a date and the value is a string, convert it to a date
@@ -847,6 +990,11 @@ export class BaseEntity {
847
990
  * NOTE: Do not call this method directly. Use the {@link From} method instead
848
991
  *
849
992
  * Sets any number of values on the entity object from the object passed in. The properties of the object being passed in must either match the field name (in most cases) or the CodeName (which is only different from field name if field name has spaces in it)
993
+ *
994
+ * For IS-A child entities, all fields are first set on self (including parent fields as mirrors),
995
+ * then parent fields are extracted and forwarded to `_parentEntity.SetMany()` for authoritative
996
+ * state, including proper OldValue tracking via the replaceOldValues parameter.
997
+ *
850
998
  * @param object
851
999
  * @param ignoreNonExistentFields - if set to true, fields that don't exist on the entity object will be ignored, if false, an error will be thrown if a field doesn't exist
852
1000
  * @param replaceOldValues - if set to true, the old values of the fields will be reset to the values provided in the object parameter, if false, they will be left alone
@@ -863,7 +1011,8 @@ export class BaseEntity {
863
1011
  if (ignoreActiveStatusAssertions) {
864
1012
  field.ActiveStatusAssertions = false; // disable active status assertions for this field
865
1013
  }
866
- this.Set(key, object[key]);
1014
+ // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
1015
+ this.SetLocal(key, object[key]);
867
1016
  if (replaceOldValues) {
868
1017
  field.ResetOldValue();
869
1018
  }
@@ -880,7 +1029,8 @@ export class BaseEntity {
880
1029
  if (ignoreActiveStatusAssertions) {
881
1030
  field.ActiveStatusAssertions = false; // disable active status assertions for this field
882
1031
  }
883
- this.Set(field.Name, object[key]);
1032
+ // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
1033
+ this.SetLocal(field.Name, object[key]);
884
1034
  if (replaceOldValues) {
885
1035
  field.ResetOldValue();
886
1036
  }
@@ -889,6 +1039,11 @@ export class BaseEntity {
889
1039
  }
890
1040
  }
891
1041
  else {
1042
+ // IS-A routing: parent fields may not have local mirrors (virtual EntityField records)
1043
+ // yet — they'll be handled by the IS-A routing block below, so skip the error/warning
1044
+ if (this._parentEntityFieldNames?.has(key)) {
1045
+ continue; // parent field — will be forwarded to _parentEntity below
1046
+ }
892
1047
  // if we get here, we have a field that doesn't match either the field name or the code name, so throw an error
893
1048
  if (!ignoreNonExistentFields)
894
1049
  throw new Error(`Field ${key} does not exist on ${this.EntityInfo.Name}`);
@@ -899,6 +1054,41 @@ export class BaseEntity {
899
1054
  }
900
1055
  }
901
1056
  }
1057
+ // IS-A routing: forward parent fields to _parentEntity for authoritative state
1058
+ // This ensures proper OldValue tracking and dirty flags on the parent entity
1059
+ if (this._parentEntity && this._parentEntityFieldNames) {
1060
+ const parentData = {};
1061
+ for (const key of Object.keys(object)) {
1062
+ if (this._parentEntityFieldNames.has(key)) {
1063
+ parentData[key] = object[key];
1064
+ }
1065
+ }
1066
+ if (Object.keys(parentData).length > 0) {
1067
+ this._parentEntity.SetMany(parentData, true, replaceOldValues, ignoreActiveStatusAssertions);
1068
+ }
1069
+ }
1070
+ // When replaceOldValues is true, this is a load operation (not a user edit).
1071
+ // If all primary keys are set, mark the entity as a saved record. This is critical
1072
+ // for IS-A parent entities whose data is populated via SetMany routing from the child's
1073
+ // load — without this, the parent would think it's a new record and attempt CREATE
1074
+ // instead of UPDATE on save.
1075
+ if (replaceOldValues) {
1076
+ this.UpdateSavedStateFromPrimaryKeys();
1077
+ }
1078
+ }
1079
+ /**
1080
+ * Checks if all primary key fields have values set, and if so, marks the entity as
1081
+ * a saved/loaded record. This enables entities populated via SetMany (e.g., IS-A parent
1082
+ * entities) to correctly recognize themselves as existing records.
1083
+ */
1084
+ UpdateSavedStateFromPrimaryKeys() {
1085
+ if (!this.PrimaryKeys || this.PrimaryKeys.length === 0)
1086
+ return;
1087
+ const allPKsSet = this.PrimaryKeys.every(pk => pk.Value !== null && pk.Value !== undefined);
1088
+ if (allPKsSet) {
1089
+ this._recordLoaded = true;
1090
+ this._everSaved = true;
1091
+ }
902
1092
  }
903
1093
  /**
904
1094
  * NOTE: Do not call this method directly. Use the {@link To} method instead
@@ -922,6 +1112,13 @@ export class BaseEntity {
922
1112
  field.ActiveStatusAssertions = tempStatus; // restore the prior status for assertions
923
1113
  }
924
1114
  }
1115
+ // IS-A composition: merge parent entity data with own data
1116
+ // Parent data goes first, own fields override (for shared PK 'ID')
1117
+ // Parent's GetAll() recursively collects from its own parent for N-level chains
1118
+ if (this._parentEntity) {
1119
+ const parentData = this._parentEntity.GetAll(oldValues, onlyDirtyFields);
1120
+ return { ...parentData, ...obj };
1121
+ }
925
1122
  return obj;
926
1123
  }
927
1124
  /**
@@ -1096,6 +1293,18 @@ export class BaseEntity {
1096
1293
  this.Set(kv.FieldName, kv.Value);
1097
1294
  });
1098
1295
  }
1296
+ // IS-A composition: propagate PK value to parent entity chain
1297
+ // Parent needs NewRecord() called first, then share the same PK value
1298
+ if (this._parentEntity) {
1299
+ this._parentEntity.NewRecord();
1300
+ // Propagate PK — child and parent must share the same UUID
1301
+ for (const pk of this.EntityInfo.PrimaryKeys) {
1302
+ const pkValue = this.Get(pk.Name);
1303
+ if (pkValue != null) {
1304
+ this._parentEntity.Set(pk.Name, pkValue);
1305
+ }
1306
+ }
1307
+ }
1099
1308
  this.RaiseEvent('new_record', null);
1100
1309
  return true;
1101
1310
  }
@@ -1136,9 +1345,44 @@ export class BaseEntity {
1136
1345
  newResult.StartedAt = new Date();
1137
1346
  try {
1138
1347
  const _options = options ? options : new EntitySaveOptions();
1348
+ // IS-A orchestration: determine if this is the initiating save in a parent chain
1349
+ const isISAInitiator = (!!this._parentEntity) && !_options.IsParentEntitySave;
1350
+ // Begin provider transaction if IS-A initiator and NOT in a TransactionGroup
1351
+ // TransactionGroup manages its own atomicity; IS-A just orchestrates save order within it
1352
+ if (isISAInitiator && !this.TransactionGroup) {
1353
+ const txn = await this.ProviderToUse?.BeginISATransaction?.();
1354
+ if (txn) {
1355
+ this.ProviderTransaction = txn;
1356
+ this.PropagateTransactionToParents();
1357
+ }
1358
+ }
1359
+ // Save parent chain first (root → branch → immediate parent)
1360
+ // Parent calls Save() recursively which handles its own parents, permissions, validation
1361
+ if (this._parentEntity) {
1362
+ const parentSaveOptions = new EntitySaveOptions();
1363
+ parentSaveOptions.IgnoreDirtyState = _options.IgnoreDirtyState;
1364
+ parentSaveOptions.SkipEntityAIActions = _options.SkipEntityAIActions;
1365
+ parentSaveOptions.SkipEntityActions = _options.SkipEntityActions;
1366
+ parentSaveOptions.ReplayOnly = _options.ReplayOnly;
1367
+ parentSaveOptions.SkipOldValuesCheck = _options.SkipOldValuesCheck;
1368
+ parentSaveOptions.SkipAsyncValidation = _options.SkipAsyncValidation;
1369
+ parentSaveOptions.IsParentEntitySave = true;
1370
+ const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
1371
+ if (!parentResult) {
1372
+ // Parent save failed — rollback if we started the transaction
1373
+ await this.RollbackISATransaction(isISAInitiator);
1374
+ return false;
1375
+ }
1376
+ }
1139
1377
  const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
1140
1378
  const saveSubType = this.IsSaved ? 'update' : 'create';
1141
1379
  this.CheckPermissions(type, true); // this will throw an error and exit out if we don't have permission
1380
+ // IS-A disjoint subtype enforcement: on CREATE, ensure parent record
1381
+ // isn't already claimed by another child type (e.g., can't create Meeting
1382
+ // if a Publication already exists with the same Product ID)
1383
+ if (!this.IsSaved && this.EntityInfo.IsChildType && !_options.ReplayOnly) {
1384
+ await this.EnforceDisjointSubtype();
1385
+ }
1142
1386
  if (_options.IgnoreDirtyState || this.Dirty || _options.ReplayOnly) {
1143
1387
  // Raise save_started event only when we're actually going to save
1144
1388
  this.RaiseEvent('save_started', null, saveSubType);
@@ -1175,12 +1419,18 @@ export class BaseEntity {
1175
1419
  const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
1176
1420
  if (!this.TransactionGroup) {
1177
1421
  // no transaction group, so we have our results here
1178
- return this.finalizeSave(data, saveSubType);
1422
+ const result = this.finalizeSave(data, saveSubType);
1423
+ // IS-A: commit transaction after successful save (only the initiator commits)
1424
+ if (isISAInitiator && this.ProviderTransaction) {
1425
+ await this.ProviderToUse.CommitISATransaction?.(this.ProviderTransaction);
1426
+ this.ProviderTransaction = null;
1427
+ }
1428
+ return result;
1179
1429
  }
1180
1430
  else {
1181
1431
  // we are part of a transaction group, so we return true and subscribe to the transaction groups' events and do the finalization work then
1182
1432
  this.TransactionGroup.TransactionNotifications$.subscribe(({ success, results, error }) => {
1183
- if (success) {
1433
+ if (success && results) {
1184
1434
  const transItem = results.find(r => r.Transaction.BaseEntity === this);
1185
1435
  if (transItem) {
1186
1436
  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
@@ -1206,6 +1456,9 @@ export class BaseEntity {
1206
1456
  return true; // nothing to save since we're not dirty
1207
1457
  }
1208
1458
  catch (e) {
1459
+ // IS-A: rollback transaction on failure (only the initiator rolls back)
1460
+ const isISAInitiator = this._parentEntity != null && !options?.IsParentEntitySave;
1461
+ await this.RollbackISATransaction(isISAInitiator);
1209
1462
  if (currentResultCount === this.ResultHistory.length) {
1210
1463
  // this means that NO new results were added to the history anywhere
1211
1464
  // so we need to add a new result to the history here
@@ -1220,6 +1473,21 @@ export class BaseEntity {
1220
1473
  return false;
1221
1474
  }
1222
1475
  }
1476
+ /**
1477
+ * Helper to rollback an IS-A provider transaction if one is active.
1478
+ * Only called by the IS-A initiator (the leaf entity that started the chain).
1479
+ */
1480
+ async RollbackISATransaction(isInitiator) {
1481
+ if (isInitiator && this.ProviderTransaction) {
1482
+ try {
1483
+ await this.ProviderToUse?.RollbackISATransaction?.(this.ProviderTransaction);
1484
+ }
1485
+ catch (rollbackError) {
1486
+ LogError(`Error rolling back IS-A transaction: ${rollbackError}`);
1487
+ }
1488
+ this.ProviderTransaction = null;
1489
+ }
1490
+ }
1223
1491
  finalizeSave(data, saveSubType) {
1224
1492
  if (data) {
1225
1493
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
@@ -1228,6 +1496,8 @@ export class BaseEntity {
1228
1496
  const result = this.LatestResult;
1229
1497
  if (result)
1230
1498
  result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
1499
+ // Cache the record name for faster lookups (updates cache with potentially new name)
1500
+ this.CacheRecordName();
1231
1501
  this.RaiseEvent('save', null, saveSubType);
1232
1502
  return true;
1233
1503
  }
@@ -1241,6 +1511,23 @@ export class BaseEntity {
1241
1511
  get ActiveUser() {
1242
1512
  return this.ContextCurrentUser || Metadata.Provider.CurrentUser; // use the context user ahead of the Provider.Current User - this is for SERVER side ops where the user changes per request
1243
1513
  }
1514
+ /**
1515
+ * Caches the entity record name in the provider's EntityRecordNameCache for faster lookups.
1516
+ * Called automatically after successful Load(), LoadFromData(), and Save() operations.
1517
+ */
1518
+ CacheRecordName() {
1519
+ // Only cache if we have a valid primary key
1520
+ if (!this.PrimaryKey || !this.PrimaryKey.HasValue) {
1521
+ return;
1522
+ }
1523
+ // Get the record name
1524
+ const recordName = this.GetRecordName();
1525
+ if (!recordName) {
1526
+ return;
1527
+ }
1528
+ // Cache it via the provider's public API
1529
+ Metadata.Provider.SetCachedRecordName(this.EntityInfo.Name, this.PrimaryKey, recordName);
1530
+ }
1244
1531
  /**
1245
1532
  * Utility method that returns true if the given permission being checked is enabled for the current user, and false if not.
1246
1533
  * @param type
@@ -1251,6 +1538,17 @@ export class BaseEntity {
1251
1538
  const u = this.ActiveUser;
1252
1539
  if (!u)
1253
1540
  throw new Error('No user set - either the context user for the entity object must be set, or the Metadata.Provider.CurrentUser must be set');
1541
+ // Virtual entities are read-only — block Create, Update, Delete at the ORM level
1542
+ // This catches server-side code calling .Save()/.Delete() directly, which bypasses the API layer flags
1543
+ if (this.EntityInfo.VirtualEntity &&
1544
+ (type === EntityPermissionType.Create ||
1545
+ type === EntityPermissionType.Update ||
1546
+ type === EntityPermissionType.Delete)) {
1547
+ const msg = `Cannot ${type} on virtual entity '${this.EntityInfo.Name}' — virtual entities are read-only`;
1548
+ if (throwError)
1549
+ throw new Error(msg);
1550
+ return false;
1551
+ }
1254
1552
  // first check if the AllowCreateAPI/AllowUpdateAPI/AllowDeleteAPI settings are flipped on for the entity in question
1255
1553
  switch (type) {
1256
1554
  case EntityPermissionType.Create:
@@ -1322,6 +1620,10 @@ export class BaseEntity {
1322
1620
  for (let field of this.Fields) {
1323
1621
  field.Value = field.OldValue;
1324
1622
  }
1623
+ // IS-A composition: revert parent entity chain as well
1624
+ if (this._parentEntity) {
1625
+ this._parentEntity.Revert();
1626
+ }
1325
1627
  }
1326
1628
  return true;
1327
1629
  }
@@ -1354,6 +1656,13 @@ export class BaseEntity {
1354
1656
  LogError(`Error in BaseEntity.Load(${this.EntityInfo.Name}, Key: ${CompositeKey.ToString()}`);
1355
1657
  return false; // no data loaded, return false
1356
1658
  }
1659
+ // IS-A: hydrate parent chain from loaded data before populating self.
1660
+ // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
1661
+ // then populates from data. This must happen before self's SetMany so parents
1662
+ // are in clean state when they receive routed field values.
1663
+ if (this._parentEntity) {
1664
+ this._parentEntity.Hydrate(data);
1665
+ }
1357
1666
  this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
1358
1667
  if (EntityRelationshipsToLoad) {
1359
1668
  for (let relationship of EntityRelationshipsToLoad) {
@@ -1366,6 +1675,8 @@ export class BaseEntity {
1366
1675
  this._recordLoaded = true;
1367
1676
  this._everSaved = true; // Mark as saved since we loaded from database
1368
1677
  this._compositeKey = CompositeKey; // set the composite key to the one we just loaded
1678
+ // Cache the record name for faster lookups
1679
+ this.CacheRecordName();
1369
1680
  // Raise load completion event
1370
1681
  this.RaiseEvent('load_complete', { CompositeKey });
1371
1682
  return true;
@@ -1429,11 +1740,16 @@ export class BaseEntity {
1429
1740
  * @returns Promise<boolean> - Returns true if the load was successful
1430
1741
  */
1431
1742
  async LoadFromData(data, _replaceOldValues = false) {
1743
+ // IS-A: hydrate parent chain from data before populating self.
1744
+ // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
1745
+ // then populates from data, ensuring correct PK and saved state on parents.
1746
+ if (this._parentEntity) {
1747
+ this._parentEntity.Hydrate(data);
1748
+ }
1432
1749
  this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
1433
1750
  // now, check to see if we have the primary key set, if so, we should consider ourselves
1434
1751
  // loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
1435
1752
  if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
1436
- // chck each pkey's value to make sur it is set
1437
1753
  this._recordLoaded = true; // all primary keys are set, so we are loaded
1438
1754
  this._everSaved = true; // Mark as saved since we loaded from data
1439
1755
  for (let pkey of this.PrimaryKeys) {
@@ -1442,6 +1758,10 @@ export class BaseEntity {
1442
1758
  this._everSaved = false; // if any primary key is not set, we cannot consider ourselves loaded
1443
1759
  }
1444
1760
  }
1761
+ // Cache the record name for faster lookups if successfully loaded
1762
+ if (this._recordLoaded) {
1763
+ this.CacheRecordName();
1764
+ }
1445
1765
  }
1446
1766
  else {
1447
1767
  // this is an error state as every entity must have > 0 primary keys defined
@@ -1460,7 +1780,19 @@ export class BaseEntity {
1460
1780
  Validate() {
1461
1781
  const result = new ValidationResult();
1462
1782
  result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
1783
+ // IS-A composition: validate parent entity first to collect all chain errors
1784
+ if (this._parentEntity) {
1785
+ const parentResult = this._parentEntity.Validate();
1786
+ if (!parentResult.Success) {
1787
+ result.Success = false;
1788
+ parentResult.Errors.forEach(err => result.Errors.push(err));
1789
+ }
1790
+ }
1791
+ // Validate own fields — for IS-A entities, skip parent field mirrors since
1792
+ // those are validated via _parentEntity above
1463
1793
  for (let field of this.Fields) {
1794
+ if (this._parentEntityFieldNames?.has(field.Name))
1795
+ continue; // skip parent field mirrors — authoritative validation is on _parentEntity
1464
1796
  const err = field.Validate();
1465
1797
  err.Errors.forEach(element => {
1466
1798
  result.Errors.push(element);
@@ -1541,6 +1873,42 @@ export class BaseEntity {
1541
1873
  throw new Error('No provider set');
1542
1874
  }
1543
1875
  else {
1876
+ const _options = options ? options : new EntityDeleteOptions();
1877
+ // IS-A orchestration: determine if this is the initiating delete
1878
+ const hasParentChain = this._parentEntity != null;
1879
+ const isISAInitiator = hasParentChain && !_options.IsParentEntityDelete;
1880
+ // IS-A parent delete protection: prevent deleting a parent record
1881
+ // that has child records — FK constraints require child deletion first.
1882
+ // If CascadeDeletes is enabled, auto-delete child records through the chain.
1883
+ if (this.EntityInfo.IsParentType && !_options.IsParentEntityDelete) {
1884
+ const childCheck = await this.CheckForChildRecords();
1885
+ if (childCheck.HasChildren) {
1886
+ if (this.EntityInfo.CascadeDeletes) {
1887
+ // CascadeDeletes enabled: auto-delete the child record
1888
+ // This will recursively cascade through the IS-A chain
1889
+ const cascadeResult = await this.CascadeDeleteChildRecord(childCheck, _options);
1890
+ if (!cascadeResult) {
1891
+ throw new Error(`Cascade delete failed for ${childCheck.ChildEntityName} child record ` +
1892
+ `of ${this.EntityInfo.Name} '${this.PrimaryKey.Values()}'.`);
1893
+ }
1894
+ // After cascade, proceed with our own delete (parent chain will be handled)
1895
+ }
1896
+ else {
1897
+ throw new Error(`Cannot delete ${this.EntityInfo.Name} record '${this.PrimaryKey.Values()}': ` +
1898
+ `it is referenced as a ${childCheck.ChildEntityName} record. ` +
1899
+ `Delete the ${childCheck.ChildEntityName} record first, ` +
1900
+ `or enable CascadeDeletes on the ${this.EntityInfo.Name} entity.`);
1901
+ }
1902
+ }
1903
+ }
1904
+ // Begin provider transaction if IS-A initiator and NOT in a TransactionGroup
1905
+ if (isISAInitiator && !this.TransactionGroup) {
1906
+ const txn = await this.ProviderToUse?.BeginISATransaction?.();
1907
+ if (txn) {
1908
+ this.ProviderTransaction = txn;
1909
+ this.PropagateTransactionToParents();
1910
+ }
1911
+ }
1544
1912
  this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
1545
1913
  // Raise delete_started event before the actual delete operation begins
1546
1914
  this.RaiseEvent('delete_started', null);
@@ -1553,7 +1921,27 @@ export class BaseEntity {
1553
1921
  includeRelatedEntityData: false,
1554
1922
  relatedEntityList: null
1555
1923
  });
1556
- if (await this.ProviderToUse.Delete(this, options, this.ActiveUser)) {
1924
+ // Delete OWN row first (FK constraint: child must be deleted before parent)
1925
+ if (await this.ProviderToUse.Delete(this, _options, this.ActiveUser)) {
1926
+ // IS-A: after own delete succeeds, cascade to parent chain
1927
+ if (hasParentChain) {
1928
+ const parentDeleteOptions = new EntityDeleteOptions();
1929
+ parentDeleteOptions.SkipEntityAIActions = _options.SkipEntityAIActions;
1930
+ parentDeleteOptions.SkipEntityActions = _options.SkipEntityActions;
1931
+ parentDeleteOptions.ReplayOnly = _options.ReplayOnly;
1932
+ parentDeleteOptions.IsParentEntityDelete = true;
1933
+ const parentResult = await this._parentEntity.Delete(parentDeleteOptions);
1934
+ if (!parentResult) {
1935
+ // Parent delete failed — rollback if we started the transaction
1936
+ await this.RollbackISATransaction(isISAInitiator);
1937
+ return false;
1938
+ }
1939
+ }
1940
+ // IS-A: commit transaction after successful chain delete
1941
+ if (isISAInitiator && this.ProviderTransaction) {
1942
+ await this.ProviderToUse.CommitISATransaction?.(this.ProviderTransaction);
1943
+ this.ProviderTransaction = null;
1944
+ }
1557
1945
  if (!this.TransactionGroup) {
1558
1946
  // NOT part of a transaction - raise event immediately
1559
1947
  // record deleted correctly
@@ -1584,11 +1972,14 @@ export class BaseEntity {
1584
1972
  }
1585
1973
  return true;
1586
1974
  }
1587
- else // record didn't save, return false, but also don't wipe out the entity like we do if the Delete() worked
1975
+ else // record didn't delete, return false, but also don't wipe out the entity like we do if the Delete() worked
1588
1976
  return false;
1589
1977
  }
1590
1978
  }
1591
1979
  catch (e) {
1980
+ // IS-A: rollback transaction on failure (only the initiator rolls back)
1981
+ const isISAInitiator = this._parentEntity != null && !options?.IsParentEntityDelete;
1982
+ await this.RollbackISATransaction(isISAInitiator);
1592
1983
  if (currentResultCount === this.ResultHistory.length) {
1593
1984
  // this means that NO new results were added to the history anywhere
1594
1985
  // so we need to add a new result to the history here
@@ -1603,6 +1994,155 @@ export class BaseEntity {
1603
1994
  return false;
1604
1995
  }
1605
1996
  }
1997
+ /**
1998
+ * Checks if this entity has any child records in IS-A child entity tables.
1999
+ * Used for parent delete protection — a parent record cannot be deleted
2000
+ * while child type records referencing it still exist.
2001
+ * @returns Object with HasChildren flag and the name of the child entity found
2002
+ */
2003
+ async CheckForChildRecords() {
2004
+ const childEntities = this.EntityInfo.ChildEntities;
2005
+ if (childEntities.length === 0) {
2006
+ return { HasChildren: false, ChildEntityName: '' };
2007
+ }
2008
+ // Use RunView to check each child entity for records with our PK
2009
+ const rv = new RunView();
2010
+ const pkValue = this.PrimaryKey.Values();
2011
+ for (const childEntity of childEntities) {
2012
+ const pkField = childEntity.PrimaryKeys[0];
2013
+ if (!pkField)
2014
+ continue;
2015
+ const result = await rv.RunView({
2016
+ EntityName: childEntity.Name,
2017
+ ExtraFilter: `${pkField.Name} = '${pkValue}'`,
2018
+ ResultType: 'simple',
2019
+ Fields: [pkField.Name],
2020
+ MaxRows: 1
2021
+ }, this._contextCurrentUser);
2022
+ if (result && result.Success && result.Results && result.Results.length > 0) {
2023
+ return { HasChildren: true, ChildEntityName: childEntity.Name };
2024
+ }
2025
+ }
2026
+ return { HasChildren: false, ChildEntityName: '' };
2027
+ }
2028
+ /**
2029
+ * Cascade-deletes an IS-A child record when the parent entity has CascadeDeletes enabled.
2030
+ * Loads the child entity, then deletes it through the normal IS-A chain. The child's
2031
+ * delete will cascade further down if it also has children and CascadeDeletes.
2032
+ */
2033
+ async CascadeDeleteChildRecord(childCheck, parentOptions) {
2034
+ const md = new Metadata();
2035
+ const childEntity = await md.GetEntityObject(childCheck.ChildEntityName, this._contextCurrentUser);
2036
+ if (!childEntity)
2037
+ return false;
2038
+ // Load the child record using the same PK as this parent
2039
+ const loaded = await childEntity.InnerLoad(this.PrimaryKey);
2040
+ if (!loaded)
2041
+ return false;
2042
+ // If the child entity itself is a parent with cascade, its delete will
2043
+ // handle its own children recursively through _InnerDelete
2044
+ const deleteOptions = new EntityDeleteOptions();
2045
+ deleteOptions.SkipEntityAIActions = parentOptions.SkipEntityAIActions;
2046
+ deleteOptions.SkipEntityActions = parentOptions.SkipEntityActions;
2047
+ deleteOptions.ReplayOnly = parentOptions.ReplayOnly;
2048
+ // Note: do NOT set IsParentEntityDelete — the child is the chain initiator
2049
+ return childEntity.Delete(deleteOptions);
2050
+ }
2051
+ /**
2052
+ * Resolves the leaf (most-derived) entity type for a given parent entity record.
2053
+ * Walks down the IS-A child hierarchy to find which child type a record belongs to.
2054
+ * Returns the child entity name, or the parent's own name if no children exist.
2055
+ * Useful for polymorphic operations where you have a parent record and need
2056
+ * to know its actual leaf type.
2057
+ *
2058
+ * @param entityName The parent entity name
2059
+ * @param primaryKey The primary key to look up
2060
+ * @param contextUser Optional context user for server-side operations
2061
+ * @returns The leaf entity name and whether it was resolved to a child type
2062
+ */
2063
+ static async ResolveLeafEntity(entityName, primaryKey, contextUser) {
2064
+ const md = new Metadata();
2065
+ const entityInfo = md.Entities.find(e => e.Name === entityName);
2066
+ if (!entityInfo) {
2067
+ return { LeafEntityName: entityName, IsLeaf: true };
2068
+ }
2069
+ return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
2070
+ }
2071
+ /**
2072
+ * Internal recursive helper for leaf entity resolution.
2073
+ * Walks down the child hierarchy until no more children are found.
2074
+ */
2075
+ static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
2076
+ const childEntities = entityInfo.ChildEntities;
2077
+ if (childEntities.length === 0) {
2078
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
2079
+ }
2080
+ const rv = new RunView();
2081
+ const pkValue = primaryKey.Values();
2082
+ for (const child of childEntities) {
2083
+ const childPK = child.PrimaryKeys[0];
2084
+ if (!childPK)
2085
+ continue;
2086
+ const result = await rv.RunView({
2087
+ EntityName: child.Name,
2088
+ ExtraFilter: `${childPK.Name} = '${pkValue}'`,
2089
+ ResultType: 'simple',
2090
+ Fields: [childPK.Name],
2091
+ MaxRows: 1
2092
+ }, contextUser);
2093
+ if (result?.Success && result.Results?.length > 0) {
2094
+ // Found a child — recurse to see if there's an even more specific leaf
2095
+ return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
2096
+ }
2097
+ }
2098
+ // No child found — this entity IS the leaf
2099
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
2100
+ }
2101
+ /**
2102
+ * Enforces disjoint subtype constraint during IS-A child entity creation.
2103
+ * A parent record can only be ONE child type at a time. Checks all sibling
2104
+ * child types (excluding self) for records with the same PK value.
2105
+ * Throws if a sibling child record is found.
2106
+ *
2107
+ * Only runs on Database providers — client-side (Network/GraphQL) skips this
2108
+ * because the server-side save will perform the check authoritatively.
2109
+ */
2110
+ async EnforceDisjointSubtype() {
2111
+ // Skip on client-side providers — the server will enforce this during its save
2112
+ const md = this.ProviderToUse;
2113
+ if (md.ProviderType !== ProviderType.Database)
2114
+ return;
2115
+ const parentEntityInfo = this.EntityInfo.ParentEntityInfo;
2116
+ if (!parentEntityInfo)
2117
+ return;
2118
+ const siblingChildEntities = parentEntityInfo.ChildEntities.filter(e => e.ID !== this.EntityInfo.ID);
2119
+ if (siblingChildEntities.length === 0)
2120
+ return;
2121
+ const pkValue = this.PrimaryKey.Values();
2122
+ if (!pkValue)
2123
+ return;
2124
+ // Build all sibling queries and execute them in a single batch
2125
+ const rv = new RunView();
2126
+ const validSiblings = siblingChildEntities.filter(s => s.PrimaryKeys[0]);
2127
+ if (validSiblings.length === 0)
2128
+ return;
2129
+ const viewParams = validSiblings.map(sibling => ({
2130
+ EntityName: sibling.Name,
2131
+ ExtraFilter: `${sibling.PrimaryKeys[0].Name} = '${pkValue}'`,
2132
+ ResultType: 'simple',
2133
+ Fields: [sibling.PrimaryKeys[0].Name],
2134
+ MaxRows: 1
2135
+ }));
2136
+ const results = await rv.RunViews(viewParams, this._contextCurrentUser);
2137
+ for (let i = 0; i < results.length; i++) {
2138
+ const result = results[i];
2139
+ if (result?.Success && result.Results?.length > 0) {
2140
+ const sibling = validSiblings[i];
2141
+ throw new Error(`Cannot create ${this.EntityInfo.Name} record: ID '${pkValue}' already exists as ${sibling.Name}. ` +
2142
+ `A ${parentEntityInfo.Name} record can only be one child type at a time.`);
2143
+ }
2144
+ }
2145
+ }
1606
2146
  /**
1607
2147
  * Called before an Action is executed by the AI Engine
1608
2148
  * This is intended to be overriden by subclass as needed, these methods called at the right time by the execution context