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

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 (65) hide show
  1. package/dist/generic/baseEngineRegistry.d.ts +14 -0
  2. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  3. package/dist/generic/baseEngineRegistry.js +32 -0
  4. package/dist/generic/baseEngineRegistry.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +329 -26
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +788 -79
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/databaseProviderBase.d.ts +44 -17
  10. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  11. package/dist/generic/databaseProviderBase.js +96 -52
  12. package/dist/generic/databaseProviderBase.js.map +1 -1
  13. package/dist/generic/entityCompanion.d.ts +218 -0
  14. package/dist/generic/entityCompanion.d.ts.map +1 -0
  15. package/dist/generic/entityCompanion.js +170 -0
  16. package/dist/generic/entityCompanion.js.map +1 -0
  17. package/dist/generic/entityInfo.d.ts +21 -0
  18. package/dist/generic/entityInfo.d.ts.map +1 -1
  19. package/dist/generic/entityInfo.js +21 -0
  20. package/dist/generic/entityInfo.js.map +1 -1
  21. package/dist/generic/entitySavePlan.d.ts +199 -0
  22. package/dist/generic/entitySavePlan.d.ts.map +1 -0
  23. package/dist/generic/entitySavePlan.js +213 -0
  24. package/dist/generic/entitySavePlan.js.map +1 -0
  25. package/dist/generic/entityTransactionScope.d.ts +125 -0
  26. package/dist/generic/entityTransactionScope.d.ts.map +1 -0
  27. package/dist/generic/entityTransactionScope.js +115 -0
  28. package/dist/generic/entityTransactionScope.js.map +1 -0
  29. package/dist/generic/interfaces.d.ts +93 -35
  30. package/dist/generic/interfaces.d.ts.map +1 -1
  31. package/dist/generic/interfaces.js +27 -0
  32. package/dist/generic/interfaces.js.map +1 -1
  33. package/dist/generic/providerBase.d.ts +13 -0
  34. package/dist/generic/providerBase.d.ts.map +1 -1
  35. package/dist/generic/providerBase.js +63 -4
  36. package/dist/generic/providerBase.js.map +1 -1
  37. package/dist/generic/relatedRecordBatchLoader.d.ts +39 -0
  38. package/dist/generic/relatedRecordBatchLoader.d.ts.map +1 -0
  39. package/dist/generic/relatedRecordBatchLoader.js +154 -0
  40. package/dist/generic/relatedRecordBatchLoader.js.map +1 -0
  41. package/dist/generic/relatedRecordCollection.d.ts +578 -0
  42. package/dist/generic/relatedRecordCollection.d.ts.map +1 -0
  43. package/dist/generic/relatedRecordCollection.js +1004 -0
  44. package/dist/generic/relatedRecordCollection.js.map +1 -0
  45. package/dist/generic/saveEntityGraphOperation.d.ts +148 -0
  46. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -0
  47. package/dist/generic/saveEntityGraphOperation.js +157 -0
  48. package/dist/generic/saveEntityGraphOperation.js.map +1 -0
  49. package/dist/generic/telemetryManager.d.ts +21 -1
  50. package/dist/generic/telemetryManager.d.ts.map +1 -1
  51. package/dist/generic/telemetryManager.js +21 -6
  52. package/dist/generic/telemetryManager.js.map +1 -1
  53. package/dist/index.d.ts +6 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +6 -3
  56. package/dist/index.js.map +1 -1
  57. package/dist/views/runView.d.ts +31 -0
  58. package/dist/views/runView.d.ts.map +1 -1
  59. package/dist/views/runView.js.map +1 -1
  60. package/package.json +3 -3
  61. package/readme.md +151 -1
  62. package/dist/generic/runReport.d.ts +0 -25
  63. package/dist/generic/runReport.d.ts.map +0 -1
  64. package/dist/generic/runReport.js +0 -38
  65. package/dist/generic/runReport.js.map +0 -1
@@ -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.
@@ -771,12 +767,21 @@ export class BaseEntity {
771
767
  */
772
768
  this._childEntities = null;
773
769
  /**
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.
770
+ * The active transaction scope owned by this entity, when it is the participant that opened (or
771
+ * joined) one for a multi-record unit of work an IS-A parent chain or a composite save graph.
772
+ *
773
+ * Only the *initiator* holds a scope; other participants in the same unit of work simply write
774
+ * into the provider's ambient transaction without knowing it exists. That is the whole point of
775
+ * routing through the provider: participants stay ignorant of one another.
776
+ */
777
+ this._entityTransactionScope = null;
778
+ /**
779
+ * Companions registered on this entity, keyed by {@link EntityCompanion.Name}.
780
+ *
781
+ * Lazily created so that the overwhelming majority of entities — which declare no companions —
782
+ * pay nothing for the feature, not even an empty Map per instance.
778
783
  */
779
- this._providerTransaction = null;
784
+ this._companions = null;
780
785
  this._compositeKey = null;
781
786
  // ────────────────────────────────────────────────────────────────────
782
787
  // Restore context — populated by callers immediately before Save() to
@@ -1024,15 +1029,572 @@ export class BaseEntity {
1024
1029
  // entities in the chain that don't exist on this entity.
1025
1030
  this.SetMany(data, true, true, true);
1026
1031
  }
1032
+ // ─── Entity Companions ──────────────────────────────────────────────────────
1033
+ //
1034
+ // Companions are named, serialisable side-channels attached to a record — most commonly
1035
+ // related-record collections. See entityCompanion.ts for the full rationale and lifecycle.
1027
1036
  /**
1028
- * Propagates the ProviderTransaction handle down the IS-A parent chain so all
1029
- * entities in the chain execute on the same database transaction.
1037
+ * The companions registered on this entity, in declaration order.
1038
+ *
1039
+ * Empty for the vast majority of entities. Nothing in the save, load or validation paths does
1040
+ * any companion work when this is empty, so the feature costs nothing where it is unused.
1030
1041
  */
1031
- PropagateTransactionToParents() {
1032
- let current = this._parentEntity;
1033
- while (current) {
1034
- current.ProviderTransaction = this._providerTransaction;
1035
- current = current._parentEntity;
1042
+ get Companions() {
1043
+ return this._companions ? Array.from(this._companions.values()) : [];
1044
+ }
1045
+ /**
1046
+ * Whether this entity has any registered companions.
1047
+ *
1048
+ * Used as the fast guard on the hot paths — a single boolean check keeps single-record saves on
1049
+ * exactly the code path they took before companions existed.
1050
+ */
1051
+ get HasCompanions() {
1052
+ return this._companions !== null && this._companions.size > 0;
1053
+ }
1054
+ /**
1055
+ * Registers a companion on this entity. Called from a subclass constructor or field
1056
+ * initialiser, normally via {@link DeclareRelatedRecords}.
1057
+ *
1058
+ * @typeParam TCompanion - The companion type.
1059
+ * @param companion - The companion to register.
1060
+ * @returns The same companion, so it can be assigned to a readonly field in one expression.
1061
+ * @throws When a companion with the same name is already registered — a duplicate name would
1062
+ * make the wire payload ambiguous and silently drop one of the two.
1063
+ */
1064
+ RegisterCompanion(companion) {
1065
+ if (!this._companions) {
1066
+ this._companions = new Map();
1067
+ }
1068
+ if (this._companions.has(companion.Name)) {
1069
+ throw new Error(`BaseEntity.RegisterCompanion: '${companion.Name}' is already registered on ` +
1070
+ `${this.EntityInfo?.Name ?? this.constructor.name}. Companion names must be unique per entity.`);
1071
+ }
1072
+ this._companions.set(companion.Name, companion);
1073
+ return companion;
1074
+ }
1075
+ /**
1076
+ * Looks up a registered companion by name.
1077
+ *
1078
+ * @typeParam TCompanion - The expected companion type.
1079
+ * @param name - The companion's {@link EntityCompanion.Name}.
1080
+ * @returns The companion, or `undefined` when none is registered under that name.
1081
+ */
1082
+ GetCompanion(name) {
1083
+ return this._companions?.get(name);
1084
+ }
1085
+ /**
1086
+ * Declares a typed child collection on this entity and registers it as a companion.
1087
+ *
1088
+ * This is the entry point for composite entities. Call it from a field initialiser on a
1089
+ * **shared** (client + server) subclass so both tiers see the collection — a declaration that
1090
+ * exists only in a server-side class makes the collection invisible to the browser, which is
1091
+ * exactly the limitation this feature removes.
1092
+ *
1093
+ * @typeParam TChild - The child entity type.
1094
+ * @param options - The collection declaration.
1095
+ * @returns The registered collection.
1096
+ *
1097
+ * @example
1098
+ * ```typescript
1099
+ * public readonly Lines = this.DeclareRelatedRecords<OrderLineEntity>({
1100
+ * Name: 'Lines',
1101
+ * ChildEntity: 'MJ_BizApps_Orders: Order Lines',
1102
+ * ForeignKey: 'OrderHeaderID',
1103
+ * OrderBy: 'LineNumber ASC',
1104
+ * Sequence: { Field: 'LineNumber', From: 1 },
1105
+ * });
1106
+ * ```
1107
+ */
1108
+ DeclareRelatedRecords(options) {
1109
+ return this.RegisterCompanion(new RelatedRecordCollection(this, options));
1110
+ }
1111
+ /**
1112
+ * Serializes every registered companion that has something to send.
1113
+ *
1114
+ * Companions returning `null` are omitted entirely, so a header-only save on a composite entity
1115
+ * ships no companion payload at all and costs nothing extra on the wire.
1116
+ *
1117
+ * @returns The companion payloads, in declaration order.
1118
+ */
1119
+ async SerializeCompanions() {
1120
+ if (!this.HasCompanions) {
1121
+ return [];
1122
+ }
1123
+ const payloads = [];
1124
+ for (const companion of this.Companions) {
1125
+ const data = await companion.Serialize();
1126
+ if (data !== null && data !== undefined) {
1127
+ payloads.push({ Name: companion.Name, Data: data });
1128
+ }
1129
+ }
1130
+ return payloads;
1131
+ }
1132
+ /**
1133
+ * Restores companion state from payloads produced by {@link SerializeCompanions} on the other
1134
+ * tier.
1135
+ *
1136
+ * Payloads naming a companion this entity does not declare are **ignored rather than rejected**.
1137
+ * That is deliberate: during a rolling deploy the two tiers can disagree about which companions
1138
+ * exist, and a hard failure would turn a harmless version skew into an outage. The mismatch is
1139
+ * logged so it is still visible.
1140
+ *
1141
+ * @param payloads - Companion payloads received from the other tier.
1142
+ * @param mode - Whether these payloads are an inbound request (the default) or authoritative
1143
+ * post-save results. See {@link EntityCompanionDeserializeMode}.
1144
+ */
1145
+ async DeserializeCompanions(payloads, mode = 'request') {
1146
+ if (!payloads || payloads.length === 0) {
1147
+ return;
1148
+ }
1149
+ for (const payload of payloads) {
1150
+ const companion = this.GetCompanion(payload.Name);
1151
+ if (!companion) {
1152
+ LogDebug(`BaseEntity.DeserializeCompanions: no companion named '${payload.Name}' on ` +
1153
+ `${this.EntityInfo?.Name}; ignoring (tier version skew is expected during rolling deploys).`);
1154
+ continue;
1155
+ }
1156
+ await companion.Deserialize(payload.Data, mode);
1157
+ }
1158
+ }
1159
+ /**
1160
+ * Executes a multi-node plan by handing the entire unit of work to the server.
1161
+ *
1162
+ * Used when the provider cannot open a local transaction — i.e. from the browser. The client
1163
+ * does not orchestrate the cascade; it ships intent, and the server runs the *same*
1164
+ * `executeGraphLocal` path inside a real transaction. One cascade implementation, two possible
1165
+ * locations.
1166
+ *
1167
+ * @param plan - The planned unit of work. Used for its node count in events and diagnostics;
1168
+ * the server rebuilds its own plan from the payload.
1169
+ * @param _options - Save options. Not forwarded over the wire: they describe local execution
1170
+ * preferences, and the server applies its own.
1171
+ * @returns True when the server reports the graph committed.
1172
+ */
1173
+ async executeGraphRemote(plan, _options) {
1174
+ try {
1175
+ const operation = MJGlobal.Instance.ClassFactory.CreateInstance(BaseRemotableOperation, SAVE_ENTITY_GRAPH_OPERATION_KEY);
1176
+ if (!operation) {
1177
+ throw new Error(`Composite save requires the '${SAVE_ENTITY_GRAPH_OPERATION_KEY}' remote operation, which is ` +
1178
+ `not registered in this process.`);
1179
+ }
1180
+ const result = await operation.Execute({
1181
+ EntityName: this.EntityInfo.Name,
1182
+ Fields: this.GetAll(),
1183
+ Companions: await this.SerializeCompanions(),
1184
+ IsExistingRecord: this.IsSaved,
1185
+ }, { provider: this.ProviderToUse, user: this.ActiveUser });
1186
+ if (!result.Success || !result.Output?.Success) {
1187
+ const detail = result.ErrorMessage ?? result.Output?.ErrorMessage ?? 'unknown error';
1188
+ this.registerGraphFailure(detail);
1189
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1190
+ return false;
1191
+ }
1192
+ await this.applyGraphResult(result.Output);
1193
+ this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
1194
+ return true;
1195
+ }
1196
+ catch (e) {
1197
+ const detail = e instanceof Error ? e.message : String(e);
1198
+ LogError(`BaseEntity.executeGraphRemote failed for ${this.EntityInfo?.Name}: ${detail}`);
1199
+ this.registerGraphFailure(detail);
1200
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1201
+ return false;
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Adopts the post-save state the server returned for the whole graph.
1206
+ *
1207
+ * Both halves matter. Refreshing only the header would leave the client holding children that
1208
+ * still look unsaved — they would be re-inserted on the next save, silently duplicating rows.
1209
+ *
1210
+ * @param output - The server's result graph.
1211
+ */
1212
+ async applyGraphResult(output) {
1213
+ // Captured at entry — the last moment the create-vs-update distinction exists on this
1214
+ // object, since `_everSaved` is stamped true a few lines down. The result history and the
1215
+ // 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
1216
+ // report (and notify subscribers) as an update.
1217
+ const saveSubType = this.IsSaved ? 'update' : 'create';
1218
+ // Root: rebase field values and dirty state onto what the database now holds.
1219
+ this.init();
1220
+ this.SetMany(output.Fields, false, true, true);
1221
+ this._recordLoaded = true;
1222
+ this._everSaved = true;
1223
+ // Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
1224
+ // returned state verbatim — these rows were just persisted by the sender, so re-loading them
1225
+ // would be one wasted round trip per record for data we already hold.
1226
+ await this.DeserializeCompanions(output.Companions ?? [], 'result');
1227
+ this.acceptCompanionChanges();
1228
+ const result = new BaseEntityResult();
1229
+ result.Success = true;
1230
+ result.Type = saveSubType;
1231
+ result.StartedAt = new Date();
1232
+ result.EndedAt = new Date();
1233
+ this.RegisterResultHistoryEntry(result);
1234
+ this.RaiseEvent('save', null, saveSubType);
1235
+ }
1236
+ /**
1237
+ * Gives every eager companion a chance to populate itself.
1238
+ *
1239
+ * Called from {@link InnerLoad} only — deliberately **not** from {@link LoadFromData}. See
1240
+ * {@link RelatedRecordLoadMode} for why that distinction is load-bearing.
1241
+ */
1242
+ /**
1243
+ * Populates this record's declared related-record collections and resolves once they are all
1244
+ * ready — the one call to `await` when you want a fully-hydrated record.
1245
+ *
1246
+ * The point is batching. Cache-sourced collections resolve synchronously against
1247
+ * `BaseEngineRegistry` and cost nothing; every database-sourced collection is gathered into a
1248
+ * **single `RunViews` call** rather than one `RunView` each. So a record with four declared
1249
+ * collections costs one round trip, or zero when they all read from engine caches — instead of
1250
+ * the four sequential queries a naive `for (…) await c.Load()` would issue.
1251
+ *
1252
+ * Collections declared `'never'` are skipped: that mode means write-only staging buffer.
1253
+ *
1254
+ * @param names - Collection names to load. Omit to load every declared collection.
1255
+ *
1256
+ * @example
1257
+ * ```typescript
1258
+ * await action.LoadRelatedRecords(); // Params, ResultCodes and Libraries, one trip
1259
+ * await agent.LoadRelatedRecords('Prompts'); // just the one
1260
+ * ```
1261
+ */
1262
+ async LoadRelatedRecords(...names) {
1263
+ const wanted = names.length > 0 ? new Set(names.map(n => n.trim().toLowerCase())) : null;
1264
+ const collections = this.Companions.filter((c) => c instanceof RelatedRecordCollection &&
1265
+ c.LoadMode !== 'never' &&
1266
+ (!wanted || wanted.has(c.Name.trim().toLowerCase())));
1267
+ if (collections.length === 0) {
1268
+ return;
1269
+ }
1270
+ // Cache-backed ones first — synchronous, zero queries. Whatever misses falls through to the
1271
+ // batched database load below, so a donor engine that is not loaded yet costs correctness
1272
+ // nothing. Database-backed collections carry the same guards Load() itself has: a
1273
+ // collection that is already loaded, or that holds staged work (new/edited children,
1274
+ // pending removals), is left alone — without those guards this call re-queried an
1275
+ // already-loaded collection and, worse, WIPED staged children on a new parent, because the
1276
+ // parent's pre-generated UUID matches zero rows and SetLoadedItems([]) discards everything.
1277
+ const needsDatabase = [];
1278
+ for (const collection of collections) {
1279
+ if (await collection.TryLoadFromCache()) {
1280
+ continue;
1281
+ }
1282
+ if (collection.IsLoaded || collection.Dirty) {
1283
+ continue;
1284
+ }
1285
+ needsDatabase.push(collection);
1286
+ }
1287
+ // An unsaved parent owns no persisted related records — mirror Load()'s own guard rather
1288
+ // than issuing queries guaranteed to return nothing (and destroy staged state adopting it).
1289
+ if (needsDatabase.length === 0 || !this.IsSaved) {
1290
+ return;
1291
+ }
1292
+ // One `RunViews` for all remaining collections — N declared collections cost one round trip,
1293
+ // not N. Params are built per collection so each keeps its own filter and ordering. The key
1294
+ // is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
1295
+ const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''");
1296
+ const rv = new RunView(this.ProviderToUse);
1297
+ const results = await rv.RunViews(needsDatabase.map(c => ({
1298
+ EntityName: c.RelatedEntityName,
1299
+ ExtraFilter: `${c.RelatedEntityJoinField} = '${parentKeyLiteral}'`,
1300
+ OrderBy: c.OrderByClause,
1301
+ ResultType: 'entity_object',
1302
+ })), this.ContextCurrentUser);
1303
+ needsDatabase.forEach((collection, i) => {
1304
+ const result = results?.[i];
1305
+ if (result?.Success) {
1306
+ collection.SetLoadedItems(result.Results ?? []);
1307
+ }
1308
+ else {
1309
+ LogError(`BaseEntity.LoadRelatedRecords: '${collection.Name}' failed — ${result?.ErrorMessage ?? 'no result'}`);
1310
+ }
1311
+ });
1312
+ }
1313
+ async loadEagerCompanions() {
1314
+ if (!this.HasCompanions) {
1315
+ return;
1316
+ }
1317
+ for (const companion of this.Companions) {
1318
+ await companion.LoadEager();
1319
+ }
1320
+ }
1321
+ /**
1322
+ * Tells every companion that the unit of work committed, so they can clear pending removals and
1323
+ * rebase their dirty state.
1324
+ */
1325
+ acceptCompanionChanges() {
1326
+ if (!this.HasCompanions) {
1327
+ return;
1328
+ }
1329
+ for (const companion of this.Companions) {
1330
+ companion.AcceptChanges();
1331
+ }
1332
+ }
1333
+ /**
1334
+ * Opens a transaction scope for a multi-record unit of work rooted at this entity, when this
1335
+ * entity is the initiator and is not already inside a `TransactionGroup`.
1336
+ *
1337
+ * Participants never ask whether a transaction is already open — the provider arbitrates and
1338
+ * either starts one or joins the one in flight. See {@link EntityTransactionScope}.
1339
+ *
1340
+ * @param isInitiator - Whether this entity is the one orchestrating the unit of work. Non-
1341
+ * initiators never open a scope; they write into the ambient transaction.
1342
+ * @returns True if a scope was opened (or joined) and is now held by this entity.
1343
+ */
1344
+ async beginEntityTransactionScope(isInitiator) {
1345
+ // A TransactionGroup manages its own atomicity and defers every save until Submit(); opening
1346
+ // a provider transaction underneath it would wrap statements that have not run yet.
1347
+ if (!isInitiator || this.TransactionGroup) {
1348
+ return false;
1349
+ }
1350
+ const provider = this.ProviderToUse;
1351
+ if (provider?.SupportsEntityTransactions !== true || !provider.BeginEntityTransaction) {
1352
+ return false; // client-side provider — no local transaction to open
1353
+ }
1354
+ this._entityTransactionScope = await provider.BeginEntityTransaction();
1355
+ return true;
1356
+ }
1357
+ /**
1358
+ * Builds the ordered unit of work for saving this record and everything its companions
1359
+ * contribute.
1360
+ *
1361
+ * The root node comes first — children need the parent's primary key, and on a create it does
1362
+ * not exist until the parent row is inserted.
1363
+ *
1364
+ * @param includeRoot - Whether to include this record's own save. False when the caller has
1365
+ * already persisted the root by other means.
1366
+ * @param saveOptions - The caller's save options, forwarded to each companion so it can honor
1367
+ * flags that change what counts as work (`IgnoreDirtyState`, most
1368
+ * importantly — a companion that skips clean children must not skip them
1369
+ * when the caller demanded a full write-out).
1370
+ * @returns The plan. A `NodeCount` of 1 means there is no graph and the caller should take the
1371
+ * ordinary single-record path.
1372
+ */
1373
+ BuildSavePlan(includeRoot = true, saveOptions) {
1374
+ const plan = new EntitySavePlan(this);
1375
+ if (includeRoot) {
1376
+ plan.AddSave(this, this.EntityInfo?.Name ?? 'root', undefined, /* selfOnly */ true);
1377
+ }
1378
+ for (const companion of this.Companions) {
1379
+ companion.ContributeSaveWork(plan, saveOptions);
1380
+ }
1381
+ return plan;
1382
+ }
1383
+ /**
1384
+ * Builds the ordered unit of work for deleting this record and everything its companions
1385
+ * contribute.
1386
+ *
1387
+ * Companions contribute **first**: children hold foreign keys pointing at the row that is about
1388
+ * to disappear, so they must be removed before it.
1389
+ *
1390
+ * @returns The plan.
1391
+ */
1392
+ BuildDeletePlan() {
1393
+ const plan = new EntitySavePlan(this);
1394
+ for (const companion of this.Companions) {
1395
+ companion.ContributeDeleteWork(plan);
1396
+ }
1397
+ plan.Add({
1398
+ Entity: this,
1399
+ Operation: 'Delete',
1400
+ Label: this.EntityInfo?.Name ?? 'root',
1401
+ SelfOnly: true,
1402
+ });
1403
+ return plan;
1404
+ }
1405
+ /**
1406
+ * Runs a multi-record save as one unit of work, choosing where it executes based on what the
1407
+ * provider can do.
1408
+ *
1409
+ * This is the single decision point for the whole feature:
1410
+ *
1411
+ * - **Provider supports entity transactions** (server): execute locally inside one transaction.
1412
+ * - **It does not** (client): serialize the graph and hand the entire unit of work to the
1413
+ * server via the `MJ.SaveEntityGraph` remote operation, which rebuilds the records — as their
1414
+ * *server-side* registered subclasses — and runs this very same local executor there.
1415
+ *
1416
+ * There is exactly one cascade implementation. The remote path relocates it; it never
1417
+ * reimplements it. That is what keeps client and server behavior from drifting.
1418
+ *
1419
+ * Validation is not performed here: the root node's own `_InnerSave` runs `Validate()` (which
1420
+ * fans out to every companion, over the complete child set including pending removals) before
1421
+ * it writes anything, and the root node executes first. So the whole graph is validated before
1422
+ * the first row lands, which is the guarantee cross-child invariants need.
1423
+ *
1424
+ * @param plan - The planned unit of work.
1425
+ * @param options - Save options forwarded to every node.
1426
+ * @returns True when the whole graph committed.
1427
+ */
1428
+ async saveGraph(plan, options) {
1429
+ this.RaiseEvent('graph_save_started', { NodeCount: plan.NodeCount });
1430
+ if (this.ProviderToUse?.SupportsEntityTransactions === true) {
1431
+ return this.executeGraphLocal(plan, options);
1432
+ }
1433
+ return this.executeGraphRemote(plan, options);
1434
+ }
1435
+ /**
1436
+ * Runs a multi-record delete as one unit of work.
1437
+ *
1438
+ * Unlike the save path there is no remote counterpart: a delete graph carries no state that
1439
+ * needs rebuilding server-side, so on a client provider the nodes execute in order over
1440
+ * ordinary mutations. That is **not atomic** — a failure partway leaves earlier deletions
1441
+ * committed. Callers needing an atomic multi-record delete from the browser should expose a
1442
+ * dedicated remote operation, which is also what MJ's own cascade-delete tooling does.
1443
+ *
1444
+ * @param plan - The planned unit of work, children first.
1445
+ * @param options - Delete options forwarded to every node.
1446
+ * @returns True when the whole graph completed.
1447
+ */
1448
+ async deleteGraph(plan, options) {
1449
+ this.RaiseEvent('graph_save_started', { NodeCount: plan.NodeCount, Operation: 'Delete' });
1450
+ return this.executeGraphLocal(plan, undefined, options, 'delete');
1451
+ }
1452
+ /**
1453
+ * Executes a multi-node plan locally, inside a single provider transaction.
1454
+ *
1455
+ * Used when the provider reports {@link IMetadataProvider.SupportsEntityTransactions} — i.e.
1456
+ * server-side. The transaction is provider-arbitrated, so this composes correctly whether it is
1457
+ * the outermost unit of work or nested inside an application cascade that already opened one.
1458
+ *
1459
+ * @param plan - The plan to run.
1460
+ * @param saveOptions - Options forwarded to save nodes.
1461
+ * @param deleteOptions - Options forwarded to delete nodes.
1462
+ * @returns True when every node succeeded and the transaction committed.
1463
+ */
1464
+ async executeGraphLocal(plan, saveOptions, deleteOptions, operationKind = 'save') {
1465
+ const provider = this.ProviderToUse;
1466
+ // Passed explicitly rather than inferred from which options object is set: `Delete()` is
1467
+ // routinely called with no arguments, so an inference would mislabel every such failure.
1468
+ const operation = operationKind;
1469
+ // One cycle-guard set per unit of work: inherited when this graph is nested inside another
1470
+ // (so a child sees its ancestors), created fresh when this graph is the outermost one.
1471
+ // Child nodes must carry it on their own options, because a child node runs the child's
1472
+ // `Save()`, which builds and executes a plan of its own.
1473
+ const visited = saveOptions?.GraphVisited ?? deleteOptions?.GraphVisited ?? new Set();
1474
+ const childSaveOptions = Object.assign(new EntitySaveOptions(), saveOptions ?? {});
1475
+ childSaveOptions.GraphVisited = visited;
1476
+ const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
1477
+ childDeleteOptions.GraphVisited = visited;
1478
+ // Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
1479
+ // save, and Save()/Delete() report failure by returning false — an escaping throw here
1480
+ // would break that contract for exactly one path.
1481
+ let scope = null;
1482
+ try {
1483
+ scope =
1484
+ provider?.SupportsEntityTransactions === true && provider.BeginEntityTransaction
1485
+ ? await provider.BeginEntityTransaction()
1486
+ : null;
1487
+ const result = await ExecuteEntitySavePlan(plan, {
1488
+ SaveOptions: childSaveOptions,
1489
+ RootSaveOptions: this.buildRootSaveOptions(saveOptions),
1490
+ DeleteOptions: childDeleteOptions,
1491
+ RootDeleteOptions: this.buildRootDeleteOptions(deleteOptions),
1492
+ Visited: visited,
1493
+ });
1494
+ if (!result.Success) {
1495
+ await scope?.Rollback();
1496
+ this.registerGraphFailure(result.ErrorMessage, operation);
1497
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
1498
+ return false;
1499
+ }
1500
+ await scope?.Commit();
1501
+ this.acceptCompanionChanges();
1502
+ this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
1503
+ return true;
1504
+ }
1505
+ catch (e) {
1506
+ // Rollback failures are logged and swallowed: this is already the failure path, and a
1507
+ // secondary throw (a doomed transaction refusing a savepoint rollback, a dropped
1508
+ // connection) would both replace the real error and escape the no-throw contract.
1509
+ try {
1510
+ await scope?.Rollback();
1511
+ }
1512
+ catch (rollbackError) {
1513
+ LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
1514
+ `${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
1515
+ }
1516
+ const detail = e instanceof Error ? e.message : String(e);
1517
+ LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
1518
+ this.registerGraphFailure(detail, operation);
1519
+ this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1520
+ return false;
1521
+ }
1522
+ }
1523
+ /**
1524
+ * Builds the save options for the graph's **root** node.
1525
+ *
1526
+ * Copies the caller's options onto a real `EntitySaveOptions` instance and stamps
1527
+ * `IsGraphNodeSave`, which is what stops the root re-entering graph planning and lets it bypass
1528
+ * its own in-flight save debounce.
1529
+ *
1530
+ * @param source - The caller's options, if any.
1531
+ * @returns Options for the root node.
1532
+ */
1533
+ buildRootSaveOptions(source) {
1534
+ const options = Object.assign(new EntitySaveOptions(), source ?? {});
1535
+ options.IsGraphNodeSave = true;
1536
+ return options;
1537
+ }
1538
+ /**
1539
+ * Builds the delete options for the graph's **root** node. Delete-path counterpart of
1540
+ * {@link buildRootSaveOptions}.
1541
+ *
1542
+ * @param source - The caller's options, if any.
1543
+ * @returns Options for the root node.
1544
+ */
1545
+ buildRootDeleteOptions(source) {
1546
+ const options = Object.assign(new EntityDeleteOptions(), source ?? {});
1547
+ options.IsGraphNodeDelete = true;
1548
+ return options;
1549
+ }
1550
+ /**
1551
+ * Records a graph-level failure on this entity's result history.
1552
+ *
1553
+ * Without this the caller gets a bare `false` while `LatestResult` still holds the *root's*
1554
+ * successful save — so a child failure reads as "it just didn't work". Mirrors the parent-chain
1555
+ * failure handling in `_InnerSave`.
1556
+ *
1557
+ * @param message - The failure detail.
1558
+ */
1559
+ registerGraphFailure(message, operation = 'save') {
1560
+ const result = new BaseEntityResult();
1561
+ result.Success = false;
1562
+ result.Type = operation === 'delete' ? 'delete' : this.IsSaved ? 'update' : 'create';
1563
+ result.Message = message ?? 'Entity graph operation failed';
1564
+ result.StartedAt = new Date();
1565
+ result.EndedAt = new Date();
1566
+ result.OriginalValues = this.Fields.map(f => ({ FieldName: f.CodeName, Value: f.OldValue }));
1567
+ this.RegisterResultHistoryEntry(result);
1568
+ }
1569
+ /**
1570
+ * Commits the transaction scope held by this entity, if any. Safe to call unconditionally — a
1571
+ * no-op when this entity holds no scope.
1572
+ */
1573
+ async commitEntityTransactionScope() {
1574
+ const scope = this._entityTransactionScope;
1575
+ if (!scope) {
1576
+ return;
1577
+ }
1578
+ this._entityTransactionScope = null;
1579
+ await scope.Commit();
1580
+ }
1581
+ /**
1582
+ * Rolls back the transaction scope held by this entity, if any. Safe to call unconditionally.
1583
+ *
1584
+ * Rollback failures are logged and swallowed: this runs on the failure path, and throwing here
1585
+ * would replace the caller's real error with a secondary one that explains less.
1586
+ */
1587
+ async rollbackEntityTransactionScope() {
1588
+ const scope = this._entityTransactionScope;
1589
+ if (!scope) {
1590
+ return;
1591
+ }
1592
+ this._entityTransactionScope = null;
1593
+ try {
1594
+ await scope.Rollback();
1595
+ }
1596
+ catch (rollbackError) {
1597
+ LogError(`Error rolling back entity transaction scope for ${this.EntityInfo?.Name}: ${rollbackError}`);
1036
1598
  }
1037
1599
  }
1038
1600
  /**
@@ -1053,12 +1615,6 @@ export class BaseEntity {
1053
1615
  get RunQueryProviderToUse() {
1054
1616
  return this.ProviderToUse;
1055
1617
  }
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
1618
  /**
1063
1619
  * 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
1620
  * 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 +1893,18 @@ export class BaseEntity {
1337
1893
  }
1338
1894
  /**
1339
1895
  * 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.
1896
+ *
1897
+ * @remarks
1898
+ * Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
1899
+ * three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
1900
+ * return, and the children are silently never persisted — the save reports success and writes
1901
+ * nothing. See {@link EntityCompanion.Dirty}.
1340
1902
  */
1341
1903
  get Dirty() {
1342
1904
  if (!this.IsSaved)
1343
1905
  return true;
1906
+ if (this.companionsDirty)
1907
+ return true;
1344
1908
  // Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
1345
1909
  // dirty. Avoid hydrating just to check.
1346
1910
  if (!this._fieldsHydrated)
@@ -1348,6 +1912,17 @@ export class BaseEntity {
1348
1912
  return this._Fields.some(f => f.Dirty) ||
1349
1913
  (this._parentEntity?.Dirty ?? false);
1350
1914
  }
1915
+ /**
1916
+ * Whether any registered companion holds unsaved work.
1917
+ *
1918
+ * Guarded on {@link HasCompanions} so entities without companions — nearly all of them — do no
1919
+ * extra work on this hot path.
1920
+ */
1921
+ get companionsDirty() {
1922
+ if (!this.HasCompanions)
1923
+ return false;
1924
+ return this.Companions.some(c => c.Dirty);
1925
+ }
1351
1926
  /**
1352
1927
  * 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
1928
  * If the entity has a single primary key, this method will return an array with a single field in it.
@@ -1734,6 +2309,16 @@ export class BaseEntity {
1734
2309
  }
1735
2310
  }
1736
2311
  }
2312
+ // Companion payloads ride along under a reserved key, following the convention already set
2313
+ // by OldValues___ and RestoreContext___. This is what lets a composite cross the wire on any
2314
+ // path that serializes via GetDataObject — including the TransactionGroup envelope, which
2315
+ // carries whole records as EntityObjectJSON — with no change to the transport itself.
2316
+ if (this.HasCompanions) {
2317
+ const companions = await this.SerializeCompanions();
2318
+ if (companions.length > 0) {
2319
+ obj[COMPANION_PAYLOAD_KEY] = companions;
2320
+ }
2321
+ }
1737
2322
  return obj;
1738
2323
  }
1739
2324
  async GetRelatedEntityData(re, filter = null, maxRecords = null) {
@@ -2028,10 +2613,48 @@ export class BaseEntity {
2028
2613
  if (options?.IsParentEntitySave) {
2029
2614
  return this._InnerSave(options);
2030
2615
  }
2031
- // If a save is already in progress, return its promise.
2616
+ // Executing our own node inside a graph we already planned. Bypass both the debounce and
2617
+ // graph routing — see EntitySaveOptions.IsGraphNodeSave for why each matters.
2618
+ if (options?.IsGraphNodeSave) {
2619
+ return this._InnerSave(options);
2620
+ }
2621
+ // If a save is already in progress, return its promise. This check MUST run before graph
2622
+ // routing: a composite save is still a save, and two concurrent Save() calls on one record
2623
+ // (double-click, autosave racing a manual save) must share the in-flight unit of work.
2624
+ // Routing first meant each call built and executed its own full graph — a double insert of
2625
+ // root and children — while single-row entities kept the debounce protection.
2032
2626
  if (this._pendingSave$) {
2033
2627
  return firstValueFrom(this._pendingSave$);
2034
2628
  }
2629
+ // Composite routing: when companions contribute work, this save is a multi-record unit of
2630
+ // work rather than a single row. Building the plan is cheap and, crucially, a plan with one
2631
+ // node falls straight through to the ordinary path below — so entities without companions,
2632
+ // and composites whose collections happen to be empty, behave exactly as they did before.
2633
+ if (this.HasCompanions) {
2634
+ const plan = this.BuildSavePlan(true, options);
2635
+ if (plan.NodeCount > 1) {
2636
+ // A TransactionGroup defers this record's own write until Submit(), while a graph
2637
+ // executes its child nodes immediately (or ships the whole graph to the server) —
2638
+ // children would insert rows pointing at a parent whose write is still queued, and
2639
+ // the remote path would commit everything before the group ever submits. The
2640
+ // combination cannot be made coherent, so refuse it loudly rather than silently
2641
+ // tearing the group's atomicity. (Same conclusion as the guide: a TransactionGroup
2642
+ // is not a composite-save engine — see guides/TRANSACTIONS_AND_BATCHING_GUIDE.md.)
2643
+ if (this.TransactionGroup) {
2644
+ throw new Error(`${this.EntityInfo?.Name}: cannot save related-record collections while enrolled in a ` +
2645
+ `TransactionGroup. The group defers the parent's write until Submit(), so the graph's ` +
2646
+ `child records would persist against a parent row that does not exist yet. Save the ` +
2647
+ `composite record on its own — entity.Save() is already atomic for its collections — ` +
2648
+ `or detach it from the TransactionGroup first.`);
2649
+ }
2650
+ // Run the graph through the same pending-save pipeline as a single-row save, so
2651
+ // concurrent callers share one execution and one result. The graph's own root node
2652
+ // re-enters Save() with IsGraphNodeSave, which bypasses this pipeline above — no
2653
+ // self-deadlock.
2654
+ this._pendingSave$ = of(options).pipe(switchMap(opts => from(this.saveGraph(plan, opts))), finalize(() => { this._pendingSave$ = null; }), shareReplay(1));
2655
+ return firstValueFrom(this._pendingSave$);
2656
+ }
2657
+ }
2035
2658
  // Create a new observable that debounces duplicative calls, and executes the save.
2036
2659
  this._pendingSave$ = of(options).pipe(
2037
2660
  // Execute the actual save logic.
@@ -2064,15 +2687,12 @@ export class BaseEntity {
2064
2687
  }
2065
2688
  // IS-A orchestration: determine if this is the initiating save in a parent chain
2066
2689
  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
- }
2690
+ // Open (or join) a transaction scope for the parent chain. The provider arbitrates:
2691
+ // if a transaction is already in flight an application cascade, an enclosing graph
2692
+ // save this joins it as a savepoint rather than starting a second physical
2693
+ // transaction. Before 6.2 this path called BeginISATransaction(), which was blind to
2694
+ // any existing transaction and produced torn writes; see EntityTransactionScope.
2695
+ await this.beginEntityTransactionScope(isISAInitiator);
2076
2696
  // Save parent chain first (root → branch → immediate parent)
2077
2697
  // Parent calls Save() recursively which handles its own parents, permissions, validation
2078
2698
  if (this._parentEntity) {
@@ -2088,7 +2708,7 @@ export class BaseEntity {
2088
2708
  const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
2089
2709
  if (!parentResult) {
2090
2710
  // Parent save failed — rollback if we started the transaction
2091
- await this.RollbackISATransaction(isISAInitiator);
2711
+ await this.rollbackEntityTransactionScope();
2092
2712
  // RECORD the failure on THIS entity's ResultHistory before returning. Without
2093
2713
  // this the caller gets `false` with LatestResult === null and an empty
2094
2714
  // ResultHistory, because every result was written to the PARENT object — which
@@ -2163,6 +2783,16 @@ export class BaseEntity {
2163
2783
  valResult.Errors.push(error);
2164
2784
  });
2165
2785
  }
2786
+ // Companion async validation runs REGARDLESS of skipAsyncValidation.
2787
+ //
2788
+ // That flag exists so an entity can opt out of its OWN expensive async
2789
+ // rules; applying it to companions silently disables cross-child invariants.
2790
+ // That is not hypothetical — it is exactly how OrderEntityServer's
2791
+ // ValidateAsync, holding both the "cannot confirm an order with no lines"
2792
+ // guard and the entire per-line validation loop, became dead code on every
2793
+ // save in production: the class never overrode DefaultSkipAsyncValidation
2794
+ // (which defaults to true) and no caller ever passed SkipAsyncValidation:false.
2795
+ await this.validateCompanionsAsync(valResult);
2166
2796
  }
2167
2797
  if (valResult.Success) {
2168
2798
  // Run registered PreSave hooks (e.g., tenant validation)
@@ -2186,10 +2816,17 @@ export class BaseEntity {
2186
2816
  if (!this.TransactionGroup) {
2187
2817
  // no transaction group, so we have our results here
2188
2818
  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;
2819
+ // Settle the scope this entity opened, if any (only the initiator holds
2820
+ // one). The provider can fail by RETURNING falsy data rather than
2821
+ // throwing — a validate-type entity action rejecting the save does
2822
+ // exactly that — and committing on that path would persist the parent
2823
+ // chain around a leaf that reported failure: precisely the torn write
2824
+ // this scope exists to prevent.
2825
+ if (result) {
2826
+ await this.commitEntityTransactionScope();
2827
+ }
2828
+ else {
2829
+ await this.rollbackEntityTransactionScope();
2193
2830
  }
2194
2831
  return result;
2195
2832
  }
@@ -2230,13 +2867,19 @@ export class BaseEntity {
2230
2867
  }
2231
2868
  }
2232
2869
  }
2233
- else
2870
+ else {
2871
+ // Nothing to save — but the scope opened above (an IS-A initiator whose whole chain
2872
+ // is clean) must still be settled. Returning with it open leaked the provider's
2873
+ // ambient transaction: every subsequent write joined a transaction nobody would ever
2874
+ // commit, so later "successful" saves were silently non-durable. Committing an
2875
+ // empty scope writes nothing; it only releases the transaction.
2876
+ await this.commitEntityTransactionScope();
2234
2877
  return true; // nothing to save since we're not dirty
2878
+ }
2235
2879
  }
2236
2880
  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);
2881
+ // Roll back the scope this entity opened, if any. No-op when it holds none.
2882
+ await this.rollbackEntityTransactionScope();
2240
2883
  if (currentResultCount === this.ResultHistory.length) {
2241
2884
  // this means that NO new results were added to the history anywhere
2242
2885
  // so we need to add a new result to the history here
@@ -2291,21 +2934,6 @@ export class BaseEntity {
2291
2934
  // If no remaining children, safe to delete parent
2292
2935
  return remainingChildren.length === 0;
2293
2936
  }
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
2937
  finalizeSave(data, saveSubType) {
2310
2938
  if (data) {
2311
2939
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
@@ -2511,6 +3139,15 @@ export class BaseEntity {
2511
3139
  // This populates _childEntity so Save/Delete can delegate to the leaf.
2512
3140
  // Only runs for parent-type entities; idempotent via _childEntityDiscoveryDone.
2513
3141
  await this.InitializeChildEntity();
3142
+ // Companions declared Load:'immediate' populate here — and ONLY here.
3143
+ //
3144
+ // Deliberately not in LoadFromData(): that is the per-row materialization path for
3145
+ // RunView(ResultType:'entity_object'), so an eager child query there becomes one query
3146
+ // per row of every view. That is a live N+1 in production accounting code today
3147
+ // (JournalEntryEntityServer.LoadFromData → LoadLines), and excluding it here is the
3148
+ // structural fix. Set-oriented eager loading is served by RunView's batched child
3149
+ // loading, which issues one WHERE fk IN (...) for the whole result set.
3150
+ await this.loadEagerCompanions();
2514
3151
  // Raise load completion event
2515
3152
  this.RaiseEvent('load_complete', { CompositeKey });
2516
3153
  return true;
@@ -2666,8 +3303,27 @@ export class BaseEntity {
2666
3303
  this._recordLoaded = false;
2667
3304
  this._everSaved = false; // Mark as NOT saved since we loaded from data without primary keys
2668
3305
  }
3306
+ // Absorb any companion payload that travelled with this record. This is the receiving half
3307
+ // of the reserved-key convention applied in GetDataObject(). Note this restores state the
3308
+ // sender already had — it never issues a query, so it cannot reintroduce the N+1 that
3309
+ // eager companion loading is kept out of this method to avoid.
3310
+ await this.deserializeCompanionsFromData(data);
2669
3311
  return true;
2670
3312
  }
3313
+ /**
3314
+ * Extracts and applies a companion payload carried inside a plain data object, if present.
3315
+ *
3316
+ * @param data - The plain object passed to {@link LoadFromData}.
3317
+ */
3318
+ async deserializeCompanionsFromData(data) {
3319
+ if (!this.HasCompanions || !data || typeof data !== 'object') {
3320
+ return;
3321
+ }
3322
+ const payloads = data[COMPANION_PAYLOAD_KEY];
3323
+ if (Array.isArray(payloads)) {
3324
+ await this.DeserializeCompanions(payloads);
3325
+ }
3326
+ }
2671
3327
  /**
2672
3328
  * 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
3329
  * override or wrap this base class method to add other logic for validation.
@@ -2696,8 +3352,47 @@ export class BaseEntity {
2696
3352
  });
2697
3353
  result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
2698
3354
  }
3355
+ // Companions validate LAST but still BEFORE any write, over their complete state including
3356
+ // pending removals. That ordering is what lets a cross-child invariant — "debits must equal
3357
+ // credits", "a confirmed order must have lines" — be enforced against the whole graph rather
3358
+ // than discovered halfway through persisting it.
3359
+ this.validateCompanions(result);
2699
3360
  return result;
2700
3361
  }
3362
+ /**
3363
+ * Fans synchronous validation out to every registered companion.
3364
+ *
3365
+ * @param result - The accumulating result companions contribute to.
3366
+ */
3367
+ validateCompanions(result) {
3368
+ if (!this.HasCompanions) {
3369
+ return;
3370
+ }
3371
+ for (const companion of this.Companions) {
3372
+ companion.Validate(result);
3373
+ }
3374
+ }
3375
+ /**
3376
+ * Fans asynchronous validation out to every registered companion.
3377
+ *
3378
+ * @remarks
3379
+ * Called from `_InnerSave` **independently of `DefaultSkipAsyncValidation`**. That flag lets an
3380
+ * entity opt out of its *own* expensive async rules; extending it to companions silently
3381
+ * disabled cross-child invariants, which is precisely how `OrderEntityServer.ValidateAsync` —
3382
+ * containing both the "cannot confirm an order with no lines" guard and the entire per-line
3383
+ * validation loop — became dead code on every save in production. Companion async validation
3384
+ * runs whenever companions are present.
3385
+ *
3386
+ * @param result - The accumulating result companions contribute to.
3387
+ */
3388
+ async validateCompanionsAsync(result) {
3389
+ if (!this.HasCompanions) {
3390
+ return;
3391
+ }
3392
+ for (const companion of this.Companions) {
3393
+ await companion.ValidateAsync(result);
3394
+ }
3395
+ }
2701
3396
  /**
2702
3397
  * Default value for whether async validation should be skipped.
2703
3398
  * Subclasses can override this property to enable async validation by default.
@@ -2741,6 +3436,20 @@ export class BaseEntity {
2741
3436
  * @returns Promise<boolean>
2742
3437
  */
2743
3438
  async Delete(options) {
3439
+ // Executing our own node inside a delete graph we already planned. Bypass both the debounce
3440
+ // and graph routing — see EntityDeleteOptions.IsGraphNodeDelete.
3441
+ if (options?.IsGraphNodeDelete) {
3442
+ return this._InnerDelete(options);
3443
+ }
3444
+ // Composite routing: companions that own their children (OnRemove:'delete') contribute
3445
+ // child deletions that must run before this row disappears. A single-node plan falls
3446
+ // through to the ordinary path, so nothing changes for entities without companions.
3447
+ if (this.HasCompanions) {
3448
+ const plan = this.BuildDeletePlan();
3449
+ if (plan.NodeCount > 1) {
3450
+ return this.deleteGraph(plan, options);
3451
+ }
3452
+ }
2744
3453
  // If a delete is already in progress, return its promise.
2745
3454
  if (this._pendingDelete$) {
2746
3455
  return firstValueFrom(this._pendingDelete$);
@@ -2808,14 +3517,10 @@ export class BaseEntity {
2808
3517
  }
2809
3518
  }
2810
3519
  }
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
- }
3520
+ // Open (or join) a transaction scope for the parent chain see the matching
3521
+ // comment in _InnerSave and EntityTransactionScope for why this is provider-
3522
+ // arbitrated rather than IS-A-specific.
3523
+ await this.beginEntityTransactionScope(isISAInitiator);
2819
3524
  this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
2820
3525
  // Raise delete_started event before the actual delete operation begins
2821
3526
  this.RaiseEvent('delete_started', null);
@@ -2844,7 +3549,7 @@ export class BaseEntity {
2844
3549
  const parentResult = await this._parentEntity.Delete(parentDeleteOptions);
2845
3550
  if (!parentResult) {
2846
3551
  // Parent delete failed — rollback if we started the transaction
2847
- await this.RollbackISATransaction(isISAInitiator);
3552
+ await this.rollbackEntityTransactionScope();
2848
3553
  // RECORD the failure on THIS entity's ResultHistory before returning —
2849
3554
  // symmetric with the parent-SAVE-failure path in _InnerSave. Without this
2850
3555
  // the caller gets `false` with LatestResult === null and an empty
@@ -2874,11 +3579,8 @@ export class BaseEntity {
2874
3579
  }
2875
3580
  }
2876
3581
  }
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
- }
3582
+ // Commit the scope this entity opened, if any (only the initiator holds one).
3583
+ await this.commitEntityTransactionScope();
2882
3584
  if (!this.TransactionGroup) {
2883
3585
  // NOT part of a transaction - raise event immediately
2884
3586
  // record deleted correctly
@@ -2919,14 +3621,21 @@ export class BaseEntity {
2919
3621
  }
2920
3622
  return true;
2921
3623
  }
2922
- else // record didn't delete, return false, but also don't wipe out the entity like we do if the Delete() worked
3624
+ else {
3625
+ // Record didn't delete. This path is ordinary, not exotic: the provider reports
3626
+ // essentially every delete failure — RLS denial, FK violation, zero rows
3627
+ // affected — by RETURNING false rather than throwing. The scope opened above
3628
+ // must be settled here or the provider's ambient transaction leaks open and
3629
+ // every subsequent "committed" write on this provider silently never commits.
3630
+ // (Also: don't wipe out the entity like we do when the Delete() worked.)
3631
+ await this.rollbackEntityTransactionScope();
2923
3632
  return false;
3633
+ }
2924
3634
  }
2925
3635
  }
2926
3636
  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);
3637
+ // Roll back the scope this entity opened, if any. No-op when it holds none.
3638
+ await this.rollbackEntityTransactionScope();
2930
3639
  if (currentResultCount === this.ResultHistory.length) {
2931
3640
  // this means that NO new results were added to the history anywhere
2932
3641
  // so we need to add a new result to the history here