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