@memberjunction/core 3.4.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 (107) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +1 -1
  2. package/dist/generic/InMemoryLocalStorageProvider.js +2 -6
  3. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  4. package/dist/generic/QueryCache.d.ts +1 -1
  5. package/dist/generic/QueryCache.js +6 -10
  6. package/dist/generic/QueryCache.js.map +1 -1
  7. package/dist/generic/QueryCacheConfig.js +1 -2
  8. package/dist/generic/RegisterForStartup.d.ts +2 -2
  9. package/dist/generic/RegisterForStartup.js +7 -12
  10. package/dist/generic/RegisterForStartup.js.map +1 -1
  11. package/dist/generic/applicationInfo.d.ts +3 -3
  12. package/dist/generic/applicationInfo.js +4 -10
  13. package/dist/generic/applicationInfo.js.map +1 -1
  14. package/dist/generic/authEvaluator.d.ts +1 -1
  15. package/dist/generic/authEvaluator.js +4 -8
  16. package/dist/generic/authEvaluator.js.map +1 -1
  17. package/dist/generic/authTypes.js +1 -4
  18. package/dist/generic/authTypes.js.map +1 -1
  19. package/dist/generic/baseEngine.d.ts +5 -5
  20. package/dist/generic/baseEngine.js +51 -56
  21. package/dist/generic/baseEngine.js.map +1 -1
  22. package/dist/generic/baseEngineRegistry.js +13 -17
  23. package/dist/generic/baseEngineRegistry.js.map +1 -1
  24. package/dist/generic/baseEntity.d.ts +171 -5
  25. package/dist/generic/baseEntity.d.ts.map +1 -1
  26. package/dist/generic/baseEntity.js +651 -121
  27. package/dist/generic/baseEntity.js.map +1 -1
  28. package/dist/generic/baseInfo.js +3 -7
  29. package/dist/generic/baseInfo.js.map +1 -1
  30. package/dist/generic/compositeKey.d.ts +2 -2
  31. package/dist/generic/compositeKey.js +5 -11
  32. package/dist/generic/compositeKey.js.map +1 -1
  33. package/dist/generic/databaseProviderBase.d.ts +2 -2
  34. package/dist/generic/databaseProviderBase.js +2 -6
  35. package/dist/generic/databaseProviderBase.js.map +1 -1
  36. package/dist/generic/entityInfo.d.ts +84 -5
  37. package/dist/generic/entityInfo.d.ts.map +1 -1
  38. package/dist/generic/entityInfo.js +235 -108
  39. package/dist/generic/entityInfo.js.map +1 -1
  40. package/dist/generic/explorerNavigationItem.d.ts +1 -1
  41. package/dist/generic/explorerNavigationItem.js +2 -6
  42. package/dist/generic/explorerNavigationItem.js.map +1 -1
  43. package/dist/generic/graphqlTypeNames.d.ts +1 -1
  44. package/dist/generic/graphqlTypeNames.js +4 -9
  45. package/dist/generic/graphqlTypeNames.js.map +1 -1
  46. package/dist/generic/interfaces.d.ts +104 -14
  47. package/dist/generic/interfaces.d.ts.map +1 -1
  48. package/dist/generic/interfaces.js +28 -30
  49. package/dist/generic/interfaces.js.map +1 -1
  50. package/dist/generic/libraryInfo.d.ts +1 -1
  51. package/dist/generic/libraryInfo.js +2 -6
  52. package/dist/generic/libraryInfo.js.map +1 -1
  53. package/dist/generic/localCacheManager.d.ts +2 -2
  54. package/dist/generic/localCacheManager.js +44 -48
  55. package/dist/generic/localCacheManager.js.map +1 -1
  56. package/dist/generic/logging.d.ts.map +1 -1
  57. package/dist/generic/logging.js +54 -67
  58. package/dist/generic/logging.js.map +1 -1
  59. package/dist/generic/metadata.d.ts +12 -12
  60. package/dist/generic/metadata.d.ts.map +1 -1
  61. package/dist/generic/metadata.js +21 -25
  62. package/dist/generic/metadata.js.map +1 -1
  63. package/dist/generic/metadataUtil.d.ts +1 -1
  64. package/dist/generic/metadataUtil.js +3 -7
  65. package/dist/generic/metadataUtil.js.map +1 -1
  66. package/dist/generic/providerBase.d.ts +63 -16
  67. package/dist/generic/providerBase.d.ts.map +1 -1
  68. package/dist/generic/providerBase.js +253 -130
  69. package/dist/generic/providerBase.js.map +1 -1
  70. package/dist/generic/queryInfo.d.ts +5 -5
  71. package/dist/generic/queryInfo.js +21 -30
  72. package/dist/generic/queryInfo.js.map +1 -1
  73. package/dist/generic/queryInfoInterfaces.js +1 -2
  74. package/dist/generic/queryInfoInterfaces.js.map +1 -1
  75. package/dist/generic/querySQLFilters.js +5 -10
  76. package/dist/generic/querySQLFilters.js.map +1 -1
  77. package/dist/generic/runQuery.d.ts +2 -2
  78. package/dist/generic/runQuery.js +5 -9
  79. package/dist/generic/runQuery.js.map +1 -1
  80. package/dist/generic/runQuerySQLFilterImplementations.d.ts +1 -1
  81. package/dist/generic/runQuerySQLFilterImplementations.js +4 -8
  82. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  83. package/dist/generic/runReport.d.ts +2 -2
  84. package/dist/generic/runReport.js +5 -9
  85. package/dist/generic/runReport.js.map +1 -1
  86. package/dist/generic/securityInfo.d.ts +2 -2
  87. package/dist/generic/securityInfo.js +10 -20
  88. package/dist/generic/securityInfo.js.map +1 -1
  89. package/dist/generic/telemetryManager.js +20 -32
  90. package/dist/generic/telemetryManager.js.map +1 -1
  91. package/dist/generic/transactionGroup.d.ts +1 -1
  92. package/dist/generic/transactionGroup.d.ts.map +1 -1
  93. package/dist/generic/transactionGroup.js +11 -19
  94. package/dist/generic/transactionGroup.js.map +1 -1
  95. package/dist/generic/util.js +15 -31
  96. package/dist/generic/util.js.map +1 -1
  97. package/dist/index.d.ts +34 -34
  98. package/dist/index.js +45 -63
  99. package/dist/index.js.map +1 -1
  100. package/dist/views/runView.d.ts +3 -3
  101. package/dist/views/runView.js +6 -11
  102. package/dist/views/runView.js.map +1 -1
  103. package/dist/views/viewInfo.d.ts +3 -3
  104. package/dist/views/viewInfo.js +10 -17
  105. package/dist/views/viewInfo.js.map +1 -1
  106. package/package.json +11 -10
  107. package/readme.md +871 -1271
@@ -1,19 +1,31 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BaseEntity = exports.BaseEntityEvent = exports.BaseEntityResult = exports.BaseEntityAIActionParams = exports.DataObjectParams = exports.DataObjectRelatedEntityParam = exports.EntityField = void 0;
4
- const global_1 = require("@memberjunction/global");
5
- const entityInfo_1 = require("./entityInfo");
6
- const interfaces_1 = require("./interfaces");
7
- const metadata_1 = require("./metadata");
8
- const runView_1 = require("../views/runView");
9
- const logging_1 = require("./logging");
10
- const compositeKey_1 = require("./compositeKey");
11
- const rxjs_1 = require("rxjs");
1
+ import { MJEventType, MJGlobal, uuidv4, WarningManager } from '@memberjunction/global';
2
+ import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
3
+ import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
4
+ import { Metadata } from './metadata.js';
5
+ import { RunView } from '../views/runView.js';
6
+ import { LogDebug, LogError } from './logging.js';
7
+ import { CompositeKey } from './compositeKey.js';
8
+ import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
12
9
  /**
13
10
  * Represents a field in an instance of the BaseEntity class. This class is used to store the value of the field, dirty state, as well as other run-time information about the field. The class encapsulates the underlying field metadata and exposes some of the more commonly
14
11
  * used properties from the entity field metadata.
15
12
  */
16
- class EntityField {
13
+ export class EntityField {
14
+ /**
15
+ * Static object containing the value ranges for various SQL number types.
16
+ * This is used to validate the value of the field when it is set or validated.
17
+ */
18
+ static { this.SQLTypeValueRanges = {
19
+ "int": { min: -2147483648, max: 2147483647 },
20
+ "bigint": { min: -9223372036854775808, max: 9223372036854775807 },
21
+ "smallint": { min: -32768, max: 32767 },
22
+ "tinyint": { min: 0, max: 255 },
23
+ "decimal": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
24
+ "numeric": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
25
+ "float": { min: -1.7976931348623157e+308, max: 1.7976931348623157e+308 },
26
+ "real": { min: -3.402823466e+38, max: 3.402823466e+38 },
27
+ "money": { min: -922337203685477.5808, max: 922337203685477.5807 },
28
+ }; }
17
29
  get Name() {
18
30
  return this._entityFieldInfo.Name;
19
31
  }
@@ -45,7 +57,7 @@ class EntityField {
45
57
  // Asserting status here for deprecated or disabled fields, not in constructor because
46
58
  // we legacy fields will exist
47
59
  if (this._assertActiveStatusRequired) {
48
- entityInfo_1.EntityFieldInfo.AssertEntityFieldActiveStatus(this._entityFieldInfo, 'EntityField.Value setter');
60
+ EntityFieldInfo.AssertEntityFieldActiveStatus(this._entityFieldInfo, 'EntityField.Value setter');
49
61
  }
50
62
  return this._Value;
51
63
  }
@@ -75,7 +87,7 @@ class EntityField {
75
87
  // asserting status here becuase the flag is on AND the values
76
88
  // are different - this avoid assertions during sysops like SetMany that often aren't changing
77
89
  // the value of the field
78
- entityInfo_1.EntityFieldInfo.AssertEntityFieldActiveStatus(this._entityFieldInfo, 'EntityField.Value setter');
90
+ EntityFieldInfo.AssertEntityFieldActiveStatus(this._entityFieldInfo, 'EntityField.Value setter');
79
91
  }
80
92
  if (!this.ReadOnly ||
81
93
  this._NeverSet /* Allow one time set of any field because BaseEntity Object passes in ReadOnly fields when we load,
@@ -121,7 +133,7 @@ class EntityField {
121
133
  newCompare = this.Value.getTime();
122
134
  }
123
135
  // Special handling for bit/Boolean types - treat truthy values as equivalent
124
- if (this._entityFieldInfo.TSType === entityInfo_1.EntityFieldTSType.Boolean ||
136
+ if (this._entityFieldInfo.TSType === EntityFieldTSType.Boolean ||
125
137
  this._entityFieldInfo.Type.toLowerCase() === 'bit') {
126
138
  // Convert both values to boolean for comparison
127
139
  const oldBool = this.convertToBoolean(oldCompare);
@@ -129,7 +141,7 @@ class EntityField {
129
141
  return oldBool !== newBool;
130
142
  }
131
143
  // Special handling for numeric types - treat numeric strings that convert to same value as equivalent
132
- if (this._entityFieldInfo.TSType === entityInfo_1.EntityFieldTSType.Number || this.isNumericType(this._entityFieldInfo.Type)) {
144
+ if (this._entityFieldInfo.TSType === EntityFieldTSType.Number || this.isNumericType(this._entityFieldInfo.Type)) {
133
145
  const oldNum = this.convertToNumber(oldCompare);
134
146
  const newNum = this.convertToNumber(newCompare);
135
147
  // Handle NaN cases - if both are NaN, they're equivalent
@@ -143,7 +155,7 @@ class EntityField {
143
155
  return oldNum !== newNum;
144
156
  }
145
157
  // for string types where the comparisons are not both strings
146
- if (this._entityFieldInfo.TSType === entityInfo_1.EntityFieldTSType.String) {
158
+ if (this._entityFieldInfo.TSType === EntityFieldTSType.String) {
147
159
  if (typeof oldCompare === 'object') {
148
160
  // need to convert the object to a string for comparison
149
161
  oldCompare = JSON.stringify(oldCompare);
@@ -228,7 +240,7 @@ class EntityField {
228
240
  */
229
241
  Validate() {
230
242
  const ef = this._entityFieldInfo;
231
- const result = new entityInfo_1.ValidationResult();
243
+ const result = new ValidationResult();
232
244
  result.Success = true; // assume success
233
245
  if (!ef.ReadOnly && !ef.SkipValidation) {
234
246
  // only do validation on updatable fields and skip the special case fields defined inside the SkipValidation property (like ID/CreatedAt/UpdatedAt)
@@ -237,24 +249,24 @@ class EntityField {
237
249
  if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
238
250
  // we have no default value, so this is an error
239
251
  result.Success = false;
240
- result.Errors.push(new entityInfo_1.ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
252
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
241
253
  }
242
254
  else {
243
255
  // 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
244
256
  if (this._OldValue !== null && this._OldValue !== undefined) {
245
257
  result.Success = false;
246
- result.Errors.push(new entityInfo_1.ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
258
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
247
259
  }
248
260
  }
249
261
  }
250
- if (ef.TSType == entityInfo_1.EntityFieldTSType.String && ef.MaxLength > 0 && this.Value && this.Value.length > ef.MaxLength) {
262
+ if (ef.TSType == EntityFieldTSType.String && ef.MaxLength > 0 && this.Value && this.Value.length > ef.MaxLength) {
251
263
  result.Success = false;
252
- result.Errors.push(new entityInfo_1.ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be longer than ${ef.MaxLength} characters. Current value is ${this.Value.length} characters`, this.Value));
264
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be longer than ${ef.MaxLength} characters. Current value is ${this.Value.length} characters`, this.Value));
253
265
  }
254
- if (ef.TSType == entityInfo_1.EntityFieldTSType.Date && (this.Value !== null && this.Value !== undefined && !(this.Value instanceof Date))) {
266
+ if (ef.TSType == EntityFieldTSType.Date && (this.Value !== null && this.Value !== undefined && !(this.Value instanceof Date))) {
255
267
  // invalid non-null date, but that is okay if we are a new record and we have a default value
256
268
  result.Success = false;
257
- result.Errors.push(new entityInfo_1.ValidationErrorInfo(ef.Name, `${this.Value} is not a valid date for ${ef.DisplayNameOrName}`, this.Value));
269
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${this.Value} is not a valid date for ${ef.DisplayNameOrName}`, this.Value));
258
270
  }
259
271
  // add validation to ensure a number value is within range based on the
260
272
  // underlying SQL type
@@ -263,7 +275,7 @@ class EntityField {
263
275
  if (typeLookup) {
264
276
  if (this.Value < typeLookup.min || this.Value > typeLookup.max) {
265
277
  result.Success = false;
266
- result.Errors.push(new entityInfo_1.ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} is ${ef.SQLFullType} in the database and must be a valid number between ${-typeLookup.min} and ${typeLookup.max}. Current value is ${this.Value}`, this.Value));
278
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} is ${ef.SQLFullType} in the database and must be a valid number between ${-typeLookup.min} and ${typeLookup.max}. Current value is ${this.Value}`, this.Value));
267
279
  }
268
280
  }
269
281
  }
@@ -288,14 +300,14 @@ class EntityField {
288
300
  this.Value = Value;
289
301
  }
290
302
  else if (fieldInfo.DefaultValue) {
291
- if (fieldInfo.TSType === entityInfo_1.EntityFieldTSType.Boolean) {
303
+ if (fieldInfo.TSType === EntityFieldTSType.Boolean) {
292
304
  // special handling for booleans as we don't want a string passed into a boolean field, we want a true boolean
293
305
  if (typeof fieldInfo.DefaultValue === "string" && fieldInfo.DefaultValue.trim() === "1" || fieldInfo.DefaultValue.trim().toLowerCase() === "true")
294
306
  this.Value = true;
295
307
  else
296
308
  this.Value = false;
297
309
  }
298
- else if (fieldInfo.TSType === entityInfo_1.EntityFieldTSType.Number) {
310
+ else if (fieldInfo.TSType === EntityFieldTSType.Number) {
299
311
  // special handling for numbers as we don't want a string passed into a value for a numeric field
300
312
  if (!isNaN(Number(fieldInfo.DefaultValue))) {
301
313
  this.Value = Number(fieldInfo.DefaultValue);
@@ -308,7 +320,7 @@ class EntityField {
308
320
  // special handling for GUIDs, we don't want to populate anything here because the server always sets the value, leave blank
309
321
  this.Value = null;
310
322
  }
311
- else if (fieldInfo.TSType === entityInfo_1.EntityFieldTSType.Date) {
323
+ else if (fieldInfo.TSType === EntityFieldTSType.Date) {
312
324
  if (fieldInfo.DefaultValue.trim().length > 0) {
313
325
  // 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
314
326
  try {
@@ -324,7 +336,7 @@ class EntityField {
324
336
  }
325
337
  catch (e) {
326
338
  // 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
327
- if (entityInfo_1.EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(fieldInfo.DefaultValue)) {
339
+ if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(fieldInfo.DefaultValue)) {
328
340
  // 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
329
341
  //setting the value, otherwise set the value to the current date
330
342
  if (fieldInfo.IsSpecialDateField) {
@@ -379,26 +391,9 @@ class EntityField {
379
391
  return this._OldValue;
380
392
  }
381
393
  }
382
- exports.EntityField = EntityField;
383
- /**
384
- * Static object containing the value ranges for various SQL number types.
385
- * This is used to validate the value of the field when it is set or validated.
386
- */
387
- EntityField.SQLTypeValueRanges = {
388
- "int": { min: -2147483648, max: 2147483647 },
389
- "bigint": { min: -9223372036854775808, max: 9223372036854775807 },
390
- "smallint": { min: -32768, max: 32767 },
391
- "tinyint": { min: 0, max: 255 },
392
- "decimal": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
393
- "numeric": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
394
- "float": { min: -1.7976931348623157e+308, max: 1.7976931348623157e+308 },
395
- "real": { min: -3.402823466e+38, max: 3.402823466e+38 },
396
- "money": { min: -922337203685477.5808, max: 922337203685477.5807 },
397
- };
398
- class DataObjectRelatedEntityParam {
394
+ export class DataObjectRelatedEntityParam {
399
395
  }
400
- exports.DataObjectRelatedEntityParam = DataObjectRelatedEntityParam;
401
- class DataObjectParams {
396
+ export class DataObjectParams {
402
397
  constructor(oldValues = false, omitNullValues = false, omitEmptyStrings = false, excludeFields = [], includeRelatedEntityData = false, relatedEntityList = []) {
403
398
  this.oldValues = oldValues;
404
399
  this.omitNullValues = omitNullValues;
@@ -408,14 +403,12 @@ class DataObjectParams {
408
403
  this.relatedEntityList = relatedEntityList;
409
404
  }
410
405
  }
411
- exports.DataObjectParams = DataObjectParams;
412
- class BaseEntityAIActionParams {
406
+ export class BaseEntityAIActionParams {
413
407
  }
414
- exports.BaseEntityAIActionParams = BaseEntityAIActionParams;
415
408
  /**
416
409
  * Used for storing the result of a Save or Delete or other transactional operation within a BaseEntity
417
410
  */
418
- class BaseEntityResult {
411
+ export class BaseEntityResult {
419
412
  constructor(success, message, type) {
420
413
  /**
421
414
  * A copy of the values of the entity object BEFORE the operation was performed
@@ -463,18 +456,32 @@ class BaseEntityResult {
463
456
  return msg;
464
457
  }
465
458
  }
466
- exports.BaseEntityResult = BaseEntityResult;
467
459
  /**
468
460
  * Event type that is used to raise events and provided structured callbacks for any caller that is interested in registering for events.
469
461
  * This type is also used for whenever a BaseEntity instance raises an event with MJGlobal.
470
462
  */
471
- class BaseEntityEvent {
463
+ export class BaseEntityEvent {
472
464
  }
473
- exports.BaseEntityEvent = BaseEntityEvent;
474
465
  /**
475
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.
476
467
  */
477
- class BaseEntity {
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; }
478
485
  constructor(Entity, Provider = null) {
479
486
  this._Fields = [];
480
487
  this._recordLoaded = false;
@@ -485,6 +492,33 @@ class BaseEntity {
485
492
  this._everSaved = false;
486
493
  this._isLoading = false;
487
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;
488
522
  this._compositeKey = null;
489
523
  // Holds the current pending save observable (if any)
490
524
  this._pendingSave$ = null;
@@ -494,12 +528,82 @@ class BaseEntity {
494
528
  * that embed multiple fields if desired.
495
529
  */
496
530
  this._vectors = new Map();
497
- this._eventSubject = new rxjs_1.Subject();
531
+ this._eventSubject = new Subject();
498
532
  this._EntityInfo = Entity;
499
- entityInfo_1.EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
533
+ EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
500
534
  this._provider = Provider;
501
535
  this.init();
502
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
+ }
503
607
  /**
504
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.
505
609
  */
@@ -552,6 +656,7 @@ class BaseEntity {
552
656
  RaiseReadyForTransaction() {
553
657
  this.RaiseEvent('transaction_ready', null);
554
658
  }
659
+ static { this._baseEventCode = 'BaseEntityEvent'; }
555
660
  /**
556
661
  * 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.
557
662
  * Any MJGlobal event that is raised by a BaseEntity class will use a BaseEntityEvent type as the args parameter
@@ -564,7 +669,7 @@ class BaseEntity {
564
669
  */
565
670
  RaiseEvent(type, payload, saveSubType = undefined) {
566
671
  // this is the local event handler that is specific to THIS instance of the entity object
567
- (0, logging_1.LogDebug)(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
672
+ LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
568
673
  this._eventSubject.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this });
569
674
  // this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
570
675
  // we broadcast save/delete/load events and their _started counterparts
@@ -575,10 +680,10 @@ class BaseEntity {
575
680
  event.payload = payload;
576
681
  event.type = type;
577
682
  event.saveSubType = saveSubType;
578
- (0, logging_1.LogDebug)(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
579
- global_1.MJGlobal.Instance.RaiseEvent({
683
+ LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
684
+ MJGlobal.Instance.RaiseEvent({
580
685
  component: this,
581
- event: global_1.MJEventType.ComponentEvent,
686
+ event: MJEventType.ComponentEvent,
582
687
  eventCode: BaseEntity.BaseEventCode,
583
688
  args: event
584
689
  });
@@ -758,7 +863,9 @@ class BaseEntity {
758
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.
759
864
  */
760
865
  get Dirty() {
761
- 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);
762
869
  }
763
870
  /**
764
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.
@@ -773,7 +880,7 @@ class BaseEntity {
773
880
  */
774
881
  get PrimaryKey() {
775
882
  if (this._compositeKey === null) {
776
- this._compositeKey = new compositeKey_1.CompositeKey();
883
+ this._compositeKey = new CompositeKey();
777
884
  this._compositeKey.LoadFromEntityFields(this.PrimaryKeys);
778
885
  }
779
886
  return this._compositeKey;
@@ -793,16 +900,36 @@ class BaseEntity {
793
900
  /**
794
901
  * Sets the value of a given field. If the field doesn't exist, nothing happens.
795
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
+ *
796
909
  * @param FieldName
797
910
  * @param Value
798
911
  */
799
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) {
800
927
  const field = this.GetFieldByName(FieldName);
801
928
  if (field != null) {
802
- if (field.EntityFieldInfo.TSType === entityInfo_1.EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
929
+ if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
803
930
  field.Value = new Date(Value);
804
931
  }
805
- else if (field.EntityFieldInfo.TSType === entityInfo_1.EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
932
+ else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
806
933
  const numericValue = Number(Value);
807
934
  if (!isNaN(numericValue)) {
808
935
  field.Value = numericValue;
@@ -811,7 +938,7 @@ class BaseEntity {
811
938
  field.Value = Value;
812
939
  }
813
940
  }
814
- else if (field.EntityFieldInfo.TSType === entityInfo_1.EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
941
+ else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
815
942
  if (typeof Value === 'string') {
816
943
  if (Value.trim() === '1' || Value.trim().toLowerCase() === 'true') {
817
944
  field.Value = true;
@@ -837,14 +964,22 @@ class BaseEntity {
837
964
  }
838
965
  /**
839
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
+ *
840
971
  * @param FieldName
841
972
  * @returns
842
973
  */
843
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
+ }
844
979
  const field = this.GetFieldByName(FieldName);
845
980
  if (field != null) {
846
981
  // if the field is a date and the value is a string, convert it to a date
847
- if (field.EntityFieldInfo.TSType === entityInfo_1.EntityFieldTSType.Date && (typeof field.Value === 'string' || typeof field.Value === 'number')) {
982
+ if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof field.Value === 'string' || typeof field.Value === 'number')) {
848
983
  field.Value = new Date(field.Value);
849
984
  }
850
985
  return field.Value;
@@ -855,6 +990,11 @@ class BaseEntity {
855
990
  * NOTE: Do not call this method directly. Use the {@link From} method instead
856
991
  *
857
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
+ *
858
998
  * @param object
859
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
860
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
@@ -871,7 +1011,8 @@ class BaseEntity {
871
1011
  if (ignoreActiveStatusAssertions) {
872
1012
  field.ActiveStatusAssertions = false; // disable active status assertions for this field
873
1013
  }
874
- 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]);
875
1016
  if (replaceOldValues) {
876
1017
  field.ResetOldValue();
877
1018
  }
@@ -888,7 +1029,8 @@ class BaseEntity {
888
1029
  if (ignoreActiveStatusAssertions) {
889
1030
  field.ActiveStatusAssertions = false; // disable active status assertions for this field
890
1031
  }
891
- 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]);
892
1034
  if (replaceOldValues) {
893
1035
  field.ResetOldValue();
894
1036
  }
@@ -897,16 +1039,56 @@ class BaseEntity {
897
1039
  }
898
1040
  }
899
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
+ }
900
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
901
1048
  if (!ignoreNonExistentFields)
902
1049
  throw new Error(`Field ${key} does not exist on ${this.EntityInfo.Name}`);
903
1050
  else {
904
1051
  // Record field-not-found warning - will be batched and displayed after debounce period
905
- global_1.WarningManager.Instance.RecordFieldNotFoundWarning(this.EntityInfo.Name, key, 'BaseEntity::SetMany');
1052
+ WarningManager.Instance.RecordFieldNotFoundWarning(this.EntityInfo.Name, key, 'BaseEntity::SetMany');
906
1053
  }
907
1054
  }
908
1055
  }
909
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
+ }
910
1092
  }
911
1093
  /**
912
1094
  * NOTE: Do not call this method directly. Use the {@link To} method instead
@@ -924,12 +1106,19 @@ class BaseEntity {
924
1106
  const tempStatus = field.ActiveStatusAssertions; // save the current active status assertions
925
1107
  field.ActiveStatusAssertions = false; // disable active status assertions for this field
926
1108
  obj[field.Name] = oldValues ? field.OldValue : field.Value;
927
- if (field.EntityFieldInfo.TSType == entityInfo_1.EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
1109
+ if (field.EntityFieldInfo.TSType == EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
928
1110
  obj[field.Name] = new Date(obj[field.Name]); // a timestamp, convert to JS Date Object
929
1111
  }
930
1112
  field.ActiveStatusAssertions = tempStatus; // restore the prior status for assertions
931
1113
  }
932
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
+ }
933
1122
  return obj;
934
1123
  }
935
1124
  /**
@@ -1009,8 +1198,8 @@ class BaseEntity {
1009
1198
  }
1010
1199
  async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
1011
1200
  // we need to query the database to get related entity info
1012
- const params = entityInfo_1.EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
1013
- const rv = new runView_1.RunView();
1201
+ const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
1202
+ const rv = new RunView();
1014
1203
  const result = await rv.RunView(params, this._contextCurrentUser);
1015
1204
  if (result && result.Success) {
1016
1205
  return {
@@ -1030,7 +1219,7 @@ class BaseEntity {
1030
1219
  for (const rawField of this.EntityInfo.Fields) {
1031
1220
  const key = this.EntityInfo.Name + '.' + rawField.Name;
1032
1221
  // support for sub-classes of the EntityField class
1033
- const newField = global_1.MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
1222
+ const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
1034
1223
  this.Fields.push(newField);
1035
1224
  }
1036
1225
  }
@@ -1059,7 +1248,7 @@ class BaseEntity {
1059
1248
  return true;
1060
1249
  }
1061
1250
  catch (e) {
1062
- (0, logging_1.LogError)(`Error in BaseEntity.CopyFrom: ${e}`);
1251
+ LogError(`Error in BaseEntity.CopyFrom: ${e}`);
1063
1252
  return false;
1064
1253
  }
1065
1254
  }
@@ -1090,7 +1279,7 @@ class BaseEntity {
1090
1279
  pk.Type.toLowerCase().trim() === 'uniqueidentifier' &&
1091
1280
  !this.Get(pk.Name)) {
1092
1281
  // Generate and set UUID for this primary key
1093
- const uuid = (0, global_1.uuidv4)();
1282
+ const uuid = uuidv4();
1094
1283
  this.Set(pk.Name, uuid);
1095
1284
  const field = this.GetFieldByName(pk.Name);
1096
1285
  if (field) {
@@ -1104,6 +1293,18 @@ class BaseEntity {
1104
1293
  this.Set(kv.FieldName, kv.Value);
1105
1294
  });
1106
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
+ }
1107
1308
  this.RaiseEvent('new_record', null);
1108
1309
  return true;
1109
1310
  }
@@ -1120,17 +1321,17 @@ class BaseEntity {
1120
1321
  async Save(options) {
1121
1322
  // If a save is already in progress, return its promise.
1122
1323
  if (this._pendingSave$) {
1123
- return (0, rxjs_1.firstValueFrom)(this._pendingSave$);
1324
+ return firstValueFrom(this._pendingSave$);
1124
1325
  }
1125
1326
  // Create a new observable that debounces duplicative calls, and executes the save.
1126
- this._pendingSave$ = (0, rxjs_1.of)(options).pipe(
1327
+ this._pendingSave$ = of(options).pipe(
1127
1328
  // Execute the actual save logic.
1128
- (0, rxjs_1.switchMap)(opts => (0, rxjs_1.from)(this._InnerSave(opts))),
1329
+ switchMap(opts => from(this._InnerSave(opts))),
1129
1330
  // When the save completes (whether successfully or not), clear the pending save observable.
1130
- (0, rxjs_1.finalize)(() => { this._pendingSave$ = null; }),
1331
+ finalize(() => { this._pendingSave$ = null; }),
1131
1332
  // Ensure that all subscribers get the same result.
1132
- (0, rxjs_1.shareReplay)(1));
1133
- return (0, rxjs_1.firstValueFrom)(this._pendingSave$);
1333
+ shareReplay(1));
1334
+ return firstValueFrom(this._pendingSave$);
1134
1335
  }
1135
1336
  /**
1136
1337
  * Private, internal method to handle saving the current state of the object to the database. This method is called by the public facing Save() method
@@ -1143,10 +1344,45 @@ class BaseEntity {
1143
1344
  const newResult = new BaseEntityResult();
1144
1345
  newResult.StartedAt = new Date();
1145
1346
  try {
1146
- const _options = options ? options : new interfaces_1.EntitySaveOptions();
1147
- const type = this.IsSaved ? entityInfo_1.EntityPermissionType.Update : entityInfo_1.EntityPermissionType.Create;
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
+ }
1377
+ const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
1148
1378
  const saveSubType = this.IsSaved ? 'update' : 'create';
1149
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
+ }
1150
1386
  if (_options.IgnoreDirtyState || this.Dirty || _options.ReplayOnly) {
1151
1387
  // Raise save_started event only when we're actually going to save
1152
1388
  this.RaiseEvent('save_started', null, saveSubType);
@@ -1154,7 +1390,7 @@ class BaseEntity {
1154
1390
  throw new Error('No provider set');
1155
1391
  }
1156
1392
  else {
1157
- let valResult = new entityInfo_1.ValidationResult();
1393
+ let valResult = new ValidationResult();
1158
1394
  if (_options.ReplayOnly) {
1159
1395
  valResult.Success = true; // bypassing validation since we are in replay only....
1160
1396
  }
@@ -1183,12 +1419,18 @@ class BaseEntity {
1183
1419
  const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
1184
1420
  if (!this.TransactionGroup) {
1185
1421
  // no transaction group, so we have our results here
1186
- 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;
1187
1429
  }
1188
1430
  else {
1189
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
1190
1432
  this.TransactionGroup.TransactionNotifications$.subscribe(({ success, results, error }) => {
1191
- if (success) {
1433
+ if (success && results) {
1192
1434
  const transItem = results.find(r => r.Transaction.BaseEntity === this);
1193
1435
  if (transItem) {
1194
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
@@ -1214,6 +1456,9 @@ class BaseEntity {
1214
1456
  return true; // nothing to save since we're not dirty
1215
1457
  }
1216
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);
1217
1462
  if (currentResultCount === this.ResultHistory.length) {
1218
1463
  // this means that NO new results were added to the history anywhere
1219
1464
  // so we need to add a new result to the history here
@@ -1228,6 +1473,21 @@ class BaseEntity {
1228
1473
  return false;
1229
1474
  }
1230
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
+ }
1231
1491
  finalizeSave(data, saveSubType) {
1232
1492
  if (data) {
1233
1493
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
@@ -1236,6 +1496,8 @@ class BaseEntity {
1236
1496
  const result = this.LatestResult;
1237
1497
  if (result)
1238
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();
1239
1501
  this.RaiseEvent('save', null, saveSubType);
1240
1502
  return true;
1241
1503
  }
@@ -1247,7 +1509,24 @@ class BaseEntity {
1247
1509
  * Internal helper method for the class and sub-classes - used to easily get the Active User which is either the ContextCurrentUser, if defined, or the Metadata.Provider.CurrentUser if not.
1248
1510
  */
1249
1511
  get ActiveUser() {
1250
- return this.ContextCurrentUser || metadata_1.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
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
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);
1251
1530
  }
1252
1531
  /**
1253
1532
  * Utility method that returns true if the given permission being checked is enabled for the current user, and false if not.
@@ -1259,9 +1538,20 @@ class BaseEntity {
1259
1538
  const u = this.ActiveUser;
1260
1539
  if (!u)
1261
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
+ }
1262
1552
  // first check if the AllowCreateAPI/AllowUpdateAPI/AllowDeleteAPI settings are flipped on for the entity in question
1263
1553
  switch (type) {
1264
- case entityInfo_1.EntityPermissionType.Create:
1554
+ case EntityPermissionType.Create:
1265
1555
  if (!this.EntityInfo.AllowCreateAPI) {
1266
1556
  if (throwError)
1267
1557
  throw new Error(`Create API is disabled for ${this.EntityInfo.Name}`);
@@ -1269,7 +1559,7 @@ class BaseEntity {
1269
1559
  return false;
1270
1560
  }
1271
1561
  break;
1272
- case entityInfo_1.EntityPermissionType.Update:
1562
+ case EntityPermissionType.Update:
1273
1563
  if (!this.EntityInfo.AllowUpdateAPI) {
1274
1564
  if (throwError)
1275
1565
  throw new Error(`Update API is disabled for ${this.EntityInfo.Name}`);
@@ -1277,7 +1567,7 @@ class BaseEntity {
1277
1567
  return false;
1278
1568
  }
1279
1569
  break;
1280
- case entityInfo_1.EntityPermissionType.Delete:
1570
+ case EntityPermissionType.Delete:
1281
1571
  if (!this.EntityInfo.AllowDeleteAPI) {
1282
1572
  if (throwError)
1283
1573
  throw new Error(`Delete API is disabled for ${this.EntityInfo.Name}`);
@@ -1285,7 +1575,7 @@ class BaseEntity {
1285
1575
  return false;
1286
1576
  }
1287
1577
  break;
1288
- case entityInfo_1.EntityPermissionType.Read:
1578
+ case EntityPermissionType.Read:
1289
1579
  if (!this.EntityInfo.IncludeInAPI) {
1290
1580
  if (throwError)
1291
1581
  throw new Error(`API is disabled for ${this.EntityInfo.Name}`);
@@ -1297,16 +1587,16 @@ class BaseEntity {
1297
1587
  const permissions = this.EntityInfo.GetUserPermisions(u);
1298
1588
  let bAllowed = false;
1299
1589
  switch (type) {
1300
- case entityInfo_1.EntityPermissionType.Create:
1590
+ case EntityPermissionType.Create:
1301
1591
  bAllowed = permissions.CanCreate;
1302
1592
  break;
1303
- case entityInfo_1.EntityPermissionType.Read:
1593
+ case EntityPermissionType.Read:
1304
1594
  bAllowed = permissions.CanRead;
1305
1595
  break;
1306
- case entityInfo_1.EntityPermissionType.Update:
1596
+ case EntityPermissionType.Update:
1307
1597
  bAllowed = permissions.CanUpdate;
1308
1598
  break;
1309
- case entityInfo_1.EntityPermissionType.Delete:
1599
+ case EntityPermissionType.Delete:
1310
1600
  bAllowed = permissions.CanDelete;
1311
1601
  break;
1312
1602
  }
@@ -1318,7 +1608,7 @@ class BaseEntity {
1318
1608
  }
1319
1609
  ThrowPermissionError(u, type, additionalInfoMessage) {
1320
1610
  throw new Error(`User: ${u.Name} (ID: ${u.ID}, Email: ${u.Email})
1321
- Does NOT have permission to ${entityInfo_1.EntityPermissionType[type]} ${this.EntityInfo.Name} records.
1611
+ Does NOT have permission to ${EntityPermissionType[type]} ${this.EntityInfo.Name} records.
1322
1612
  If you believe this is an error, please contact your system administrator.${additionalInfoMessage ? '\nAdditional Information: ' + additionalInfoMessage : ''}}`);
1323
1613
  }
1324
1614
  /**
@@ -1330,6 +1620,10 @@ class BaseEntity {
1330
1620
  for (let field of this.Fields) {
1331
1621
  field.Value = field.OldValue;
1332
1622
  }
1623
+ // IS-A composition: revert parent entity chain as well
1624
+ if (this._parentEntity) {
1625
+ this._parentEntity.Revert();
1626
+ }
1333
1627
  }
1334
1628
  return true;
1335
1629
  }
@@ -1349,7 +1643,7 @@ class BaseEntity {
1349
1643
  const valResult = CompositeKey.Validate();
1350
1644
  if (!valResult || !valResult.IsValid)
1351
1645
  throw new Error(`Invalid CompositeKey passed to BaseEntity.Load(${this.EntityInfo.Name}): ${valResult.ErrorMessage}`);
1352
- this.CheckPermissions(entityInfo_1.EntityPermissionType.Read, true); // this will throw an error and exit out if we don't have permission
1646
+ this.CheckPermissions(EntityPermissionType.Read, true); // this will throw an error and exit out if we don't have permission
1353
1647
  // Set loading state and raise load_started event
1354
1648
  this._isLoading = true;
1355
1649
  this.RaiseEvent('load_started', { CompositeKey });
@@ -1359,9 +1653,16 @@ class BaseEntity {
1359
1653
  }
1360
1654
  const data = await this.ProviderToUse.Load(this, CompositeKey, EntityRelationshipsToLoad, this.ActiveUser);
1361
1655
  if (!data) {
1362
- (0, logging_1.LogError)(`Error in BaseEntity.Load(${this.EntityInfo.Name}, Key: ${CompositeKey.ToString()}`);
1656
+ LogError(`Error in BaseEntity.Load(${this.EntityInfo.Name}, Key: ${CompositeKey.ToString()}`);
1363
1657
  return false; // no data loaded, return false
1364
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
+ }
1365
1666
  this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
1366
1667
  if (EntityRelationshipsToLoad) {
1367
1668
  for (let relationship of EntityRelationshipsToLoad) {
@@ -1374,6 +1675,8 @@ class BaseEntity {
1374
1675
  this._recordLoaded = true;
1375
1676
  this._everSaved = true; // Mark as saved since we loaded from database
1376
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();
1377
1680
  // Raise load completion event
1378
1681
  this.RaiseEvent('load_complete', { CompositeKey });
1379
1682
  return true;
@@ -1437,11 +1740,16 @@ class BaseEntity {
1437
1740
  * @returns Promise<boolean> - Returns true if the load was successful
1438
1741
  */
1439
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
+ }
1440
1749
  this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
1441
1750
  // now, check to see if we have the primary key set, if so, we should consider ourselves
1442
1751
  // loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
1443
1752
  if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
1444
- // chck each pkey's value to make sur it is set
1445
1753
  this._recordLoaded = true; // all primary keys are set, so we are loaded
1446
1754
  this._everSaved = true; // Mark as saved since we loaded from data
1447
1755
  for (let pkey of this.PrimaryKeys) {
@@ -1450,10 +1758,14 @@ class BaseEntity {
1450
1758
  this._everSaved = false; // if any primary key is not set, we cannot consider ourselves loaded
1451
1759
  }
1452
1760
  }
1761
+ // Cache the record name for faster lookups if successfully loaded
1762
+ if (this._recordLoaded) {
1763
+ this.CacheRecordName();
1764
+ }
1453
1765
  }
1454
1766
  else {
1455
1767
  // this is an error state as every entity must have > 0 primary keys defined
1456
- (0, logging_1.LogError)(`BaseEntity.LoadFromData() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
1768
+ LogError(`BaseEntity.LoadFromData() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
1457
1769
  this._recordLoaded = false;
1458
1770
  this._everSaved = false; // Mark as NOT saved since we loaded from data without primary keys
1459
1771
  }
@@ -1466,9 +1778,21 @@ class BaseEntity {
1466
1778
  * @returns ValidationResult The validation result
1467
1779
  */
1468
1780
  Validate() {
1469
- const result = new entityInfo_1.ValidationResult();
1781
+ const result = new ValidationResult();
1470
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
1471
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
1472
1796
  const err = field.Validate();
1473
1797
  err.Errors.forEach(element => {
1474
1798
  result.Errors.push(element);
@@ -1507,7 +1831,7 @@ class BaseEntity {
1507
1831
  async ValidateAsync() {
1508
1832
  // Default implementation just returns success
1509
1833
  // Subclasses should override this to perform actual async validation
1510
- const result = new entityInfo_1.ValidationResult();
1834
+ const result = new ValidationResult();
1511
1835
  result.Success = true;
1512
1836
  return result;
1513
1837
  }
@@ -1522,17 +1846,17 @@ class BaseEntity {
1522
1846
  async Delete(options) {
1523
1847
  // If a delete is already in progress, return its promise.
1524
1848
  if (this._pendingDelete$) {
1525
- return (0, rxjs_1.firstValueFrom)(this._pendingDelete$);
1849
+ return firstValueFrom(this._pendingDelete$);
1526
1850
  }
1527
1851
  // Create a new observable that debounces duplicative calls, and executes the delete.
1528
- this._pendingDelete$ = (0, rxjs_1.of)(options).pipe(
1852
+ this._pendingDelete$ = of(options).pipe(
1529
1853
  // Execute the actual delete logic.
1530
- (0, rxjs_1.switchMap)(opts => (0, rxjs_1.from)(this._InnerDelete(opts))),
1854
+ switchMap(opts => from(this._InnerDelete(opts))),
1531
1855
  // When the delete completes (whether successfully or not), clear the pending delete observable.
1532
- (0, rxjs_1.finalize)(() => { this._pendingDelete$ = null; }),
1856
+ finalize(() => { this._pendingDelete$ = null; }),
1533
1857
  // Ensure that all subscribers get the same result.
1534
- (0, rxjs_1.shareReplay)(1));
1535
- return (0, rxjs_1.firstValueFrom)(this._pendingDelete$);
1858
+ shareReplay(1));
1859
+ return firstValueFrom(this._pendingDelete$);
1536
1860
  }
1537
1861
  /**
1538
1862
  * Private, internal method to handle deleting a record from the database. This method is called by the public facing Delete() method
@@ -1549,7 +1873,43 @@ class BaseEntity {
1549
1873
  throw new Error('No provider set');
1550
1874
  }
1551
1875
  else {
1552
- this.CheckPermissions(entityInfo_1.EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
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
+ }
1912
+ this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
1553
1913
  // Raise delete_started event before the actual delete operation begins
1554
1914
  this.RaiseEvent('delete_started', null);
1555
1915
  // stash the old values for the event
@@ -1561,7 +1921,27 @@ class BaseEntity {
1561
1921
  includeRelatedEntityData: false,
1562
1922
  relatedEntityList: null
1563
1923
  });
1564
- 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
+ }
1565
1945
  if (!this.TransactionGroup) {
1566
1946
  // NOT part of a transaction - raise event immediately
1567
1947
  // record deleted correctly
@@ -1592,11 +1972,14 @@ class BaseEntity {
1592
1972
  }
1593
1973
  return true;
1594
1974
  }
1595
- 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
1596
1976
  return false;
1597
1977
  }
1598
1978
  }
1599
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);
1600
1983
  if (currentResultCount === this.ResultHistory.length) {
1601
1984
  // this means that NO new results were added to the history anywhere
1602
1985
  // so we need to add a new result to the history here
@@ -1611,6 +1994,155 @@ class BaseEntity {
1611
1994
  return false;
1612
1995
  }
1613
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
+ }
1614
2146
  /**
1615
2147
  * Called before an Action is executed by the AI Engine
1616
2148
  * This is intended to be overriden by subclass as needed, these methods called at the right time by the execution context
@@ -1624,20 +2156,21 @@ class BaseEntity {
1624
2156
  async AfterEntityAIAction(params) {
1625
2157
  return true; // default implementation does nothing
1626
2158
  }
2159
+ static { this._globalProviderKey = 'MJ_BaseEntityProvider'; }
1627
2160
  /**
1628
2161
  * Static property to get/set the IEntityDataProvider that is used by all BaseEntity objects. This is a global setting that is used by all BaseEntity objects. It can be overriden for a given BaseEntity object instance by passing in a provider to the
1629
2162
  * constructor of the BaseEntity object. Typically, a provider will pass itself into BaseEntity objects it creates to create a tight coupling between the provider and the BaseEntity objects it creates. This allows multiple concurrent
1630
2163
  * connections to exist in the same process space without interfering with each other.
1631
2164
  */
1632
2165
  static get Provider() {
1633
- const g = global_1.MJGlobal.Instance.GetGlobalObjectStore();
2166
+ const g = MJGlobal.Instance.GetGlobalObjectStore();
1634
2167
  if (g)
1635
2168
  return g[BaseEntity._globalProviderKey];
1636
2169
  else
1637
2170
  throw new Error('No global object store, so we cant get the static provider');
1638
2171
  }
1639
2172
  static set Provider(value) {
1640
- const g = global_1.MJGlobal.Instance.GetGlobalObjectStore();
2173
+ const g = MJGlobal.Instance.GetGlobalObjectStore();
1641
2174
  if (g)
1642
2175
  g[BaseEntity._globalProviderKey] = value;
1643
2176
  else
@@ -1683,7 +2216,7 @@ class BaseEntity {
1683
2216
  if (results) {
1684
2217
  const changes = [];
1685
2218
  for (let result of results)
1686
- changes.push(new entityInfo_1.RecordChange(result));
2219
+ changes.push(new RecordChange(result));
1687
2220
  return changes;
1688
2221
  }
1689
2222
  else
@@ -1704,7 +2237,7 @@ class BaseEntity {
1704
2237
  return true;
1705
2238
  }
1706
2239
  else {
1707
- (0, logging_1.LogError)(parseResult.error.flatten());
2240
+ LogError(parseResult.error.flatten());
1708
2241
  return false;
1709
2242
  }
1710
2243
  }
@@ -1725,7 +2258,7 @@ class BaseEntity {
1725
2258
  parseResult.data;
1726
2259
  }
1727
2260
  else {
1728
- (0, logging_1.LogError)(parseResult.error.flatten());
2261
+ LogError(parseResult.error.flatten());
1729
2262
  return null;
1730
2263
  }
1731
2264
  }
@@ -1846,7 +2379,4 @@ class BaseEntity {
1846
2379
  this._vectors.clear();
1847
2380
  }
1848
2381
  }
1849
- exports.BaseEntity = BaseEntity;
1850
- BaseEntity._baseEventCode = 'BaseEntityEvent';
1851
- BaseEntity._globalProviderKey = 'MJ_BaseEntityProvider';
1852
2382
  //# sourceMappingURL=baseEntity.js.map