@memberjunction/core 5.51.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.
- package/LICENSE +7 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +13 -2
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +14 -0
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +32 -0
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +329 -26
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +788 -79
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +54 -17
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +133 -52
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +218 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -0
- package/dist/generic/entityCompanion.js +170 -0
- package/dist/generic/entityCompanion.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +146 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +188 -0
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySavePlan.d.ts +199 -0
- package/dist/generic/entitySavePlan.d.ts.map +1 -0
- package/dist/generic/entitySavePlan.js +213 -0
- package/dist/generic/entitySavePlan.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +125 -0
- package/dist/generic/entityTransactionScope.d.ts.map +1 -0
- package/dist/generic/entityTransactionScope.js +115 -0
- package/dist/generic/entityTransactionScope.js.map +1 -0
- package/dist/generic/interfaces.d.ts +93 -35
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +27 -0
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/providerBase.d.ts +13 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +64 -5
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordBatchLoader.d.ts +39 -0
- package/dist/generic/relatedRecordBatchLoader.d.ts.map +1 -0
- package/dist/generic/relatedRecordBatchLoader.js +154 -0
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -0
- package/dist/generic/relatedRecordCollection.d.ts +578 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -0
- package/dist/generic/relatedRecordCollection.js +1004 -0
- package/dist/generic/relatedRecordCollection.js.map +1 -0
- package/dist/generic/saveEntityGraphOperation.d.ts +148 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -0
- package/dist/generic/saveEntityGraphOperation.js +157 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -0
- package/dist/generic/securityInfo.d.ts +99 -1
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +88 -6
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +21 -1
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +21 -6
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +31 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +13 -13
- package/readme.md +159 -1
- package/dist/generic/runReport.d.ts +0 -25
- package/dist/generic/runReport.d.ts.map +0 -1
- package/dist/generic/runReport.js +0 -38
- 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
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
1029
|
-
*
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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.
|
|
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
|
-
//
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
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
|
-
//
|
|
2238
|
-
|
|
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
|
-
//
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
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.
|
|
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
|
-
//
|
|
2878
|
-
|
|
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
|
|
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
|
-
//
|
|
2928
|
-
|
|
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
|