@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.
Files changed (287) hide show
  1. package/README.md +20 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionJanitor.d.ts +91 -30
  4. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  5. package/dist/agentSessions/SessionJanitor.js +311 -81
  6. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  7. package/dist/agentSessions/SessionManager.d.ts +5 -1
  8. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  9. package/dist/agentSessions/SessionManager.js +13 -1
  10. package/dist/agentSessions/SessionManager.js.map +1 -1
  11. package/dist/apiRoot.d.ts +32 -0
  12. package/dist/apiRoot.d.ts.map +1 -0
  13. package/dist/apiRoot.js +67 -0
  14. package/dist/apiRoot.js.map +1 -0
  15. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
  16. package/dist/auth/AuthProviderCatalogRouter.js +25 -0
  17. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
  18. package/dist/auth/LoginScreenEngine.d.ts +22 -0
  19. package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
  20. package/dist/auth/LoginScreenEngine.js +57 -0
  21. package/dist/auth/LoginScreenEngine.js.map +1 -0
  22. package/dist/auth/index.d.ts +1 -0
  23. package/dist/auth/index.d.ts.map +1 -1
  24. package/dist/auth/index.js +1 -0
  25. package/dist/auth/index.js.map +1 -1
  26. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
  27. package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
  28. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  29. package/dist/config.d.ts +136 -22
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +43 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/coreRoutes.d.ts +29 -0
  34. package/dist/coreRoutes.d.ts.map +1 -0
  35. package/dist/coreRoutes.js +142 -0
  36. package/dist/coreRoutes.js.map +1 -0
  37. package/dist/generated/graphql-schemas/__mj.d.ts +369 -3
  38. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  39. package/dist/generated/graphql-schemas/__mj.js +1750 -7
  40. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  41. package/dist/generic/CacheInvalidationResolver.d.ts +43 -8
  42. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  43. package/dist/generic/CacheInvalidationResolver.js +59 -19
  44. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.js +1 -1
  46. package/dist/generic/PushStatusResolver.js.map +1 -1
  47. package/dist/generic/ResolverBase.d.ts +103 -44
  48. package/dist/generic/ResolverBase.d.ts.map +1 -1
  49. package/dist/generic/ResolverBase.js +264 -305
  50. package/dist/generic/ResolverBase.js.map +1 -1
  51. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  52. package/dist/generic/RunViewResolver.js +12 -0
  53. package/dist/generic/RunViewResolver.js.map +1 -1
  54. package/dist/globalCors.d.ts +33 -0
  55. package/dist/globalCors.d.ts.map +1 -0
  56. package/dist/globalCors.js +58 -0
  57. package/dist/globalCors.js.map +1 -0
  58. package/dist/index.d.ts +11 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +112 -160
  61. package/dist/index.js.map +1 -1
  62. package/dist/multiTenancy/index.d.ts.map +1 -1
  63. package/dist/multiTenancy/index.js +22 -2
  64. package/dist/multiTenancy/index.js.map +1 -1
  65. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -1
  66. package/dist/realtimeSessions/RealtimeSessionVerificationService.js +2 -1
  67. package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -1
  68. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -1
  69. package/dist/realtimeSessions/RealtimeVerifyRouter.js +2 -1
  70. package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -1
  71. package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
  72. package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
  73. package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
  74. package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
  75. package/dist/realtimeSessions/index.d.ts +2 -0
  76. package/dist/realtimeSessions/index.d.ts.map +1 -1
  77. package/dist/realtimeSessions/index.js +2 -0
  78. package/dist/realtimeSessions/index.js.map +1 -1
  79. package/dist/realtimeSessions/ownedRealtimeSession.d.ts +30 -0
  80. package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
  81. package/dist/realtimeSessions/ownedRealtimeSession.js +47 -0
  82. package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
  83. package/dist/realtimeSessions/sessionAccess.d.ts +6 -0
  84. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
  85. package/dist/realtimeSessions/sessionAccess.js +7 -3
  86. package/dist/realtimeSessions/sessionAccess.js.map +1 -1
  87. package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
  88. package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
  89. package/dist/resolvers/ArtifactFileResolver.js +9 -7
  90. package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
  91. package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
  92. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
  93. package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
  94. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
  95. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  96. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  97. package/dist/resolvers/DatasetResolver.js +57 -8
  98. package/dist/resolvers/DatasetResolver.js.map +1 -1
  99. package/dist/resolvers/EntityRecordNameResolver.d.ts +2 -1
  100. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  101. package/dist/resolvers/EntityRecordNameResolver.js +3 -2
  102. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  103. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +18 -3
  104. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  105. package/dist/resolvers/FetchEntityVectorsResolver.js +100 -35
  106. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  107. package/dist/resolvers/FileResolver.d.ts +82 -11
  108. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  109. package/dist/resolvers/FileResolver.js +279 -101
  110. package/dist/resolvers/FileResolver.js.map +1 -1
  111. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  112. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  113. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  114. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  115. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  116. package/dist/resolvers/GetDataResolver.js +6 -3
  117. package/dist/resolvers/GetDataResolver.js.map +1 -1
  118. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/IntegrationDiscoveryResolver.js +4 -2
  120. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  121. package/dist/resolvers/QueryResolver.d.ts +2 -0
  122. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  123. package/dist/resolvers/QueryResolver.js +13 -0
  124. package/dist/resolvers/QueryResolver.js.map +1 -1
  125. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +2 -8
  126. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  127. package/dist/resolvers/RealtimeClientSessionResolver.js +10 -26
  128. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  129. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  130. package/dist/resolvers/RunAIAgentResolver.js +35 -6
  131. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  132. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
  133. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
  134. package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
  135. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  136. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  137. package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
  138. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  139. package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
  140. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  141. package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
  142. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  143. package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
  144. package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
  145. package/dist/resolvers/TestQuerySQLResolver.js +65 -13
  146. package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
  147. package/dist/resolvers/TransactionGroupResolver.d.ts +19 -2
  148. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  149. package/dist/resolvers/TransactionGroupResolver.js +102 -24
  150. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  151. package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
  152. package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
  153. package/dist/resolvers/agent-run-data-guard.js +72 -0
  154. package/dist/resolvers/agent-run-data-guard.js.map +1 -0
  155. package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
  156. package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
  157. package/dist/rest/RealtimeRecordingHandler.js +383 -0
  158. package/dist/rest/RealtimeRecordingHandler.js.map +1 -0
  159. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  160. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  161. package/dist/rest/ViewOperationsHandler.js +21 -12
  162. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  163. package/dist/rest/setupRESTEndpoints.d.ts +6 -0
  164. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  165. package/dist/rest/setupRESTEndpoints.js +6 -0
  166. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  167. package/dist/serverExtensionReservedRoots.d.ts +12 -10
  168. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  169. package/dist/serverExtensionReservedRoots.js +14 -17
  170. package/dist/serverExtensionReservedRoots.js.map +1 -1
  171. package/dist/services/MJStorageBlobStore.d.ts +8 -1
  172. package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
  173. package/dist/services/MJStorageBlobStore.js +25 -19
  174. package/dist/services/MJStorageBlobStore.js.map +1 -1
  175. package/dist/services/StartTaskGraphDispatcher.js +2 -2
  176. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  177. package/dist/services/TaskGraphActionRunner.d.ts +5 -3
  178. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  179. package/dist/services/TaskGraphActionRunner.js +27 -5
  180. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  181. package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
  182. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  183. package/dist/services/TaskGraphAgentRunner.js +157 -9
  184. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  185. package/dist/services/TaskGraphContinuationDeliverer.d.ts +54 -2
  186. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  187. package/dist/services/TaskGraphContinuationDeliverer.js +598 -15
  188. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  189. package/dist/sharedCache.d.ts.map +1 -1
  190. package/dist/sharedCache.js +3 -0
  191. package/dist/sharedCache.js.map +1 -1
  192. package/package.json +99 -98
  193. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  194. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  195. package/src/__tests__/FetchEntityVectorsResolver.test.ts +163 -15
  196. package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
  197. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  198. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  199. package/src/__tests__/LoginScreenEngine.test.ts +86 -0
  200. package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
  201. package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
  202. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
  203. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  204. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  205. package/src/__tests__/ResolverBase.clientOldValues.test.ts +209 -6
  206. package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
  207. package/src/__tests__/RunAIAgentResolver.branch.test.ts +1 -0
  208. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +5 -3
  209. package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +258 -0
  210. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  211. package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
  212. package/src/__tests__/SessionJanitor.test.ts +365 -10
  213. package/src/__tests__/SessionManager.test.ts +27 -0
  214. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
  215. package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
  216. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +356 -22
  217. package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
  218. package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
  219. package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
  220. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  221. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  222. package/src/__tests__/agent-run-data-guard.test.ts +130 -0
  223. package/src/__tests__/apiRootBoot.test.ts +202 -0
  224. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  225. package/src/__tests__/conversationRunEvents.test.ts +56 -14
  226. package/src/__tests__/globalCors.test.ts +217 -0
  227. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  228. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  229. package/src/__tests__/multiTenancy.test.ts +1 -1
  230. package/src/__tests__/ownedRealtimeSession.test.ts +39 -0
  231. package/src/__tests__/pushStatusResolver.filter.test.ts +7 -0
  232. package/src/__tests__/realtimeRecordingHandler.test.ts +652 -0
  233. package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
  234. package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
  235. package/src/__tests__/sharedCache.test.ts +5 -3
  236. package/src/agentSessions/SessionJanitor.ts +369 -93
  237. package/src/agentSessions/SessionManager.ts +13 -1
  238. package/src/apiRoot.ts +78 -0
  239. package/src/auth/AuthProviderCatalogRouter.ts +26 -0
  240. package/src/auth/LoginScreenEngine.ts +50 -0
  241. package/src/auth/index.ts +1 -0
  242. package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
  243. package/src/config.ts +46 -5
  244. package/src/coreRoutes.ts +166 -0
  245. package/src/generated/graphql-schemas/__mj.ts +1273 -10
  246. package/src/generic/CacheInvalidationResolver.ts +73 -18
  247. package/src/generic/PushStatusResolver.ts +1 -1
  248. package/src/generic/ResolverBase.ts +304 -325
  249. package/src/generic/RunViewResolver.ts +17 -0
  250. package/src/globalCors.ts +71 -0
  251. package/src/index.ts +116 -174
  252. package/src/multiTenancy/index.ts +24 -2
  253. package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
  254. package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
  255. package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
  256. package/src/realtimeSessions/index.ts +2 -0
  257. package/src/realtimeSessions/ownedRealtimeSession.ts +55 -0
  258. package/src/realtimeSessions/sessionAccess.ts +7 -3
  259. package/src/resolvers/ArtifactFileResolver.ts +9 -8
  260. package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
  261. package/src/resolvers/DatasetResolver.ts +70 -10
  262. package/src/resolvers/EntityRecordNameResolver.ts +3 -2
  263. package/src/resolvers/FetchEntityVectorsResolver.ts +121 -39
  264. package/src/resolvers/FileResolver.ts +321 -106
  265. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  266. package/src/resolvers/GetDataResolver.ts +6 -3
  267. package/src/resolvers/IntegrationDiscoveryResolver.ts +4 -2
  268. package/src/resolvers/QueryResolver.ts +11 -0
  269. package/src/resolvers/RealtimeClientSessionResolver.ts +10 -33
  270. package/src/resolvers/RunAIAgentResolver.ts +39 -4
  271. package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
  272. package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
  273. package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
  274. package/src/resolvers/TestQuerySQLResolver.ts +68 -13
  275. package/src/resolvers/TransactionGroupResolver.ts +131 -27
  276. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  277. package/src/resolvers/agent-run-data-guard.ts +89 -0
  278. package/src/rest/RealtimeRecordingHandler.ts +464 -0
  279. package/src/rest/ViewOperationsHandler.ts +29 -13
  280. package/src/rest/setupRESTEndpoints.ts +6 -0
  281. package/src/serverExtensionReservedRoots.ts +14 -17
  282. package/src/services/MJStorageBlobStore.ts +31 -30
  283. package/src/services/StartTaskGraphDispatcher.ts +2 -2
  284. package/src/services/TaskGraphActionRunner.ts +25 -5
  285. package/src/services/TaskGraphAgentRunner.ts +188 -11
  286. package/src/services/TaskGraphContinuationDeliverer.ts +704 -14
  287. package/src/sharedCache.ts +3 -0
@@ -26,7 +26,7 @@ import {
26
26
  } from '@memberjunction/core';
27
27
  import { MJAuditLogEntity, MJErrorLogEntity, MJUserViewEntityExtended } from '@memberjunction/core-entities';
28
28
  import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
29
- import { UserCache } from '@memberjunction/generic-database-provider';
29
+ import { ClientClauseScreen, UserCache, type EntityBaseViewAllowList } from '@memberjunction/generic-database-provider';
30
30
  import { PubSubEngine, AuthorizationError } from 'type-graphql';
31
31
  import { GraphQLError } from 'graphql';
32
32
  import { RefusalExtensions } from './refusalExtensions.js';
@@ -38,8 +38,8 @@ import { RunViewGenericParams, UserPayload } from '../types.js';
38
38
  import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
39
39
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
40
40
  import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
41
- import { SQLParser } from '@memberjunction/sql-parser';
42
- import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
41
+ import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
42
+ import type { SQLParserDialect } from '@memberjunction/sql-dialect';
43
43
  import { EncryptionEngine, IsEncryptionKeyUnavailable, ReportEncryptionKeyUnavailable } from '@memberjunction/encryption';
44
44
  import { PUSH_STATUS_UPDATES_TOPIC, PublishStatusUpdate } from './PushStatusResolver.js';
45
45
  import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
@@ -47,6 +47,17 @@ import { PubSubManager } from './PubSubManager.js';
47
47
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
48
48
  import { Subscription } from 'rxjs';
49
49
 
50
+ /**
51
+ * Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
52
+ * do not track record changes: their server-side write rules compare against the prior state, which a client could
53
+ * otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
54
+ */
55
+ const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set([
56
+ 'mj: record changes',
57
+ 'mj: conversation details',
58
+ 'mj: data context items',
59
+ ]);
60
+
50
61
  export class ResolverBase {
51
62
  private static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
52
63
  private static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
@@ -86,141 +97,158 @@ export class ResolverBase {
86
97
  * @param contextUser - Optional user context for decryption (required for encrypted fields)
87
98
  * @returns A new object in transport shape, or null when there is nothing to map
88
99
  */
89
- protected async MapFieldNamesToCodeNames(
100
+ /**
101
+ * Precomputed per-entity / per-request field-mapping plan shared across rows in
102
+ * {@link MapFieldNamesToCodeNames} and {@link ArrayMapFieldNamesToCodeNames}.
103
+ */
104
+ private buildFieldMappingPlan(
90
105
  entityName: string,
91
- dataObject: any,
92
106
  contextUser?: UserInfo,
93
107
  provider?: IMetadataProvider,
94
108
  deniedReadFields?: Set<string>,
95
109
  recordChangeProjector?: RecordChangeFieldSecurityProjector
96
- ): Promise<any> {
97
- // Return null for empty objects (e.g. when no rows found due to RLS filtering)
110
+ ): {
111
+ entityInfo: EntityInfo;
112
+ fieldsToProcess: Array<{ name: string; mappedFieldName: string; isDenied: boolean }>;
113
+ readableFields: string[] | null;
114
+ projector: RecordChangeFieldSecurityProjector | undefined;
115
+ encryptedFields: EntityFieldInfo[];
116
+ checkBinary: boolean;
117
+ } {
118
+ const md = provider ?? new Metadata();
119
+ const entityInfo = md.EntityByName(entityName);
120
+ if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
121
+
122
+ const denied = deniedReadFields ?? (
123
+ entityInfo.EnableFieldLevelSecurity && contextUser
124
+ ? entityInfo.GetDeniedReadFields(contextUser)
125
+ : null
126
+ );
127
+
128
+ const mapper = new FieldMapper();
129
+ const fieldsToProcess: Array<{ name: string; mappedFieldName: string; isDenied: boolean }> = [];
130
+ for (const f of entityInfo.Fields) {
131
+ const mappedFieldName = mapper.MapFieldName(f.CodeName);
132
+ const isDenied = Boolean(denied?.has(f.Name.trim().toLowerCase()));
133
+ if (isDenied || mappedFieldName !== f.Name) {
134
+ fieldsToProcess.push({ name: f.Name, mappedFieldName, isDenied });
135
+ }
136
+ }
137
+
138
+ const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
139
+ const projector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
140
+ ? (recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser))
141
+ : undefined;
142
+ const encryptedFields = entityInfo.EncryptedFields ?? [];
143
+ const checkBinary = entityInfo.HasBinaryFields !== false;
144
+
145
+ return {
146
+ entityInfo,
147
+ fieldsToProcess,
148
+ readableFields,
149
+ projector,
150
+ encryptedFields,
151
+ checkBinary,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Applies synchronous field renaming, FLS stripping, readable-fields transport stamping, and
157
+ * Record Changes payload projection for a single row using a precomputed mapping plan.
158
+ */
159
+ private mapRowSynchronously(
160
+ dataObject: Record<string, unknown> | null | undefined,
161
+ plan: ReturnType<ResolverBase['buildFieldMappingPlan']>
162
+ ): Record<string, unknown> | null {
98
163
  if (!dataObject || Object.keys(dataObject).length === 0) {
99
164
  return null;
100
165
  }
101
- // Shallow copy up front so every write below lands on our object, never the caller's.
102
- // Binary values are base64 strings everywhere above the providers; any byte array that still
103
- // reaches a resolver (custom code, an external driver) is converted here, because GraphQL's
104
- // String scalar cannot serialize a Buffer and would fail the whole response.
105
- dataObject = ReplaceByteArraysWithBase64({ ...dataObject });
106
-
107
- // for the given entity name provided, check to see if there are any fields
108
- // where the code name is different from the field name, and for just those
109
- // fields, iterate through the dataObject and REPLACE the property that has the field name
110
- // with the CodeName, because we can't transfer those via GraphQL as they are not
111
- // valid property names in GraphQL
112
- {
113
- const md = provider ?? new Metadata();
114
- const entityInfo = md.EntityByName(entityName);
115
- if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
116
- // const fields = entityInfo.Fields.filter((f) => f.Name !== f.CodeName || f.Name.startsWith('__mj_'));
117
- // FIELD-LEVEL SECURITY — this is the authoritative read boundary for every GraphQL
118
- // return path (single-record resolvers, external-data-source loads, and anything else
119
- // routed through here), sitting exactly where encryption masking already lives.
120
- //
121
- // The denied set is normally computed ONCE per (entity, user) by the caller and passed
122
- // in: this method runs once per ROW, so resolving it here would cost fields x rows
123
- // aggregations. We fall back to computing it when a caller doesn't supply it (the
124
- // single-record resolvers, where "once per row" and "once per request" are the same
125
- // thing) rather than failing open — a missing argument must never mean missing security.
126
- const denied = deniedReadFields ?? (
127
- entityInfo.EnableFieldLevelSecurity && contextUser
128
- ? entityInfo.GetDeniedReadFields(contextUser)
129
- : null
130
- );
166
+ let row: Record<string, unknown> = plan.checkBinary
167
+ ? ReplaceByteArraysWithBase64({ ...dataObject })
168
+ : { ...dataObject };
131
169
 
132
- const mapper = new FieldMapper();
133
- entityInfo.Fields.forEach((f) => {
134
- if (dataObject.hasOwnProperty(f.Name)) {
135
- // 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
136
- const mappedFieldName = mapper.MapFieldName(f.CodeName);
137
- if (denied?.has(f.Name.trim().toLowerCase())) {
138
- // Omit entirely rather than nulling — a null is indistinguishable from a real
139
- // null value, and the client should see the field as absent, not as empty.
140
- // Both key shapes are removed because callers reach this method with rows keyed
141
- // either way depending on whether mapping has already run.
142
- delete dataObject[f.Name];
143
- delete dataObject[mappedFieldName];
144
- return;
145
- }
146
- if (mappedFieldName !== f.Name) {
147
- dataObject[mappedFieldName] = dataObject[f.Name];
148
- delete dataObject[f.Name];
149
- }
170
+ for (let i = 0; i < plan.fieldsToProcess.length; i++) {
171
+ const fp = plan.fieldsToProcess[i];
172
+ if (Object.prototype.hasOwnProperty.call(row, fp.name)) {
173
+ if (fp.isDenied) {
174
+ delete row[fp.name];
175
+ delete row[fp.mappedFieldName];
176
+ } else {
177
+ row[fp.mappedFieldName] = row[fp.name];
178
+ delete row[fp.name];
150
179
  }
151
- });
152
-
153
- // Field security, second half: say IN-BAND which fields this caller may read. Deleting the
154
- // key above is not enough on its own — GraphQL emits every SELECTED field regardless, so a
155
- // denied field the client asked for arrives as an explicit null it cannot tell apart from a
156
- // genuine one. The client must not settle that from its own metadata: during the window
157
- // after a permission change its copy disagrees with ours, and once metadata filtering lands
158
- // (issue #3485) it may not hold the permission rows at all. Null for unrestricted callers,
159
- // which is nearly every request.
160
- const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
161
- if (readableFields) {
162
- dataObject[ReadableFieldsTransportKey] = readableFields;
163
180
  }
181
+ }
164
182
 
165
- // Field security, third part — the audit trail. A 'MJ: Record Changes' row carries the old
166
- // and new values of ANOTHER entity's fields, and its own EnableFieldLevelSecurity flag is
167
- // off, so everything above is a no-op for it and the payload would flow out untouched. The
168
- // projector recomputes the denied set against the entity each row is about; see
169
- // RecordChangeFieldSecurityProjector for what each payload column gets.
170
- //
171
- // Same "a missing argument must never mean missing security" rule as the denied set above:
172
- // the projector is normally built ONCE per request by the array caller and passed in, and
173
- // we build one here when it wasn't — the single-record resolvers, where once-per-row and
174
- // once-per-request are the same thing.
175
- if (contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
176
- const projector = recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser);
177
- dataObject = projector.ProjectRow(dataObject);
178
- }
183
+ if (plan.readableFields) {
184
+ row[ReadableFieldsTransportKey] = plan.readableFields;
185
+ }
186
+
187
+ if (plan.projector) {
188
+ row = plan.projector.ProjectRow(row);
189
+ }
179
190
 
180
- // Handle encrypted fields - data from raw SQL queries is still encrypted
181
- const encryptedFields = entityInfo.EncryptedFields;
182
- if (encryptedFields.length > 0) {
183
- for (const field of encryptedFields) {
184
- const fieldName = field.CodeName;
185
- const value = dataObject[fieldName];
186
-
187
- // Skip null/undefined values
188
- if (value === null || value === undefined) continue;
189
-
190
- // Check if value is encrypted (raw SQL returns encrypted values)
191
- const engine = EncryptionEngine.Instance;
192
- await engine.Config(false, contextUser);
193
- const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
194
- if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
195
- if (field.AllowDecryptInAPI) {
196
- // Decrypt the value for the client
197
- if (contextUser) {
198
- try {
199
- const decryptedValue = await engine.Decrypt(value, contextUser);
200
- dataObject[fieldName] = decryptedValue;
201
- } catch (err) {
202
- if (IsEncryptionKeyUnavailable(err)) {
203
- ReportEncryptionKeyUnavailable(err);
204
- } else {
205
- LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
206
- }
207
- // On decryption failure, use sentinel for safety
208
- dataObject[fieldName] = ENCRYPTED_SENTINEL;
209
- }
191
+ return row;
192
+ }
193
+
194
+ /**
195
+ * Decrypts or masks encrypted fields on a mapped row when `plan.encryptedFields.length > 0`.
196
+ */
197
+ private async decryptRowEncryptedFields(
198
+ row: Record<string, unknown>,
199
+ encryptedFields: EntityFieldInfo[],
200
+ contextUser?: UserInfo
201
+ ): Promise<void> {
202
+ for (const field of encryptedFields) {
203
+ const fieldName = field.CodeName;
204
+ const value = row[fieldName];
205
+
206
+ if (value === null || value === undefined) continue;
207
+
208
+ const engine = EncryptionEngine.Instance;
209
+ await engine.Config(false, contextUser);
210
+ const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
211
+ if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
212
+ if (field.AllowDecryptInAPI) {
213
+ if (contextUser) {
214
+ try {
215
+ const decryptedValue = await engine.Decrypt(value, contextUser);
216
+ row[fieldName] = decryptedValue;
217
+ } catch (err) {
218
+ if (IsEncryptionKeyUnavailable(err)) {
219
+ ReportEncryptionKeyUnavailable(err);
210
220
  } else {
211
- // No context user, can't decrypt - use sentinel
212
- dataObject[fieldName] = ENCRYPTED_SENTINEL;
221
+ LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
213
222
  }
214
- } else if (!field.SendEncryptedValue) {
215
- // AllowDecryptInAPI=false and SendEncryptedValue=false - use sentinel
216
- dataObject[fieldName] = ENCRYPTED_SENTINEL;
223
+ row[fieldName] = ENCRYPTED_SENTINEL;
217
224
  }
218
- // else: AllowDecryptInAPI=false and SendEncryptedValue=true - keep encrypted value as-is
225
+ } else {
226
+ row[fieldName] = ENCRYPTED_SENTINEL;
219
227
  }
228
+ } else if (!field.SendEncryptedValue) {
229
+ row[fieldName] = ENCRYPTED_SENTINEL;
220
230
  }
221
231
  }
222
232
  }
223
- return dataObject;
233
+ }
234
+
235
+ protected async MapFieldNamesToCodeNames(
236
+ entityName: string,
237
+ dataObject: any,
238
+ contextUser?: UserInfo,
239
+ provider?: IMetadataProvider,
240
+ deniedReadFields?: Set<string>,
241
+ recordChangeProjector?: RecordChangeFieldSecurityProjector
242
+ ): Promise<any> {
243
+ if (!dataObject || Object.keys(dataObject).length === 0) {
244
+ return null;
245
+ }
246
+ const plan = this.buildFieldMappingPlan(entityName, contextUser, provider, deniedReadFields, recordChangeProjector);
247
+ const mapped = this.mapRowSynchronously(dataObject as Record<string, unknown>, plan);
248
+ if (mapped && plan.encryptedFields.length > 0) {
249
+ await this.decryptRowEncryptedFields(mapped, plan.encryptedFields, contextUser);
250
+ }
251
+ return mapped;
224
252
  }
225
253
 
226
254
  /**
@@ -280,26 +308,17 @@ export class ResolverBase {
280
308
  if (!dataObjectArray || dataObjectArray.length === 0) {
281
309
  return dataObjectArray;
282
310
  }
283
- // Resolve the field-security denied set ONCE for the whole array and pass it into the
284
- // per-row mapper. MapFieldNamesToCodeNames runs once per row, so computing this inside
285
- // the loop would be fields x rows aggregations — 40,000 for a 1,000-row x 40-column
286
- // result, each re-scanning the user's roles and allocating. Gated on the entity-level
287
- // flag so non-FLS entities (nearly all of them) don't even resolve the entity twice.
288
- const md = provider ?? new Metadata();
289
- const entityInfo = md.EntityByName(entityName);
290
- const deniedReadFields = entityInfo?.EnableFieldLevelSecurity && contextUser
291
- ? entityInfo.GetDeniedReadFields(contextUser)
292
- : undefined;
293
- // Record Changes payload security is resolved per DISTINCT target entity, and one array can
294
- // span many — so the projector, which owns that memo, must outlive the row loop. Built once
295
- // here for the same reason the denied set above is: GetDeniedReadFields walks every field on
296
- // an entity and must never be called per row.
297
- const recordChangeProjector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
298
- ? new RecordChangeFieldSecurityProjector(md, contextUser)
299
- : undefined;
300
- const mapped: any[] = [];
301
- for (const element of dataObjectArray) {
302
- mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider, deniedReadFields, recordChangeProjector));
311
+ // Precompute the entity lookup, FieldMapper plan, denied-fields set, readable-fields list,
312
+ // and Record Changes projector ONCE for the whole array rather than per row.
313
+ const plan = this.buildFieldMappingPlan(entityName, contextUser, provider);
314
+ const hasEncryptedFields = plan.encryptedFields.length > 0;
315
+ const mapped: Array<Record<string, unknown> | null> = new Array(dataObjectArray.length);
316
+ for (let i = 0; i < dataObjectArray.length; i++) {
317
+ const row = this.mapRowSynchronously(dataObjectArray[i] as Record<string, unknown>, plan);
318
+ if (row && hasEncryptedFields) {
319
+ await this.decryptRowEncryptedFields(row, plan.encryptedFields, contextUser);
320
+ }
321
+ mapped[i] = row;
303
322
  }
304
323
  return mapped;
305
324
  }
@@ -366,13 +385,16 @@ export class ResolverBase {
366
385
  * @param dataObject - The data object containing field values
367
386
  * @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
368
387
  * @param contextUser - User context for encryption operations
388
+ * @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
389
+ * default) or field Names (raw provider rows, such as dataset item results)
369
390
  * @returns The filtered data object
370
391
  */
371
392
  protected async FilterEncryptedFieldsForAPI(
372
393
  entityName: string,
373
394
  dataObject: Record<string, unknown>,
374
395
  contextUser: UserInfo,
375
- provider?: IMetadataProvider
396
+ provider?: IMetadataProvider,
397
+ fieldKey: 'CodeName' | 'Name' = 'CodeName'
376
398
  ): Promise<Record<string, unknown>> {
377
399
  if (!dataObject) return dataObject;
378
400
 
@@ -386,7 +408,7 @@ export class ResolverBase {
386
408
 
387
409
  // Process each encrypted field
388
410
  for (const field of encryptedFields) {
389
- const fieldName = field.CodeName;
411
+ const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
390
412
  const value = dataObject[fieldName];
391
413
 
392
414
  // Skip null/undefined values
@@ -427,13 +449,15 @@ export class ResolverBase {
427
449
  }
428
450
 
429
451
  /**
430
- * Filters encrypted fields for an array of data objects
452
+ * Filters encrypted fields for an array of data objects. `fieldKey` is as for
453
+ * {@link FilterEncryptedFieldsForAPI}.
431
454
  */
432
455
  protected async ArrayFilterEncryptedFieldsForAPI(
433
456
  entityName: string,
434
457
  dataObjectArray: Record<string, unknown>[],
435
458
  contextUser: UserInfo,
436
- provider?: IMetadataProvider
459
+ provider?: IMetadataProvider,
460
+ fieldKey: 'CodeName' | 'Name' = 'CodeName'
437
461
  ): Promise<Record<string, unknown>[]> {
438
462
  if (!dataObjectArray || dataObjectArray.length === 0) return dataObjectArray;
439
463
 
@@ -447,7 +471,7 @@ export class ResolverBase {
447
471
 
448
472
  // Process each element
449
473
  for (const element of dataObjectArray) {
450
- await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
474
+ await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
451
475
  }
452
476
 
453
477
  return dataObjectArray;
@@ -925,11 +949,26 @@ export class ResolverBase {
925
949
  * This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
926
950
  * wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
927
951
  * allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
928
- * and catalogs are rejected. Server-internal RunView callers never hit this.
952
+ * and catalogs are rejected. Server-internal RunView callers get only the provider's
953
+ * statement-separator check, which cannot refuse a legitimate filter.
954
+ *
955
+ * The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
956
+ * with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
957
+ *
958
+ * Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
959
+ * provider and the tenant hook splice it inside parentheses and AND their own filters after
960
+ * it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
961
+ * security, API-key row filters and tenant scope on one side of an OR the caller controls.
962
+ * The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
963
+ * A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
964
+ * inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
965
+ * there, so the text after it could be code that the parse-based checks never saw.
929
966
  *
930
967
  * RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
931
- * into the view. Subqueries against another entity's BaseView therefore do not inherit that
932
- * entity's RLS; they are still restricted to the view (not the table).
968
+ * into the view, and RunView removes denied fields from its own result only. A subquery reads a
969
+ * base view directly with neither, so the screen refuses one over any entity the user reads under
970
+ * a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
971
+ * entity included.
933
972
  */
934
973
  protected assertClientClauseUsesEntityBaseViews(
935
974
  clause: string | undefined | null,
@@ -940,43 +979,37 @@ export class ResolverBase {
940
979
  ): void {
941
980
  if (!clause?.trim()) return;
942
981
 
982
+ // The balance and backslash checks run before the shared screen: it parses the fragment
983
+ // inside its own parentheses, so it cannot see a fragment that closes the parentheses the
984
+ // provider and the tenant hook splice it into.
943
985
  const dialect = this.dialectForProvider(provider);
944
986
  if (SQLParser.HasStackedStatements(clause, dialect)) {
945
987
  throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
946
988
  }
947
-
948
- const wrapped =
949
- label === 'OrderBy'
950
- ? `SELECT 1 FROM __mj_clause_screen ORDER BY ${clause}`
951
- : `SELECT 1 FROM __mj_clause_screen WHERE (${clause})`;
952
- const parser = new SQLParser(wrapped, dialect);
953
- // A clause the provider checks again on its own (an aggregate expression) leaves text the parser
954
- // cannot read to that per-item check instead of failing the whole request.
955
- if (!parser.IsValid && options?.LeaveUnparseableToProvider) return;
956
- if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
957
- throw new Error(
958
- `Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`,
959
- );
989
+ const balance = IsBalancedSQLFragment(clause, dialect);
990
+ if (!balance.IsBalanced) {
991
+ throw new Error(`Invalid ${label}: ${balance.Reason}`);
960
992
  }
961
- if (this.astContainsWriteNode(parser.AST)) {
962
- throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
993
+ if (this.hasBackslashInQuotes(clause, dialect)) {
994
+ throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
963
995
  }
964
996
 
965
- const allowed = this.entityBaseViewAllowList(provider);
966
- const tables = SQLParser.ExtractTableRefs(wrapped, dialect);
967
- for (const t of tables) {
968
- const table = this.stripSqlIdent(t.TableName);
969
- const schema = this.stripSqlIdent(t.SchemaName);
970
- if (table.toLowerCase() === '__mj_clause_screen') continue;
971
- this.assertTableRefReadable(allowed, schema, table, label, user);
972
- }
997
+ ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
973
998
  }
974
999
 
975
1000
  /**
976
1001
  * SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
977
1002
  * to the SELECT-only validation the caller performs, every table reference must resolve to an
978
- * entity BaseView the acting user can read UNSCOPED. CTE names defined by the statement itself
979
- * are excluded from the check. Fails closed: no user, or an unresolvable reference, refuses.
1003
+ * entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
1004
+ * CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
1005
+ * own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
1006
+ * for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
1007
+ * Fails closed: no user, or an unresolvable reference, refuses.
1008
+ *
1009
+ * The references come from a walk of the whole parse tree
1010
+ * (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
1011
+ * account for every SELECT in the text, or when a FROM item names no table (a table function or
1012
+ * a row list).
980
1013
  *
981
1014
  * Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
982
1015
  * row-level security and denied-field projection, and raw SQL applies neither. Rather than try
@@ -994,6 +1027,7 @@ export class ResolverBase {
994
1027
  throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
995
1028
  }
996
1029
  const dialect = this.dialectForProvider(provider);
1030
+ ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
997
1031
  const parser = new SQLParser(sqlText, dialect);
998
1032
  if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
999
1033
  throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
@@ -1001,52 +1035,19 @@ export class ResolverBase {
1001
1035
  if (this.astContainsWriteNode(parser.AST)) {
1002
1036
  throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
1003
1037
  }
1004
- const cteNames = new Set<string>();
1005
- this.collectCTENames(parser.AST, cteNames);
1038
+ const reads = ClientClauseScreen.CollectStatementReads(parser);
1039
+ ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
1006
1040
  const allowed = this.entityBaseViewAllowList(provider);
1007
- const tables = SQLParser.ExtractTableRefs(sqlText, dialect);
1008
- for (const t of tables) {
1009
- const table = this.stripSqlIdent(t.TableName);
1010
- const schema = this.stripSqlIdent(t.SchemaName);
1011
- if (this.isUnqualifiedSchema(schema) && cteNames.has(table.toLowerCase())) continue;
1012
- const entity = this.assertTableRefReadable(allowed, schema, table, label, user);
1013
- this.assertUnscopedRead(entity, user, label);
1041
+ for (const relation of reads.Relations) {
1042
+ if (relation.IsCTEReference) continue;
1043
+ const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
1044
+ if (entity) this.assertUnscopedRead(entity, user, label);
1014
1045
  }
1015
1046
  }
1016
1047
 
1017
- /** Refuses an entity whose read is narrowed for this user by row-level security or field-level denials. */
1048
+ /** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
1018
1049
  private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
1019
- if (entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '').length > 0) {
1020
- throw new Error(
1021
- `Invalid ${label}: entity '${entity.Name}' is row-level-security filtered for you — ad-hoc SQL cannot apply that filter; use RunView`,
1022
- );
1023
- }
1024
- if (entity.GetDeniedReadFields(user).size > 0) {
1025
- throw new Error(
1026
- `Invalid ${label}: entity '${entity.Name}' has fields you are not permitted to read — ad-hoc SQL cannot project them away; use RunView`,
1027
- );
1028
- }
1029
- }
1030
-
1031
- /** Walks a parsed AST collecting the names of CTEs the statement itself defines. */
1032
- private collectCTENames(node: unknown, names: Set<string>): void {
1033
- if (!node || typeof node !== 'object') return;
1034
- if (Array.isArray(node)) {
1035
- for (const n of node) this.collectCTENames(n, names);
1036
- return;
1037
- }
1038
- const obj = node as Record<string, unknown>;
1039
- const withClause = obj.with;
1040
- if (Array.isArray(withClause)) {
1041
- for (const cte of withClause) {
1042
- const name = (cte as Record<string, unknown>)?.name;
1043
- if (typeof name === 'string') names.add(name.toLowerCase());
1044
- else if (name && typeof name === 'object' && typeof (name as Record<string, unknown>).value === 'string') {
1045
- names.add(((name as Record<string, unknown>).value as string).toLowerCase());
1046
- }
1047
- }
1048
- }
1049
- for (const v of Object.values(obj)) this.collectCTENames(v, names);
1050
+ ClientClauseScreen.AssertUnscopedRead(entity, user, label);
1050
1051
  }
1051
1052
 
1052
1053
  /**
@@ -1095,105 +1096,26 @@ export class ResolverBase {
1095
1096
  }
1096
1097
  }
1097
1098
 
1098
- /** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
1099
- private astContainsWriteNode(node: unknown): boolean {
1100
- if (!node || typeof node !== 'object') return false;
1101
- if (Array.isArray(node)) return node.some((n) => this.astContainsWriteNode(n));
1102
- const obj = node as Record<string, unknown>;
1103
- const type = obj.type;
1104
- if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
1105
- return true;
1106
- }
1107
- return Object.values(obj).some((v) => this.astContainsWriteNode(v));
1108
- }
1109
-
1110
- private static readonly WRITE_NODE_TYPES = new Set<string>([
1111
- 'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
1112
- 'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
1113
- ]);
1114
-
1115
- private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
1116
- const name = provider?.constructor?.name ?? '';
1117
- if (/postgres/i.test(name)) return new PostgreSQLDialect();
1118
- return new SQLServerDialect();
1119
- }
1120
-
1121
- /** True for a reference with no schema — which `SQLParser.ExtractTableRefs` reports as `dbo`. */
1122
- private isUnqualifiedSchema(schema: string): boolean {
1123
- return !schema || schema.toLowerCase() === 'dbo';
1099
+ /**
1100
+ * True when a string literal or quoted identifier in `clause`, read the way the database reads
1101
+ * it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
1102
+ */
1103
+ private hasBackslashInQuotes(clause: string, dialect: SQLParserDialect): boolean {
1104
+ return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
1124
1105
  }
1125
1106
 
1126
- private stripSqlIdent(name: string | null | undefined): string {
1127
- if (!name) return '';
1128
- return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
1107
+ /** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
1108
+ private astContainsWriteNode(node: unknown): boolean {
1109
+ return ClientClauseScreen.AstContainsWriteNode(node);
1129
1110
  }
1130
1111
 
1131
- /**
1132
- * `bare` maps an unqualified view name to its entity, or to `null` when the name exists in
1133
- * more than one schema — the database, not this screen, would decide which one runs.
1134
- */
1135
- private entityBaseViewAllowList(provider?: IMetadataProvider): {
1136
- qualified: Map<string, EntityInfo>;
1137
- bare: Map<string, EntityInfo | null>;
1138
- } {
1139
- const qualified = new Map<string, EntityInfo>();
1140
- const bare = new Map<string, EntityInfo | null>();
1141
- const entities = provider?.Entities ?? [];
1142
- for (const e of entities) {
1143
- const view = this.stripSqlIdent(e.BaseView);
1144
- if (!view) continue;
1145
- const schema = this.stripSqlIdent(e.SchemaName);
1146
- const bareKey = view.toLowerCase();
1147
- bare.set(bareKey, bare.has(bareKey) ? null : e);
1148
- if (schema) qualified.set(`${schema}.${view}`.toLowerCase(), e);
1149
- }
1150
- return { qualified, bare };
1112
+ private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
1113
+ return ClientClauseScreen.DialectFor(provider);
1151
1114
  }
1152
1115
 
1153
- /**
1154
- * SECURITY — resolves a table reference against the entity BaseView allow-list and, when an
1155
- * acting user is supplied, additionally requires that user to hold CanRead on the referenced
1156
- * entity. Base views do not embed RLS and entity permissions are otherwise checked only on
1157
- * the TOP entity of a request, so without this check a subquery (or ad-hoc query) could read
1158
- * entities the caller has no read grant on.
1159
- *
1160
- * A schema-qualified reference must match that exact schema — `secret.vwFoo` never resolves
1161
- * through another schema's `vwFoo`. An unqualified reference resolves only when the view name
1162
- * is unique across schemas; an ambiguous one must be qualified, because the database's
1163
- * default-schema resolution, not this screen, would pick which view actually runs.
1164
- *
1165
- * `SQLParser.ExtractTableRefs` reports an unqualified reference as schema `dbo`, so `dbo` is
1166
- * indistinguishable from "no schema" here and both take the unqualified path (an exact
1167
- * `dbo.<view>` entity wins first).
1168
- */
1169
- private assertTableRefReadable(
1170
- allowed: { qualified: Map<string, EntityInfo>; bare: Map<string, EntityInfo | null> },
1171
- schema: string,
1172
- table: string,
1173
- label: string,
1174
- user?: UserInfo,
1175
- ): EntityInfo {
1176
- const qualified = allowed.qualified.get(`${schema}.${table}`.toLowerCase());
1177
- const entity = this.isUnqualifiedSchema(schema) ? qualified ?? allowed.bare.get(table.toLowerCase()) : qualified;
1178
- if (entity === null) {
1179
- throw new Error(
1180
- `Invalid ${label}: '${table}' is a base view in more than one schema — qualify it with its schema`,
1181
- );
1182
- }
1183
- if (!entity) {
1184
- throw new Error(
1185
- `Invalid ${label}: subquery must use an entity base view, not '${schema ? schema + '.' : ''}${table}'`,
1186
- );
1187
- }
1188
- if (user) {
1189
- const perms = entity.GetUserPermisions(user);
1190
- if (!perms.CanRead) {
1191
- throw new Error(
1192
- `Invalid ${label}: you do not have read permission on entity '${entity.Name}' referenced by '${schema ? schema + '.' : ''}${table}'`,
1193
- );
1194
- }
1195
- }
1196
- return entity;
1116
+ /** The entity base-view allow-list for a provider's entities. */
1117
+ private entityBaseViewAllowList(provider?: IMetadataProvider): EntityBaseViewAllowList {
1118
+ return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
1197
1119
  }
1198
1120
 
1199
1121
  /**
@@ -1913,13 +1835,41 @@ export class ResolverBase {
1913
1835
  });
1914
1836
  }
1915
1837
 
1838
+ /**
1839
+ * Checks whether `oldValues` (`input.OldValues___`) contains every writable in-table field of
1840
+ * `entityInfo` (plus primary keys).
1841
+ *
1842
+ * With the delta-only `Save()` wire protocol, `GraphQLDataProvider.Save()` sends only primary keys
1843
+ * and dirty writable fields in `vars.input` and `OldValues___` on normal updates. Hydrating an
1844
+ * entity directly from a partial-delta `OldValues___` via `entityObject.LoadFromData(oldValues)`
1845
+ * (without first loading the authoritative row via `InnerLoad`) would leave every untouched column
1846
+ * `NotLoaded = true` (or `null`), causing `spUpdate` or custom `Validate()` hooks to see incomplete
1847
+ * state. Returning `false` for partial deltas ensures {@link MustLoadTruthFromDatabase} routes
1848
+ * delta updates through `InnerLoad(cKey)` + {@link TestAndSetClientOldValuesToDBValues}, even on
1849
+ * entities with `TrackRecordChanges === false`.
1850
+ */
1851
+ protected hasCompleteOldValues(
1852
+ entityInfo: EntityInfo,
1853
+ oldValues?: Array<{ Key: string; Value: unknown }> | null
1854
+ ): boolean {
1855
+ if (!oldValues || oldValues.length === 0) {
1856
+ return false;
1857
+ }
1858
+ const providedKeys = new Set(oldValues.map((kv) => kv.Key));
1859
+ return entityInfo.Fields.every(
1860
+ (f) => (!f.IsPrimaryKey && (f.ReadOnly || f.IsVirtual)) || providedKeys.has(f.Name) || providedKeys.has(f.CodeName)
1861
+ );
1862
+ }
1863
+
1916
1864
  /**
1917
1865
  * Whether `UpdateRecord` must hydrate the entity from the DATABASE rather than from the
1918
1866
  * client's `OldValues___`.
1919
1867
  *
1920
- * The `OldValues___` path exists as an optimization: when nothing needs the true prior state,
1921
- * the client already holds it and a round trip is wasted. Each condition below is a reason
1922
- * that assumption fails.
1868
+ * The `OldValues___` path exists as an optimization when a client supplies the complete pre-save
1869
+ * record (e.g. `IgnoreDirtyState: true`) and nothing else requires the database's authoritative prior
1870
+ * state. Under the delta-only `Save()` wire protocol, normal updates send only primary keys and dirty
1871
+ * writable fields in `OldValues___`, so {@link hasCompleteOldValues} returns `false` and forces the
1872
+ * database load (`InnerLoad`) so untouched columns retain their stored values.
1923
1873
  *
1924
1874
  * `EnableFieldLevelSecurity` is the security-critical one, and it is NOT redundant with
1925
1875
  * `hasDeniedReadFields`. That flag reports READ denials, and the canonical FLS configuration is
@@ -1945,6 +1895,16 @@ export class ResolverBase {
1945
1895
  * caller pin forged audit columns as both old and new values. It doesn't track its own changes, so
1946
1896
  * nothing else forces the load.
1947
1897
  *
1898
+ * `MJ: Data Context Items` always loads from the database for the same reason: its server class
1899
+ * lets only an administrator save an item that holds SQL, and decides that from the item's `Type`
1900
+ * and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored procedure would
1901
+ * keep the stored SQL item while the rule saw none.
1902
+ *
1903
+ * `MJ: Conversation Details` always loads for the same reason: its write gate decides from the stored
1904
+ * row (who wrote a person's message, its role, the owner's rating), and an OldValues-hydrated entity
1905
+ * would let a grantee claim to be a message's author while rewriting it. See
1906
+ * {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
1907
+ *
1948
1908
  * A caller under an Update row filter (a role's Update RLS filter or an API-key row filter) always
1949
1909
  * loads from the database too (#4919). The save's post-image check skips its query when no column
1950
1910
  * the filter reads is dirty, and dirtiness measured against client OldValues is the caller's to
@@ -1962,8 +1922,8 @@ export class ResolverBase {
1962
1922
  ): boolean {
1963
1923
  return (
1964
1924
  entityInfo.TrackRecordChanges ||
1965
- entityInfo.Name.trim().toLowerCase() === 'mj: record changes' ||
1966
- !input.OldValues___ ||
1925
+ ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
1926
+ !this.hasCompleteOldValues(entityInfo, input.OldValues___) ||
1967
1927
  hasDeniedReadFields ||
1968
1928
  hasNarrowedAuditPayload ||
1969
1929
  entityInfo.EnableFieldLevelSecurity ||
@@ -2137,19 +2097,36 @@ export class ResolverBase {
2137
2097
  }
2138
2098
 
2139
2099
  /**
2140
- * This routine compares the OldValues property in the input object to the values in the DB that we just loaded. If there are differences, we need to check to see if the client
2141
- * is trying to update any of those fields (e.g. overlap). If there is overlap, we throw an error. If there is no overlap, we can proceed with the update even if the DB Values
2142
- * and the ClientOldValues are not 100% the same, so long as there is no overlap in the specific FIELDS that are different.
2100
+ * Compares the client-supplied `OldValues___` array in `input` against the authoritative database
2101
+ * values just loaded onto `entityObject` via `InnerLoad`, checks for overlapping concurrent field
2102
+ * modifications, and then applies `clientNewValues` onto `entityObject` via `SetMany`.
2143
2103
  *
2144
- * ASSUMES: input object has an OldValues___ property that is an array of Key/Value pairs that represent the old values of the record that the client is trying to update.
2104
+ * Supports both full-record and delta-only `OldValues___` payloads: only keys present in
2105
+ * `input.OldValues___` (e.g. primary keys + dirty writable fields) are compared against `dbValues`,
2106
+ * and `entityObject.SetMany(clientNewValues)` mutates only the fields sent in `clientNewValues`
2107
+ * while preserving all untouched DB-loaded fields on `entityObject`.
2108
+ *
2109
+ * ASSUMES: `input` has an `OldValues___` property that is an array of Key/Value pairs representing
2110
+ * the pre-mutation values of the fields the client is updating.
2145
2111
  */
2112
+ /**
2113
+ * Resolves a field on `entityInfo` by `CodeName` or `Name` in $O(1)$ via `FieldByCodeName` /
2114
+ * `FieldByName` when available on `EntityInfo`, falling back to `.Fields.find()` for test stubs.
2115
+ */
2116
+ private findEntityFieldByCodeNameOrName(entityInfo: EntityInfo, key: string): EntityFieldInfo | undefined {
2117
+ if (typeof entityInfo.FieldByCodeName === 'function') {
2118
+ return entityInfo.FieldByCodeName(key) ?? entityInfo.FieldByName(key);
2119
+ }
2120
+ return entityInfo.Fields.find((f) => f.CodeName === key || f.Name === key);
2121
+ }
2122
+
2146
2123
  protected async TestAndSetClientOldValuesToDBValues(input: any, clientNewValues: any, entityObject: BaseEntity, contextUser: UserInfo) {
2147
2124
  // we have OldValues, so we need to compare them to the values we just loaded from the DB
2148
2125
  const clientOldValues = {};
2149
2126
  // for each item in the oldValues array, add it to the clientOldValues object
2150
2127
  input.OldValues___.forEach((item) => {
2151
2128
  // 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
2152
- const field = entityObject.EntityInfo.Fields.find((f) => f.CodeName === item.Key);
2129
+ const field = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, item.Key);
2153
2130
  const val = this.ClientOldValueToFieldValue(field, item.Value);
2154
2131
  clientOldValues[item.Key] = val;
2155
2132
  });
@@ -2161,19 +2138,20 @@ export class ResolverBase {
2161
2138
  // 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
2162
2139
  const dbDifferences = [];
2163
2140
  Object.keys(clientOldValues).forEach((key) => {
2164
- const f = entityObject.EntityInfo.Fields.find((f) => f.CodeName === key);
2141
+ const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
2142
+ const dbVal = f && f.Name in dbValues ? dbValues[f.Name] : dbValues[key];
2165
2143
  let different = false;
2166
2144
  switch (typeof clientOldValues[key]) {
2167
2145
  case 'number':
2168
- different = clientOldValues[key] !== dbValues[key];
2146
+ different = clientOldValues[key] !== dbVal;
2169
2147
  break;
2170
2148
  case 'boolean':
2171
- different = clientOldValues[key] !== dbValues[key];
2149
+ different = clientOldValues[key] !== dbVal;
2172
2150
  break;
2173
2151
  case 'object':
2174
2152
  if (clientOldValues[key] instanceof Date) {
2175
2153
  const clientDate = clientOldValues[key] as Date;
2176
- const dbDate = dbValues[key];
2154
+ const dbDate = dbVal;
2177
2155
  if (dbDate == null || !(dbDate instanceof Date)) {
2178
2156
  different = true;
2179
2157
  } else if (clientDate.getTime() !== dbDate.getTime()) {
@@ -2191,11 +2169,11 @@ export class ResolverBase {
2191
2169
  }
2192
2170
  }
2193
2171
  } else if (clientOldValues[key] === null) {
2194
- different = dbValues[key] !== null;
2172
+ different = dbVal !== null;
2195
2173
  }
2196
2174
  break;
2197
2175
  default:
2198
- different = clientOldValues[key] !== dbValues[key];
2176
+ different = clientOldValues[key] !== dbVal;
2199
2177
  break;
2200
2178
  }
2201
2179
  if (different && f && !f.ReadOnly) {
@@ -2203,7 +2181,7 @@ export class ResolverBase {
2203
2181
  dbDifferences.push({
2204
2182
  FieldName: key,
2205
2183
  ClientOldValue: clientOldValues[key],
2206
- DBValue: dbValues[key],
2184
+ DBValue: dbVal,
2207
2185
  });
2208
2186
  }
2209
2187
  });
@@ -2213,8 +2191,8 @@ export class ResolverBase {
2213
2191
  // 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
2214
2192
  const clientDifferences = [];
2215
2193
  Object.keys(clientOldValues).forEach((key) => {
2216
- const f = entityObject.EntityInfo.Fields.find((f) => f.CodeName === key);
2217
- if (clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
2194
+ const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
2195
+ if (key in clientNewValues && clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
2218
2196
  // only include updateable fields
2219
2197
  clientDifferences.push({
2220
2198
  FieldName: key,
@@ -2226,7 +2204,8 @@ export class ResolverBase {
2226
2204
 
2227
2205
  // 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
2228
2206
  // 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
2229
- const overlap = clientDifferences.filter((cd) => dbDifferences.find((dd) => dd.FieldName === cd.FieldName));
2207
+ const dbDiffFieldNames = new Set(dbDifferences.map((dd) => dd.FieldName));
2208
+ const overlap = clientDifferences.filter((cd) => dbDiffFieldNames.has(cd.FieldName));
2230
2209
  if (overlap.length > 0) {
2231
2210
  const msg = {
2232
2211
  Message: