@memberjunction/server 6.2.0-edge.4 → 6.2.0-edge.5
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/README.md +20 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.d.ts +91 -30
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +311 -81
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +13 -1
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/apiRoot.d.ts +32 -0
- package/dist/apiRoot.d.ts.map +1 -0
- package/dist/apiRoot.js +67 -0
- package/dist/apiRoot.js.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
- package/dist/auth/AuthProviderCatalogRouter.js +25 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
- package/dist/auth/LoginScreenEngine.d.ts +22 -0
- package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
- package/dist/auth/LoginScreenEngine.js +57 -0
- package/dist/auth/LoginScreenEngine.js.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/config.d.ts +136 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -4
- package/dist/config.js.map +1 -1
- package/dist/coreRoutes.d.ts +29 -0
- package/dist/coreRoutes.d.ts.map +1 -0
- package/dist/coreRoutes.js +142 -0
- package/dist/coreRoutes.js.map +1 -0
- package/dist/generated/graphql-schemas/__mj.d.ts +369 -3
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +1750 -7
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +43 -8
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +59 -19
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/PushStatusResolver.js +1 -1
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +103 -44
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +264 -305
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/globalCors.d.ts +33 -0
- package/dist/globalCors.d.ts.map +1 -0
- package/dist/globalCors.js +58 -0
- package/dist/globalCors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -160
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js +2 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js +2 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -1
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
- package/dist/realtimeSessions/index.d.ts +2 -0
- package/dist/realtimeSessions/index.d.ts.map +1 -1
- package/dist/realtimeSessions/index.js +2 -0
- package/dist/realtimeSessions/index.js.map +1 -1
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts +30 -0
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js +47 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
- package/dist/realtimeSessions/sessionAccess.d.ts +6 -0
- package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
- package/dist/realtimeSessions/sessionAccess.js +7 -3
- package/dist/realtimeSessions/sessionAccess.js.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.js +9 -7
- package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
- package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.d.ts +24 -3
- package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
- package/dist/resolvers/DatasetResolver.js +57 -8
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +2 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +3 -2
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts +18 -3
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +100 -35
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +82 -11
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +279 -101
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +117 -38
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +6 -3
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +4 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +2 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +13 -0
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +2 -8
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +10 -26
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +35 -6
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
- package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +65 -13
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +19 -2
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +102 -24
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
- package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
- package/dist/resolvers/agent-run-data-guard.js +72 -0
- package/dist/resolvers/agent-run-data-guard.js.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.js +383 -0
- package/dist/rest/RealtimeRecordingHandler.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +21 -12
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts +6 -0
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +6 -0
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/serverExtensionReservedRoots.d.ts +12 -10
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +14 -17
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/dist/services/MJStorageBlobStore.d.ts +8 -1
- package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
- package/dist/services/MJStorageBlobStore.js +25 -19
- package/dist/services/MJStorageBlobStore.js.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +2 -2
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +5 -3
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -5
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +157 -9
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts +54 -2
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +598 -15
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/sharedCache.d.ts.map +1 -1
- package/dist/sharedCache.js +3 -0
- package/dist/sharedCache.js.map +1 -1
- package/package.json +99 -98
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/FetchEntityVectorsResolver.test.ts +163 -15
- package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/LoginScreenEngine.test.ts +86 -0
- package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
- package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +209 -6
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
- package/src/__tests__/RunAIAgentResolver.branch.test.ts +1 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +5 -3
- package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +258 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
- package/src/__tests__/SessionJanitor.test.ts +365 -10
- package/src/__tests__/SessionManager.test.ts +27 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
- package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
- package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +356 -22
- package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
- package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/agent-run-data-guard.test.ts +130 -0
- package/src/__tests__/apiRootBoot.test.ts +202 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/conversationRunEvents.test.ts +56 -14
- package/src/__tests__/globalCors.test.ts +217 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/ownedRealtimeSession.test.ts +39 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +7 -0
- package/src/__tests__/realtimeRecordingHandler.test.ts +652 -0
- package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
- package/src/__tests__/sharedCache.test.ts +5 -3
- package/src/agentSessions/SessionJanitor.ts +369 -93
- package/src/agentSessions/SessionManager.ts +13 -1
- package/src/apiRoot.ts +78 -0
- package/src/auth/AuthProviderCatalogRouter.ts +26 -0
- package/src/auth/LoginScreenEngine.ts +50 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
- package/src/config.ts +46 -5
- package/src/coreRoutes.ts +166 -0
- package/src/generated/graphql-schemas/__mj.ts +1273 -10
- package/src/generic/CacheInvalidationResolver.ts +73 -18
- package/src/generic/PushStatusResolver.ts +1 -1
- package/src/generic/ResolverBase.ts +304 -325
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/globalCors.ts +71 -0
- package/src/index.ts +116 -174
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
- package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
- package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
- package/src/realtimeSessions/index.ts +2 -0
- package/src/realtimeSessions/ownedRealtimeSession.ts +55 -0
- package/src/realtimeSessions/sessionAccess.ts +7 -3
- package/src/resolvers/ArtifactFileResolver.ts +9 -8
- package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +3 -2
- package/src/resolvers/FetchEntityVectorsResolver.ts +121 -39
- package/src/resolvers/FileResolver.ts +321 -106
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +6 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +4 -2
- package/src/resolvers/QueryResolver.ts +11 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +10 -33
- package/src/resolvers/RunAIAgentResolver.ts +39 -4
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
- package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
- package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
- package/src/resolvers/TestQuerySQLResolver.ts +68 -13
- package/src/resolvers/TransactionGroupResolver.ts +131 -27
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/agent-run-data-guard.ts +89 -0
- package/src/rest/RealtimeRecordingHandler.ts +464 -0
- package/src/rest/ViewOperationsHandler.ts +29 -13
- package/src/rest/setupRESTEndpoints.ts +6 -0
- package/src/serverExtensionReservedRoots.ts +14 -17
- package/src/services/MJStorageBlobStore.ts +31 -30
- package/src/services/StartTaskGraphDispatcher.ts +2 -2
- package/src/services/TaskGraphActionRunner.ts +25 -5
- package/src/services/TaskGraphAgentRunner.ts +188 -11
- package/src/services/TaskGraphContinuationDeliverer.ts +704 -14
- package/src/sharedCache.ts +3 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, EntityPermissionType, KeyValuePair, LogDebug, LogError, LogStatus, Metadata, ReadableFieldsTransportKey, RecordChangeFieldSecurityProjector, RecordChangePayloadFields, } from '@memberjunction/core';
|
|
2
|
-
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
2
|
+
import { ClientClauseScreen, UserCache } from '@memberjunction/generic-database-provider';
|
|
3
3
|
import { AuthorizationError } from 'type-graphql';
|
|
4
4
|
import { GraphQLError } from 'graphql';
|
|
5
5
|
import { RefusalExtensions } from './refusalExtensions.js';
|
|
@@ -7,13 +7,22 @@ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
|
7
7
|
import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
8
8
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
9
9
|
import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
10
|
-
import { SQLParser } from '@memberjunction/sql-parser';
|
|
11
|
-
import { PostgreSQLDialect, SQLServerDialect } from '@memberjunction/sql-dialect';
|
|
10
|
+
import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
|
|
12
11
|
import { EncryptionEngine, IsEncryptionKeyUnavailable, ReportEncryptionKeyUnavailable } from '@memberjunction/encryption';
|
|
13
12
|
import { PublishStatusUpdate } from './PushStatusResolver.js';
|
|
14
13
|
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
15
14
|
import { PubSubManager } from './PubSubManager.js';
|
|
16
15
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
16
|
+
/**
|
|
17
|
+
* Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
|
|
18
|
+
* do not track record changes: their server-side write rules compare against the prior state, which a client could
|
|
19
|
+
* otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
|
|
20
|
+
*/
|
|
21
|
+
const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW = new Set([
|
|
22
|
+
'mj: record changes',
|
|
23
|
+
'mj: conversation details',
|
|
24
|
+
'mj: data context items',
|
|
25
|
+
]);
|
|
17
26
|
export class ResolverBase {
|
|
18
27
|
static { this._emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null; }
|
|
19
28
|
static { this._cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {}; }
|
|
@@ -51,131 +60,123 @@ export class ResolverBase {
|
|
|
51
60
|
* @param contextUser - Optional user context for decryption (required for encrypted fields)
|
|
52
61
|
* @returns A new object in transport shape, or null when there is nothing to map
|
|
53
62
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Precomputed per-entity / per-request field-mapping plan shared across rows in
|
|
65
|
+
* {@link MapFieldNamesToCodeNames} and {@link ArrayMapFieldNamesToCodeNames}.
|
|
66
|
+
*/
|
|
67
|
+
buildFieldMappingPlan(entityName, contextUser, provider, deniedReadFields, recordChangeProjector) {
|
|
68
|
+
const md = provider ?? new Metadata();
|
|
69
|
+
const entityInfo = md.EntityByName(entityName);
|
|
70
|
+
if (!entityInfo)
|
|
71
|
+
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
72
|
+
const denied = deniedReadFields ?? (entityInfo.EnableFieldLevelSecurity && contextUser
|
|
73
|
+
? entityInfo.GetDeniedReadFields(contextUser)
|
|
74
|
+
: null);
|
|
75
|
+
const mapper = new FieldMapper();
|
|
76
|
+
const fieldsToProcess = [];
|
|
77
|
+
for (const f of entityInfo.Fields) {
|
|
78
|
+
const mappedFieldName = mapper.MapFieldName(f.CodeName);
|
|
79
|
+
const isDenied = Boolean(denied?.has(f.Name.trim().toLowerCase()));
|
|
80
|
+
if (isDenied || mappedFieldName !== f.Name) {
|
|
81
|
+
fieldsToProcess.push({ name: f.Name, mappedFieldName, isDenied });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
85
|
+
const projector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
|
|
86
|
+
? (recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser))
|
|
87
|
+
: undefined;
|
|
88
|
+
const encryptedFields = entityInfo.EncryptedFields ?? [];
|
|
89
|
+
const checkBinary = entityInfo.HasBinaryFields !== false;
|
|
90
|
+
return {
|
|
91
|
+
entityInfo,
|
|
92
|
+
fieldsToProcess,
|
|
93
|
+
readableFields,
|
|
94
|
+
projector,
|
|
95
|
+
encryptedFields,
|
|
96
|
+
checkBinary,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Applies synchronous field renaming, FLS stripping, readable-fields transport stamping, and
|
|
101
|
+
* Record Changes payload projection for a single row using a precomputed mapping plan.
|
|
102
|
+
*/
|
|
103
|
+
mapRowSynchronously(dataObject, plan) {
|
|
56
104
|
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
57
105
|
return null;
|
|
58
106
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!entityInfo)
|
|
73
|
-
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
74
|
-
// const fields = entityInfo.Fields.filter((f) => f.Name !== f.CodeName || f.Name.startsWith('__mj_'));
|
|
75
|
-
// FIELD-LEVEL SECURITY — this is the authoritative read boundary for every GraphQL
|
|
76
|
-
// return path (single-record resolvers, external-data-source loads, and anything else
|
|
77
|
-
// routed through here), sitting exactly where encryption masking already lives.
|
|
78
|
-
//
|
|
79
|
-
// The denied set is normally computed ONCE per (entity, user) by the caller and passed
|
|
80
|
-
// in: this method runs once per ROW, so resolving it here would cost fields x rows
|
|
81
|
-
// aggregations. We fall back to computing it when a caller doesn't supply it (the
|
|
82
|
-
// single-record resolvers, where "once per row" and "once per request" are the same
|
|
83
|
-
// thing) rather than failing open — a missing argument must never mean missing security.
|
|
84
|
-
const denied = deniedReadFields ?? (entityInfo.EnableFieldLevelSecurity && contextUser
|
|
85
|
-
? entityInfo.GetDeniedReadFields(contextUser)
|
|
86
|
-
: null);
|
|
87
|
-
const mapper = new FieldMapper();
|
|
88
|
-
entityInfo.Fields.forEach((f) => {
|
|
89
|
-
if (dataObject.hasOwnProperty(f.Name)) {
|
|
90
|
-
// GraphQL doesn't allow us to pass back fields with __ so we are mapping our special field cases that start with __mj_ to _mj__ for transport - they are converted back on the other side automatically
|
|
91
|
-
const mappedFieldName = mapper.MapFieldName(f.CodeName);
|
|
92
|
-
if (denied?.has(f.Name.trim().toLowerCase())) {
|
|
93
|
-
// Omit entirely rather than nulling — a null is indistinguishable from a real
|
|
94
|
-
// null value, and the client should see the field as absent, not as empty.
|
|
95
|
-
// Both key shapes are removed because callers reach this method with rows keyed
|
|
96
|
-
// either way depending on whether mapping has already run.
|
|
97
|
-
delete dataObject[f.Name];
|
|
98
|
-
delete dataObject[mappedFieldName];
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (mappedFieldName !== f.Name) {
|
|
102
|
-
dataObject[mappedFieldName] = dataObject[f.Name];
|
|
103
|
-
delete dataObject[f.Name];
|
|
104
|
-
}
|
|
107
|
+
let row = plan.checkBinary
|
|
108
|
+
? ReplaceByteArraysWithBase64({ ...dataObject })
|
|
109
|
+
: { ...dataObject };
|
|
110
|
+
for (let i = 0; i < plan.fieldsToProcess.length; i++) {
|
|
111
|
+
const fp = plan.fieldsToProcess[i];
|
|
112
|
+
if (Object.prototype.hasOwnProperty.call(row, fp.name)) {
|
|
113
|
+
if (fp.isDenied) {
|
|
114
|
+
delete row[fp.name];
|
|
115
|
+
delete row[fp.mappedFieldName];
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
row[fp.mappedFieldName] = row[fp.name];
|
|
119
|
+
delete row[fp.name];
|
|
105
120
|
}
|
|
106
|
-
});
|
|
107
|
-
// Field security, second half: say IN-BAND which fields this caller may read. Deleting the
|
|
108
|
-
// key above is not enough on its own — GraphQL emits every SELECTED field regardless, so a
|
|
109
|
-
// denied field the client asked for arrives as an explicit null it cannot tell apart from a
|
|
110
|
-
// genuine one. The client must not settle that from its own metadata: during the window
|
|
111
|
-
// after a permission change its copy disagrees with ours, and once metadata filtering lands
|
|
112
|
-
// (issue #3485) it may not hold the permission rows at all. Null for unrestricted callers,
|
|
113
|
-
// which is nearly every request.
|
|
114
|
-
const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
115
|
-
if (readableFields) {
|
|
116
|
-
dataObject[ReadableFieldsTransportKey] = readableFields;
|
|
117
|
-
}
|
|
118
|
-
// Field security, third part — the audit trail. A 'MJ: Record Changes' row carries the old
|
|
119
|
-
// and new values of ANOTHER entity's fields, and its own EnableFieldLevelSecurity flag is
|
|
120
|
-
// off, so everything above is a no-op for it and the payload would flow out untouched. The
|
|
121
|
-
// projector recomputes the denied set against the entity each row is about; see
|
|
122
|
-
// RecordChangeFieldSecurityProjector for what each payload column gets.
|
|
123
|
-
//
|
|
124
|
-
// Same "a missing argument must never mean missing security" rule as the denied set above:
|
|
125
|
-
// the projector is normally built ONCE per request by the array caller and passed in, and
|
|
126
|
-
// we build one here when it wasn't — the single-record resolvers, where once-per-row and
|
|
127
|
-
// once-per-request are the same thing.
|
|
128
|
-
if (contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
|
|
129
|
-
const projector = recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser);
|
|
130
|
-
dataObject = projector.ProjectRow(dataObject);
|
|
131
121
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
122
|
+
}
|
|
123
|
+
if (plan.readableFields) {
|
|
124
|
+
row[ReadableFieldsTransportKey] = plan.readableFields;
|
|
125
|
+
}
|
|
126
|
+
if (plan.projector) {
|
|
127
|
+
row = plan.projector.ProjectRow(row);
|
|
128
|
+
}
|
|
129
|
+
return row;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Decrypts or masks encrypted fields on a mapped row when `plan.encryptedFields.length > 0`.
|
|
133
|
+
*/
|
|
134
|
+
async decryptRowEncryptedFields(row, encryptedFields, contextUser) {
|
|
135
|
+
for (const field of encryptedFields) {
|
|
136
|
+
const fieldName = field.CodeName;
|
|
137
|
+
const value = row[fieldName];
|
|
138
|
+
if (value === null || value === undefined)
|
|
139
|
+
continue;
|
|
140
|
+
const engine = EncryptionEngine.Instance;
|
|
141
|
+
await engine.Config(false, contextUser);
|
|
142
|
+
const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
|
|
143
|
+
if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
|
|
144
|
+
if (field.AllowDecryptInAPI) {
|
|
145
|
+
if (contextUser) {
|
|
146
|
+
try {
|
|
147
|
+
const decryptedValue = await engine.Decrypt(value, contextUser);
|
|
148
|
+
row[fieldName] = decryptedValue;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (IsEncryptionKeyUnavailable(err)) {
|
|
152
|
+
ReportEncryptionKeyUnavailable(err);
|
|
163
153
|
}
|
|
164
154
|
else {
|
|
165
|
-
|
|
166
|
-
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
155
|
+
LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
|
|
167
156
|
}
|
|
157
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
168
158
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
// else: AllowDecryptInAPI=false and SendEncryptedValue=true - keep encrypted value as-is
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
174
162
|
}
|
|
175
163
|
}
|
|
164
|
+
else if (!field.SendEncryptedValue) {
|
|
165
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
166
|
+
}
|
|
176
167
|
}
|
|
177
168
|
}
|
|
178
|
-
|
|
169
|
+
}
|
|
170
|
+
async MapFieldNamesToCodeNames(entityName, dataObject, contextUser, provider, deniedReadFields, recordChangeProjector) {
|
|
171
|
+
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const plan = this.buildFieldMappingPlan(entityName, contextUser, provider, deniedReadFields, recordChangeProjector);
|
|
175
|
+
const mapped = this.mapRowSynchronously(dataObject, plan);
|
|
176
|
+
if (mapped && plan.encryptedFields.length > 0) {
|
|
177
|
+
await this.decryptRowEncryptedFields(mapped, plan.encryptedFields, contextUser);
|
|
178
|
+
}
|
|
179
|
+
return mapped;
|
|
179
180
|
}
|
|
180
181
|
/**
|
|
181
182
|
* Reverse-maps GraphQL-safe field names back to entity CodeNames in a mutation input object.
|
|
@@ -232,26 +233,17 @@ export class ResolverBase {
|
|
|
232
233
|
if (!dataObjectArray || dataObjectArray.length === 0) {
|
|
233
234
|
return dataObjectArray;
|
|
234
235
|
}
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// span many — so the projector, which owns that memo, must outlive the row loop. Built once
|
|
247
|
-
// here for the same reason the denied set above is: GetDeniedReadFields walks every field on
|
|
248
|
-
// an entity and must never be called per row.
|
|
249
|
-
const recordChangeProjector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
|
|
250
|
-
? new RecordChangeFieldSecurityProjector(md, contextUser)
|
|
251
|
-
: undefined;
|
|
252
|
-
const mapped = [];
|
|
253
|
-
for (const element of dataObjectArray) {
|
|
254
|
-
mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider, deniedReadFields, recordChangeProjector));
|
|
236
|
+
// Precompute the entity lookup, FieldMapper plan, denied-fields set, readable-fields list,
|
|
237
|
+
// and Record Changes projector ONCE for the whole array rather than per row.
|
|
238
|
+
const plan = this.buildFieldMappingPlan(entityName, contextUser, provider);
|
|
239
|
+
const hasEncryptedFields = plan.encryptedFields.length > 0;
|
|
240
|
+
const mapped = new Array(dataObjectArray.length);
|
|
241
|
+
for (let i = 0; i < dataObjectArray.length; i++) {
|
|
242
|
+
const row = this.mapRowSynchronously(dataObjectArray[i], plan);
|
|
243
|
+
if (row && hasEncryptedFields) {
|
|
244
|
+
await this.decryptRowEncryptedFields(row, plan.encryptedFields, contextUser);
|
|
245
|
+
}
|
|
246
|
+
mapped[i] = row;
|
|
255
247
|
}
|
|
256
248
|
return mapped;
|
|
257
249
|
}
|
|
@@ -307,9 +299,11 @@ export class ResolverBase {
|
|
|
307
299
|
* @param dataObject - The data object containing field values
|
|
308
300
|
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
309
301
|
* @param contextUser - User context for encryption operations
|
|
302
|
+
* @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
|
|
303
|
+
* default) or field Names (raw provider rows, such as dataset item results)
|
|
310
304
|
* @returns The filtered data object
|
|
311
305
|
*/
|
|
312
|
-
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider) {
|
|
306
|
+
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider, fieldKey = 'CodeName') {
|
|
313
307
|
if (!dataObject)
|
|
314
308
|
return dataObject;
|
|
315
309
|
const md = provider ?? new Metadata();
|
|
@@ -322,7 +316,7 @@ export class ResolverBase {
|
|
|
322
316
|
return dataObject;
|
|
323
317
|
// Process each encrypted field
|
|
324
318
|
for (const field of encryptedFields) {
|
|
325
|
-
const fieldName = field.CodeName;
|
|
319
|
+
const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
|
|
326
320
|
const value = dataObject[fieldName];
|
|
327
321
|
// Skip null/undefined values
|
|
328
322
|
if (value === null || value === undefined)
|
|
@@ -358,9 +352,10 @@ export class ResolverBase {
|
|
|
358
352
|
return dataObject;
|
|
359
353
|
}
|
|
360
354
|
/**
|
|
361
|
-
* Filters encrypted fields for an array of data objects
|
|
355
|
+
* Filters encrypted fields for an array of data objects. `fieldKey` is as for
|
|
356
|
+
* {@link FilterEncryptedFieldsForAPI}.
|
|
362
357
|
*/
|
|
363
|
-
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider) {
|
|
358
|
+
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider, fieldKey = 'CodeName') {
|
|
364
359
|
if (!dataObjectArray || dataObjectArray.length === 0)
|
|
365
360
|
return dataObjectArray;
|
|
366
361
|
// Check if entity has any encrypted fields first to avoid unnecessary processing
|
|
@@ -373,7 +368,7 @@ export class ResolverBase {
|
|
|
373
368
|
return dataObjectArray;
|
|
374
369
|
// Process each element
|
|
375
370
|
for (const element of dataObjectArray) {
|
|
376
|
-
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
|
|
371
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
|
|
377
372
|
}
|
|
378
373
|
return dataObjectArray;
|
|
379
374
|
}
|
|
@@ -732,48 +727,59 @@ export class ResolverBase {
|
|
|
732
727
|
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
733
728
|
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
734
729
|
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
735
|
-
* and catalogs are rejected. Server-internal RunView callers
|
|
730
|
+
* and catalogs are rejected. Server-internal RunView callers get only the provider's
|
|
731
|
+
* statement-separator check, which cannot refuse a legitimate filter.
|
|
732
|
+
*
|
|
733
|
+
* The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
|
|
734
|
+
* with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
|
|
735
|
+
*
|
|
736
|
+
* Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
|
|
737
|
+
* provider and the tenant hook splice it inside parentheses and AND their own filters after
|
|
738
|
+
* it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
|
|
739
|
+
* security, API-key row filters and tenant scope on one side of an OR the caller controls.
|
|
740
|
+
* The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
|
|
741
|
+
* A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
|
|
742
|
+
* inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
|
|
743
|
+
* there, so the text after it could be code that the parse-based checks never saw.
|
|
736
744
|
*
|
|
737
745
|
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
738
|
-
* into the view
|
|
739
|
-
*
|
|
746
|
+
* into the view, and RunView removes denied fields from its own result only. A subquery reads a
|
|
747
|
+
* base view directly with neither, so the screen refuses one over any entity the user reads under
|
|
748
|
+
* a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
|
|
749
|
+
* entity included.
|
|
740
750
|
*/
|
|
741
751
|
assertClientClauseUsesEntityBaseViews(clause, label, provider, user, options) {
|
|
742
752
|
if (!clause?.trim())
|
|
743
753
|
return;
|
|
754
|
+
// The balance and backslash checks run before the shared screen: it parses the fragment
|
|
755
|
+
// inside its own parentheses, so it cannot see a fragment that closes the parentheses the
|
|
756
|
+
// provider and the tenant hook splice it into.
|
|
744
757
|
const dialect = this.dialectForProvider(provider);
|
|
745
758
|
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
746
759
|
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
747
760
|
}
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const parser = new SQLParser(wrapped, dialect);
|
|
752
|
-
// A clause the provider checks again on its own (an aggregate expression) leaves text the parser
|
|
753
|
-
// cannot read to that per-item check instead of failing the whole request.
|
|
754
|
-
if (!parser.IsValid && options?.LeaveUnparseableToProvider)
|
|
755
|
-
return;
|
|
756
|
-
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
757
|
-
throw new Error(`Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`);
|
|
761
|
+
const balance = IsBalancedSQLFragment(clause, dialect);
|
|
762
|
+
if (!balance.IsBalanced) {
|
|
763
|
+
throw new Error(`Invalid ${label}: ${balance.Reason}`);
|
|
758
764
|
}
|
|
759
|
-
if (this.
|
|
760
|
-
throw new Error(`Invalid ${label}:
|
|
761
|
-
}
|
|
762
|
-
const allowed = this.entityBaseViewAllowList(provider);
|
|
763
|
-
const tables = SQLParser.ExtractTableRefs(wrapped, dialect);
|
|
764
|
-
for (const t of tables) {
|
|
765
|
-
const table = this.stripSqlIdent(t.TableName);
|
|
766
|
-
const schema = this.stripSqlIdent(t.SchemaName);
|
|
767
|
-
if (table.toLowerCase() === '__mj_clause_screen')
|
|
768
|
-
continue;
|
|
769
|
-
this.assertTableRefReadable(allowed, schema, table, label, user);
|
|
765
|
+
if (this.hasBackslashInQuotes(clause, dialect)) {
|
|
766
|
+
throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
|
|
770
767
|
}
|
|
768
|
+
ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
|
|
771
769
|
}
|
|
772
770
|
/**
|
|
773
771
|
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
774
772
|
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
775
|
-
* entity BaseView the acting user can read UNSCOPED.
|
|
776
|
-
*
|
|
773
|
+
* entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
|
|
774
|
+
* CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
|
|
775
|
+
* own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
|
|
776
|
+
* for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
|
|
777
|
+
* Fails closed: no user, or an unresolvable reference, refuses.
|
|
778
|
+
*
|
|
779
|
+
* The references come from a walk of the whole parse tree
|
|
780
|
+
* (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
|
|
781
|
+
* account for every SELECT in the text, or when a FROM item names no table (a table function or
|
|
782
|
+
* a row list).
|
|
777
783
|
*
|
|
778
784
|
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
779
785
|
* row-level security and denied-field projection, and raw SQL applies neither. Rather than try
|
|
@@ -786,6 +792,7 @@ export class ResolverBase {
|
|
|
786
792
|
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
787
793
|
}
|
|
788
794
|
const dialect = this.dialectForProvider(provider);
|
|
795
|
+
ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
|
|
789
796
|
const parser = new SQLParser(sqlText, dialect);
|
|
790
797
|
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
791
798
|
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
@@ -793,51 +800,20 @@ export class ResolverBase {
|
|
|
793
800
|
if (this.astContainsWriteNode(parser.AST)) {
|
|
794
801
|
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
795
802
|
}
|
|
796
|
-
const
|
|
797
|
-
|
|
803
|
+
const reads = ClientClauseScreen.CollectStatementReads(parser);
|
|
804
|
+
ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
|
|
798
805
|
const allowed = this.entityBaseViewAllowList(provider);
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
const table = this.stripSqlIdent(t.TableName);
|
|
802
|
-
const schema = this.stripSqlIdent(t.SchemaName);
|
|
803
|
-
if (this.isUnqualifiedSchema(schema) && cteNames.has(table.toLowerCase()))
|
|
806
|
+
for (const relation of reads.Relations) {
|
|
807
|
+
if (relation.IsCTEReference)
|
|
804
808
|
continue;
|
|
805
|
-
const entity =
|
|
806
|
-
|
|
809
|
+
const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
|
|
810
|
+
if (entity)
|
|
811
|
+
this.assertUnscopedRead(entity, user, label);
|
|
807
812
|
}
|
|
808
813
|
}
|
|
809
|
-
/** Refuses an entity whose read is narrowed for this user
|
|
814
|
+
/** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
|
|
810
815
|
assertUnscopedRead(entity, user, label) {
|
|
811
|
-
|
|
812
|
-
throw new Error(`Invalid ${label}: entity '${entity.Name}' is row-level-security filtered for you — ad-hoc SQL cannot apply that filter; use RunView`);
|
|
813
|
-
}
|
|
814
|
-
if (entity.GetDeniedReadFields(user).size > 0) {
|
|
815
|
-
throw new Error(`Invalid ${label}: entity '${entity.Name}' has fields you are not permitted to read — ad-hoc SQL cannot project them away; use RunView`);
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
/** Walks a parsed AST collecting the names of CTEs the statement itself defines. */
|
|
819
|
-
collectCTENames(node, names) {
|
|
820
|
-
if (!node || typeof node !== 'object')
|
|
821
|
-
return;
|
|
822
|
-
if (Array.isArray(node)) {
|
|
823
|
-
for (const n of node)
|
|
824
|
-
this.collectCTENames(n, names);
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
const obj = node;
|
|
828
|
-
const withClause = obj.with;
|
|
829
|
-
if (Array.isArray(withClause)) {
|
|
830
|
-
for (const cte of withClause) {
|
|
831
|
-
const name = cte?.name;
|
|
832
|
-
if (typeof name === 'string')
|
|
833
|
-
names.add(name.toLowerCase());
|
|
834
|
-
else if (name && typeof name === 'object' && typeof name.value === 'string') {
|
|
835
|
-
names.add(name.value.toLowerCase());
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
for (const v of Object.values(obj))
|
|
840
|
-
this.collectCTENames(v, names);
|
|
816
|
+
ClientClauseScreen.AssertUnscopedRead(entity, user, label);
|
|
841
817
|
}
|
|
842
818
|
/**
|
|
843
819
|
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
@@ -874,90 +850,23 @@ export class ResolverBase {
|
|
|
874
850
|
this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
|
|
875
851
|
}
|
|
876
852
|
}
|
|
877
|
-
/**
|
|
853
|
+
/**
|
|
854
|
+
* True when a string literal or quoted identifier in `clause`, read the way the database reads
|
|
855
|
+
* it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
|
|
856
|
+
*/
|
|
857
|
+
hasBackslashInQuotes(clause, dialect) {
|
|
858
|
+
return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
|
|
859
|
+
}
|
|
860
|
+
/** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
|
|
878
861
|
astContainsWriteNode(node) {
|
|
879
|
-
|
|
880
|
-
return false;
|
|
881
|
-
if (Array.isArray(node))
|
|
882
|
-
return node.some((n) => this.astContainsWriteNode(n));
|
|
883
|
-
const obj = node;
|
|
884
|
-
const type = obj.type;
|
|
885
|
-
if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
|
|
886
|
-
return true;
|
|
887
|
-
}
|
|
888
|
-
return Object.values(obj).some((v) => this.astContainsWriteNode(v));
|
|
862
|
+
return ClientClauseScreen.AstContainsWriteNode(node);
|
|
889
863
|
}
|
|
890
|
-
static { this.WRITE_NODE_TYPES = new Set([
|
|
891
|
-
'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
|
|
892
|
-
'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
|
|
893
|
-
]); }
|
|
894
864
|
dialectForProvider(provider) {
|
|
895
|
-
|
|
896
|
-
if (/postgres/i.test(name))
|
|
897
|
-
return new PostgreSQLDialect();
|
|
898
|
-
return new SQLServerDialect();
|
|
899
|
-
}
|
|
900
|
-
/** True for a reference with no schema — which `SQLParser.ExtractTableRefs` reports as `dbo`. */
|
|
901
|
-
isUnqualifiedSchema(schema) {
|
|
902
|
-
return !schema || schema.toLowerCase() === 'dbo';
|
|
865
|
+
return ClientClauseScreen.DialectFor(provider);
|
|
903
866
|
}
|
|
904
|
-
|
|
905
|
-
if (!name)
|
|
906
|
-
return '';
|
|
907
|
-
return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
|
908
|
-
}
|
|
909
|
-
/**
|
|
910
|
-
* `bare` maps an unqualified view name to its entity, or to `null` when the name exists in
|
|
911
|
-
* more than one schema — the database, not this screen, would decide which one runs.
|
|
912
|
-
*/
|
|
867
|
+
/** The entity base-view allow-list for a provider's entities. */
|
|
913
868
|
entityBaseViewAllowList(provider) {
|
|
914
|
-
|
|
915
|
-
const bare = new Map();
|
|
916
|
-
const entities = provider?.Entities ?? [];
|
|
917
|
-
for (const e of entities) {
|
|
918
|
-
const view = this.stripSqlIdent(e.BaseView);
|
|
919
|
-
if (!view)
|
|
920
|
-
continue;
|
|
921
|
-
const schema = this.stripSqlIdent(e.SchemaName);
|
|
922
|
-
const bareKey = view.toLowerCase();
|
|
923
|
-
bare.set(bareKey, bare.has(bareKey) ? null : e);
|
|
924
|
-
if (schema)
|
|
925
|
-
qualified.set(`${schema}.${view}`.toLowerCase(), e);
|
|
926
|
-
}
|
|
927
|
-
return { qualified, bare };
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* SECURITY — resolves a table reference against the entity BaseView allow-list and, when an
|
|
931
|
-
* acting user is supplied, additionally requires that user to hold CanRead on the referenced
|
|
932
|
-
* entity. Base views do not embed RLS and entity permissions are otherwise checked only on
|
|
933
|
-
* the TOP entity of a request, so without this check a subquery (or ad-hoc query) could read
|
|
934
|
-
* entities the caller has no read grant on.
|
|
935
|
-
*
|
|
936
|
-
* A schema-qualified reference must match that exact schema — `secret.vwFoo` never resolves
|
|
937
|
-
* through another schema's `vwFoo`. An unqualified reference resolves only when the view name
|
|
938
|
-
* is unique across schemas; an ambiguous one must be qualified, because the database's
|
|
939
|
-
* default-schema resolution, not this screen, would pick which view actually runs.
|
|
940
|
-
*
|
|
941
|
-
* `SQLParser.ExtractTableRefs` reports an unqualified reference as schema `dbo`, so `dbo` is
|
|
942
|
-
* indistinguishable from "no schema" here and both take the unqualified path (an exact
|
|
943
|
-
* `dbo.<view>` entity wins first).
|
|
944
|
-
*/
|
|
945
|
-
assertTableRefReadable(allowed, schema, table, label, user) {
|
|
946
|
-
const qualified = allowed.qualified.get(`${schema}.${table}`.toLowerCase());
|
|
947
|
-
const entity = this.isUnqualifiedSchema(schema) ? qualified ?? allowed.bare.get(table.toLowerCase()) : qualified;
|
|
948
|
-
if (entity === null) {
|
|
949
|
-
throw new Error(`Invalid ${label}: '${table}' is a base view in more than one schema — qualify it with its schema`);
|
|
950
|
-
}
|
|
951
|
-
if (!entity) {
|
|
952
|
-
throw new Error(`Invalid ${label}: subquery must use an entity base view, not '${schema ? schema + '.' : ''}${table}'`);
|
|
953
|
-
}
|
|
954
|
-
if (user) {
|
|
955
|
-
const perms = entity.GetUserPermisions(user);
|
|
956
|
-
if (!perms.CanRead) {
|
|
957
|
-
throw new Error(`Invalid ${label}: you do not have read permission on entity '${entity.Name}' referenced by '${schema ? schema + '.' : ''}${table}'`);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
return entity;
|
|
869
|
+
return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
|
|
961
870
|
}
|
|
962
871
|
/**
|
|
963
872
|
* Optimized RunViewGenericInternal implementation with:
|
|
@@ -1571,13 +1480,35 @@ export class ResolverBase {
|
|
|
1571
1480
|
extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
|
|
1572
1481
|
});
|
|
1573
1482
|
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Checks whether `oldValues` (`input.OldValues___`) contains every writable in-table field of
|
|
1485
|
+
* `entityInfo` (plus primary keys).
|
|
1486
|
+
*
|
|
1487
|
+
* With the delta-only `Save()` wire protocol, `GraphQLDataProvider.Save()` sends only primary keys
|
|
1488
|
+
* and dirty writable fields in `vars.input` and `OldValues___` on normal updates. Hydrating an
|
|
1489
|
+
* entity directly from a partial-delta `OldValues___` via `entityObject.LoadFromData(oldValues)`
|
|
1490
|
+
* (without first loading the authoritative row via `InnerLoad`) would leave every untouched column
|
|
1491
|
+
* `NotLoaded = true` (or `null`), causing `spUpdate` or custom `Validate()` hooks to see incomplete
|
|
1492
|
+
* state. Returning `false` for partial deltas ensures {@link MustLoadTruthFromDatabase} routes
|
|
1493
|
+
* delta updates through `InnerLoad(cKey)` + {@link TestAndSetClientOldValuesToDBValues}, even on
|
|
1494
|
+
* entities with `TrackRecordChanges === false`.
|
|
1495
|
+
*/
|
|
1496
|
+
hasCompleteOldValues(entityInfo, oldValues) {
|
|
1497
|
+
if (!oldValues || oldValues.length === 0) {
|
|
1498
|
+
return false;
|
|
1499
|
+
}
|
|
1500
|
+
const providedKeys = new Set(oldValues.map((kv) => kv.Key));
|
|
1501
|
+
return entityInfo.Fields.every((f) => (!f.IsPrimaryKey && (f.ReadOnly || f.IsVirtual)) || providedKeys.has(f.Name) || providedKeys.has(f.CodeName));
|
|
1502
|
+
}
|
|
1574
1503
|
/**
|
|
1575
1504
|
* Whether `UpdateRecord` must hydrate the entity from the DATABASE rather than from the
|
|
1576
1505
|
* client's `OldValues___`.
|
|
1577
1506
|
*
|
|
1578
|
-
* The `OldValues___` path exists as an optimization
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1507
|
+
* The `OldValues___` path exists as an optimization when a client supplies the complete pre-save
|
|
1508
|
+
* record (e.g. `IgnoreDirtyState: true`) and nothing else requires the database's authoritative prior
|
|
1509
|
+
* state. Under the delta-only `Save()` wire protocol, normal updates send only primary keys and dirty
|
|
1510
|
+
* writable fields in `OldValues___`, so {@link hasCompleteOldValues} returns `false` and forces the
|
|
1511
|
+
* database load (`InnerLoad`) so untouched columns retain their stored values.
|
|
1581
1512
|
*
|
|
1582
1513
|
* `EnableFieldLevelSecurity` is the security-critical one, and it is NOT redundant with
|
|
1583
1514
|
* `hasDeniedReadFields`. That flag reports READ denials, and the canonical FLS configuration is
|
|
@@ -1603,6 +1534,16 @@ export class ResolverBase {
|
|
|
1603
1534
|
* caller pin forged audit columns as both old and new values. It doesn't track its own changes, so
|
|
1604
1535
|
* nothing else forces the load.
|
|
1605
1536
|
*
|
|
1537
|
+
* `MJ: Data Context Items` always loads from the database for the same reason: its server class
|
|
1538
|
+
* lets only an administrator save an item that holds SQL, and decides that from the item's `Type`
|
|
1539
|
+
* and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored procedure would
|
|
1540
|
+
* keep the stored SQL item while the rule saw none.
|
|
1541
|
+
*
|
|
1542
|
+
* `MJ: Conversation Details` always loads for the same reason: its write gate decides from the stored
|
|
1543
|
+
* row (who wrote a person's message, its role, the owner's rating), and an OldValues-hydrated entity
|
|
1544
|
+
* would let a grantee claim to be a message's author while rewriting it. See
|
|
1545
|
+
* {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
|
|
1546
|
+
*
|
|
1606
1547
|
* A caller under an Update row filter (a role's Update RLS filter or an API-key row filter) always
|
|
1607
1548
|
* loads from the database too (#4919). The save's post-image check skips its query when no column
|
|
1608
1549
|
* the filter reads is dirty, and dirtiness measured against client OldValues is the caller's to
|
|
@@ -1613,8 +1554,8 @@ export class ResolverBase {
|
|
|
1613
1554
|
*/
|
|
1614
1555
|
MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload, user) {
|
|
1615
1556
|
return (entityInfo.TrackRecordChanges ||
|
|
1616
|
-
entityInfo.Name.trim().toLowerCase()
|
|
1617
|
-
!input.OldValues___ ||
|
|
1557
|
+
ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
|
|
1558
|
+
!this.hasCompleteOldValues(entityInfo, input.OldValues___) ||
|
|
1618
1559
|
hasDeniedReadFields ||
|
|
1619
1560
|
hasNarrowedAuditPayload ||
|
|
1620
1561
|
entityInfo.EnableFieldLevelSecurity ||
|
|
@@ -1765,19 +1706,35 @@ export class ResolverBase {
|
|
|
1765
1706
|
}
|
|
1766
1707
|
}
|
|
1767
1708
|
/**
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1709
|
+
* Compares the client-supplied `OldValues___` array in `input` against the authoritative database
|
|
1710
|
+
* values just loaded onto `entityObject` via `InnerLoad`, checks for overlapping concurrent field
|
|
1711
|
+
* modifications, and then applies `clientNewValues` onto `entityObject` via `SetMany`.
|
|
1712
|
+
*
|
|
1713
|
+
* Supports both full-record and delta-only `OldValues___` payloads: only keys present in
|
|
1714
|
+
* `input.OldValues___` (e.g. primary keys + dirty writable fields) are compared against `dbValues`,
|
|
1715
|
+
* and `entityObject.SetMany(clientNewValues)` mutates only the fields sent in `clientNewValues`
|
|
1716
|
+
* while preserving all untouched DB-loaded fields on `entityObject`.
|
|
1771
1717
|
*
|
|
1772
|
-
* ASSUMES: input
|
|
1718
|
+
* ASSUMES: `input` has an `OldValues___` property that is an array of Key/Value pairs representing
|
|
1719
|
+
* the pre-mutation values of the fields the client is updating.
|
|
1720
|
+
*/
|
|
1721
|
+
/**
|
|
1722
|
+
* Resolves a field on `entityInfo` by `CodeName` or `Name` in $O(1)$ via `FieldByCodeName` /
|
|
1723
|
+
* `FieldByName` when available on `EntityInfo`, falling back to `.Fields.find()` for test stubs.
|
|
1773
1724
|
*/
|
|
1725
|
+
findEntityFieldByCodeNameOrName(entityInfo, key) {
|
|
1726
|
+
if (typeof entityInfo.FieldByCodeName === 'function') {
|
|
1727
|
+
return entityInfo.FieldByCodeName(key) ?? entityInfo.FieldByName(key);
|
|
1728
|
+
}
|
|
1729
|
+
return entityInfo.Fields.find((f) => f.CodeName === key || f.Name === key);
|
|
1730
|
+
}
|
|
1774
1731
|
async TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject, contextUser) {
|
|
1775
1732
|
// we have OldValues, so we need to compare them to the values we just loaded from the DB
|
|
1776
1733
|
const clientOldValues = {};
|
|
1777
1734
|
// for each item in the oldValues array, add it to the clientOldValues object
|
|
1778
1735
|
input.OldValues___.forEach((item) => {
|
|
1779
1736
|
// we need to do a quick transform on the values to make sure they match the TS Type for the given field because item.Value will always be a string
|
|
1780
|
-
const field = entityObject.EntityInfo
|
|
1737
|
+
const field = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, item.Key);
|
|
1781
1738
|
const val = this.ClientOldValueToFieldValue(field, item.Value);
|
|
1782
1739
|
clientOldValues[item.Key] = val;
|
|
1783
1740
|
});
|
|
@@ -1787,19 +1744,20 @@ export class ResolverBase {
|
|
|
1787
1744
|
// now we need to compare clientOldValues and dbValues and have a new array that has entries for any differences and have FieldName, clientOldValue and dbValue as properties
|
|
1788
1745
|
const dbDifferences = [];
|
|
1789
1746
|
Object.keys(clientOldValues).forEach((key) => {
|
|
1790
|
-
const f = entityObject.EntityInfo
|
|
1747
|
+
const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
|
|
1748
|
+
const dbVal = f && f.Name in dbValues ? dbValues[f.Name] : dbValues[key];
|
|
1791
1749
|
let different = false;
|
|
1792
1750
|
switch (typeof clientOldValues[key]) {
|
|
1793
1751
|
case 'number':
|
|
1794
|
-
different = clientOldValues[key] !==
|
|
1752
|
+
different = clientOldValues[key] !== dbVal;
|
|
1795
1753
|
break;
|
|
1796
1754
|
case 'boolean':
|
|
1797
|
-
different = clientOldValues[key] !==
|
|
1755
|
+
different = clientOldValues[key] !== dbVal;
|
|
1798
1756
|
break;
|
|
1799
1757
|
case 'object':
|
|
1800
1758
|
if (clientOldValues[key] instanceof Date) {
|
|
1801
1759
|
const clientDate = clientOldValues[key];
|
|
1802
|
-
const dbDate =
|
|
1760
|
+
const dbDate = dbVal;
|
|
1803
1761
|
if (dbDate == null || !(dbDate instanceof Date)) {
|
|
1804
1762
|
different = true;
|
|
1805
1763
|
}
|
|
@@ -1818,11 +1776,11 @@ export class ResolverBase {
|
|
|
1818
1776
|
}
|
|
1819
1777
|
}
|
|
1820
1778
|
else if (clientOldValues[key] === null) {
|
|
1821
|
-
different =
|
|
1779
|
+
different = dbVal !== null;
|
|
1822
1780
|
}
|
|
1823
1781
|
break;
|
|
1824
1782
|
default:
|
|
1825
|
-
different = clientOldValues[key] !==
|
|
1783
|
+
different = clientOldValues[key] !== dbVal;
|
|
1826
1784
|
break;
|
|
1827
1785
|
}
|
|
1828
1786
|
if (different && f && !f.ReadOnly) {
|
|
@@ -1830,7 +1788,7 @@ export class ResolverBase {
|
|
|
1830
1788
|
dbDifferences.push({
|
|
1831
1789
|
FieldName: key,
|
|
1832
1790
|
ClientOldValue: clientOldValues[key],
|
|
1833
|
-
DBValue:
|
|
1791
|
+
DBValue: dbVal,
|
|
1834
1792
|
});
|
|
1835
1793
|
}
|
|
1836
1794
|
});
|
|
@@ -1839,8 +1797,8 @@ export class ResolverBase {
|
|
|
1839
1797
|
// first step is to get clientNewValues into an object that is like clientOldValues, get the diff and then compare that diff to the differences array that shows diff between DB and ClientOld
|
|
1840
1798
|
const clientDifferences = [];
|
|
1841
1799
|
Object.keys(clientOldValues).forEach((key) => {
|
|
1842
|
-
const f = entityObject.EntityInfo
|
|
1843
|
-
if (clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
|
|
1800
|
+
const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
|
|
1801
|
+
if (key in clientNewValues && clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
|
|
1844
1802
|
// only include updateable fields
|
|
1845
1803
|
clientDifferences.push({
|
|
1846
1804
|
FieldName: key,
|
|
@@ -1851,7 +1809,8 @@ export class ResolverBase {
|
|
|
1851
1809
|
});
|
|
1852
1810
|
// now we have clientDifferences which shows what the client thinks they are changing. And, we have the dbDifferences array that shows changes between the clientOldValues and the dbValues
|
|
1853
1811
|
// if there is ANY overlap in the FIELDS that appear in both arrays, we need to log a warning but allow the save to continue
|
|
1854
|
-
const
|
|
1812
|
+
const dbDiffFieldNames = new Set(dbDifferences.map((dd) => dd.FieldName));
|
|
1813
|
+
const overlap = clientDifferences.filter((cd) => dbDiffFieldNames.has(cd.FieldName));
|
|
1855
1814
|
if (overlap.length > 0) {
|
|
1856
1815
|
const msg = {
|
|
1857
1816
|
Message: 'Inconsistency between old values provided for changed fields, and the values of one or more of those fields in the database. Save operation continued with warning.',
|