@memberjunction/core 6.2.0-edge.1 → 6.2.0-edge.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +5 -0
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  3. package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
  4. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +192 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
  8. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
  9. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +95 -0
  10. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
  11. package/dist/generic/baseEngine.d.ts +21 -1
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +15 -1
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEntity.d.ts +292 -18
  16. package/dist/generic/baseEntity.d.ts.map +1 -1
  17. package/dist/generic/baseEntity.js +553 -105
  18. package/dist/generic/baseEntity.js.map +1 -1
  19. package/dist/generic/databaseProviderBase.d.ts +47 -7
  20. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  21. package/dist/generic/databaseProviderBase.js +106 -29
  22. package/dist/generic/databaseProviderBase.js.map +1 -1
  23. package/dist/generic/entityConfiguration.d.ts +21 -0
  24. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  25. package/dist/generic/entityConfiguration.js +34 -0
  26. package/dist/generic/entityConfiguration.js.map +1 -1
  27. package/dist/generic/entityInfo.d.ts +60 -2
  28. package/dist/generic/entityInfo.d.ts.map +1 -1
  29. package/dist/generic/entityInfo.js +81 -2
  30. package/dist/generic/entityInfo.js.map +1 -1
  31. package/dist/generic/entityRecordNameCache.d.ts +48 -0
  32. package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
  33. package/dist/generic/entityRecordNameCache.js +92 -0
  34. package/dist/generic/entityRecordNameCache.js.map +1 -0
  35. package/dist/generic/interfaces.d.ts +45 -9
  36. package/dist/generic/interfaces.d.ts.map +1 -1
  37. package/dist/generic/interfaces.js.map +1 -1
  38. package/dist/generic/jsonFieldBinding.d.ts +196 -0
  39. package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
  40. package/dist/generic/jsonFieldBinding.js +508 -0
  41. package/dist/generic/jsonFieldBinding.js.map +1 -0
  42. package/dist/generic/localCacheManager.d.ts +11 -0
  43. package/dist/generic/localCacheManager.d.ts.map +1 -1
  44. package/dist/generic/localCacheManager.js +9 -0
  45. package/dist/generic/localCacheManager.js.map +1 -1
  46. package/dist/generic/providerBase.d.ts +150 -33
  47. package/dist/generic/providerBase.d.ts.map +1 -1
  48. package/dist/generic/providerBase.js +304 -139
  49. package/dist/generic/providerBase.js.map +1 -1
  50. package/dist/generic/runQuery.d.ts +18 -1
  51. package/dist/generic/runQuery.d.ts.map +1 -1
  52. package/dist/generic/runQuery.js.map +1 -1
  53. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  54. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  55. package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
  56. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  57. package/dist/generic/telemetryManager.d.ts +11 -0
  58. package/dist/generic/telemetryManager.d.ts.map +1 -1
  59. package/dist/generic/telemetryManager.js +40 -0
  60. package/dist/generic/telemetryManager.js.map +1 -1
  61. package/dist/generic/util.d.ts +6 -0
  62. package/dist/generic/util.d.ts.map +1 -1
  63. package/dist/generic/util.js +20 -0
  64. package/dist/generic/util.js.map +1 -1
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/views/runView.d.ts +24 -0
  70. package/dist/views/runView.d.ts.map +1 -1
  71. package/dist/views/runView.js +4 -0
  72. package/dist/views/runView.js.map +1 -1
  73. package/package.json +3 -3
  74. package/readme.md +29 -2
@@ -8,7 +8,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  var EntityField_1;
11
- import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
11
+ import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
12
12
  import { GetDataHooks } from './dataHooks.js';
13
13
  import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
14
14
  import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
@@ -23,6 +23,7 @@ import { RelatedRecordCollection } from './relatedRecordCollection.js';
23
23
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
24
24
  import { EmbeddedRecord } from './embeddedRecord.js';
25
25
  import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
26
+ import { JSONFieldBinding, ValidateJSONFieldValue } from './jsonFieldBinding.js';
26
27
  import { BaseRemotableOperation } from './baseRemotableOperation.js';
27
28
  import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
28
29
  import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
@@ -379,6 +380,9 @@ let EntityField = class EntityField {
379
380
  result.Success = false;
380
381
  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));
381
382
  }
383
+ if (ef.IsBinaryFieldType && this.Value !== null && this.Value !== undefined) {
384
+ this.validateBinaryValue(ef, result);
385
+ }
382
386
  if (ef.TSType == EntityFieldTSType.Date && (this.Value !== null && this.Value !== undefined && !(this.Value instanceof Date))) {
383
387
  // invalid non-null date, but that is okay if we are a new record and we have a default value
384
388
  result.Success = false;
@@ -441,6 +445,26 @@ let EntityField = class EntityField {
441
445
  }
442
446
  return result;
443
447
  }
448
+ /**
449
+ * Validates a non-null binary field value. A binary field (`varbinary` / `bytea`) holds a base64
450
+ * string in a `BaseEntity`, and the database providers decode it when the record is saved, so a
451
+ * value that is not base64 — a data URI, raw text, a byte array — would fail the save with a
452
+ * database error that names no field. Catching it here reports it against the field instead.
453
+ * A fixed-size column (`varbinary(n)` / `binary(n)`) also has its decoded byte length checked.
454
+ */
455
+ validateBinaryValue(ef, result) {
456
+ const value = this.Value;
457
+ if (typeof value !== 'string' || !IsValidBase64(value)) {
458
+ result.Success = false;
459
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} is a binary field; its value must be a base64-encoded string`, this.Value));
460
+ return;
461
+ }
462
+ const byteLength = Base64DecodedByteLength(value);
463
+ if (ef.MaxByteLength > 0 && byteLength > ef.MaxByteLength) {
464
+ result.Success = false;
465
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be longer than ${ef.MaxByteLength} bytes. Current value is ${byteLength} bytes`, this.Value));
466
+ }
467
+ }
444
468
  constructor(fieldInfo, Value) {
445
469
  this._neverSet = true;
446
470
  this._notLoaded = false;
@@ -552,11 +576,27 @@ let EntityField = class EntityField {
552
576
  }
553
577
  /**
554
578
  * Restores the dirty-tracking baseline to a previously captured value.
555
- * Used by graph rollback so a retried save still sees the pre-attempt dirty set.
556
579
  */
557
580
  RestoreOldValue(value) {
558
581
  this._oldValue = value;
559
582
  }
583
+ /**
584
+ * Framework-internal: the field's value and dirty-tracking state, so a unit of work that rolls
585
+ * back can put them back with {@link RestoreState}.
586
+ */
587
+ GetState() {
588
+ return { Value: this._value, OldValue: this._oldValue, NotLoaded: this._notLoaded, NeverSet: this._neverSet };
589
+ }
590
+ /**
591
+ * Framework-internal: puts back a state {@link GetState} captured. It writes the state as it was,
592
+ * so the read-only and first-set rules of the {@link Value} setter don't apply.
593
+ */
594
+ RestoreState(state) {
595
+ this._value = state.Value;
596
+ this._oldValue = state.OldValue;
597
+ this._notLoaded = state.NotLoaded;
598
+ this._neverSet = state.NeverSet;
599
+ }
560
600
  /**
561
601
  * @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
562
602
  * assertions are now enforced at BaseEntity.Get/Set/SetMany — the entry points genuine code uses —
@@ -583,6 +623,13 @@ EntityField = EntityField_1 = __decorate([
583
623
  __metadata("design:paramtypes", [EntityFieldInfo, Object])
584
624
  ], EntityField);
585
625
  export { EntityField };
626
+ /**
627
+ * The `ChangeContext` JSON a clone writes on its Record Change rows (`IRecordChangeContext`,
628
+ * Kind 'Clone'). The one serializer every provider path uses, so the shape can't drift.
629
+ */
630
+ export function SerializeCloneChangeContext(context) {
631
+ return JSON.stringify({ Version: 1, Kind: 'Clone', Clone: context });
632
+ }
586
633
  export class DataObjectRelatedEntityParam {
587
634
  }
588
635
  export class DataObjectParams {
@@ -730,6 +777,9 @@ export class BaseEntityEvent {
730
777
  function sameEntityName(a, b) {
731
778
  return a.trim().toLowerCase() === b.trim().toLowerCase();
732
779
  }
780
+ /**
781
+ * 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.
782
+ */
733
783
  export class BaseEntity {
734
784
  /**
735
785
  * Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
@@ -1009,6 +1059,12 @@ export class BaseEntity {
1009
1059
  * routing through the provider: participants stay ignorant of one another.
1010
1060
  */
1011
1061
  this._entityTransactionScope = null;
1062
+ /**
1063
+ * The unit of work this record is saved or deleted in, while it runs as part of a larger one: an
1064
+ * IS-A chain's initiator sets it on each parent for the parent's write, and a graph sets it on
1065
+ * each of its records. See {@link UnitOfWork}.
1066
+ */
1067
+ this._unitOfWork = null;
1012
1068
  /**
1013
1069
  * Companions registered on this entity, keyed by {@link EntityCompanion.Name}.
1014
1070
  *
@@ -1041,6 +1097,7 @@ export class BaseEntity {
1041
1097
  // provider may capture the context now but use it later.
1042
1098
  // ────────────────────────────────────────────────────────────────────
1043
1099
  this._restoreContext = null;
1100
+ this._cloneContext = null;
1044
1101
  // Holds the current pending save observable (if any)
1045
1102
  this._pendingSave$ = null;
1046
1103
  /**
@@ -1123,10 +1180,27 @@ export class BaseEntity {
1123
1180
  // route the parent through the same connection — not the global default.
1124
1181
  const parentProvider = this.ProviderToUse;
1125
1182
  this._parentEntity = await parentProvider.GetEntityObject(parentEntityInfo.Name, this._contextCurrentUser);
1183
+ // A chain loaded through the parent is linked the other way too, so the parent's
1184
+ // LeafEntity (and its save hooks) see this child (MJ#4870).
1185
+ this.linkParentBackToThisChild();
1126
1186
  // Recursive: the parent's InitializeParentEntity() was called by GetEntityObject()
1127
1187
  // Cache the parent field names for O(1) routing lookups
1128
1188
  this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
1129
1189
  }
1190
+ /**
1191
+ * Points this child's disjoint parent back at this instance (`parent._childEntity = this`).
1192
+ *
1193
+ * Same gate as {@link InitializeParentEntity}: a parent that allows several subtypes keeps no
1194
+ * single child. `NewRecord()` sets `_childEntity` to null, so this has to run AFTER the
1195
+ * parent's `NewRecord()` — calling it before is wiped out.
1196
+ */
1197
+ linkParentBackToThisChild() {
1198
+ const parentEntityInfo = this.EntityInfo?.ParentEntityInfo;
1199
+ if (!this._parentEntity || !parentEntityInfo || parentEntityInfo.AllowMultipleSubtypes) {
1200
+ return;
1201
+ }
1202
+ this._parentEntity._childEntity = this;
1203
+ }
1130
1204
  /**
1131
1205
  * IS-A PROMOTION (#3825): binds this NEW child record to an EXISTING parent row, so saving it
1132
1206
  * ADDS a subtype to a person/org/product that already exists instead of trying to create a
@@ -1205,8 +1279,9 @@ export class BaseEntity {
1205
1279
  if (!loaded) {
1206
1280
  // Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
1207
1281
  // the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
1208
- // is bit-for-bit the fresh record they built.
1282
+ // is bit-for-bit the fresh record they built. Re-seed nulls the parent's back-link.
1209
1283
  this._parentEntity.NewRecord();
1284
+ this.linkParentBackToThisChild();
1210
1285
  for (const pk of freshPkValues) {
1211
1286
  if (pk.value != null) {
1212
1287
  this._parentEntity.Set(pk.name, pk.value);
@@ -1705,15 +1780,7 @@ export class BaseEntity {
1705
1780
  this.replaceChildParentChain(childEntity);
1706
1781
  this._childEntity = childEntity;
1707
1782
  const dirtySnapshots = this.captureChainDirtyState();
1708
- if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1709
- const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1710
- if (!loaded) {
1711
- this.mirrorSharedKeysToChild(childEntity);
1712
- }
1713
- }
1714
- else {
1715
- this.mirrorSharedKeysToChild(childEntity);
1716
- }
1783
+ await this.loadOrMirrorChildRow(childEntity);
1717
1784
  this.restoreChainDirtyState(dirtySnapshots);
1718
1785
  // Recursively discover grandchildren if the child is also a parent type
1719
1786
  if (childEntity.EntityInfo.IsParentType) {
@@ -1733,15 +1800,7 @@ export class BaseEntity {
1733
1800
  const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
1734
1801
  this.replaceChildParentChain(childEntity);
1735
1802
  const dirtySnapshots = this.captureChainDirtyState();
1736
- if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1737
- const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1738
- if (!loaded) {
1739
- this.mirrorSharedKeysToChild(childEntity);
1740
- }
1741
- }
1742
- else {
1743
- this.mirrorSharedKeysToChild(childEntity);
1744
- }
1803
+ await this.loadOrMirrorChildRow(childEntity);
1745
1804
  this.restoreChainDirtyState(dirtySnapshots);
1746
1805
  if (childEntity.EntityInfo.IsParentType) {
1747
1806
  await childEntity.EnsureISAChild();
@@ -1749,6 +1808,32 @@ export class BaseEntity {
1749
1808
  return childEntity;
1750
1809
  }
1751
1810
  }
1811
+ /**
1812
+ * A new parent's child is new too: there is no row to read, so copy the parent's keys.
1813
+ * A saved parent still reads (promotion's usual answer is "no row"), and that miss is not
1814
+ * an error — the same way a subtype-hint probe treats an empty read (MJ#4859).
1815
+ */
1816
+ async loadOrMirrorChildRow(childEntity) {
1817
+ if (this.IsSaved && this.PrimaryKey?.HasValue) {
1818
+ const loaded = await this.loadChildRowQuietly(childEntity);
1819
+ if (!loaded) {
1820
+ this.mirrorSharedKeysToChild(childEntity);
1821
+ }
1822
+ return;
1823
+ }
1824
+ this.mirrorSharedKeysToChild(childEntity);
1825
+ }
1826
+ /** Loads the child by the shared key. An empty result is an answer, not a logged error. */
1827
+ async loadChildRowQuietly(childEntity) {
1828
+ const probe = { RowReadFailed: false };
1829
+ childEntity._subtypeHintProbe = probe;
1830
+ try {
1831
+ return await childEntity.InnerLoad(this.PrimaryKey);
1832
+ }
1833
+ finally {
1834
+ childEntity._subtypeHintProbe = null;
1835
+ }
1836
+ }
1752
1837
  mirrorSharedKeysToChild(childEntity) {
1753
1838
  const parentPks = this.EntityInfo.PrimaryKeys;
1754
1839
  if (!parentPks || parentPks.length === 0)
@@ -2056,6 +2141,7 @@ export class BaseEntity {
2056
2141
  // Reset this entity to pristine state: clears _compositeKey, _recordLoaded,
2057
2142
  // _everSaved, and recreates all EntityField instances with _NeverSet = true
2058
2143
  this.init();
2144
+ this.discardJSONFieldObjects();
2059
2145
  // Recursively hydrate parent entities first (deepest ancestor resets first).
2060
2146
  // After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
2061
2147
  if (this._parentEntity) {
@@ -2163,6 +2249,20 @@ export class BaseEntity {
2163
2249
  DeclareRelatedRecords(options) {
2164
2250
  return this.RegisterCompanion(new RelatedRecordCollection(this, options));
2165
2251
  }
2252
+ /**
2253
+ * Dynamically registers a database-sourced child collection companion on this entity instance.
2254
+ * Used by generic composite persistence engines (Record Cloning, Metadata Sync) when a relationship
2255
+ * has no static collection companion declared on the generated entity class.
2256
+ *
2257
+ * Refuses duplicate names or re-registering an already existing companion.
2258
+ *
2259
+ * @typeParam TChild - The child entity type.
2260
+ * @param options - The collection declaration options.
2261
+ * @returns The registered collection companion.
2262
+ */
2263
+ DeclareRelatedRecordsDynamic(options) {
2264
+ return this.DeclareRelatedRecords(options);
2265
+ }
2166
2266
  /**
2167
2267
  * Declares a 1:1 embedded peer on this entity, joined by an owner-held foreign key,
2168
2268
  * and registers it as a companion.
@@ -2624,10 +2724,15 @@ export class BaseEntity {
2624
2724
  childSaveOptions.GraphVisited = visited;
2625
2725
  const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
2626
2726
  childDeleteOptions.GraphVisited = visited;
2627
- // Snapshot dirty/saved bookkeeping so a rolled-back graph can be retried.
2628
- // Node Save() finalizes each participant as saved+clean; DB rollback does
2629
- // not undo that, and the next Save() would skip the peer and fail the FK.
2630
- const participants = this.captureGraphParticipants(plan);
2727
+ // A graph that holds a scope is a unit of work: what its records write is put back if it
2728
+ // rolls back, and what they delete is reset only once it commits (see UnitOfWork). Nested in
2729
+ // another unit — the companions of an IS-A parent deleted in its child's chain, say — it
2730
+ // joins that one. Without a scope each write stands as it runs, so a failure leaves the
2731
+ // records that already wrote as they are, which is what the database holds.
2732
+ const enclosing = this._unitOfWork;
2733
+ const records = plan.Nodes.map(node => node.Entity);
2734
+ const previousUnits = records.map(record => record._unitOfWork);
2735
+ let part = null;
2631
2736
  // Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
2632
2737
  // save, and Save()/Delete() report failure by returning false — an escaping throw here
2633
2738
  // would break that contract for exactly one path.
@@ -2637,6 +2742,12 @@ export class BaseEntity {
2637
2742
  provider?.SupportsEntityTransactions === true && provider.BeginEntityTransaction
2638
2743
  ? await provider.BeginEntityTransaction()
2639
2744
  : null;
2745
+ if (scope) {
2746
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains(records));
2747
+ for (const record of records) {
2748
+ record._unitOfWork = part.unit;
2749
+ }
2750
+ }
2640
2751
  const result = await ExecuteEntitySavePlan(plan, {
2641
2752
  SaveOptions: childSaveOptions,
2642
2753
  DeleteOptions: childDeleteOptions,
@@ -2646,12 +2757,13 @@ export class BaseEntity {
2646
2757
  });
2647
2758
  if (!result.Success) {
2648
2759
  await scope?.Rollback();
2649
- this.revertGraphParticipants(participants);
2760
+ BaseEntity.settleRollback(part);
2650
2761
  this.registerGraphFailure(result.ErrorMessage, operation);
2651
2762
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
2652
2763
  return false;
2653
2764
  }
2654
2765
  await scope?.Commit();
2766
+ BaseEntity.settleCommit(part);
2655
2767
  this.acceptCompanionChanges();
2656
2768
  this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
2657
2769
  return true;
@@ -2667,13 +2779,18 @@ export class BaseEntity {
2667
2779
  LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
2668
2780
  `${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
2669
2781
  }
2670
- this.revertGraphParticipants(participants);
2782
+ BaseEntity.settleRollback(part);
2671
2783
  const detail = e instanceof Error ? e.message : String(e);
2672
2784
  LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
2673
2785
  this.registerGraphFailure(detail, operation);
2674
2786
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
2675
2787
  return false;
2676
2788
  }
2789
+ finally {
2790
+ records.forEach((record, index) => {
2791
+ record._unitOfWork = previousUnits[index];
2792
+ });
2793
+ }
2677
2794
  }
2678
2795
  /**
2679
2796
  * Persist this record as one already-planned graph node. Private: the public
@@ -2690,38 +2807,71 @@ export class BaseEntity {
2690
2807
  return this._innerDelete(options);
2691
2808
  }
2692
2809
  /**
2693
- * Captures saved/dirty baselines for every plan participant so a rolled-back
2694
- * graph can be retried without re-INSERTing a "saved" peer or skipping it.
2810
+ * Captures each record's IS-A chain, from its leaf up to its root, so a unit of work that rolls
2811
+ * back can put the chain back with {@link restoreChains}. A record in more than one chain is
2812
+ * captured once.
2695
2813
  */
2696
- captureGraphParticipants(plan) {
2697
- return plan.Nodes.map(node => ({
2698
- entity: node.Entity,
2699
- wasSaved: node.Entity.IsSaved,
2700
- oldValues: node.Entity.Fields.map(f => ({ name: f.Name, old: f.OldValue })),
2701
- }));
2814
+ static captureChains(entities) {
2815
+ const captured = new Set();
2816
+ const snapshots = [];
2817
+ for (const entity of entities) {
2818
+ for (let level = entity.LeafEntity; level && !captured.has(level); level = level._parentEntity) {
2819
+ captured.add(level);
2820
+ snapshots.push({
2821
+ entity: level,
2822
+ wasSaved: level._everSaved,
2823
+ wasLoaded: level._recordLoaded,
2824
+ resultHistory: [...level._resultHistory],
2825
+ fields: level.Fields.map(field => ({ field, state: field.GetState() })),
2826
+ });
2827
+ }
2828
+ }
2829
+ return snapshots;
2702
2830
  }
2703
2831
  /**
2704
- * Restores in-memory saved/dirty state after the database rolled the graph back.
2832
+ * Puts back, after a rollback, what the unit of work changed in memory. See {@link restoreSnapshot}.
2705
2833
  */
2706
- revertGraphParticipants(snapshots) {
2707
- for (const snap of snapshots) {
2708
- snap.entity.revertUncommittedGraphSave(snap);
2834
+ static restoreChains(snapshots) {
2835
+ for (const snapshot of snapshots) {
2836
+ snapshot.entity.restoreSnapshot(snapshot);
2709
2837
  }
2710
2838
  }
2711
2839
  /**
2712
- * After a graph node Save() the fields look clean and `_everSaved` is true.
2713
- * The DB rollback does not undo that. Restore the pre-attempt baseline so
2714
- * a retry still writes the peer and the owner FK still matches.
2840
+ * Puts this record back as a snapshot captured it, after the unit of work that finalized it
2841
+ * rolled back.
2842
+ *
2843
+ * A save's `finalizeSave()` rebuilds the record's fields from what its write returned, marks it
2844
+ * saved and empties its result history. The database undid the write, so the record gets back
2845
+ * its saved and loaded flags, its result history, and each field's value and tracking state. A
2846
+ * field edited since then keeps the edit, and is compared with its captured baseline again, since
2847
+ * that is what the database holds after the rollback. (A delete leaves nothing to put back: a
2848
+ * record deleted inside a unit of work isn't reset until the unit commits.)
2849
+ *
2850
+ * The history matters to the caller: a save records its failure only when nothing else did,
2851
+ * judged by the history's length when it started, which an emptied history would make wrong.
2852
+ *
2853
+ * A record still holding the captured field objects was never finalized, so nothing about it
2854
+ * changed and it is left alone.
2715
2855
  */
2716
- revertUncommittedGraphSave(snap) {
2717
- if (!snap.wasSaved) {
2718
- this._everSaved = false;
2719
- this._recordLoaded = false;
2856
+ restoreSnapshot(snapshot) {
2857
+ const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
2858
+ if (!finalized) {
2859
+ return;
2720
2860
  }
2721
- for (const captured of snap.oldValues) {
2722
- const field = this.GetFieldByName(captured.name);
2723
- if (field) {
2724
- field.RestoreOldValue(captured.old);
2861
+ this._everSaved = snapshot.wasSaved;
2862
+ this._recordLoaded = snapshot.wasLoaded;
2863
+ this._resultHistory = snapshot.resultHistory;
2864
+ this._compositeKey = null; // cached from the finalized key; rebuilt on the next read
2865
+ for (const { field: capturedField, state } of snapshot.fields) {
2866
+ const field = this.GetFieldByName(capturedField.Name);
2867
+ if (!field) {
2868
+ continue;
2869
+ }
2870
+ if (field.Dirty) {
2871
+ field.RestoreOldValue(state.OldValue);
2872
+ }
2873
+ else {
2874
+ field.RestoreState(state);
2725
2875
  }
2726
2876
  }
2727
2877
  }
@@ -2780,6 +2930,89 @@ export class BaseEntity {
2780
2930
  LogError(`Error rolling back entity transaction scope for ${this.EntityInfo?.Name}: ${rollbackError}`);
2781
2931
  }
2782
2932
  }
2933
+ /**
2934
+ * The failure path of a save: rolls back the transaction scope this entity holds, if any, then
2935
+ * settles this save's part in its unit of work (see {@link settleRollback}). `part` is null when
2936
+ * this save took none (see `_innerSave`), and then only the scope is rolled back.
2937
+ */
2938
+ async rollbackChainSave(part) {
2939
+ await this.rollbackEntityTransactionScope();
2940
+ BaseEntity.settleRollback(part);
2941
+ }
2942
+ /**
2943
+ * Starts a unit of work's part in a {@link UnitOfWork}: joins `enclosing` when the unit runs
2944
+ * inside one, and owns a new one when it doesn't. `snapshots` are the unit's records as they are
2945
+ * before it writes them. They join the unit's, so that the outermost unit can put them back.
2946
+ */
2947
+ static beginUnitOfWork(enclosing, snapshots) {
2948
+ const unit = enclosing ?? { Snapshots: [], DeletedRecords: [] };
2949
+ unit.Snapshots.push(...snapshots);
2950
+ return { unit, owns: !enclosing, snapshots, deletedMark: unit.DeletedRecords.length };
2951
+ }
2952
+ /**
2953
+ * Runs `work` on `record` as part of `unit` (see {@link UnitOfWork}), and gives the record back
2954
+ * the unit it was in before, however `work` ends. Used for an IS-A parent's delete, which runs
2955
+ * inside its child's chain, along with the graph of its companions if it has any.
2956
+ */
2957
+ static async inUnitOfWork(record, unit, work) {
2958
+ const previous = record._unitOfWork;
2959
+ record._unitOfWork = unit;
2960
+ try {
2961
+ return await work(record);
2962
+ }
2963
+ finally {
2964
+ record._unitOfWork = previous;
2965
+ }
2966
+ }
2967
+ /**
2968
+ * After a unit of work rolls back: puts its records back as they were before it wrote them, and
2969
+ * drops the deletes the rollback undid, so that nothing resets their records. The owner does this
2970
+ * for everything the unit and the units nested in it wrote. A nested part does it for its own
2971
+ * records only; when the owner then rolls back too, restoring them again changes nothing.
2972
+ */
2973
+ static settleRollback(part) {
2974
+ if (!part) {
2975
+ return;
2976
+ }
2977
+ BaseEntity.restoreChains(part.owns ? part.unit.Snapshots : part.snapshots);
2978
+ part.unit.DeletedRecords.length = part.owns ? 0 : part.deletedMark;
2979
+ }
2980
+ /**
2981
+ * After a unit of work commits: its owner resets the records it deleted. A nested part leaves
2982
+ * them to the owner, since the enclosing unit can still roll back.
2983
+ */
2984
+ static settleCommit(part) {
2985
+ if (part?.owns) {
2986
+ BaseEntity.resetDeletedRecords(part.unit.DeletedRecords);
2987
+ }
2988
+ }
2989
+ /**
2990
+ * Resets the records a unit of work deleted, once the unit has committed, as each record's own
2991
+ * `Delete()` does when no unit of work is involved. See {@link UnitOfWork}.
2992
+ *
2993
+ * Last-deleted first, skipping a record that no longer reads as saved. `NewRecord()` on a record
2994
+ * also resets every IS-A level above it, and relinks them. A chain's initiator records its delete
2995
+ * after its parents do, so it's reset first, and its parents with it. Resetting a parent again
2996
+ * after that would unlink it from its child and give it a key the child doesn't share.
2997
+ *
2998
+ * The unit has committed, so a reset that throws is logged and the rest still run: the deletes
2999
+ * stand whatever happens here.
3000
+ */
3001
+ static resetDeletedRecords(records) {
3002
+ for (let index = records.length - 1; index >= 0; index--) {
3003
+ const record = records[index];
3004
+ if (!record.IsSaved) {
3005
+ continue;
3006
+ }
3007
+ try {
3008
+ record.NewRecord();
3009
+ }
3010
+ catch (e) {
3011
+ LogError(`BaseEntity.resetDeletedRecords: resetting a deleted ${record.EntityInfo?.Name} record failed: ` +
3012
+ `${e instanceof Error ? e.message : String(e)}`);
3013
+ }
3014
+ }
3015
+ }
2783
3016
  /**
2784
3017
  * 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.
2785
3018
  */
@@ -3791,6 +4024,7 @@ export class BaseEntity {
3791
4024
  */
3792
4025
  NewRecord(newValues) {
3793
4026
  this.init();
4027
+ this.discardJSONFieldObjects();
3794
4028
  this._everSaved = false; // Reset save state for new record
3795
4029
  // Clear child entity state — new records don't have children yet
3796
4030
  this._childEntity = null;
@@ -3809,6 +4043,8 @@ export class BaseEntity {
3809
4043
  // when setting keys.
3810
4044
  if (this._parentEntity) {
3811
4045
  this._parentEntity.NewRecord();
4046
+ // The parent's NewRecord() just nulled its back-link. Put this child back.
4047
+ this.linkParentBackToThisChild();
3812
4048
  for (const pk of this.EntityInfo.PrimaryKeys) {
3813
4049
  const parentValue = this._parentEntity.Get(pk.Name);
3814
4050
  if (parentValue != null) {
@@ -3917,6 +4153,42 @@ export class BaseEntity {
3917
4153
  ClearRestoreContext() {
3918
4154
  this._restoreContext = null;
3919
4155
  }
4156
+ /**
4157
+ * Returns the active clone context for the next save, if any.
4158
+ *
4159
+ * Read by the data provider when generating the RecordChange SQL: when
4160
+ * non-null, the resulting RecordChange row is written with
4161
+ * `Source='Clone'` and `ChangeContext` populated with structured JSON provenance.
4162
+ * Returns null for ordinary saves.
4163
+ */
4164
+ get CloneContext() {
4165
+ return this._cloneContext;
4166
+ }
4167
+ /**
4168
+ * Marks the next Save() as part of a record clone operation.
4169
+ *
4170
+ * The provider will write a new RecordChange entry with `Source='Clone'`
4171
+ * and `ChangeContext` populated with structured JSON provenance.
4172
+ *
4173
+ * The context persists on the entity until either (a) overwritten by a
4174
+ * subsequent SetCloneContext() call or (b) explicitly cleared via
4175
+ * ClearCloneContext(). It is NOT auto-cleared inside Save() because
4176
+ * TransactionGroup execution is deferred.
4177
+ *
4178
+ * @param context Structured clone context. Throws if missing required fields.
4179
+ */
4180
+ SetCloneContext(context) {
4181
+ if (!context || !context.CloneLogID || !context.SourceRecordID || !context.RootEntityName) {
4182
+ throw new Error('BaseEntity.SetCloneContext: context is required with CloneLogID, SourceRecordID, and RootEntityName');
4183
+ }
4184
+ this._cloneContext = context;
4185
+ }
4186
+ /**
4187
+ * Clears any pending clone context. Safe to call when no context is set.
4188
+ */
4189
+ ClearCloneContext() {
4190
+ this._cloneContext = null;
4191
+ }
3920
4192
  /**
3921
4193
  * Saves the current state of the object to the database. Uses the active provider to handle the actual saving of the record.
3922
4194
  * If the record is new, it will be created, if it already exists, it will be updated.
@@ -3958,6 +4230,9 @@ export class BaseEntity {
3958
4230
  * @returns Promise<boolean>
3959
4231
  */
3960
4232
  async Save(options) {
4233
+ // JSONType safety net: pick up in-place edits made through un-proxied references BEFORE any
4234
+ // dirty evaluation, plan building or SQL construction below reads the raw field values.
4235
+ this.FlushJSONFieldObjects();
3961
4236
  // IS-A parent chain saves bypass the debounce to prevent deadlock:
3962
4237
  // Root.Save() → delegates to Leaf.Save() → Leaf saves parent chain →
3963
4238
  // calls Root.Save(IsParentEntitySave=true). Without this bypass, the second
@@ -4022,7 +4297,12 @@ export class BaseEntity {
4022
4297
  const currentResultCount = this.ResultHistory.length;
4023
4298
  const newResult = new BaseEntityResult();
4024
4299
  newResult.StartedAt = new Date();
4300
+ // The unit of work this save runs inside, if any (see UnitOfWork), and this save's own part
4301
+ // in one, taken only when this entity starts an IS-A chain save whose writes a failure undoes.
4302
+ const enclosing = this._unitOfWork;
4303
+ let part = null;
4025
4304
  try {
4305
+ this.FlushJSONFieldObjects(); // idempotent; covers graph-node saves that bypass Save()
4026
4306
  const initialDirtyState = this.Dirty; // save this because parent entity save cycle, if any, will clear their dirty flags
4027
4307
  const _options = options ? options : new EntitySaveOptions();
4028
4308
  // IS-A leaf delegation: if this entity has a child entity and we are NOT
@@ -4039,7 +4319,20 @@ export class BaseEntity {
4039
4319
  // save — this joins it as a savepoint rather than starting a second physical
4040
4320
  // transaction. Before 6.2 this path called BeginISATransaction(), which was blind to
4041
4321
  // any existing transaction and produced torn writes; see EntityTransactionScope.
4042
- await this.beginEntityTransactionScope(isISAInitiator);
4322
+ const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
4323
+ // Each level of the chain is finalized as saved and clean when its own write returns,
4324
+ // before the chain commits. A failure that undoes those writes has to put the chain
4325
+ // back in memory too, so the chain save is a unit of work, captured before the parents
4326
+ // save. The writes are undone when this save holds a scope, and on a provider without
4327
+ // entity transactions, which doesn't talk to a database directly: there each parent's
4328
+ // save is recorded in memory and the leaf's one write carries the chain
4329
+ // (GraphQLDataProvider). Anywhere else a level may really have written (in a
4330
+ // TransactionGroup, a level outside the group does), and marking it unsaved would make
4331
+ // the retry insert it twice.
4332
+ if (isISAInitiator && !this.TransactionGroup &&
4333
+ (scopeOpened || this.ProviderToUse?.SupportsEntityTransactions !== true)) {
4334
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
4335
+ }
4043
4336
  // Save parent chain first (root → branch → immediate parent)
4044
4337
  // Parent calls Save() recursively which handles its own parents, permissions, validation
4045
4338
  if (this._parentEntity) {
@@ -4054,8 +4347,9 @@ export class BaseEntity {
4054
4347
  parentSaveOptions.ISAActiveChildEntityName = this.EntityInfo.Name;
4055
4348
  const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
4056
4349
  if (!parentResult) {
4057
- // Parent save failed — rollback if we started the transaction
4058
- await this.rollbackEntityTransactionScope();
4350
+ // Parent save failed — roll back if we started the transaction, and put back
4351
+ // the levels above it that had already saved
4352
+ await this.rollbackChainSave(part);
4059
4353
  // RECORD the failure on THIS entity's ResultHistory before returning. Without
4060
4354
  // this the caller gets `false` with LatestResult === null and an empty
4061
4355
  // ResultHistory, because every result was written to the PARENT object — which
@@ -4194,9 +4488,10 @@ export class BaseEntity {
4194
4488
  // this scope exists to prevent.
4195
4489
  if (result) {
4196
4490
  await this.commitEntityTransactionScope();
4491
+ BaseEntity.settleCommit(part);
4197
4492
  }
4198
4493
  else {
4199
- await this.rollbackEntityTransactionScope();
4494
+ await this.rollbackChainSave(part);
4200
4495
  }
4201
4496
  return result;
4202
4497
  }
@@ -4244,12 +4539,15 @@ export class BaseEntity {
4244
4539
  // commit, so later "successful" saves were silently non-durable. Committing an
4245
4540
  // empty scope writes nothing; it only releases the transaction.
4246
4541
  await this.commitEntityTransactionScope();
4542
+ BaseEntity.settleCommit(part);
4247
4543
  return true; // nothing to save since we're not dirty
4248
4544
  }
4249
4545
  }
4250
4546
  catch (e) {
4251
- // Roll back the scope this entity opened, if any. No-op when it holds none.
4252
- await this.rollbackEntityTransactionScope();
4547
+ // Roll back the scope this entity opened, if any (a no-op when it holds none), and put
4548
+ // back what the chain's saves changed in memory, this entity included when the commit
4549
+ // is what threw. Before the result below, which reads IsSaved and the old values.
4550
+ await this.rollbackChainSave(part);
4253
4551
  if (currentResultCount === this.ResultHistory.length) {
4254
4552
  // this means that NO new results were added to the history anywhere
4255
4553
  // so we need to add a new result to the history here
@@ -4342,7 +4640,8 @@ export class BaseEntity {
4342
4640
  return this.ContextCurrentUser || this.ProviderToUse?.CurrentUser || Metadata.Provider.CurrentUser; // global-provider-ok: final fallback when no provider is bound
4343
4641
  }
4344
4642
  /**
4345
- * Caches the entity record name in the provider's EntityRecordNameCache for faster lookups.
4643
+ * Offers the record's name to the provider for later synchronous lookups. Only a provider that
4644
+ * serves a single user keeps it; see {@link IMetadataProvider.GetCachedRecordName}.
4346
4645
  * Called automatically after successful Load(), LoadFromData(), and Save() operations.
4347
4646
  */
4348
4647
  cacheRecordName() {
@@ -4601,6 +4900,7 @@ export class BaseEntity {
4601
4900
  * @returns
4602
4901
  */
4603
4902
  Revert() {
4903
+ this.discardJSONFieldObjects(); // unconditional: an un-flushed edit through a stale reference must go too
4604
4904
  if (this.Dirty) {
4605
4905
  for (let field of this.Fields) {
4606
4906
  field.Value = field.OldValue;
@@ -4637,6 +4937,7 @@ export class BaseEntity {
4637
4937
  this.init(); // wipe out current data if we're loading on top of existing record
4638
4938
  }
4639
4939
  const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
4940
+ this.discardJSONFieldObjects(); // a reload replaces the record's data
4640
4941
  if (!data) {
4641
4942
  // A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
4642
4943
  if (!this._subtypeHintProbe) {
@@ -4793,6 +5094,7 @@ export class BaseEntity {
4793
5094
  * @returns Promise<boolean> - Returns true if the load was successful
4794
5095
  */
4795
5096
  async LoadFromData(data, _replaceOldValues = false) {
5097
+ this.discardJSONFieldObjects(); // the record's data is being replaced: parsed JSON objects are stale
4796
5098
  // IS-A: hydrate parent chain from data before populating self.
4797
5099
  // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
4798
5100
  // then populates from data, ensuring correct PK and saved state on parents.
@@ -4896,6 +5198,90 @@ export class BaseEntity {
4896
5198
  await this.DeserializeCompanions(payloads);
4897
5199
  }
4898
5200
  }
5201
+ getJSONFieldBinding(fieldName) {
5202
+ this._jsonFieldBindings ??= new Map();
5203
+ let binding = this._jsonFieldBindings.get(fieldName);
5204
+ if (!binding) {
5205
+ binding = new JSONFieldBinding(fieldName, () => this.Get(fieldName), (raw) => this.Set(fieldName, raw));
5206
+ this._jsonFieldBindings.set(fieldName, binding);
5207
+ }
5208
+ return binding;
5209
+ }
5210
+ /**
5211
+ * Backs the generated typed `<Field>Object` accessor of a JSONType field. Returns a live view of
5212
+ * the parsed JSON: assigning to it, or mutating it in place at any depth (`obj.a.b = 1`,
5213
+ * `arr.push(x)`, `delete obj.k`), updates the raw field through {@link Set}, so the field becomes
5214
+ * dirty and `Save()` persists the edit. Reading after the raw value changed by any other route
5215
+ * (`Load`, `LoadFromData`, `Set`, revert) re-parses; references obtained earlier are then
5216
+ * detached. Use `ToPlainJSON()` to clone/`structuredClone`/`postMessage` a value from here.
5217
+ *
5218
+ * @param fieldName - the JSON text field (its `EntityField.Name`)
5219
+ * @returns the live object/array, a bare primitive for a primitive JSON root, or `null`
5220
+ * @throws Error when the field holds text that is not valid JSON
5221
+ */
5222
+ GetJSONFieldObject(fieldName) {
5223
+ return this.getJSONFieldBinding(fieldName).GetValue();
5224
+ }
5225
+ /**
5226
+ * Backs the generated setter of a `<Field>Object` accessor. `null`/`undefined` clears the field.
5227
+ * A plain object/array is adopted, so later edits through the caller's own reference are still
5228
+ * picked up before validation and save (see {@link FlushJSONFieldObjects}).
5229
+ */
5230
+ SetJSONFieldObject(fieldName, value) {
5231
+ this.getJSONFieldBinding(fieldName).SetValue(value);
5232
+ }
5233
+ /**
5234
+ * Safety net for JSONType accessors: re-serializes every materialized JSON object and writes any
5235
+ * difference to its raw field. Catches an object that was assigned into the tree and then
5236
+ * mutated through the caller's original (un-proxied) reference. Called automatically before
5237
+ * `Validate()` and at the start of `Save()`; cheap because only fields whose object was actually
5238
+ * read are visited.
5239
+ */
5240
+ FlushJSONFieldObjects() {
5241
+ if (!this._jsonFieldBindings) {
5242
+ return;
5243
+ }
5244
+ for (const binding of this._jsonFieldBindings.values()) {
5245
+ binding.Flush();
5246
+ }
5247
+ }
5248
+ /**
5249
+ * Drops every materialized JSON object so the next accessor read re-parses from the raw field and
5250
+ * all earlier references detach. Called wherever the record's data is replaced wholesale
5251
+ * (`Revert`, `NewRecord`, `Hydrate`, `From`, a load) — NOT on the save round trip, so a reference
5252
+ * held across `Save()` stays live.
5253
+ */
5254
+ discardJSONFieldObjects() {
5255
+ if (!this._jsonFieldBindings) {
5256
+ return;
5257
+ }
5258
+ for (const binding of this._jsonFieldBindings.values()) {
5259
+ binding.Invalidate();
5260
+ }
5261
+ }
5262
+ /**
5263
+ * Validates one JSONType field against its structural schema and `@CHECK` rules, appending any
5264
+ * problems to `result`. Called from generated `Validate()` overrides for JSONTypes opted in with
5265
+ * `@mjValidate`; the field is checked only when it is dirty or the record is new, so opting a type
5266
+ * in never blocks unrelated edits to existing rows.
5267
+ *
5268
+ * Error `Source`s are dotted/indexed paths (`Configuration.Items[2].EndHour`). `severity` is
5269
+ * `'Warning'` for `@mjValidate warn`: such errors are reported but do not fail the save.
5270
+ *
5271
+ * @param fieldName - the JSON text field
5272
+ * @param schema - structural Zod schema for the whole field value (arrays included)
5273
+ * @param rules - `@CHECK` rules and the type graph to find their scope, or null
5274
+ * @param severity - severity the results are reported at
5275
+ * @param result - the result object to add errors to
5276
+ */
5277
+ ValidateJSONField(fieldName, schema, rules, severity, result) {
5278
+ // Flush FIRST: a field changed only through an un-proxied reference is not dirty until flushed.
5279
+ this.FlushJSONFieldObjects();
5280
+ if (this.IsSaved && !this.FieldIsDirty(fieldName)) {
5281
+ return;
5282
+ }
5283
+ ValidateJSONFieldValue(fieldName, this.Get(fieldName), schema, rules, severity, this, result);
5284
+ }
4899
5285
  Validate() {
4900
5286
  if (this._isValidating) {
4901
5287
  const emptyResult = new ValidationResult();
@@ -4904,6 +5290,9 @@ export class BaseEntity {
4904
5290
  }
4905
5291
  this._isValidating = true;
4906
5292
  try {
5293
+ // Safety net for JSONType object accessors: an object mutated through the caller's own
5294
+ // (un-proxied) reference must reach the raw field before that field is validated.
5295
+ this.FlushJSONFieldObjects();
4907
5296
  const result = new ValidationResult();
4908
5297
  result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
4909
5298
  // IS-A composition: validate parent entity first to collect all chain errors
@@ -5039,6 +5428,12 @@ export class BaseEntity {
5039
5428
  return this.deleteGraph(plan, options);
5040
5429
  }
5041
5430
  }
5431
+ // IS-A parent chain deletes bypass the debounce, as parent chain saves do: the leaf's
5432
+ // call back up the chain would otherwise wait on the pending delete that handed the
5433
+ // delete to the leaf, and Delete() would never return (MJ#4850).
5434
+ if (options?.IsParentEntityDelete) {
5435
+ return this._innerDelete(options);
5436
+ }
5042
5437
  // If a delete is already in progress, return its promise.
5043
5438
  if (this._pendingDelete$) {
5044
5439
  return firstValueFrom(this._pendingDelete$);
@@ -5063,6 +5458,13 @@ export class BaseEntity {
5063
5458
  const currentResultCount = this.ResultHistory.length;
5064
5459
  const newResult = new BaseEntityResult();
5065
5460
  newResult.StartedAt = new Date();
5461
+ // The unit of work this delete runs inside, if any (see UnitOfWork), and this delete's own
5462
+ // part in one, taken when it opens a scope for an IS-A chain.
5463
+ const enclosing = this._unitOfWork;
5464
+ let part = null;
5465
+ // Set once this record's own row is deleted. From then on its history holds the provider's
5466
+ // entry for that delete, which doesn't report a failure later in the chain.
5467
+ let ownRowDeleted = false;
5066
5468
  try {
5067
5469
  const _options = options ? options : new EntityDeleteOptions();
5068
5470
  // IS-A leaf delegation: if this entity has a child entity and we are NOT
@@ -5109,7 +5511,15 @@ export class BaseEntity {
5109
5511
  // Open (or join) a transaction scope for the parent chain — see the matching
5110
5512
  // comment in _InnerSave and EntityTransactionScope for why this is provider-
5111
5513
  // arbitrated rather than IS-A-specific.
5112
- await this.beginEntityTransactionScope(isISAInitiator);
5514
+ const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
5515
+ // A chain delete that holds a scope is a unit of work. Each parent is deleted inside
5516
+ // it and isn't reset when its delete returns: the unit resets the chain once it
5517
+ // commits, so a failure that rolls it back leaves every level as it was. Without a
5518
+ // scope each delete stands as it runs and resets its record at once, as before.
5519
+ if (scopeOpened) {
5520
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
5521
+ }
5522
+ const unit = part?.unit ?? enclosing;
5113
5523
  this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
5114
5524
  // Raise delete_started event before the actual delete operation begins
5115
5525
  this.RaiseEvent('delete_started', null);
@@ -5124,6 +5534,7 @@ export class BaseEntity {
5124
5534
  });
5125
5535
  // Delete OWN row first (FK constraint: child must be deleted before parent)
5126
5536
  if (await this.ProviderToUse.Delete(this, _options, this.ActiveUser)) {
5537
+ ownRowDeleted = true;
5127
5538
  // IS-A: after own delete succeeds, cascade to parent chain
5128
5539
  if (hasParentChain) {
5129
5540
  // For overlapping subtypes, check if other children still reference
@@ -5135,38 +5546,40 @@ export class BaseEntity {
5135
5546
  parentDeleteOptions.SkipEntityActions = _options.SkipEntityActions;
5136
5547
  parentDeleteOptions.ReplayOnly = _options.ReplayOnly;
5137
5548
  parentDeleteOptions.IsParentEntityDelete = true;
5138
- const parentResult = await this._parentEntity.Delete(parentDeleteOptions);
5549
+ // The parent's delete belongs to this chain's unit of work, so it doesn't
5550
+ // reset the parent when it returns: the unit does, once it commits.
5551
+ const parentResult = await BaseEntity.inUnitOfWork(this._parentEntity, unit, parent => parent.Delete(parentDeleteOptions));
5139
5552
  if (!parentResult) {
5140
5553
  // Parent delete failed — rollback if we started the transaction
5141
5554
  await this.rollbackEntityTransactionScope();
5555
+ BaseEntity.settleRollback(part);
5142
5556
  // RECORD the failure on THIS entity's ResultHistory before returning —
5143
- // symmetric with the parent-SAVE-failure path in _InnerSave. Without this
5144
- // the caller gets `false` with LatestResult === null and an empty
5145
- // ResultHistory, because every result was written to the PARENT object,
5146
- // which callers have no reference to (`_parentEntity` is private). Note
5147
- // THIS entity's own row was already deleted successfully above; it is the
5148
- // parent-chain delete that failed and rolled the transaction back.
5149
- if (currentResultCount === this.ResultHistory.length) {
5150
- const parentLatest = this._parentEntity.LatestResult;
5151
- const parentErrors = parentLatest?.Errors ?? [];
5152
- // A failed parent commonly reports its detail ONLY in Errors, so fall
5153
- // back to the error text rather than a message that says nothing.
5154
- const detail = parentLatest?.Message ||
5155
- parentErrors.map(e => e?.Message ?? String(e)).filter(Boolean).join('; ') ||
5156
- 'no error detail was reported by the parent';
5157
- newResult.Success = false;
5158
- newResult.Type = 'delete';
5159
- newResult.Message =
5160
- `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
5161
- // Surface the parent's field-level errors so the caller can act on them.
5162
- newResult.Errors = parentErrors;
5163
- // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
5164
- // them — say so, or CompleteMessage repeats every one.
5165
- newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
5166
- newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
5167
- newResult.EndedAt = new Date();
5168
- this.RegisterResultHistoryEntry(newResult);
5169
- }
5557
+ // symmetric with the parent-SAVE-failure path in _InnerSave. Every result
5558
+ // for the parent's failure was written to the PARENT object, which callers
5559
+ // have no reference to (`_parentEntity` is private). THIS entity's own row
5560
+ // was already deleted successfully above, and the only entry its history
5561
+ // gained is the provider's for that delete, which the rollback just undid.
5562
+ // So the failure is recorded whatever that entry says: skipping it when
5563
+ // the history had grown left the caller holding the provider's entry.
5564
+ const parentLatest = this._parentEntity.LatestResult;
5565
+ const parentErrors = parentLatest?.Errors ?? [];
5566
+ // A failed parent commonly reports its detail ONLY in Errors, so fall
5567
+ // back to the error text rather than a message that says nothing.
5568
+ const detail = parentLatest?.Message ||
5569
+ parentErrors.map(e => e?.Message ?? String(e)).filter(Boolean).join('; ') ||
5570
+ 'no error detail was reported by the parent';
5571
+ newResult.Success = false;
5572
+ newResult.Type = 'delete';
5573
+ newResult.Message =
5574
+ `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
5575
+ // Surface the parent's field-level errors so the caller can act on them.
5576
+ newResult.Errors = parentErrors;
5577
+ // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
5578
+ // them — say so, or CompleteMessage repeats every one.
5579
+ newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
5580
+ newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
5581
+ newResult.EndedAt = new Date();
5582
+ this.RegisterResultHistoryEntry(newResult);
5170
5583
  return false;
5171
5584
  }
5172
5585
  }
@@ -5177,8 +5590,16 @@ export class BaseEntity {
5177
5590
  // NOT part of a transaction - raise event immediately
5178
5591
  // record deleted correctly
5179
5592
  this.RaiseEvent('delete', { OldValues: oldVals });
5180
- // wipe out the current data to flush out the DIRTY flags by calling NewRecord()
5181
- this.NewRecord(); // will trigger a new record event here too
5593
+ if (unit) {
5594
+ // Deleted inside a unit of work, which resets the record once it commits:
5595
+ // now, when this delete owns the unit and has just committed it.
5596
+ unit.DeletedRecords.push(this);
5597
+ BaseEntity.settleCommit(part);
5598
+ }
5599
+ else {
5600
+ // wipe out the current data to flush out the DIRTY flags by calling NewRecord()
5601
+ this.NewRecord(); // will trigger a new record event here too
5602
+ }
5182
5603
  }
5183
5604
  else {
5184
5605
  // part of a transaction, wait for the transaction to submit successfully and then
@@ -5221,6 +5642,7 @@ export class BaseEntity {
5221
5642
  // every subsequent "committed" write on this provider silently never commits.
5222
5643
  // (Also: don't wipe out the entity like we do when the Delete() worked.)
5223
5644
  await this.rollbackEntityTransactionScope();
5645
+ BaseEntity.settleRollback(part);
5224
5646
  return false;
5225
5647
  }
5226
5648
  }
@@ -5228,9 +5650,11 @@ export class BaseEntity {
5228
5650
  catch (e) {
5229
5651
  // Roll back the scope this entity opened, if any. No-op when it holds none.
5230
5652
  await this.rollbackEntityTransactionScope();
5231
- if (currentResultCount === this.ResultHistory.length) {
5232
- // this means that NO new results were added to the history anywhere
5233
- // so we need to add a new result to the history here
5653
+ BaseEntity.settleRollback(part);
5654
+ // Record the failure when nothing else did. Once this record's own row was deleted, its
5655
+ // history holds the provider's entry for that delete, which doesn't report this failure
5656
+ // (a failed commit, most often), so the failure is recorded then too.
5657
+ if (ownRowDeleted || currentResultCount === this.ResultHistory.length) {
5234
5658
  newResult.Success = false;
5235
5659
  newResult.Type = 'delete';
5236
5660
  newResult.Message = e.message || null;
@@ -5462,9 +5886,7 @@ export class BaseEntity {
5462
5886
  // field that reads like the record itself is broken.
5463
5887
  //
5464
5888
  // Returning null is the same answer callers already handle for "this entity has no name
5465
- // field", and every one of them degrades to the primary key. It also keeps a denied name
5466
- // OUT of the provider's record-name cache, which is keyed by entity + primary key and NOT
5467
- // by user — caching it would leak it to the next caller.
5889
+ // field", and every one of them degrades to the primary key.
5468
5890
  if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
5469
5891
  return null;
5470
5892
  }
@@ -5499,6 +5921,7 @@ export class BaseEntity {
5499
5921
  */
5500
5922
  From(data, schema) {
5501
5923
  this.init();
5924
+ this.discardJSONFieldObjects();
5502
5925
  if (schema) {
5503
5926
  const parseResult = schema.safeParse(data);
5504
5927
  if (parseResult.success) {
@@ -5536,13 +5959,14 @@ export class BaseEntity {
5536
5959
  /**
5537
5960
  * Generates vector embeddings for multiple text fields by their field names.
5538
5961
  * Processes fields in parallel for better performance.
5539
- * @param fields - Array of field configurations specifying source text field, target vector field, and model ID field names
5962
+ * @param fields - Array of field configurations specifying source text field, target vector field, model ID field
5963
+ * and, optionally, the binary companion of the vector field (see {@link GenerateEmbedding})
5540
5964
  * @returns Promise that resolves to true if all embeddings were generated successfully, false if any failed
5541
5965
  */
5542
5966
  async GenerateEmbeddingsByFieldName(fields) {
5543
5967
  const promises = [];
5544
- for (const { fieldName, vectorFieldName, modelFieldName } of fields) {
5545
- promises.push(this.GenerateEmbeddingByFieldName(fieldName, vectorFieldName, modelFieldName));
5968
+ for (const { fieldName, vectorFieldName, modelFieldName, binaryVectorFieldName } of fields) {
5969
+ promises.push(this.GenerateEmbeddingByFieldName(fieldName, vectorFieldName, modelFieldName, binaryVectorFieldName));
5546
5970
  }
5547
5971
  const results = await Promise.all(promises);
5548
5972
  return results.every(result => result === true);
@@ -5551,11 +5975,14 @@ export class BaseEntity {
5551
5975
  * Generates a vector embedding for a single text field identified by field name.
5552
5976
  * Retrieves the field objects and delegates to GenerateEmbedding method.
5553
5977
  * @param fieldName - Name of the text field to generate embedding from
5554
- * @param vectorFieldName - Name of the field to store the vector embedding
5978
+ * @param vectorFieldName - Name of the field to store the vector embedding (JSON)
5555
5979
  * @param modelFieldName - Name of the field to store the model ID used for embedding
5980
+ * @param binaryVectorFieldName - Optional name of the binary (varbinary/bytea) companion of `vectorFieldName`,
5981
+ * which receives the same vector as little-endian float32 bytes. Omit for entities without one.
5556
5982
  * @returns Promise that resolves to true if embedding was generated successfully, false otherwise
5983
+ * @throws Error when a named field does not exist on the entity
5557
5984
  */
5558
- async GenerateEmbeddingByFieldName(fieldName, vectorFieldName, modelFieldName) {
5985
+ async GenerateEmbeddingByFieldName(fieldName, vectorFieldName, modelFieldName, binaryVectorFieldName) {
5559
5986
  const field = this.GetFieldByName(fieldName);
5560
5987
  const vectorField = this.GetFieldByName(vectorFieldName);
5561
5988
  const modelField = this.GetFieldByName(modelFieldName);
@@ -5565,18 +5992,25 @@ export class BaseEntity {
5565
5992
  throw new Error(`Vector field not found: ${vectorFieldName}`);
5566
5993
  if (modelFieldName?.trim().length > 0 && !modelField)
5567
5994
  throw new Error(`Model field not found: ${modelFieldName}`);
5568
- return await this.GenerateEmbedding(field, vectorField, modelField);
5995
+ let binaryVectorField;
5996
+ if (binaryVectorFieldName?.trim().length) {
5997
+ binaryVectorField = this.GetFieldByName(binaryVectorFieldName);
5998
+ if (!binaryVectorField)
5999
+ throw new Error(`Binary vector field not found: ${binaryVectorFieldName}`);
6000
+ }
6001
+ return await this.GenerateEmbedding(field, vectorField, modelField, binaryVectorField);
5569
6002
  }
5570
6003
  /**
5571
6004
  * Generates vector embeddings for multiple text fields using EntityField objects.
5572
6005
  * Processes fields in parallel for better performance.
5573
- * @param fields - Array of field configurations with EntityField objects for source, vector, and model fields
6006
+ * @param fields - Array of field configurations with EntityField objects for source, vector, model and
6007
+ * (optionally) binary vector fields
5574
6008
  * @returns Promise that resolves to true if all embeddings were generated successfully, false if any failed
5575
6009
  */
5576
6010
  async GenerateEmbeddings(fields) {
5577
6011
  const promises = [];
5578
- for (const { field, vectorField, modelField } of fields) {
5579
- promises.push(this.GenerateEmbedding(field, vectorField, modelField));
6012
+ for (const { field, vectorField, modelField, binaryVectorField } of fields) {
6013
+ promises.push(this.GenerateEmbedding(field, vectorField, modelField, binaryVectorField));
5580
6014
  }
5581
6015
  const results = await Promise.all(promises);
5582
6016
  return results.every(result => result === true);
@@ -5584,13 +6018,23 @@ export class BaseEntity {
5584
6018
  /**
5585
6019
  * Generates a vector embedding for a single text field using AI engine.
5586
6020
  * Only generates embeddings for new records or when the source field has changed.
5587
- * Stores both the vector embedding and the model ID used to generate it.
6021
+ * Stores the vector embedding, the model ID used to generate it and, when the entity has one, the
6022
+ * vector's binary companion.
6023
+ *
6024
+ * **Two persisted forms.** The JSON field holds the vector as a JSON number array. The optional
6025
+ * binary field (`varbinary(MAX)` / `bytea`) holds the same vector as little-endian float32 bytes —
6026
+ * set here as a base64 string via `Float32VectorToBase64`, which is how every binary field travels
6027
+ * in MJ. Readers prefer the binary form because decoding it is a copy, not a JSON parse (see
6028
+ * `ReadStoredVector` in `@memberjunction/ai-vectors-memory`). Both are written and cleared together
6029
+ * so they never disagree.
6030
+ *
5588
6031
  * @param field - The EntityField containing the text to embed
5589
6032
  * @param vectorField - The EntityField to store the generated vector embedding (as JSON string)
5590
6033
  * @param modelField - The EntityField to store the ID of the AI model used
6034
+ * @param binaryVectorField - Optional EntityField to store the same vector as base64-encoded float32 bytes
5591
6035
  * @returns Promise that resolves to true if embedding was generated successfully, false otherwise
5592
6036
  */
5593
- async GenerateEmbedding(field, vectorField, modelField) {
6037
+ async GenerateEmbedding(field, vectorField, modelField, binaryVectorField) {
5594
6038
  try {
5595
6039
  if (this._skipEmbeddings)
5596
6040
  return true;
@@ -5600,12 +6044,16 @@ export class BaseEntity {
5600
6044
  const e = await this.EmbedTextLocal(field.Value);
5601
6045
  if (e && e.vector) {
5602
6046
  vectorField.Value = JSON.stringify(e.vector);
6047
+ if (binaryVectorField)
6048
+ binaryVectorField.Value = Float32VectorToBase64(e.vector);
5603
6049
  if (modelField)
5604
6050
  modelField.Value = e.modelID;
5605
6051
  }
5606
6052
  }
5607
6053
  else {
5608
6054
  vectorField.Value = null;
6055
+ if (binaryVectorField)
6056
+ binaryVectorField.Value = null;
5609
6057
  if (modelField)
5610
6058
  modelField.Value = null;
5611
6059
  }