@memberjunction/core 6.2.0-edge.5 → 6.2.0-edge.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +15 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +21 -0
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  6. package/dist/generic/EntityFieldRules.js +1 -0
  7. package/dist/generic/EntityFieldRules.js.map +1 -1
  8. package/dist/generic/alternateBaseViews.d.ts +119 -0
  9. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  10. package/dist/generic/alternateBaseViews.js +223 -0
  11. package/dist/generic/alternateBaseViews.js.map +1 -0
  12. package/dist/generic/baseEngine.d.ts +171 -17
  13. package/dist/generic/baseEngine.d.ts.map +1 -1
  14. package/dist/generic/baseEngine.js +722 -117
  15. package/dist/generic/baseEngine.js.map +1 -1
  16. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  17. package/dist/generic/baseEngineRegistry.js +2 -1
  18. package/dist/generic/baseEngineRegistry.js.map +1 -1
  19. package/dist/generic/baseEntity.d.ts +216 -21
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +969 -100
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  24. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  25. package/dist/generic/baseRemotableOperation.js +112 -8
  26. package/dist/generic/baseRemotableOperation.js.map +1 -1
  27. package/dist/generic/contextTokens.d.ts +136 -0
  28. package/dist/generic/contextTokens.d.ts.map +1 -0
  29. package/dist/generic/contextTokens.js +319 -0
  30. package/dist/generic/contextTokens.js.map +1 -0
  31. package/dist/generic/databaseProviderBase.d.ts +103 -5
  32. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  33. package/dist/generic/databaseProviderBase.js +381 -52
  34. package/dist/generic/databaseProviderBase.js.map +1 -1
  35. package/dist/generic/embeddedRecord.d.ts +6 -0
  36. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  37. package/dist/generic/embeddedRecord.js +15 -1
  38. package/dist/generic/embeddedRecord.js.map +1 -1
  39. package/dist/generic/entityCompanion.d.ts +7 -0
  40. package/dist/generic/entityCompanion.d.ts.map +1 -1
  41. package/dist/generic/entityCompanion.js +8 -0
  42. package/dist/generic/entityCompanion.js.map +1 -1
  43. package/dist/generic/entityInfo.d.ts +234 -9
  44. package/dist/generic/entityInfo.d.ts.map +1 -1
  45. package/dist/generic/entityInfo.js +592 -77
  46. package/dist/generic/entityInfo.js.map +1 -1
  47. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  48. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  49. package/dist/generic/entityValueFieldSecurity.js +250 -0
  50. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  51. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  52. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  53. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  54. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  55. package/dist/generic/fieldSecuritySQLGate.d.ts +359 -0
  56. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  57. package/dist/generic/fieldSecuritySQLGate.js +902 -0
  58. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  59. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  60. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  61. package/dist/generic/indexRestrictedFields.js +129 -0
  62. package/dist/generic/indexRestrictedFields.js.map +1 -0
  63. package/dist/generic/interfaces.d.ts +64 -2
  64. package/dist/generic/interfaces.d.ts.map +1 -1
  65. package/dist/generic/interfaces.js.map +1 -1
  66. package/dist/generic/listMembershipFilter.d.ts +5 -0
  67. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  68. package/dist/generic/listMembershipFilter.js +23 -0
  69. package/dist/generic/listMembershipFilter.js.map +1 -0
  70. package/dist/generic/localCacheManager.d.ts +126 -14
  71. package/dist/generic/localCacheManager.d.ts.map +1 -1
  72. package/dist/generic/localCacheManager.js +348 -48
  73. package/dist/generic/localCacheManager.js.map +1 -1
  74. package/dist/generic/logging.d.ts.map +1 -1
  75. package/dist/generic/logging.js +7 -1
  76. package/dist/generic/logging.js.map +1 -1
  77. package/dist/generic/metadata.d.ts +3 -2
  78. package/dist/generic/metadata.d.ts.map +1 -1
  79. package/dist/generic/metadata.js +5 -2
  80. package/dist/generic/metadata.js.map +1 -1
  81. package/dist/generic/platformSQL.d.ts +54 -0
  82. package/dist/generic/platformSQL.d.ts.map +1 -1
  83. package/dist/generic/platformSQL.js +55 -0
  84. package/dist/generic/platformSQL.js.map +1 -1
  85. package/dist/generic/providerBase.d.ts +408 -57
  86. package/dist/generic/providerBase.d.ts.map +1 -1
  87. package/dist/generic/providerBase.js +1051 -310
  88. package/dist/generic/providerBase.js.map +1 -1
  89. package/dist/generic/queryInfo.d.ts +13 -0
  90. package/dist/generic/queryInfo.d.ts.map +1 -1
  91. package/dist/generic/queryInfo.js +13 -0
  92. package/dist/generic/queryInfo.js.map +1 -1
  93. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  94. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  95. package/dist/generic/queryTemplateContext.d.ts +49 -0
  96. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  97. package/dist/generic/queryTemplateContext.js +113 -0
  98. package/dist/generic/queryTemplateContext.js.map +1 -0
  99. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  100. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  101. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  102. package/dist/generic/relatedRecordCollection.d.ts +18 -0
  103. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  104. package/dist/generic/relatedRecordCollection.js +55 -7
  105. package/dist/generic/relatedRecordCollection.js.map +1 -1
  106. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  107. package/dist/generic/remoteOperationDispatch.js +14 -2
  108. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  109. package/dist/generic/runQuery.d.ts +42 -0
  110. package/dist/generic/runQuery.d.ts.map +1 -1
  111. package/dist/generic/runQuery.js +14 -0
  112. package/dist/generic/runQuery.js.map +1 -1
  113. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  114. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  115. package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
  116. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  118. package/dist/generic/saveEntityGraphOperation.js +3 -0
  119. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  120. package/dist/generic/securityInfo.d.ts +11 -0
  121. package/dist/generic/securityInfo.d.ts.map +1 -1
  122. package/dist/generic/securityInfo.js +15 -0
  123. package/dist/generic/securityInfo.js.map +1 -1
  124. package/dist/generic/telemetryManager.d.ts +9 -0
  125. package/dist/generic/telemetryManager.d.ts.map +1 -1
  126. package/dist/generic/telemetryManager.js +5 -1
  127. package/dist/generic/telemetryManager.js.map +1 -1
  128. package/dist/generic/transportError.d.ts +41 -0
  129. package/dist/generic/transportError.d.ts.map +1 -0
  130. package/dist/generic/transportError.js +167 -0
  131. package/dist/generic/transportError.js.map +1 -0
  132. package/dist/generic/util.d.ts +12 -0
  133. package/dist/generic/util.d.ts.map +1 -1
  134. package/dist/generic/util.js +52 -3
  135. package/dist/generic/util.js.map +1 -1
  136. package/dist/index.d.ts +9 -0
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +9 -0
  139. package/dist/index.js.map +1 -1
  140. package/dist/views/runView.d.ts +102 -0
  141. package/dist/views/runView.d.ts.map +1 -1
  142. package/dist/views/runView.js +40 -0
  143. package/dist/views/runView.js.map +1 -1
  144. package/dist/views/viewProperties.d.ts +55 -0
  145. package/dist/views/viewProperties.d.ts.map +1 -0
  146. package/dist/views/viewProperties.js +258 -0
  147. package/dist/views/viewProperties.js.map +1 -0
  148. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import { ProviderBase } from "./providerBase.js";
2
+ import { UserInfo } from "./securityInfo.js";
2
3
  import { EntityFieldTSType, EntityPermissionType } from "./entityInfo.js";
3
4
  import { BaseEntityResult, SerializeCloneChangeContext } from "./baseEntity.js";
4
5
  import { EntitySaveOptions, EntityDeleteOptions } from "./interfaces.js";
@@ -7,7 +8,9 @@ import { TransactionItem } from "./transactionGroup.js";
7
8
  import { CompositeKey } from "./compositeKey.js";
8
9
  import { LogError } from "./logging.js";
9
10
  import { LocalCacheManager } from "./localCacheManager.js";
10
- import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4 } from "@memberjunction/global";
11
+ import { AssertClientInputWritableForUser } from "./entityValueFieldSecurity.js";
12
+ import { CanonicalPrimaryKeyValue } from "./util.js";
13
+ import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4, EscapeSQLString } from "@memberjunction/global";
11
14
  import { GetDialect } from "@memberjunction/sql-dialect";
12
15
  // Re-export PlatformSQL types from their canonical location for backward compatibility
13
16
  export { IsPlatformSQL } from "./platformSQL.js";
@@ -319,6 +322,28 @@ export class DatabaseProviderBase extends ProviderBase {
319
322
  get MJCoreSchemaName() {
320
323
  return this.ConfigData?.MJCoreSchemaName ?? '__mj';
321
324
  }
325
+ /**
326
+ * Builds a batched UNION ALL query probing all `childEntities`' `BaseView`s for a set
327
+ * of parent primary-key values (`WHERE pk IN (...)`), returning `(EntityName, RecordPK, Priority)`.
328
+ * Dialect providers override this to use platform-native string-literal and CAST syntax.
329
+ */
330
+ BuildChildDiscoveryBatchSQL(childEntities, recordPKValues) {
331
+ if (recordPKValues.length === 0)
332
+ return '';
333
+ const escapedKeys = recordPKValues
334
+ .map(v => `'${EscapeSQLString(String(v))}'`)
335
+ .join(', ');
336
+ const unionParts = childEntities
337
+ .filter(child => child.PrimaryKeys.length > 0)
338
+ .map((child, idx) => {
339
+ const schema = child.SchemaName || this.MJCoreSchemaName || '__mj';
340
+ const sourceRef = this.QuoteSchemaAndView(schema, child.BaseView);
341
+ const pkRef = this.QuoteIdentifier(child.FirstPrimaryKey.Name); // first-pk-ok: IS-A children share the parent's single key
342
+ const escapedName = `'${EscapeSQLString(child.Name)}'`;
343
+ return `SELECT ${escapedName} AS ${this.QuoteIdentifier('EntityName')}, CAST(${pkRef} AS VARCHAR(255)) AS ${this.QuoteIdentifier('RecordPK')}, ${idx + 1} AS ${this.QuoteIdentifier('Priority')} FROM ${sourceRef} WHERE ${pkRef} IN (${escapedKeys})`;
344
+ });
345
+ return unionParts.length > 0 ? unionParts.join(' UNION ALL ') : '';
346
+ }
322
347
  /**
323
348
  * Combined regex covering UUID functions from ALL platforms.
324
349
  * Used by the static convenience method for code that doesn't have a provider instance.
@@ -583,6 +608,91 @@ export class DatabaseProviderBase extends ProviderBase {
583
608
  }
584
609
  return [];
585
610
  }
611
+ static { this.DEFAULT_ISA_DISCOVERY_BATCH_CHUNK_SIZE = 500; }
612
+ /**
613
+ * Maximum number of parent primary keys included in a single `BuildChildDiscoveryBatchSQL`
614
+ * `WHERE PK IN (...)` statement inside {@link FindISAChildEntitiesBatch}.
615
+ */
616
+ get ISADiscoveryBatchChunkSize() {
617
+ return DatabaseProviderBase.DEFAULT_ISA_DISCOVERY_BATCH_CHUNK_SIZE;
618
+ }
619
+ /**
620
+ * Batch-discovers IS-A child entity types for multiple parent primary keys in a single
621
+ * UNION ALL SQL query (chunked at {@link ISADiscoveryBatchChunkSize} keys per statement to
622
+ * stay well within SQL limits).
623
+ */
624
+ async FindISAChildEntitiesBatch(entityInfo, recordPKValues, contextUser) {
625
+ const resultMap = new Map();
626
+ const childEntities = entityInfo.ChildEntities;
627
+ if (childEntities.length === 0 || recordPKValues.length === 0) {
628
+ return resultMap;
629
+ }
630
+ // IS-A children share the parent's single key, so the parent's key type decides how values
631
+ // are canonicalized (UUIDs case-insensitively, anything else by exact text).
632
+ const pkIsUuid = Boolean(entityInfo.FirstPrimaryKey?.IsUniqueIdentifier); // first-pk-ok: IS-A children share the parent's single key
633
+ const uniquePKs = this.deduplicateCanonicalPKs(recordPKValues, pkIsUuid);
634
+ if (uniquePKs.length === 0) {
635
+ return resultMap;
636
+ }
637
+ const priorityByChildName = new Map();
638
+ for (let i = 0; i < childEntities.length; i++) {
639
+ priorityByChildName.set(childEntities[i].Name.toLowerCase(), i + 1);
640
+ }
641
+ const chunkSize = this.ISADiscoveryBatchChunkSize;
642
+ const collected = new Map();
643
+ for (let start = 0; start < uniquePKs.length; start += chunkSize) {
644
+ const chunk = uniquePKs.slice(start, start + chunkSize);
645
+ await this.collectISAChildBatchChunk(childEntities, chunk, pkIsUuid, priorityByChildName, collected, contextUser);
646
+ }
647
+ for (const [pkKey, entries] of collected) {
648
+ entries.sort((a, b) => a.priority - b.priority);
649
+ const names = [];
650
+ for (const entry of entries) {
651
+ if (!names.includes(entry.name)) {
652
+ names.push(entry.name);
653
+ }
654
+ }
655
+ resultMap.set(pkKey, names);
656
+ }
657
+ return resultMap;
658
+ }
659
+ deduplicateCanonicalPKs(recordPKValues, pkIsUuid) {
660
+ const uniquePKs = [];
661
+ const seen = new Set();
662
+ for (const rawPk of recordPKValues) {
663
+ if (!rawPk)
664
+ continue;
665
+ const norm = CanonicalPrimaryKeyValue(String(rawPk), pkIsUuid);
666
+ if (!seen.has(norm)) {
667
+ seen.add(norm);
668
+ uniquePKs.push(String(rawPk).trim());
669
+ }
670
+ }
671
+ return uniquePKs;
672
+ }
673
+ async collectISAChildBatchChunk(childEntities, chunk, pkIsUuid, priorityByChildName, collected, contextUser) {
674
+ const batchSQL = this.BuildChildDiscoveryBatchSQL(childEntities, chunk);
675
+ if (!batchSQL)
676
+ return;
677
+ const rows = await this.ExecuteSQL(batchSQL, undefined, undefined, contextUser);
678
+ if (!rows || rows.length === 0)
679
+ return;
680
+ for (const row of rows) {
681
+ const entityName = row.EntityName ?? row.ChildEntityName;
682
+ if (!entityName || row.RecordPK == null)
683
+ continue;
684
+ const pkKey = CanonicalPrimaryKeyValue(String(row.RecordPK), pkIsUuid);
685
+ const priority = typeof row.Priority === 'number'
686
+ ? row.Priority
687
+ : (priorityByChildName.get(entityName.toLowerCase()) ?? Number.MAX_SAFE_INTEGER);
688
+ let bucket = collected.get(pkKey);
689
+ if (!bucket) {
690
+ bucket = [];
691
+ collected.set(pkKey, bucket);
692
+ }
693
+ bucket.push({ name: entityName, priority });
694
+ }
695
+ }
586
696
  /**
587
697
  * Checks whether a given entity matches the target name, or is an ancestor
588
698
  * of the target (i.e., the target is somewhere in its descendant sub-tree).
@@ -710,6 +820,110 @@ export class DatabaseProviderBase extends ProviderBase {
710
820
  /**************************************************************************/
711
821
  // START ---- Record Dependencies
712
822
  /**************************************************************************/
823
+ /**
824
+ * High-volume system audit/telemetry entities excluded when `options.IncludeSystemEntities === false`.
825
+ */
826
+ static { this._systemDependencyEntityNames = new Set([
827
+ 'mj: record changes',
828
+ 'mj: audit logs',
829
+ 'mj: user record logs',
830
+ 'mj: user recordaccess',
831
+ 'mj: entity record documents',
832
+ ]); }
833
+ /**
834
+ * Normalizes `(contextUserOrOptions, options)` so callers can invoke `GetRecordDependencies`
835
+ * with either `(entityName, key, options)` or `(entityName, key, contextUser, options)`.
836
+ */
837
+ resolveRecordDependencyArgs(contextUserOrOptions, options) {
838
+ if (options !== undefined) {
839
+ return { contextUser: contextUserOrOptions, options };
840
+ }
841
+ if (!contextUserOrOptions) {
842
+ return { contextUser: undefined, options: undefined };
843
+ }
844
+ if (contextUserOrOptions instanceof UserInfo ||
845
+ 'UserRoles' in contextUserOrOptions ||
846
+ 'Email' in contextUserOrOptions) {
847
+ return { contextUser: contextUserOrOptions, options: undefined };
848
+ }
849
+ return { contextUser: undefined, options: contextUserOrOptions };
850
+ }
851
+ /**
852
+ * Determines whether `relatedEntity` should be included in a record dependency scan
853
+ * based on `options` (`IncludeSystemEntities`, `EntityNames`) and `contextUser` read permissions.
854
+ */
855
+ shouldIncludeDependencyEntity(relatedEntity, options, contextUser) {
856
+ if (!this.shouldIncludeDependencyEntityName(relatedEntity.Name, options)) {
857
+ return false;
858
+ }
859
+ if (contextUser && typeof relatedEntity.CheckPermissions === 'function') {
860
+ if (!relatedEntity.CheckPermissions(contextUser, EntityPermissionType.Read, false)) {
861
+ return false;
862
+ }
863
+ }
864
+ return true;
865
+ }
866
+ /**
867
+ * Checks name-based dependency filters (`IncludeSystemEntities` and `EntityNames`)
868
+ * when only the entity name is available.
869
+ */
870
+ shouldIncludeDependencyEntityName(relatedEntityName, options) {
871
+ const normalizedName = relatedEntityName.trim().toLowerCase();
872
+ if (options?.IncludeSystemEntities === false) {
873
+ if (DatabaseProviderBase._systemDependencyEntityNames.has(normalizedName)) {
874
+ return false;
875
+ }
876
+ }
877
+ if (options?.EntityNames && options.EntityNames.length > 0) {
878
+ const allowed = options.EntityNames.some(n => n.trim().toLowerCase() === normalizedName);
879
+ if (!allowed) {
880
+ return false;
881
+ }
882
+ }
883
+ return true;
884
+ }
885
+ /**
886
+ * Resolves an `EntityDependency[] | string` argument to an `EntityDependency[]` array.
887
+ * Supports passing either a pre-resolved `EntityDependency[]` list or an `entityName` string.
888
+ */
889
+ resolveEntityDependencies(entityDependenciesOrName) {
890
+ if (Array.isArray(entityDependenciesOrName)) {
891
+ return entityDependenciesOrName;
892
+ }
893
+ const eName = entityDependenciesOrName.trim().toLowerCase();
894
+ const result = [];
895
+ for (const re of this.Entities) {
896
+ const relatedFields = re.Fields.filter(f => f.RelatedEntity?.trim().toLowerCase() === eName && (!f.EntityIDFieldName || f.EntityIDFieldName.trim().length === 0));
897
+ for (const f of relatedFields) {
898
+ result.push({
899
+ EntityName: entityDependenciesOrName,
900
+ RelatedEntityName: re.Name,
901
+ FieldName: f.Name,
902
+ });
903
+ }
904
+ }
905
+ return result;
906
+ }
907
+ /**
908
+ * Selects `relatedEntity.BaseTable` when `BaseTable` is defined, the entity is not virtual,
909
+ * and all referenced columns (`PrimaryKeys` plus `referencedFieldNames`) are physical (`!f.IsVirtual`).
910
+ * Falls back to `relatedEntity.BaseView` when any referenced column is virtual or `BaseTable` is absent.
911
+ */
912
+ resolveDependencySourceObject(relatedEntity, referencedFieldNames) {
913
+ if (!relatedEntity.BaseTable || relatedEntity.BaseTable.trim().length === 0 || relatedEntity.VirtualEntity) {
914
+ return relatedEntity.BaseView;
915
+ }
916
+ if (relatedEntity.PrimaryKeys?.some(pk => pk.IsVirtual === true)) {
917
+ return relatedEntity.BaseView;
918
+ }
919
+ for (const fieldName of referencedFieldNames) {
920
+ const field = relatedEntity.Fields?.find(f => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
921
+ if (field?.IsVirtual === true) {
922
+ return relatedEntity.BaseView;
923
+ }
924
+ }
925
+ return relatedEntity.BaseTable;
926
+ }
713
927
  /**
714
928
  * Returns a list of record-level dependencies — records in other entities linked to
715
929
  * the specified entity/record via foreign keys (hard links) or EntityIDFieldName
@@ -717,10 +931,12 @@ export class DatabaseProviderBase extends ProviderBase {
717
931
  *
718
932
  * @param entityName The entity name to check
719
933
  * @param compositeKey The primary key(s) of the record
720
- * @param contextUser Optional context user
934
+ * @param contextUser Optional context user (or options when called with 3 args)
935
+ * @param options Optional filtering options (`LinkType`, `IncludeSystemEntities`, `EntityNames`)
721
936
  */
722
- async GetRecordDependencies(entityName, compositeKey, contextUser) {
937
+ async GetRecordDependencies(entityName, compositeKey, contextUser, options) {
723
938
  try {
939
+ const { contextUser: resolvedUser, options: resolvedOptions } = this.resolveRecordDependencyArgs(contextUser, options);
724
940
  const recordDependencies = [];
725
941
  // Validate up front. Reporting "no dependencies" for an entity we cannot resolve would be
726
942
  // indistinguishable, to a caller about to delete a record, from "this record is safe to
@@ -728,19 +944,40 @@ export class DatabaseProviderBase extends ProviderBase {
728
944
  if (!this.EntityByName(entityName)) {
729
945
  throw new Error(`Entity ${entityName} not found in metadata`);
730
946
  }
731
- const entityDependencies = await this.GetEntityDependencies(entityName);
732
- // Deliberately NO early return when there are no hard (foreign key) dependents. An entity
733
- // whose only dependents are polymorphic EntityID/RecordID links is precisely the case the
734
- // soft-link query exists to serve, and returning here skipped building it entirely.
735
- const hardSQL = entityDependencies.length > 0 ? this.BuildHardLinkDependencySQL(entityDependencies, compositeKey) : '';
736
- const softSQL = this.BuildSoftLinkDependencySQL(entityName, compositeKey);
947
+ const linkType = resolvedOptions?.LinkType ?? 'both';
948
+ let hardSQL = '';
949
+ if (linkType === 'both' || linkType === 'hard') {
950
+ const rawEntityDependencies = await this.GetEntityDependencies(entityName);
951
+ const entityDependencies = rawEntityDependencies.filter((dep) => {
952
+ const relEntity = this.EntityByName(dep.RelatedEntityName);
953
+ if (relEntity) {
954
+ return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
955
+ }
956
+ return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
957
+ });
958
+ // Deliberately NO early return when there are no hard (foreign key) dependents. An entity
959
+ // whose only dependents are polymorphic EntityID/RecordID links is precisely the case the
960
+ // soft-link query exists to serve, and returning here skipped building it entirely.
961
+ hardSQL = entityDependencies.length > 0
962
+ ? this.BuildHardLinkDependencySQL(entityDependencies, compositeKey, resolvedOptions, resolvedUser)
963
+ : '';
964
+ }
965
+ const softSQL = linkType === 'both' || linkType === 'soft'
966
+ ? this.BuildSoftLinkDependencySQL(entityName, compositeKey, resolvedOptions, resolvedUser)
967
+ : '';
737
968
  const sSQL = [hardSQL, softSQL].filter(s => s.length > 0).join(' UNION ALL ');
738
969
  if (!sSQL)
739
970
  return recordDependencies;
740
- const result = await this.ExecuteSQL(sSQL, undefined, undefined, contextUser);
971
+ const result = await this.ExecuteSQL(sSQL, undefined, undefined, resolvedUser);
741
972
  if (!result || result.length === 0)
742
973
  return recordDependencies;
743
- return this.parseRecordDependencyResults(result);
974
+ return this.parseRecordDependencyResults(result).filter((dep) => {
975
+ const relEntity = this.EntityByName(dep.RelatedEntityName);
976
+ if (relEntity) {
977
+ return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
978
+ }
979
+ return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
980
+ });
744
981
  }
745
982
  catch (e) {
746
983
  LogError(e);
@@ -787,10 +1024,16 @@ export class DatabaseProviderBase extends ProviderBase {
787
1024
  pkeys[parts[0]] = parts.slice(1).join(CompositeKey.DefaultValueDelimiter);
788
1025
  });
789
1026
  depCompositeKey.LoadFromEntityInfoAndRecord(relatedEntityInfo, pkeys);
1027
+ const fieldName = r.FieldName;
1028
+ const fieldInfo = relatedEntityInfo.Fields?.find((f) => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
1029
+ const fieldDisplayName = fieldInfo
1030
+ ? (fieldInfo.DisplayNameOrName || fieldInfo.DisplayName || fieldInfo.Name)
1031
+ : undefined;
790
1032
  recordDependencies.push({
791
1033
  EntityName: r.EntityName,
792
1034
  RelatedEntityName: r.RelatedEntityName,
793
- FieldName: r.FieldName,
1035
+ FieldName: fieldName,
1036
+ FieldDisplayName: fieldDisplayName,
794
1037
  PrimaryKey: depCompositeKey,
795
1038
  // Both dialects emit this literal on every row of the union: 1/true for the polymorphic
796
1039
  // branch, 0/false for the foreign key branch.
@@ -824,6 +1067,27 @@ export class DatabaseProviderBase extends ProviderBase {
824
1067
  async OnBeforeSaveExecute(_entity, _user, _options, _context) {
825
1068
  /* no-op by default */
826
1069
  }
1070
+ /**
1071
+ * Returns true when `Save()` must enlist {@link OnDurableAfterSaveExecute} transactionally
1072
+ * (wrapping direct saves in an {@link EntityTransactionScope}, and awaiting durable action
1073
+ * submission inside `TransactionGroup` save callbacks before group completion).
1074
+ *
1075
+ * Default: `false` (zero extra transaction overhead on ordinary entity saves).
1076
+ */
1077
+ async ShouldEnlistSaveInTransaction(_entity, _user, _options, _isNewRecord) {
1078
+ return false;
1079
+ }
1080
+ /**
1081
+ * Awaited immediately after the save SQL succeeds (inside direct `Save()`'s
1082
+ * {@link EntityTransactionScope} before commit, or inside a `TransactionGroup` item's completion
1083
+ * callback after hydrating DB-generated PKs and before {@link OnAfterSaveExecute}) when
1084
+ * {@link ShouldEnlistSaveInTransaction} returned `true`.
1085
+ * Used by `GenericDatabaseProvider` to enlist durable after-save entity actions atomically in the
1086
+ * same SQL transaction as the entity write.
1087
+ */
1088
+ async OnDurableAfterSaveExecute(_entity, _user, _options, _context) {
1089
+ /* no-op by default */
1090
+ }
827
1091
  /**
828
1092
  * Called after a successful save (both direct and transaction-callback paths).
829
1093
  * Intentionally synchronous (fire-and-forget) — SQL Server overrides to dispatch
@@ -1252,7 +1516,7 @@ export class DatabaseProviderBase extends ProviderBase {
1252
1516
  * because there is nobody to check against.
1253
1517
  */
1254
1518
  CanUserReadRecordName(entity, contextUser) {
1255
- if (!entity.EnableFieldLevelSecurity) {
1519
+ if (!entity.FieldLevelSecurityApplies) {
1256
1520
  return true;
1257
1521
  }
1258
1522
  if (!contextUser) {
@@ -1490,10 +1754,28 @@ export class DatabaseProviderBase extends ProviderBase {
1490
1754
  configurable: true,
1491
1755
  });
1492
1756
  extraData.entityName = entity.EntityInfo.Name;
1493
- entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
1757
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, async (transactionResult, success) => {
1494
1758
  resumeRefresh();
1495
1759
  entityResult.EndedAt = new Date();
1496
1760
  if (success && transactionResult) {
1761
+ if (bNewRecord) {
1762
+ let hydratedPk = false;
1763
+ for (const pkField of entity.EntityInfo.PrimaryKeys) {
1764
+ const fieldObj = entity.Fields.find((f) => f.Name === pkField.Name);
1765
+ if (fieldObj &&
1766
+ (fieldObj.Value === null || fieldObj.Value === undefined) &&
1767
+ transactionResult[pkField.Name] != null) {
1768
+ fieldObj.Value = transactionResult[pkField.Name];
1769
+ hydratedPk = true;
1770
+ }
1771
+ }
1772
+ if (hydratedPk) {
1773
+ entity.PrimaryKey.LoadFromEntityFields(entity.PrimaryKeys);
1774
+ }
1775
+ }
1776
+ if (await this.ShouldEnlistSaveInTransaction(entity, user, options, bNewRecord)) {
1777
+ await this.OnDurableAfterSaveExecute(entity, user, options, saveContext);
1778
+ }
1497
1779
  this.OnAfterSaveExecute(entity, user, options, saveContext);
1498
1780
  entityResult.Success = true;
1499
1781
  const mergedResult = preSaveValues
@@ -1503,59 +1785,94 @@ export class DatabaseProviderBase extends ProviderBase {
1503
1785
  }
1504
1786
  else {
1505
1787
  entityResult.Success = false;
1506
- entityResult.Message = 'Transaction Failed';
1788
+ if (!entityResult.Message) {
1789
+ entityResult.Message = transactionResult instanceof Error
1790
+ ? transactionResult.message
1791
+ : 'Transaction Failed';
1792
+ }
1507
1793
  }
1508
1794
  }));
1509
1795
  return true;
1510
1796
  }
1511
1797
  else {
1512
1798
  // ---- Direct execution path ----
1799
+ const enlistInTx = !bReplay && await this.ShouldEnlistSaveInTransaction(entity, user, options, bNewRecord);
1800
+ const saveTxScope = enlistInTx ? await this.BeginEntityTransaction() : null;
1801
+ let saveTxCommitted = false;
1513
1802
  suspendRefresh();
1514
1803
  let result;
1515
- if (bReplay) {
1516
- result = [entity.GetAll()];
1517
- }
1518
- else {
1519
- const execOptions = this.BuildSaveExecuteOptions(entity, sqlDetails);
1520
- const rawResult = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
1521
- result = await this.PostProcessRows(rawResult, entity.EntityInfo, user);
1522
- }
1523
- resumeRefresh();
1524
- entityResult.EndedAt = new Date();
1525
- if (result && result.length > 0) {
1526
- this.OnAfterSaveExecute(entity, user, options, saveContext);
1527
- entityResult.Success = true;
1528
- // In 'datestamps_only' mode, the SP/query returned only the PK and
1529
- // __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
1530
- // onto the pre-save snapshot so caller-set fields remain intact without
1531
- // falsely marking concurrent in-flight mutations as clean.
1532
- let mergedRow;
1533
- if (preSaveValues) {
1534
- mergedRow = { ...preSaveValues, ...result[0] };
1535
- Object.defineProperty(mergedRow, '__mj_preMerged', {
1536
- value: true,
1537
- enumerable: false,
1538
- configurable: true,
1539
- });
1804
+ try {
1805
+ if (bReplay) {
1806
+ result = [entity.GetAll()];
1540
1807
  }
1541
1808
  else {
1542
- mergedRow = result[0];
1809
+ const execOptions = this.BuildSaveExecuteOptions(entity, sqlDetails);
1810
+ const rawResult = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
1811
+ result = await this.PostProcessRows(rawResult, entity.EntityInfo, user);
1812
+ }
1813
+ resumeRefresh();
1814
+ entityResult.EndedAt = new Date();
1815
+ if (result && result.length > 0) {
1816
+ if (bNewRecord) {
1817
+ let hydratedPk = false;
1818
+ for (const pkField of entity.EntityInfo.PrimaryKeys) {
1819
+ const fieldObj = entity.Fields.find(f => f.Name === pkField.Name);
1820
+ if (fieldObj && (fieldObj.Value === null || fieldObj.Value === undefined) && result[0][pkField.Name] != null) {
1821
+ fieldObj.Value = result[0][pkField.Name];
1822
+ hydratedPk = true;
1823
+ }
1824
+ }
1825
+ if (hydratedPk) {
1826
+ entity.PrimaryKey.LoadFromEntityFields(entity.PrimaryKeys);
1827
+ }
1828
+ }
1829
+ if (enlistInTx) {
1830
+ await this.OnDurableAfterSaveExecute(entity, user, options, saveContext);
1831
+ }
1832
+ if (saveTxScope) {
1833
+ await saveTxScope.Commit();
1834
+ saveTxCommitted = true;
1835
+ }
1543
1836
  }
1544
- // OnSaveCompleted may return patch fields (e.g., updated lat/lng
1545
- // from geocoding) that need to be merged onto the SP result before
1546
- // the entity loads the data via finalizeSave().
1547
- const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
1548
- if (patches) {
1549
- Object.assign(mergedRow, patches);
1837
+ else {
1838
+ if (bNewRecord)
1839
+ throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1840
+ else
1841
+ throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1550
1842
  }
1551
- return mergedRow;
1843
+ }
1844
+ catch (innerErr) {
1845
+ if (saveTxScope && !saveTxCommitted) {
1846
+ await saveTxScope.Rollback();
1847
+ }
1848
+ throw innerErr;
1849
+ }
1850
+ this.OnAfterSaveExecute(entity, user, options, saveContext);
1851
+ entityResult.Success = true;
1852
+ // In 'datestamps_only' mode, the SP/query returned only the PK and
1853
+ // __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
1854
+ // onto the pre-save snapshot so caller-set fields remain intact without
1855
+ // falsely marking concurrent in-flight mutations as clean.
1856
+ let mergedRow;
1857
+ if (preSaveValues) {
1858
+ mergedRow = { ...preSaveValues, ...result[0] };
1859
+ Object.defineProperty(mergedRow, '__mj_preMerged', {
1860
+ value: true,
1861
+ enumerable: false,
1862
+ configurable: true,
1863
+ });
1552
1864
  }
1553
1865
  else {
1554
- if (bNewRecord)
1555
- throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1556
- else
1557
- throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1866
+ mergedRow = result[0];
1867
+ }
1868
+ // OnSaveCompleted may return patch fields (e.g., updated lat/lng
1869
+ // from geocoding) that need to be merged onto the SP result before
1870
+ // the entity loads the data via finalizeSave().
1871
+ const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
1872
+ if (patches) {
1873
+ Object.assign(mergedRow, patches);
1558
1874
  }
1875
+ return mergedRow;
1559
1876
  }
1560
1877
  }
1561
1878
  else {
@@ -1987,6 +2304,11 @@ export class DatabaseProviderBase extends ProviderBase {
1987
2304
  duplicateRun.Set('ProcessingStatus', 'In Progress');
1988
2305
  duplicateRun.Set('ApprovalStatus', 'Pending');
1989
2306
  duplicateRun.Set('SourceListID', listEntity.Get('ID'));
2307
+ // Without a document, the server-side Duplicate Run entity defaults to the entity's
2308
+ // duplicate-detection document.
2309
+ if (params.EntityDocumentID) {
2310
+ duplicateRun.Set('EntityDocumentID', params.EntityDocumentID);
2311
+ }
1990
2312
  const saveResult = await duplicateRun.Save();
1991
2313
  if (!saveResult) {
1992
2314
  throw new Error('Failed to save Duplicate Run Entity');
@@ -2023,6 +2345,13 @@ export class DatabaseProviderBase extends ProviderBase {
2023
2345
  throw new Error(`Record ${key.ToString()} of ${request.EntityName} has a ${child.ChildEntityName} subtype row; merging records another entity extends is not supported yet`);
2024
2346
  }
2025
2347
  }
2348
+ // The field map is client input applied to the surviving record: a caller with any field
2349
+ // denial may name only fields they can read, and a denied name and an unknown name get the
2350
+ // same refusal. Checked before the merge log and the transaction, so a refusal leaves no trace.
2351
+ if (request.FieldMap?.length) {
2352
+ const values = Object.fromEntries(request.FieldMap.map(f => [f.FieldName, f.Value]));
2353
+ AssertClientInputWritableForUser(e, values, contextUser);
2354
+ }
2026
2355
  const result = {
2027
2356
  Success: false,
2028
2357
  RecordMergeLogID: null,