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