@memberjunction/core 6.1.0-edge.0 → 6.1.0-edge.2

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 (77) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +6 -0
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  3. package/dist/generic/InMemoryLocalStorageProvider.js +6 -0
  4. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  5. package/dist/generic/baseEngineRegistry.d.ts +14 -0
  6. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  7. package/dist/generic/baseEngineRegistry.js +32 -0
  8. package/dist/generic/baseEngineRegistry.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts +366 -29
  10. package/dist/generic/baseEntity.d.ts.map +1 -1
  11. package/dist/generic/baseEntity.js +872 -96
  12. package/dist/generic/baseEntity.js.map +1 -1
  13. package/dist/generic/dataHooks.d.ts +5 -0
  14. package/dist/generic/dataHooks.d.ts.map +1 -1
  15. package/dist/generic/dataHooks.js +27 -3
  16. package/dist/generic/dataHooks.js.map +1 -1
  17. package/dist/generic/databaseProviderBase.d.ts +44 -17
  18. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  19. package/dist/generic/databaseProviderBase.js +96 -52
  20. package/dist/generic/databaseProviderBase.js.map +1 -1
  21. package/dist/generic/entityCompanion.d.ts +218 -0
  22. package/dist/generic/entityCompanion.d.ts.map +1 -0
  23. package/dist/generic/entityCompanion.js +170 -0
  24. package/dist/generic/entityCompanion.js.map +1 -0
  25. package/dist/generic/entityInfo.d.ts +21 -0
  26. package/dist/generic/entityInfo.d.ts.map +1 -1
  27. package/dist/generic/entityInfo.js +21 -0
  28. package/dist/generic/entityInfo.js.map +1 -1
  29. package/dist/generic/entitySavePlan.d.ts +199 -0
  30. package/dist/generic/entitySavePlan.d.ts.map +1 -0
  31. package/dist/generic/entitySavePlan.js +213 -0
  32. package/dist/generic/entitySavePlan.js.map +1 -0
  33. package/dist/generic/entityTransactionScope.d.ts +125 -0
  34. package/dist/generic/entityTransactionScope.d.ts.map +1 -0
  35. package/dist/generic/entityTransactionScope.js +115 -0
  36. package/dist/generic/entityTransactionScope.js.map +1 -0
  37. package/dist/generic/interfaces.d.ts +122 -35
  38. package/dist/generic/interfaces.d.ts.map +1 -1
  39. package/dist/generic/interfaces.js +27 -0
  40. package/dist/generic/interfaces.js.map +1 -1
  41. package/dist/generic/localCacheManager.d.ts +123 -5
  42. package/dist/generic/localCacheManager.d.ts.map +1 -1
  43. package/dist/generic/localCacheManager.js +254 -9
  44. package/dist/generic/localCacheManager.js.map +1 -1
  45. package/dist/generic/providerBase.d.ts +112 -0
  46. package/dist/generic/providerBase.d.ts.map +1 -1
  47. package/dist/generic/providerBase.js +303 -9
  48. package/dist/generic/providerBase.js.map +1 -1
  49. package/dist/generic/relatedRecordBatchLoader.d.ts +39 -0
  50. package/dist/generic/relatedRecordBatchLoader.d.ts.map +1 -0
  51. package/dist/generic/relatedRecordBatchLoader.js +154 -0
  52. package/dist/generic/relatedRecordBatchLoader.js.map +1 -0
  53. package/dist/generic/relatedRecordCollection.d.ts +588 -0
  54. package/dist/generic/relatedRecordCollection.d.ts.map +1 -0
  55. package/dist/generic/relatedRecordCollection.js +1020 -0
  56. package/dist/generic/relatedRecordCollection.js.map +1 -0
  57. package/dist/generic/saveEntityGraphOperation.d.ts +148 -0
  58. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -0
  59. package/dist/generic/saveEntityGraphOperation.js +157 -0
  60. package/dist/generic/saveEntityGraphOperation.js.map +1 -0
  61. package/dist/generic/telemetryManager.d.ts +21 -1
  62. package/dist/generic/telemetryManager.d.ts.map +1 -1
  63. package/dist/generic/telemetryManager.js +21 -6
  64. package/dist/generic/telemetryManager.js.map +1 -1
  65. package/dist/index.d.ts +6 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +6 -3
  68. package/dist/index.js.map +1 -1
  69. package/dist/views/runView.d.ts +66 -0
  70. package/dist/views/runView.d.ts.map +1 -1
  71. package/dist/views/runView.js.map +1 -1
  72. package/package.json +3 -3
  73. package/readme.md +195 -1
  74. package/dist/generic/runReport.d.ts +0 -25
  75. package/dist/generic/runReport.d.ts.map +0 -1
  76. package/dist/generic/runReport.js +0 -38
  77. package/dist/generic/runReport.js.map +0 -1
@@ -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 { MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
11
+ import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
12
12
  import { GetDataHooks } from './dataHooks.js';
13
13
  import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
14
14
  import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
@@ -16,6 +16,11 @@ import { Metadata } from './metadata.js';
16
16
  import { RunView } from '../views/runView.js';
17
17
  import { LogDebug, LogError } from './logging.js';
18
18
  import { CompositeKey } from './compositeKey.js';
19
+ import { RelatedRecordCollection } from './relatedRecordCollection.js';
20
+ import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
21
+ import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
22
+ import { BaseRemotableOperation } from './baseRemotableOperation.js';
23
+ import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
19
24
  import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
20
25
  /**
21
26
  * 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
@@ -537,15 +542,6 @@ export class BaseEntity {
537
542
  this._resultHistory.splice(0, overflow);
538
543
  }
539
544
  }
540
- /**
541
- * Gets the provider transaction handle for IS-A chain orchestration.
542
- */
543
- get ProviderTransaction() { return this._providerTransaction; }
544
- /**
545
- * Sets the provider transaction handle. Used during IS-A save/delete to share
546
- * a single database transaction across the entire parent chain.
547
- */
548
- set ProviderTransaction(value) { this._providerTransaction = value; }
549
545
  /**
550
546
  * Returns the parent entity in the IS-A composition chain, or null if this
551
547
  * entity is not an IS-A child type.
@@ -618,6 +614,21 @@ export class BaseEntity {
618
614
  * authoritative source.
619
615
  */
620
616
  this._raw = null;
617
+ /**
618
+ * Per-instance memo for values `Get()` derives from `_raw` — a parsed `Date`, an rtrimmed
619
+ * fixed-width string. Lazily created; only converted fields ever get an entry.
620
+ *
621
+ * This deliberately does NOT write back into `_raw`. `LoadFromData`'s fast path keeps the
622
+ * caller's row BY REFERENCE, and that row is frequently a LocalCacheManager entry, which the
623
+ * cache deep-freezes on reference-sharing providers. Memoizing into the row therefore made a
624
+ * plain field READ throw — and gating that write on a once-sampled `Object.isFrozen` could
625
+ * not be made correct, because the freeze is asynchronous relative to the consumer (cache
626
+ * writes are not always awaited), so the sample can be stale by the first read.
627
+ *
628
+ * Keeping the memo here makes freeze timing irrelevant AND restores the optimization on
629
+ * frozen rows, which the isFrozen-guard version had to give up.
630
+ */
631
+ this._rawConverted = null;
621
632
  /**
622
633
  * Whether a database record has been loaded into this instance (via `Load`, `NewRecord`,
623
634
  * `LoadFromData`, etc.). Used to gate operations that require loaded state and to distinguish
@@ -771,12 +782,21 @@ export class BaseEntity {
771
782
  */
772
783
  this._childEntities = null;
773
784
  /**
774
- * Opaque provider-level transaction handle. Used by IS-A save/delete orchestration
775
- * to share a single SQL transaction across the parent chain.
776
- * On client (GraphQLDataProvider), this remains null.
777
- * On server (SQLServerDataProvider), this holds a sql.Transaction.
785
+ * The active transaction scope owned by this entity, when it is the participant that opened (or
786
+ * joined) one for a multi-record unit of work an IS-A parent chain or a composite save graph.
787
+ *
788
+ * Only the *initiator* holds a scope; other participants in the same unit of work simply write
789
+ * into the provider's ambient transaction without knowing it exists. That is the whole point of
790
+ * routing through the provider: participants stay ignorant of one another.
778
791
  */
779
- this._providerTransaction = null;
792
+ this._entityTransactionScope = null;
793
+ /**
794
+ * Companions registered on this entity, keyed by {@link EntityCompanion.Name}.
795
+ *
796
+ * Lazily created so that the overwhelming majority of entities — which declare no companions —
797
+ * pay nothing for the feature, not even an empty Map per instance.
798
+ */
799
+ this._companions = null;
780
800
  this._compositeKey = null;
781
801
  // ────────────────────────────────────────────────────────────────────
782
802
  // Restore context — populated by callers immediately before Save() to
@@ -1024,15 +1044,572 @@ export class BaseEntity {
1024
1044
  // entities in the chain that don't exist on this entity.
1025
1045
  this.SetMany(data, true, true, true);
1026
1046
  }
1047
+ // ─── Entity Companions ──────────────────────────────────────────────────────
1048
+ //
1049
+ // Companions are named, serialisable side-channels attached to a record — most commonly
1050
+ // related-record collections. See entityCompanion.ts for the full rationale and lifecycle.
1027
1051
  /**
1028
- * Propagates the ProviderTransaction handle down the IS-A parent chain so all
1029
- * entities in the chain execute on the same database transaction.
1052
+ * The companions registered on this entity, in declaration order.
1053
+ *
1054
+ * Empty for the vast majority of entities. Nothing in the save, load or validation paths does
1055
+ * any companion work when this is empty, so the feature costs nothing where it is unused.
1030
1056
  */
1031
- PropagateTransactionToParents() {
1032
- let current = this._parentEntity;
1033
- while (current) {
1034
- current.ProviderTransaction = this._providerTransaction;
1035
- current = current._parentEntity;
1057
+ get Companions() {
1058
+ return this._companions ? Array.from(this._companions.values()) : [];
1059
+ }
1060
+ /**
1061
+ * Whether this entity has any registered companions.
1062
+ *
1063
+ * Used as the fast guard on the hot paths — a single boolean check keeps single-record saves on
1064
+ * exactly the code path they took before companions existed.
1065
+ */
1066
+ get HasCompanions() {
1067
+ return this._companions !== null && this._companions.size > 0;
1068
+ }
1069
+ /**
1070
+ * Registers a companion on this entity. Called from a subclass constructor or field
1071
+ * initialiser, normally via {@link DeclareRelatedRecords}.
1072
+ *
1073
+ * @typeParam TCompanion - The companion type.
1074
+ * @param companion - The companion to register.
1075
+ * @returns The same companion, so it can be assigned to a readonly field in one expression.
1076
+ * @throws When a companion with the same name is already registered — a duplicate name would
1077
+ * make the wire payload ambiguous and silently drop one of the two.
1078
+ */
1079
+ RegisterCompanion(companion) {
1080
+ if (!this._companions) {
1081
+ this._companions = new Map();
1082
+ }
1083
+ if (this._companions.has(companion.Name)) {
1084
+ throw new Error(`BaseEntity.RegisterCompanion: '${companion.Name}' is already registered on ` +
1085
+ `${this.EntityInfo?.Name ?? this.constructor.name}. Companion names must be unique per entity.`);
1086
+ }
1087
+ this._companions.set(companion.Name, companion);
1088
+ return companion;
1089
+ }
1090
+ /**
1091
+ * Looks up a registered companion by name.
1092
+ *
1093
+ * @typeParam TCompanion - The expected companion type.
1094
+ * @param name - The companion's {@link EntityCompanion.Name}.
1095
+ * @returns The companion, or `undefined` when none is registered under that name.
1096
+ */
1097
+ GetCompanion(name) {
1098
+ return this._companions?.get(name);
1099
+ }
1100
+ /**
1101
+ * Declares a typed child collection on this entity and registers it as a companion.
1102
+ *
1103
+ * This is the entry point for composite entities. Call it from a field initialiser on a
1104
+ * **shared** (client + server) subclass so both tiers see the collection — a declaration that
1105
+ * exists only in a server-side class makes the collection invisible to the browser, which is
1106
+ * exactly the limitation this feature removes.
1107
+ *
1108
+ * @typeParam TChild - The child entity type.
1109
+ * @param options - The collection declaration.
1110
+ * @returns The registered collection.
1111
+ *
1112
+ * @example
1113
+ * ```typescript
1114
+ * public readonly Lines = this.DeclareRelatedRecords<OrderLineEntity>({
1115
+ * Name: 'Lines',
1116
+ * ChildEntity: 'MJ_BizApps_Orders: Order Lines',
1117
+ * ForeignKey: 'OrderHeaderID',
1118
+ * OrderBy: 'LineNumber ASC',
1119
+ * Sequence: { Field: 'LineNumber', From: 1 },
1120
+ * });
1121
+ * ```
1122
+ */
1123
+ DeclareRelatedRecords(options) {
1124
+ return this.RegisterCompanion(new RelatedRecordCollection(this, options));
1125
+ }
1126
+ /**
1127
+ * Serializes every registered companion that has something to send.
1128
+ *
1129
+ * Companions returning `null` are omitted entirely, so a header-only save on a composite entity
1130
+ * ships no companion payload at all and costs nothing extra on the wire.
1131
+ *
1132
+ * @returns The companion payloads, in declaration order.
1133
+ */
1134
+ async SerializeCompanions() {
1135
+ if (!this.HasCompanions) {
1136
+ return [];
1137
+ }
1138
+ const payloads = [];
1139
+ for (const companion of this.Companions) {
1140
+ const data = await companion.Serialize();
1141
+ if (data !== null && data !== undefined) {
1142
+ payloads.push({ Name: companion.Name, Data: data });
1143
+ }
1144
+ }
1145
+ return payloads;
1146
+ }
1147
+ /**
1148
+ * Restores companion state from payloads produced by {@link SerializeCompanions} on the other
1149
+ * tier.
1150
+ *
1151
+ * Payloads naming a companion this entity does not declare are **ignored rather than rejected**.
1152
+ * That is deliberate: during a rolling deploy the two tiers can disagree about which companions
1153
+ * exist, and a hard failure would turn a harmless version skew into an outage. The mismatch is
1154
+ * logged so it is still visible.
1155
+ *
1156
+ * @param payloads - Companion payloads received from the other tier.
1157
+ * @param mode - Whether these payloads are an inbound request (the default) or authoritative
1158
+ * post-save results. See {@link EntityCompanionDeserializeMode}.
1159
+ */
1160
+ async DeserializeCompanions(payloads, mode = 'request') {
1161
+ if (!payloads || payloads.length === 0) {
1162
+ return;
1163
+ }
1164
+ for (const payload of payloads) {
1165
+ const companion = this.GetCompanion(payload.Name);
1166
+ if (!companion) {
1167
+ LogDebug(`BaseEntity.DeserializeCompanions: no companion named '${payload.Name}' on ` +
1168
+ `${this.EntityInfo?.Name}; ignoring (tier version skew is expected during rolling deploys).`);
1169
+ continue;
1170
+ }
1171
+ await companion.Deserialize(payload.Data, mode);
1172
+ }
1173
+ }
1174
+ /**
1175
+ * Executes a multi-node plan by handing the entire unit of work to the server.
1176
+ *
1177
+ * Used when the provider cannot open a local transaction — i.e. from the browser. The client
1178
+ * does not orchestrate the cascade; it ships intent, and the server runs the *same*
1179
+ * `executeGraphLocal` path inside a real transaction. One cascade implementation, two possible
1180
+ * locations.
1181
+ *
1182
+ * @param plan - The planned unit of work. Used for its node count in events and diagnostics;
1183
+ * the server rebuilds its own plan from the payload.
1184
+ * @param _options - Save options. Not forwarded over the wire: they describe local execution
1185
+ * preferences, and the server applies its own.
1186
+ * @returns True when the server reports the graph committed.
1187
+ */
1188
+ async executeGraphRemote(plan, _options) {
1189
+ try {
1190
+ const operation = MJGlobal.Instance.ClassFactory.CreateInstance(BaseRemotableOperation, SAVE_ENTITY_GRAPH_OPERATION_KEY);
1191
+ if (!operation) {
1192
+ throw new Error(`Composite save requires the '${SAVE_ENTITY_GRAPH_OPERATION_KEY}' remote operation, which is ` +
1193
+ `not registered in this process.`);
1194
+ }
1195
+ const result = await operation.Execute({
1196
+ EntityName: this.EntityInfo.Name,
1197
+ Fields: this.GetAll(),
1198
+ Companions: await this.SerializeCompanions(),
1199
+ IsExistingRecord: this.IsSaved,
1200
+ }, { provider: this.ProviderToUse, user: this.ActiveUser });
1201
+ if (!result.Success || !result.Output?.Success) {
1202
+ const detail = result.ErrorMessage ?? result.Output?.ErrorMessage ?? 'unknown error';
1203
+ this.registerGraphFailure(detail);
1204
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1205
+ return false;
1206
+ }
1207
+ await this.applyGraphResult(result.Output);
1208
+ this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
1209
+ return true;
1210
+ }
1211
+ catch (e) {
1212
+ const detail = e instanceof Error ? e.message : String(e);
1213
+ LogError(`BaseEntity.executeGraphRemote failed for ${this.EntityInfo?.Name}: ${detail}`);
1214
+ this.registerGraphFailure(detail);
1215
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1216
+ return false;
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Adopts the post-save state the server returned for the whole graph.
1221
+ *
1222
+ * Both halves matter. Refreshing only the header would leave the client holding children that
1223
+ * still look unsaved — they would be re-inserted on the next save, silently duplicating rows.
1224
+ *
1225
+ * @param output - The server's result graph.
1226
+ */
1227
+ async applyGraphResult(output) {
1228
+ // Captured at entry — the last moment the create-vs-update distinction exists on this
1229
+ // object, since `_everSaved` is stamped true a few lines down. The result history and the
1230
+ // 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
1231
+ // report (and notify subscribers) as an update.
1232
+ const saveSubType = this.IsSaved ? 'update' : 'create';
1233
+ // Root: rebase field values and dirty state onto what the database now holds.
1234
+ this.init();
1235
+ this.SetMany(output.Fields, false, true, true);
1236
+ this._recordLoaded = true;
1237
+ this._everSaved = true;
1238
+ // Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
1239
+ // returned state verbatim — these rows were just persisted by the sender, so re-loading them
1240
+ // would be one wasted round trip per record for data we already hold.
1241
+ await this.DeserializeCompanions(output.Companions ?? [], 'result');
1242
+ this.acceptCompanionChanges();
1243
+ const result = new BaseEntityResult();
1244
+ result.Success = true;
1245
+ result.Type = saveSubType;
1246
+ result.StartedAt = new Date();
1247
+ result.EndedAt = new Date();
1248
+ this.RegisterResultHistoryEntry(result);
1249
+ this.RaiseEvent('save', null, saveSubType);
1250
+ }
1251
+ /**
1252
+ * Gives every eager companion a chance to populate itself.
1253
+ *
1254
+ * Called from {@link InnerLoad} only — deliberately **not** from {@link LoadFromData}. See
1255
+ * {@link RelatedRecordLoadMode} for why that distinction is load-bearing.
1256
+ */
1257
+ /**
1258
+ * Populates this record's declared related-record collections and resolves once they are all
1259
+ * ready — the one call to `await` when you want a fully-hydrated record.
1260
+ *
1261
+ * The point is batching. Cache-sourced collections resolve synchronously against
1262
+ * `BaseEngineRegistry` and cost nothing; every database-sourced collection is gathered into a
1263
+ * **single `RunViews` call** rather than one `RunView` each. So a record with four declared
1264
+ * collections costs one round trip, or zero when they all read from engine caches — instead of
1265
+ * the four sequential queries a naive `for (…) await c.Load()` would issue.
1266
+ *
1267
+ * Collections declared `'never'` are skipped: that mode means write-only staging buffer.
1268
+ *
1269
+ * @param names - Collection names to load. Omit to load every declared collection.
1270
+ *
1271
+ * @example
1272
+ * ```typescript
1273
+ * await action.LoadRelatedRecords(); // Params, ResultCodes and Libraries, one trip
1274
+ * await agent.LoadRelatedRecords('Prompts'); // just the one
1275
+ * ```
1276
+ */
1277
+ async LoadRelatedRecords(...names) {
1278
+ const wanted = names.length > 0 ? new Set(names.map(n => n.trim().toLowerCase())) : null;
1279
+ const collections = this.Companions.filter((c) => c instanceof RelatedRecordCollection &&
1280
+ c.LoadMode !== 'never' &&
1281
+ (!wanted || wanted.has(c.Name.trim().toLowerCase())));
1282
+ if (collections.length === 0) {
1283
+ return;
1284
+ }
1285
+ // Cache-backed ones first — synchronous, zero queries. Whatever misses falls through to the
1286
+ // batched database load below, so a donor engine that is not loaded yet costs correctness
1287
+ // nothing. Database-backed collections carry the same guards Load() itself has: a
1288
+ // collection that is already loaded, or that holds staged work (new/edited children,
1289
+ // pending removals), is left alone — without those guards this call re-queried an
1290
+ // already-loaded collection and, worse, WIPED staged children on a new parent, because the
1291
+ // parent's pre-generated UUID matches zero rows and SetLoadedItems([]) discards everything.
1292
+ const needsDatabase = [];
1293
+ for (const collection of collections) {
1294
+ if (await collection.TryLoadFromCache()) {
1295
+ continue;
1296
+ }
1297
+ if (collection.IsLoaded || collection.Dirty) {
1298
+ continue;
1299
+ }
1300
+ needsDatabase.push(collection);
1301
+ }
1302
+ // An unsaved parent owns no persisted related records — mirror Load()'s own guard rather
1303
+ // than issuing queries guaranteed to return nothing (and destroy staged state adopting it).
1304
+ if (needsDatabase.length === 0 || !this.IsSaved) {
1305
+ return;
1306
+ }
1307
+ // One `RunViews` for all remaining collections — N declared collections cost one round trip,
1308
+ // not N. Params are built per collection so each keeps its own filter and ordering. The key
1309
+ // is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
1310
+ const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''");
1311
+ const rv = new RunView(this.ProviderToUse);
1312
+ const results = await rv.RunViews(needsDatabase.map(c => ({
1313
+ EntityName: c.RelatedEntityName,
1314
+ ExtraFilter: `${c.RelatedEntityJoinField} = '${parentKeyLiteral}'`,
1315
+ OrderBy: c.OrderByClause,
1316
+ ResultType: 'entity_object',
1317
+ })), this.ContextCurrentUser);
1318
+ needsDatabase.forEach((collection, i) => {
1319
+ const result = results?.[i];
1320
+ if (result?.Success) {
1321
+ collection.SetLoadedItems(result.Results ?? []);
1322
+ }
1323
+ else {
1324
+ LogError(`BaseEntity.LoadRelatedRecords: '${collection.Name}' failed — ${result?.ErrorMessage ?? 'no result'}`);
1325
+ }
1326
+ });
1327
+ }
1328
+ async loadEagerCompanions() {
1329
+ if (!this.HasCompanions) {
1330
+ return;
1331
+ }
1332
+ for (const companion of this.Companions) {
1333
+ await companion.LoadEager();
1334
+ }
1335
+ }
1336
+ /**
1337
+ * Tells every companion that the unit of work committed, so they can clear pending removals and
1338
+ * rebase their dirty state.
1339
+ */
1340
+ acceptCompanionChanges() {
1341
+ if (!this.HasCompanions) {
1342
+ return;
1343
+ }
1344
+ for (const companion of this.Companions) {
1345
+ companion.AcceptChanges();
1346
+ }
1347
+ }
1348
+ /**
1349
+ * Opens a transaction scope for a multi-record unit of work rooted at this entity, when this
1350
+ * entity is the initiator and is not already inside a `TransactionGroup`.
1351
+ *
1352
+ * Participants never ask whether a transaction is already open — the provider arbitrates and
1353
+ * either starts one or joins the one in flight. See {@link EntityTransactionScope}.
1354
+ *
1355
+ * @param isInitiator - Whether this entity is the one orchestrating the unit of work. Non-
1356
+ * initiators never open a scope; they write into the ambient transaction.
1357
+ * @returns True if a scope was opened (or joined) and is now held by this entity.
1358
+ */
1359
+ async beginEntityTransactionScope(isInitiator) {
1360
+ // A TransactionGroup manages its own atomicity and defers every save until Submit(); opening
1361
+ // a provider transaction underneath it would wrap statements that have not run yet.
1362
+ if (!isInitiator || this.TransactionGroup) {
1363
+ return false;
1364
+ }
1365
+ const provider = this.ProviderToUse;
1366
+ if (provider?.SupportsEntityTransactions !== true || !provider.BeginEntityTransaction) {
1367
+ return false; // client-side provider — no local transaction to open
1368
+ }
1369
+ this._entityTransactionScope = await provider.BeginEntityTransaction();
1370
+ return true;
1371
+ }
1372
+ /**
1373
+ * Builds the ordered unit of work for saving this record and everything its companions
1374
+ * contribute.
1375
+ *
1376
+ * The root node comes first — children need the parent's primary key, and on a create it does
1377
+ * not exist until the parent row is inserted.
1378
+ *
1379
+ * @param includeRoot - Whether to include this record's own save. False when the caller has
1380
+ * already persisted the root by other means.
1381
+ * @param saveOptions - The caller's save options, forwarded to each companion so it can honor
1382
+ * flags that change what counts as work (`IgnoreDirtyState`, most
1383
+ * importantly — a companion that skips clean children must not skip them
1384
+ * when the caller demanded a full write-out).
1385
+ * @returns The plan. A `NodeCount` of 1 means there is no graph and the caller should take the
1386
+ * ordinary single-record path.
1387
+ */
1388
+ BuildSavePlan(includeRoot = true, saveOptions) {
1389
+ const plan = new EntitySavePlan(this);
1390
+ if (includeRoot) {
1391
+ plan.AddSave(this, this.EntityInfo?.Name ?? 'root', undefined, /* selfOnly */ true);
1392
+ }
1393
+ for (const companion of this.Companions) {
1394
+ companion.ContributeSaveWork(plan, saveOptions);
1395
+ }
1396
+ return plan;
1397
+ }
1398
+ /**
1399
+ * Builds the ordered unit of work for deleting this record and everything its companions
1400
+ * contribute.
1401
+ *
1402
+ * Companions contribute **first**: children hold foreign keys pointing at the row that is about
1403
+ * to disappear, so they must be removed before it.
1404
+ *
1405
+ * @returns The plan.
1406
+ */
1407
+ BuildDeletePlan() {
1408
+ const plan = new EntitySavePlan(this);
1409
+ for (const companion of this.Companions) {
1410
+ companion.ContributeDeleteWork(plan);
1411
+ }
1412
+ plan.Add({
1413
+ Entity: this,
1414
+ Operation: 'Delete',
1415
+ Label: this.EntityInfo?.Name ?? 'root',
1416
+ SelfOnly: true,
1417
+ });
1418
+ return plan;
1419
+ }
1420
+ /**
1421
+ * Runs a multi-record save as one unit of work, choosing where it executes based on what the
1422
+ * provider can do.
1423
+ *
1424
+ * This is the single decision point for the whole feature:
1425
+ *
1426
+ * - **Provider supports entity transactions** (server): execute locally inside one transaction.
1427
+ * - **It does not** (client): serialize the graph and hand the entire unit of work to the
1428
+ * server via the `MJ.SaveEntityGraph` remote operation, which rebuilds the records — as their
1429
+ * *server-side* registered subclasses — and runs this very same local executor there.
1430
+ *
1431
+ * There is exactly one cascade implementation. The remote path relocates it; it never
1432
+ * reimplements it. That is what keeps client and server behavior from drifting.
1433
+ *
1434
+ * Validation is not performed here: the root node's own `_InnerSave` runs `Validate()` (which
1435
+ * fans out to every companion, over the complete child set including pending removals) before
1436
+ * it writes anything, and the root node executes first. So the whole graph is validated before
1437
+ * the first row lands, which is the guarantee cross-child invariants need.
1438
+ *
1439
+ * @param plan - The planned unit of work.
1440
+ * @param options - Save options forwarded to every node.
1441
+ * @returns True when the whole graph committed.
1442
+ */
1443
+ async saveGraph(plan, options) {
1444
+ this.RaiseEvent('graph_save_started', { NodeCount: plan.NodeCount });
1445
+ if (this.ProviderToUse?.SupportsEntityTransactions === true) {
1446
+ return this.executeGraphLocal(plan, options);
1447
+ }
1448
+ return this.executeGraphRemote(plan, options);
1449
+ }
1450
+ /**
1451
+ * Runs a multi-record delete as one unit of work.
1452
+ *
1453
+ * Unlike the save path there is no remote counterpart: a delete graph carries no state that
1454
+ * needs rebuilding server-side, so on a client provider the nodes execute in order over
1455
+ * ordinary mutations. That is **not atomic** — a failure partway leaves earlier deletions
1456
+ * committed. Callers needing an atomic multi-record delete from the browser should expose a
1457
+ * dedicated remote operation, which is also what MJ's own cascade-delete tooling does.
1458
+ *
1459
+ * @param plan - The planned unit of work, children first.
1460
+ * @param options - Delete options forwarded to every node.
1461
+ * @returns True when the whole graph completed.
1462
+ */
1463
+ async deleteGraph(plan, options) {
1464
+ this.RaiseEvent('graph_save_started', { NodeCount: plan.NodeCount, Operation: 'Delete' });
1465
+ return this.executeGraphLocal(plan, undefined, options, 'delete');
1466
+ }
1467
+ /**
1468
+ * Executes a multi-node plan locally, inside a single provider transaction.
1469
+ *
1470
+ * Used when the provider reports {@link IMetadataProvider.SupportsEntityTransactions} — i.e.
1471
+ * server-side. The transaction is provider-arbitrated, so this composes correctly whether it is
1472
+ * the outermost unit of work or nested inside an application cascade that already opened one.
1473
+ *
1474
+ * @param plan - The plan to run.
1475
+ * @param saveOptions - Options forwarded to save nodes.
1476
+ * @param deleteOptions - Options forwarded to delete nodes.
1477
+ * @returns True when every node succeeded and the transaction committed.
1478
+ */
1479
+ async executeGraphLocal(plan, saveOptions, deleteOptions, operationKind = 'save') {
1480
+ const provider = this.ProviderToUse;
1481
+ // Passed explicitly rather than inferred from which options object is set: `Delete()` is
1482
+ // routinely called with no arguments, so an inference would mislabel every such failure.
1483
+ const operation = operationKind;
1484
+ // One cycle-guard set per unit of work: inherited when this graph is nested inside another
1485
+ // (so a child sees its ancestors), created fresh when this graph is the outermost one.
1486
+ // Child nodes must carry it on their own options, because a child node runs the child's
1487
+ // `Save()`, which builds and executes a plan of its own.
1488
+ const visited = saveOptions?.GraphVisited ?? deleteOptions?.GraphVisited ?? new Set();
1489
+ const childSaveOptions = Object.assign(new EntitySaveOptions(), saveOptions ?? {});
1490
+ childSaveOptions.GraphVisited = visited;
1491
+ const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
1492
+ childDeleteOptions.GraphVisited = visited;
1493
+ // Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
1494
+ // save, and Save()/Delete() report failure by returning false — an escaping throw here
1495
+ // would break that contract for exactly one path.
1496
+ let scope = null;
1497
+ try {
1498
+ scope =
1499
+ provider?.SupportsEntityTransactions === true && provider.BeginEntityTransaction
1500
+ ? await provider.BeginEntityTransaction()
1501
+ : null;
1502
+ const result = await ExecuteEntitySavePlan(plan, {
1503
+ SaveOptions: childSaveOptions,
1504
+ RootSaveOptions: this.buildRootSaveOptions(saveOptions),
1505
+ DeleteOptions: childDeleteOptions,
1506
+ RootDeleteOptions: this.buildRootDeleteOptions(deleteOptions),
1507
+ Visited: visited,
1508
+ });
1509
+ if (!result.Success) {
1510
+ await scope?.Rollback();
1511
+ this.registerGraphFailure(result.ErrorMessage, operation);
1512
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
1513
+ return false;
1514
+ }
1515
+ await scope?.Commit();
1516
+ this.acceptCompanionChanges();
1517
+ this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
1518
+ return true;
1519
+ }
1520
+ catch (e) {
1521
+ // Rollback failures are logged and swallowed: this is already the failure path, and a
1522
+ // secondary throw (a doomed transaction refusing a savepoint rollback, a dropped
1523
+ // connection) would both replace the real error and escape the no-throw contract.
1524
+ try {
1525
+ await scope?.Rollback();
1526
+ }
1527
+ catch (rollbackError) {
1528
+ LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
1529
+ `${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
1530
+ }
1531
+ const detail = e instanceof Error ? e.message : String(e);
1532
+ LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
1533
+ this.registerGraphFailure(detail, operation);
1534
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1535
+ return false;
1536
+ }
1537
+ }
1538
+ /**
1539
+ * Builds the save options for the graph's **root** node.
1540
+ *
1541
+ * Copies the caller's options onto a real `EntitySaveOptions` instance and stamps
1542
+ * `IsGraphNodeSave`, which is what stops the root re-entering graph planning and lets it bypass
1543
+ * its own in-flight save debounce.
1544
+ *
1545
+ * @param source - The caller's options, if any.
1546
+ * @returns Options for the root node.
1547
+ */
1548
+ buildRootSaveOptions(source) {
1549
+ const options = Object.assign(new EntitySaveOptions(), source ?? {});
1550
+ options.IsGraphNodeSave = true;
1551
+ return options;
1552
+ }
1553
+ /**
1554
+ * Builds the delete options for the graph's **root** node. Delete-path counterpart of
1555
+ * {@link buildRootSaveOptions}.
1556
+ *
1557
+ * @param source - The caller's options, if any.
1558
+ * @returns Options for the root node.
1559
+ */
1560
+ buildRootDeleteOptions(source) {
1561
+ const options = Object.assign(new EntityDeleteOptions(), source ?? {});
1562
+ options.IsGraphNodeDelete = true;
1563
+ return options;
1564
+ }
1565
+ /**
1566
+ * Records a graph-level failure on this entity's result history.
1567
+ *
1568
+ * Without this the caller gets a bare `false` while `LatestResult` still holds the *root's*
1569
+ * successful save — so a child failure reads as "it just didn't work". Mirrors the parent-chain
1570
+ * failure handling in `_InnerSave`.
1571
+ *
1572
+ * @param message - The failure detail.
1573
+ */
1574
+ registerGraphFailure(message, operation = 'save') {
1575
+ const result = new BaseEntityResult();
1576
+ result.Success = false;
1577
+ result.Type = operation === 'delete' ? 'delete' : this.IsSaved ? 'update' : 'create';
1578
+ result.Message = message ?? 'Entity graph operation failed';
1579
+ result.StartedAt = new Date();
1580
+ result.EndedAt = new Date();
1581
+ result.OriginalValues = this.Fields.map(f => ({ FieldName: f.CodeName, Value: f.OldValue }));
1582
+ this.RegisterResultHistoryEntry(result);
1583
+ }
1584
+ /**
1585
+ * Commits the transaction scope held by this entity, if any. Safe to call unconditionally — a
1586
+ * no-op when this entity holds no scope.
1587
+ */
1588
+ async commitEntityTransactionScope() {
1589
+ const scope = this._entityTransactionScope;
1590
+ if (!scope) {
1591
+ return;
1592
+ }
1593
+ this._entityTransactionScope = null;
1594
+ await scope.Commit();
1595
+ }
1596
+ /**
1597
+ * Rolls back the transaction scope held by this entity, if any. Safe to call unconditionally.
1598
+ *
1599
+ * Rollback failures are logged and swallowed: this runs on the failure path, and throwing here
1600
+ * would replace the caller's real error with a secondary one that explains less.
1601
+ */
1602
+ async rollbackEntityTransactionScope() {
1603
+ const scope = this._entityTransactionScope;
1604
+ if (!scope) {
1605
+ return;
1606
+ }
1607
+ this._entityTransactionScope = null;
1608
+ try {
1609
+ await scope.Rollback();
1610
+ }
1611
+ catch (rollbackError) {
1612
+ LogError(`Error rolling back entity transaction scope for ${this.EntityInfo?.Name}: ${rollbackError}`);
1036
1613
  }
1037
1614
  }
1038
1615
  /**
@@ -1053,12 +1630,6 @@ export class BaseEntity {
1053
1630
  get RunQueryProviderToUse() {
1054
1631
  return this.ProviderToUse;
1055
1632
  }
1056
- /**
1057
- * Returns the RunReportProvider to be used for a given instance of a BaseEntity derived subclass.
1058
- */
1059
- get RunReportProviderToUse() {
1060
- return this.ProviderToUse;
1061
- }
1062
1633
  /**
1063
1634
  * This method can be used to register a callback for events that will be raised by the instance of the BaseEntity object. The callback will be called with a
1064
1635
  * BaseEntityEvent object that contains the type of event and any payload that is associated with the event. Subclasses of the BaseEntity can define their
@@ -1337,10 +1908,18 @@ export class BaseEntity {
1337
1908
  }
1338
1909
  /**
1339
1910
  * 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.
1911
+ *
1912
+ * @remarks
1913
+ * Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
1914
+ * three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
1915
+ * return, and the children are silently never persisted — the save reports success and writes
1916
+ * nothing. See {@link EntityCompanion.Dirty}.
1340
1917
  */
1341
1918
  get Dirty() {
1342
1919
  if (!this.IsSaved)
1343
1920
  return true;
1921
+ if (this.companionsDirty)
1922
+ return true;
1344
1923
  // Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
1345
1924
  // dirty. Avoid hydrating just to check.
1346
1925
  if (!this._fieldsHydrated)
@@ -1348,6 +1927,17 @@ export class BaseEntity {
1348
1927
  return this._Fields.some(f => f.Dirty) ||
1349
1928
  (this._parentEntity?.Dirty ?? false);
1350
1929
  }
1930
+ /**
1931
+ * Whether any registered companion holds unsaved work.
1932
+ *
1933
+ * Guarded on {@link HasCompanions} so entities without companions — nearly all of them — do no
1934
+ * extra work on this hot path.
1935
+ */
1936
+ get companionsDirty() {
1937
+ if (!this.HasCompanions)
1938
+ return false;
1939
+ return this.Companions.some(c => c.Dirty);
1940
+ }
1351
1941
  /**
1352
1942
  * 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.
1353
1943
  * If the entity has a single primary key, this method will return an array with a single field in it.
@@ -1475,6 +2065,10 @@ export class BaseEntity {
1475
2065
  * @param FieldName
1476
2066
  * @returns
1477
2067
  */
2068
+ /** Records a value derived from `_raw` so later reads skip the conversion. See {@link _rawConverted}. */
2069
+ memoizeRawConversion(fieldName, value) {
2070
+ (this._rawConverted ??= new Map()).set(fieldName, value);
2071
+ }
1478
2072
  Get(FieldName) {
1479
2073
  // IS-A routing: return the authoritative value from the parent entity
1480
2074
  if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
@@ -1495,20 +2089,26 @@ export class BaseEntity {
1495
2089
  let value = this._raw[FieldName];
1496
2090
  if (value === undefined)
1497
2091
  return null;
1498
- // Date conversion mirrors the hydrated path. Mutating _raw to cache the converted
1499
- // Date avoids reparsing on every read.
2092
+ // Conversions mirror the hydrated path, and memoize into `_rawConverted` rather than
2093
+ // back into `_raw` the row may be shared, frozen cache state. Fields needing no
2094
+ // conversion (the vast majority) never touch the memo at all, so the fast path stays
2095
+ // a single property read.
1500
2096
  const fi = this._EntityInfo?.FieldByName(FieldName);
1501
2097
  if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
2098
+ const memo = this._rawConverted?.get(FieldName);
2099
+ if (memo !== undefined)
2100
+ return memo;
1502
2101
  const d = new Date(value);
1503
- this._raw[FieldName] = d;
2102
+ this.memoizeRawConversion(FieldName, d);
1504
2103
  return d;
1505
2104
  }
1506
- // Mirror the EntityField.Value setter: rtrim padding for fixed-
1507
- // width string columns. Memoize back into _raw so we don't
1508
- // re-trim on every read.
2105
+ // Mirror the EntityField.Value setter: rtrim padding for fixed-width string columns.
1509
2106
  if (typeof value === 'string' && fi?.FixedWidthColumn) {
2107
+ const memo = this._rawConverted?.get(FieldName);
2108
+ if (memo !== undefined)
2109
+ return memo;
1510
2110
  value = value.replace(/ +$/, '');
1511
- this._raw[FieldName] = value;
2111
+ this.memoizeRawConversion(FieldName, value);
1512
2112
  }
1513
2113
  return value;
1514
2114
  }
@@ -1734,6 +2334,16 @@ export class BaseEntity {
1734
2334
  }
1735
2335
  }
1736
2336
  }
2337
+ // Companion payloads ride along under a reserved key, following the convention already set
2338
+ // by OldValues___ and RestoreContext___. This is what lets a composite cross the wire on any
2339
+ // path that serializes via GetDataObject — including the TransactionGroup envelope, which
2340
+ // carries whole records as EntityObjectJSON — with no change to the transport itself.
2341
+ if (this.HasCompanions) {
2342
+ const companions = await this.SerializeCompanions();
2343
+ if (companions.length > 0) {
2344
+ obj[COMPANION_PAYLOAD_KEY] = companions;
2345
+ }
2346
+ }
1737
2347
  return obj;
1738
2348
  }
1739
2349
  async GetRelatedEntityData(re, filter = null, maxRecords = null) {
@@ -1761,6 +2371,7 @@ export class BaseEntity {
1761
2371
  this._Fields = [];
1762
2372
  this._fieldsHydrated = false;
1763
2373
  this._raw = null;
2374
+ this._rawConverted = null;
1764
2375
  this._fieldCache = null;
1765
2376
  this._codeNameCache = null;
1766
2377
  // Field construction is deferred to hydrateFieldsIfNeeded(). Constructor / init() stays
@@ -1817,8 +2428,10 @@ export class BaseEntity {
1817
2428
  }
1818
2429
  }
1819
2430
  // Raw data has been promoted into Fields — release the reference so we don't carry
1820
- // duplicate state.
2431
+ // duplicate state. Fields hold their own copies, so a frozen source no longer
2432
+ // constrains anything from here on.
1821
2433
  this._raw = null;
2434
+ this._rawConverted = null;
1822
2435
  }
1823
2436
  }
1824
2437
  /**
@@ -2028,10 +2641,48 @@ export class BaseEntity {
2028
2641
  if (options?.IsParentEntitySave) {
2029
2642
  return this._InnerSave(options);
2030
2643
  }
2031
- // If a save is already in progress, return its promise.
2644
+ // Executing our own node inside a graph we already planned. Bypass both the debounce and
2645
+ // graph routing — see EntitySaveOptions.IsGraphNodeSave for why each matters.
2646
+ if (options?.IsGraphNodeSave) {
2647
+ return this._InnerSave(options);
2648
+ }
2649
+ // If a save is already in progress, return its promise. This check MUST run before graph
2650
+ // routing: a composite save is still a save, and two concurrent Save() calls on one record
2651
+ // (double-click, autosave racing a manual save) must share the in-flight unit of work.
2652
+ // Routing first meant each call built and executed its own full graph — a double insert of
2653
+ // root and children — while single-row entities kept the debounce protection.
2032
2654
  if (this._pendingSave$) {
2033
2655
  return firstValueFrom(this._pendingSave$);
2034
2656
  }
2657
+ // Composite routing: when companions contribute work, this save is a multi-record unit of
2658
+ // work rather than a single row. Building the plan is cheap and, crucially, a plan with one
2659
+ // node falls straight through to the ordinary path below — so entities without companions,
2660
+ // and composites whose collections happen to be empty, behave exactly as they did before.
2661
+ if (this.HasCompanions) {
2662
+ const plan = this.BuildSavePlan(true, options);
2663
+ if (plan.NodeCount > 1) {
2664
+ // A TransactionGroup defers this record's own write until Submit(), while a graph
2665
+ // executes its child nodes immediately (or ships the whole graph to the server) —
2666
+ // children would insert rows pointing at a parent whose write is still queued, and
2667
+ // the remote path would commit everything before the group ever submits. The
2668
+ // combination cannot be made coherent, so refuse it loudly rather than silently
2669
+ // tearing the group's atomicity. (Same conclusion as the guide: a TransactionGroup
2670
+ // is not a composite-save engine — see guides/TRANSACTIONS_AND_BATCHING_GUIDE.md.)
2671
+ if (this.TransactionGroup) {
2672
+ throw new Error(`${this.EntityInfo?.Name}: cannot save related-record collections while enrolled in a ` +
2673
+ `TransactionGroup. The group defers the parent's write until Submit(), so the graph's ` +
2674
+ `child records would persist against a parent row that does not exist yet. Save the ` +
2675
+ `composite record on its own — entity.Save() is already atomic for its collections — ` +
2676
+ `or detach it from the TransactionGroup first.`);
2677
+ }
2678
+ // Run the graph through the same pending-save pipeline as a single-row save, so
2679
+ // concurrent callers share one execution and one result. The graph's own root node
2680
+ // re-enters Save() with IsGraphNodeSave, which bypasses this pipeline above — no
2681
+ // self-deadlock.
2682
+ this._pendingSave$ = of(options).pipe(switchMap(opts => from(this.saveGraph(plan, opts))), finalize(() => { this._pendingSave$ = null; }), shareReplay(1));
2683
+ return firstValueFrom(this._pendingSave$);
2684
+ }
2685
+ }
2035
2686
  // Create a new observable that debounces duplicative calls, and executes the save.
2036
2687
  this._pendingSave$ = of(options).pipe(
2037
2688
  // Execute the actual save logic.
@@ -2064,15 +2715,12 @@ export class BaseEntity {
2064
2715
  }
2065
2716
  // IS-A orchestration: determine if this is the initiating save in a parent chain
2066
2717
  const isISAInitiator = (!!this._parentEntity) && !_options.IsParentEntitySave;
2067
- // Begin provider transaction if IS-A initiator and NOT in a TransactionGroup
2068
- // TransactionGroup manages its own atomicity; IS-A just orchestrates save order within it
2069
- if (isISAInitiator && !this.TransactionGroup) {
2070
- const txn = await this.ProviderToUse?.BeginISATransaction?.();
2071
- if (txn) {
2072
- this.ProviderTransaction = txn;
2073
- this.PropagateTransactionToParents();
2074
- }
2075
- }
2718
+ // Open (or join) a transaction scope for the parent chain. The provider arbitrates:
2719
+ // if a transaction is already in flight an application cascade, an enclosing graph
2720
+ // save this joins it as a savepoint rather than starting a second physical
2721
+ // transaction. Before 6.2 this path called BeginISATransaction(), which was blind to
2722
+ // any existing transaction and produced torn writes; see EntityTransactionScope.
2723
+ await this.beginEntityTransactionScope(isISAInitiator);
2076
2724
  // Save parent chain first (root → branch → immediate parent)
2077
2725
  // Parent calls Save() recursively which handles its own parents, permissions, validation
2078
2726
  if (this._parentEntity) {
@@ -2088,7 +2736,7 @@ export class BaseEntity {
2088
2736
  const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
2089
2737
  if (!parentResult) {
2090
2738
  // Parent save failed — rollback if we started the transaction
2091
- await this.RollbackISATransaction(isISAInitiator);
2739
+ await this.rollbackEntityTransactionScope();
2092
2740
  // RECORD the failure on THIS entity's ResultHistory before returning. Without
2093
2741
  // this the caller gets `false` with LatestResult === null and an empty
2094
2742
  // ResultHistory, because every result was written to the PARENT object — which
@@ -2146,11 +2794,29 @@ export class BaseEntity {
2146
2794
  else {
2147
2795
  // First run synchronous validation
2148
2796
  valResult = this.Validate();
2149
- // Determine if we should run async validation:
2150
- // 1. Explicitly set in options, OR
2151
- // 2. Use the subclass's default if not specified in options
2152
- const skipAsyncValidation = _options.SkipAsyncValidation !== undefined ?
2153
- _options.SkipAsyncValidation : this.DefaultSkipAsyncValidation;
2797
+ // Determine if we should run async validation, in order of authority:
2798
+ // 1. An explicit SkipAsyncValidation in the options.
2799
+ // 2. An explicit DefaultSkipAsyncValidation override on the subclass.
2800
+ // 3. Neither: run it if and only if — a subclass wrote a ValidateAsync
2801
+ // to run. Overriding the method IS the request to run it.
2802
+ //
2803
+ // Case 3 is the fix for a silent no-op. The default is `true`, and the base
2804
+ // ValidateAsync just returns success, so skipping costs a subclass that did
2805
+ // not override it precisely nothing. The flag's only reachable effect was
2806
+ // therefore to disable the async rules of subclasses that WROTE async rules
2807
+ // and never learned a second, separate getter had to be overridden too —
2808
+ // which the ValidateAsync docstring did not mention while promising the
2809
+ // method was "automatically called by Save()".
2810
+ //
2811
+ // That is how OrderEntityServer.ValidateAsync — holding both the "cannot
2812
+ // confirm an order with no lines" guard and an entire per-line validation
2813
+ // loop — was dead on every save in production, and it is the same reasoning
2814
+ // that already exempts companions below.
2815
+ const skipAsyncValidation = _options.SkipAsyncValidation !== undefined
2816
+ ? _options.SkipAsyncValidation
2817
+ : IsMemberOverridden(this, 'DefaultSkipAsyncValidation', BaseEntity)
2818
+ ? this.DefaultSkipAsyncValidation
2819
+ : !IsMemberOverridden(this, 'ValidateAsync', BaseEntity);
2154
2820
  // If not skipping async validation, run it - even if sync validation failed
2155
2821
  // This ensures all validation errors (sync and async) are collected
2156
2822
  if (!skipAsyncValidation) {
@@ -2163,6 +2829,16 @@ export class BaseEntity {
2163
2829
  valResult.Errors.push(error);
2164
2830
  });
2165
2831
  }
2832
+ // Companion async validation runs REGARDLESS of skipAsyncValidation.
2833
+ //
2834
+ // That flag exists so an entity can opt out of its OWN expensive async
2835
+ // rules; applying it to companions silently disables cross-child invariants.
2836
+ // That is not hypothetical — it is exactly how OrderEntityServer's
2837
+ // ValidateAsync, holding both the "cannot confirm an order with no lines"
2838
+ // guard and the entire per-line validation loop, became dead code on every
2839
+ // save in production: the class never overrode DefaultSkipAsyncValidation
2840
+ // (which defaults to true) and no caller ever passed SkipAsyncValidation:false.
2841
+ await this.validateCompanionsAsync(valResult);
2166
2842
  }
2167
2843
  if (valResult.Success) {
2168
2844
  // Run registered PreSave hooks (e.g., tenant validation)
@@ -2186,10 +2862,17 @@ export class BaseEntity {
2186
2862
  if (!this.TransactionGroup) {
2187
2863
  // no transaction group, so we have our results here
2188
2864
  const result = this.finalizeSave(data, saveSubType);
2189
- // IS-A: commit transaction after successful save (only the initiator commits)
2190
- if (isISAInitiator && this.ProviderTransaction) {
2191
- await this.ProviderToUse.CommitISATransaction?.(this.ProviderTransaction);
2192
- this.ProviderTransaction = null;
2865
+ // Settle the scope this entity opened, if any (only the initiator holds
2866
+ // one). The provider can fail by RETURNING falsy data rather than
2867
+ // throwing — a validate-type entity action rejecting the save does
2868
+ // exactly that — and committing on that path would persist the parent
2869
+ // chain around a leaf that reported failure: precisely the torn write
2870
+ // this scope exists to prevent.
2871
+ if (result) {
2872
+ await this.commitEntityTransactionScope();
2873
+ }
2874
+ else {
2875
+ await this.rollbackEntityTransactionScope();
2193
2876
  }
2194
2877
  return result;
2195
2878
  }
@@ -2230,13 +2913,19 @@ export class BaseEntity {
2230
2913
  }
2231
2914
  }
2232
2915
  }
2233
- else
2916
+ else {
2917
+ // Nothing to save — but the scope opened above (an IS-A initiator whose whole chain
2918
+ // is clean) must still be settled. Returning with it open leaked the provider's
2919
+ // ambient transaction: every subsequent write joined a transaction nobody would ever
2920
+ // commit, so later "successful" saves were silently non-durable. Committing an
2921
+ // empty scope writes nothing; it only releases the transaction.
2922
+ await this.commitEntityTransactionScope();
2234
2923
  return true; // nothing to save since we're not dirty
2924
+ }
2235
2925
  }
2236
2926
  catch (e) {
2237
- // IS-A: rollback transaction on failure (only the initiator rolls back)
2238
- const isISAInitiator = this._parentEntity != null && !options?.IsParentEntitySave;
2239
- await this.RollbackISATransaction(isISAInitiator);
2927
+ // Roll back the scope this entity opened, if any. No-op when it holds none.
2928
+ await this.rollbackEntityTransactionScope();
2240
2929
  if (currentResultCount === this.ResultHistory.length) {
2241
2930
  // this means that NO new results were added to the history anywhere
2242
2931
  // so we need to add a new result to the history here
@@ -2291,21 +2980,6 @@ export class BaseEntity {
2291
2980
  // If no remaining children, safe to delete parent
2292
2981
  return remainingChildren.length === 0;
2293
2982
  }
2294
- /**
2295
- * Helper to rollback an IS-A provider transaction if one is active.
2296
- * Only called by the IS-A initiator (the leaf entity that started the chain).
2297
- */
2298
- async RollbackISATransaction(isInitiator) {
2299
- if (isInitiator && this.ProviderTransaction) {
2300
- try {
2301
- await this.ProviderToUse?.RollbackISATransaction?.(this.ProviderTransaction);
2302
- }
2303
- catch (rollbackError) {
2304
- LogError(`Error rolling back IS-A transaction: ${rollbackError}`);
2305
- }
2306
- this.ProviderTransaction = null;
2307
- }
2308
- }
2309
2983
  finalizeSave(data, saveSubType) {
2310
2984
  if (data) {
2311
2985
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
@@ -2511,6 +3185,15 @@ export class BaseEntity {
2511
3185
  // This populates _childEntity so Save/Delete can delegate to the leaf.
2512
3186
  // Only runs for parent-type entities; idempotent via _childEntityDiscoveryDone.
2513
3187
  await this.InitializeChildEntity();
3188
+ // Companions declared Load:'immediate' populate here — and ONLY here.
3189
+ //
3190
+ // Deliberately not in LoadFromData(): that is the per-row materialization path for
3191
+ // RunView(ResultType:'entity_object'), so an eager child query there becomes one query
3192
+ // per row of every view. That is a live N+1 in production accounting code today
3193
+ // (JournalEntryEntityServer.LoadFromData → LoadLines), and excluding it here is the
3194
+ // structural fix. Set-oriented eager loading is served by RunView's batched child
3195
+ // loading, which issues one WHERE fk IN (...) for the whole result set.
3196
+ await this.loadEagerCompanions();
2514
3197
  // Raise load completion event
2515
3198
  this.RaiseEvent('load_complete', { CompositeKey });
2516
3199
  return true;
@@ -2612,6 +3295,10 @@ export class BaseEntity {
2612
3295
  !this.EntityInfo?.IsParentType;
2613
3296
  if (canTakeFastPath) {
2614
3297
  this._raw = data;
3298
+ // Drop any conversions memoized from a previously-loaded row — they describe the old
3299
+ // `_raw`, not this one. No isFrozen probe is needed: `Get()` never writes to `_raw`,
3300
+ // so whether the row is frozen (now, or at any point later) does not affect reads.
3301
+ this._rawConverted = null;
2615
3302
  // Mirror the "are PKs present?" check that the hydrated path does, but read straight
2616
3303
  // from the raw data so we don't trigger hydration.
2617
3304
  const pks = this.EntityInfo?.PrimaryKeys ?? [];
@@ -2666,8 +3353,27 @@ export class BaseEntity {
2666
3353
  this._recordLoaded = false;
2667
3354
  this._everSaved = false; // Mark as NOT saved since we loaded from data without primary keys
2668
3355
  }
3356
+ // Absorb any companion payload that travelled with this record. This is the receiving half
3357
+ // of the reserved-key convention applied in GetDataObject(). Note this restores state the
3358
+ // sender already had — it never issues a query, so it cannot reintroduce the N+1 that
3359
+ // eager companion loading is kept out of this method to avoid.
3360
+ await this.deserializeCompanionsFromData(data);
2669
3361
  return true;
2670
3362
  }
3363
+ /**
3364
+ * Extracts and applies a companion payload carried inside a plain data object, if present.
3365
+ *
3366
+ * @param data - The plain object passed to {@link LoadFromData}.
3367
+ */
3368
+ async deserializeCompanionsFromData(data) {
3369
+ if (!this.HasCompanions || !data || typeof data !== 'object') {
3370
+ return;
3371
+ }
3372
+ const payloads = data[COMPANION_PAYLOAD_KEY];
3373
+ if (Array.isArray(payloads)) {
3374
+ await this.DeserializeCompanions(payloads);
3375
+ }
3376
+ }
2671
3377
  /**
2672
3378
  * This method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can
2673
3379
  * override or wrap this base class method to add other logic for validation.
@@ -2696,15 +3402,62 @@ export class BaseEntity {
2696
3402
  });
2697
3403
  result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
2698
3404
  }
3405
+ // Companions validate LAST but still BEFORE any write, over their complete state including
3406
+ // pending removals. That ordering is what lets a cross-child invariant — "debits must equal
3407
+ // credits", "a confirmed order must have lines" — be enforced against the whole graph rather
3408
+ // than discovered halfway through persisting it.
3409
+ this.validateCompanions(result);
2699
3410
  return result;
2700
3411
  }
3412
+ /**
3413
+ * Fans synchronous validation out to every registered companion.
3414
+ *
3415
+ * @param result - The accumulating result companions contribute to.
3416
+ */
3417
+ validateCompanions(result) {
3418
+ if (!this.HasCompanions) {
3419
+ return;
3420
+ }
3421
+ for (const companion of this.Companions) {
3422
+ companion.Validate(result);
3423
+ }
3424
+ }
3425
+ /**
3426
+ * Fans asynchronous validation out to every registered companion.
3427
+ *
3428
+ * @remarks
3429
+ * Called from `_InnerSave` **independently of `DefaultSkipAsyncValidation`**. That flag lets an
3430
+ * entity opt out of its *own* expensive async rules; extending it to companions silently
3431
+ * disabled cross-child invariants, which is precisely how `OrderEntityServer.ValidateAsync` —
3432
+ * containing both the "cannot confirm an order with no lines" guard and the entire per-line
3433
+ * validation loop — became dead code on every save in production. Companion async validation
3434
+ * runs whenever companions are present.
3435
+ *
3436
+ * @param result - The accumulating result companions contribute to.
3437
+ */
3438
+ async validateCompanionsAsync(result) {
3439
+ if (!this.HasCompanions) {
3440
+ return;
3441
+ }
3442
+ for (const companion of this.Companions) {
3443
+ await companion.ValidateAsync(result);
3444
+ }
3445
+ }
2701
3446
  /**
2702
3447
  * Default value for whether async validation should be skipped.
2703
- * Subclasses can override this property to enable async validation by default.
2704
- * When the options object is passed to Save(), and it includes a value for the
2705
- * SkipAsyncValidation property, that value will take precedence over this default.
3448
+ *
3449
+ * @remarks
3450
+ * Override this to state a policy explicitly; an explicit override always wins over the
3451
+ * inference described below. When the options object passed to `Save()` includes
3452
+ * `SkipAsyncValidation`, that value takes precedence over both.
3453
+ *
3454
+ * **If no subclass overrides this getter**, the answer is inferred instead: async validation
3455
+ * runs when a subclass has overridden {@link ValidateAsync}, and is skipped when none has.
3456
+ * Reading the literal `true` below as "async validation is off unless you find this getter"
3457
+ * made every hand-written `ValidateAsync` a silent no-op — see the note on that method.
2706
3458
  *
2707
3459
  * @see {@link Save}
3460
+ * @see {@link ValidateAsync}
2708
3461
  *
2709
3462
  * @protected
2710
3463
  */
@@ -2719,6 +3472,15 @@ export class BaseEntity {
2719
3472
  * 1. This should NEVER be called INSTEAD of the synchronous Validate() method
2720
3473
  * 2. This is meant to be overridden by subclasses that need to perform async validations
2721
3474
  * 3. The base implementation just returns success - no actual validation is performed
3475
+ * 4. Overriding this method is what turns it on. You do NOT also have to override
3476
+ * {@link DefaultSkipAsyncValidation} — that getter is for stating a policy explicitly, and
3477
+ * an explicit override of it (either value) still wins. To suppress async validation for one
3478
+ * call, pass `SkipAsyncValidation: true` in the save options.
3479
+ *
3480
+ * Point 4 used to be the opposite, and it was not discoverable: `DefaultSkipAsyncValidation`
3481
+ * defaults to `true`, so an override written against this docstring alone never ran. It reads
3482
+ * as enforced, reviews as enforced, and was not — the failure mode that let an order confirm
3483
+ * with no lines in production.
2722
3484
  *
2723
3485
  * Subclasses should override this to add complex validations that require database queries
2724
3486
  * or other async operations that cannot be performed in the synchronous Validate() method.
@@ -2741,6 +3503,20 @@ export class BaseEntity {
2741
3503
  * @returns Promise<boolean>
2742
3504
  */
2743
3505
  async Delete(options) {
3506
+ // Executing our own node inside a delete graph we already planned. Bypass both the debounce
3507
+ // and graph routing — see EntityDeleteOptions.IsGraphNodeDelete.
3508
+ if (options?.IsGraphNodeDelete) {
3509
+ return this._InnerDelete(options);
3510
+ }
3511
+ // Composite routing: companions that own their children (OnRemove:'delete') contribute
3512
+ // child deletions that must run before this row disappears. A single-node plan falls
3513
+ // through to the ordinary path, so nothing changes for entities without companions.
3514
+ if (this.HasCompanions) {
3515
+ const plan = this.BuildDeletePlan();
3516
+ if (plan.NodeCount > 1) {
3517
+ return this.deleteGraph(plan, options);
3518
+ }
3519
+ }
2744
3520
  // If a delete is already in progress, return its promise.
2745
3521
  if (this._pendingDelete$) {
2746
3522
  return firstValueFrom(this._pendingDelete$);
@@ -2808,14 +3584,10 @@ export class BaseEntity {
2808
3584
  }
2809
3585
  }
2810
3586
  }
2811
- // Begin provider transaction if IS-A initiator and NOT in a TransactionGroup
2812
- if (isISAInitiator && !this.TransactionGroup) {
2813
- const txn = await this.ProviderToUse?.BeginISATransaction?.();
2814
- if (txn) {
2815
- this.ProviderTransaction = txn;
2816
- this.PropagateTransactionToParents();
2817
- }
2818
- }
3587
+ // Open (or join) a transaction scope for the parent chain see the matching
3588
+ // comment in _InnerSave and EntityTransactionScope for why this is provider-
3589
+ // arbitrated rather than IS-A-specific.
3590
+ await this.beginEntityTransactionScope(isISAInitiator);
2819
3591
  this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
2820
3592
  // Raise delete_started event before the actual delete operation begins
2821
3593
  this.RaiseEvent('delete_started', null);
@@ -2844,7 +3616,7 @@ export class BaseEntity {
2844
3616
  const parentResult = await this._parentEntity.Delete(parentDeleteOptions);
2845
3617
  if (!parentResult) {
2846
3618
  // Parent delete failed — rollback if we started the transaction
2847
- await this.RollbackISATransaction(isISAInitiator);
3619
+ await this.rollbackEntityTransactionScope();
2848
3620
  // RECORD the failure on THIS entity's ResultHistory before returning —
2849
3621
  // symmetric with the parent-SAVE-failure path in _InnerSave. Without this
2850
3622
  // the caller gets `false` with LatestResult === null and an empty
@@ -2874,11 +3646,8 @@ export class BaseEntity {
2874
3646
  }
2875
3647
  }
2876
3648
  }
2877
- // IS-A: commit transaction after successful chain delete
2878
- if (isISAInitiator && this.ProviderTransaction) {
2879
- await this.ProviderToUse.CommitISATransaction?.(this.ProviderTransaction);
2880
- this.ProviderTransaction = null;
2881
- }
3649
+ // Commit the scope this entity opened, if any (only the initiator holds one).
3650
+ await this.commitEntityTransactionScope();
2882
3651
  if (!this.TransactionGroup) {
2883
3652
  // NOT part of a transaction - raise event immediately
2884
3653
  // record deleted correctly
@@ -2919,14 +3688,21 @@ export class BaseEntity {
2919
3688
  }
2920
3689
  return true;
2921
3690
  }
2922
- else // record didn't delete, return false, but also don't wipe out the entity like we do if the Delete() worked
3691
+ else {
3692
+ // Record didn't delete. This path is ordinary, not exotic: the provider reports
3693
+ // essentially every delete failure — RLS denial, FK violation, zero rows
3694
+ // affected — by RETURNING false rather than throwing. The scope opened above
3695
+ // must be settled here or the provider's ambient transaction leaks open and
3696
+ // every subsequent "committed" write on this provider silently never commits.
3697
+ // (Also: don't wipe out the entity like we do when the Delete() worked.)
3698
+ await this.rollbackEntityTransactionScope();
2923
3699
  return false;
3700
+ }
2924
3701
  }
2925
3702
  }
2926
3703
  catch (e) {
2927
- // IS-A: rollback transaction on failure (only the initiator rolls back)
2928
- const isISAInitiator = this._parentEntity != null && !options?.IsParentEntityDelete;
2929
- await this.RollbackISATransaction(isISAInitiator);
3704
+ // Roll back the scope this entity opened, if any. No-op when it holds none.
3705
+ await this.rollbackEntityTransactionScope();
2930
3706
  if (currentResultCount === this.ResultHistory.length) {
2931
3707
  // this means that NO new results were added to the history anywhere
2932
3708
  // so we need to add a new result to the history here