@memberjunction/server 6.2.0-edge.1 → 6.2.0-edge.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +2189 -71
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +123 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +11 -23
  9. package/dist/context.js.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.d.ts +2143 -205
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +24441 -14433
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  15. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  16. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  17. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  18. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  19. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  20. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  21. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  22. package/dist/generic/PushStatusResolver.d.ts +38 -0
  23. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  24. package/dist/generic/PushStatusResolver.js +84 -1
  25. package/dist/generic/PushStatusResolver.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +56 -4
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +191 -74
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +33 -1
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +204 -50
  37. package/dist/index.js.map +1 -1
  38. package/dist/postgresqlCompat.d.ts +8 -0
  39. package/dist/postgresqlCompat.d.ts.map +1 -0
  40. package/dist/postgresqlCompat.js +19 -0
  41. package/dist/postgresqlCompat.js.map +1 -0
  42. package/dist/postgresqlPoolSettings.d.ts +108 -0
  43. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  44. package/dist/postgresqlPoolSettings.js +134 -0
  45. package/dist/postgresqlPoolSettings.js.map +1 -0
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  51. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  52. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  53. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  54. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  55. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  56. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  57. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  58. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  59. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  60. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  61. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  62. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  63. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  64. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  65. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  66. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  67. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  68. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  69. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  70. package/dist/resolvers/GetDataResolver.js +9 -0
  71. package/dist/resolvers/GetDataResolver.js.map +1 -1
  72. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  73. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  74. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  75. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  76. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  77. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  78. package/dist/resolvers/MeetingResolver.js +381 -0
  79. package/dist/resolvers/MeetingResolver.js.map +1 -0
  80. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  81. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  82. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  83. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  84. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  85. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  86. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  87. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +45 -89
  89. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  90. package/dist/resolvers/RealtimeClientSessionResolver.js +94 -240
  91. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  92. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  93. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  94. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  95. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  96. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  97. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  98. package/dist/resolvers/RunDecisionResolver.js +396 -0
  99. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  100. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  101. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  102. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  103. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  104. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  105. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  106. package/dist/resolvers/adhocTimeout.js +12 -0
  107. package/dist/resolvers/adhocTimeout.js.map +1 -0
  108. package/dist/resolvers/meetings/index.d.ts +5 -0
  109. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  110. package/dist/resolvers/meetings/index.js +5 -0
  111. package/dist/resolvers/meetings/index.js.map +1 -0
  112. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  113. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  114. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  115. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  116. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  117. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  118. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  119. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  120. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  121. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  122. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  123. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  124. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  125. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  126. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  127. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  128. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  129. package/dist/rest/ViewOperationsHandler.js +8 -5
  130. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  131. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  132. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  133. package/dist/services/TaskGraphActionRunner.js +27 -1
  134. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  135. package/dist/services/WorkQueueHostService.d.ts +36 -0
  136. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  137. package/dist/services/WorkQueueHostService.js +97 -0
  138. package/dist/services/WorkQueueHostService.js.map +1 -0
  139. package/dist/services/workQueueConfig.d.ts +103 -0
  140. package/dist/services/workQueueConfig.d.ts.map +1 -0
  141. package/dist/services/workQueueConfig.js +48 -0
  142. package/dist/services/workQueueConfig.js.map +1 -0
  143. package/dist/types.d.ts +5 -0
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js.map +1 -1
  146. package/package.json +95 -92
  147. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  148. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  149. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  150. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  151. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  152. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  153. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  154. package/src/__tests__/MeetingResolver.test.ts +374 -0
  155. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  156. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  157. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  158. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  159. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  160. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  161. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  162. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  163. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  164. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  165. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  166. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  167. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +43 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  170. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  171. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  172. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  173. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  174. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  175. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  176. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  177. package/src/config.ts +130 -3
  178. package/src/context.ts +11 -24
  179. package/src/generated/graphql-schemas/__mj.ts +24183 -17182
  180. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  181. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  182. package/src/generic/PushStatusResolver.ts +97 -1
  183. package/src/generic/ResolverBase.ts +220 -76
  184. package/src/generic/RunViewResolver.ts +33 -2
  185. package/src/index.ts +228 -51
  186. package/src/postgresqlCompat.ts +21 -0
  187. package/src/postgresqlPoolSettings.ts +213 -0
  188. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  189. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  190. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  191. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  192. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  193. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  194. package/src/resolvers/GetDataResolver.ts +10 -0
  195. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  196. package/src/resolvers/MeetingResolver.ts +438 -0
  197. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  198. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  199. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -287
  200. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  201. package/src/resolvers/RunDecisionResolver.ts +435 -0
  202. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  203. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
  204. package/src/resolvers/adhocTimeout.ts +12 -0
  205. package/src/resolvers/meetings/index.ts +4 -0
  206. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  207. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  208. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  209. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  210. package/src/rest/ViewOperationsHandler.ts +8 -5
  211. package/src/services/TaskGraphActionRunner.ts +29 -1
  212. package/src/services/WorkQueueHostService.ts +129 -0
  213. package/src/services/workQueueConfig.ts +52 -0
  214. package/src/types.ts +5 -0
  215. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  216. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  217. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  218. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  219. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  220. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -0,0 +1,308 @@
1
+ import { Arg, Ctx, Field, Int, ObjectType, Query, Resolver } from 'type-graphql';
2
+ import { RunView } from '@memberjunction/core';
3
+ import { EscapeSQLString } from '@memberjunction/global';
4
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
5
+ import type { MJAIAgentRunEntity, MJAIAgentRunStepEntity, MJConversationDetailEntity } from '@memberjunction/core-entities';
6
+ import type { AppContext } from '../types.js';
7
+ import { GetReadOnlyProvider } from '../util.js';
8
+
9
+ /** The run columns the tail reads. */
10
+ type TailRunRow = Pick<MJAIAgentRunEntity, 'ID' | 'Status' | 'Result'>;
11
+
12
+ /** The step columns an event carries. The large data columns are never read. */
13
+ type TailStepRow = Pick<
14
+ MJAIAgentRunStepEntity,
15
+ 'StepNumber' | 'StepType' | 'StepName' | 'Status' | 'Success' | 'ErrorMessage' | 'StartedAt' | 'CompletedAt'
16
+ >;
17
+
18
+ const RUN_FIELDS: (keyof TailRunRow)[] = ['ID', 'Status', 'Result'];
19
+ const STEP_FIELDS: (keyof TailStepRow)[] = [
20
+ 'StepNumber', 'StepType', 'StepName', 'Status', 'Success', 'ErrorMessage', 'StartedAt', 'CompletedAt',
21
+ ];
22
+
23
+ /**
24
+ * One step of an agent run, projected for the client's progress view.
25
+ *
26
+ * Carries metadata only. `InputData`, `OutputData` and the payload snapshots are
27
+ * deliberately excluded: they are the largest columns on the row and a run can hold
28
+ * hundreds of steps, so including them would let a single tail call return tens of
29
+ * megabytes. The final result is delivered once, via `FinalPayload`, when the run
30
+ * reaches a terminal state.
31
+ */
32
+ @ObjectType()
33
+ export class ConversationRunEvent {
34
+ /** Monotonic position within the run. The cursor a client pages from. */
35
+ @Field(() => Int)
36
+ Seq: number;
37
+
38
+ @Field(() => String)
39
+ StepType: string;
40
+
41
+ @Field(() => String)
42
+ StepName: string;
43
+
44
+ @Field(() => String)
45
+ Status: string;
46
+
47
+ @Field(() => Boolean, { nullable: true })
48
+ Success?: boolean;
49
+
50
+ @Field(() => String, { nullable: true })
51
+ ErrorMessage?: string;
52
+
53
+ @Field(() => Date)
54
+ StartedAt: Date;
55
+
56
+ @Field(() => Date, { nullable: true })
57
+ CompletedAt?: Date;
58
+ }
59
+
60
+ /** Result of a tail call. */
61
+ @ObjectType()
62
+ export class ConversationRunEventsOutput {
63
+ @Field(() => Boolean)
64
+ Success: boolean;
65
+
66
+ @Field(() => String)
67
+ Message: string;
68
+
69
+ @Field(() => [ConversationRunEvent])
70
+ Events: ConversationRunEvent[];
71
+
72
+ /**
73
+ * Highest `Seq` the caller has now seen. Pass it back as `sinceSeq` on the next call.
74
+ * Echoes the request's `sinceSeq` when nothing new has landed, so a client that polls
75
+ * a quiet run never rewinds.
76
+ */
77
+ @Field(() => Int)
78
+ LatestSeq: number;
79
+
80
+ /**
81
+ * True while the run is still executing, including a run `Paused` on a workflow that is still
82
+ * running. False means terminal — stop tailing, and `FinalPayload` / `RunStatus` hold the
83
+ * outcome. A failed call (`Success` false) reports true, because it knows nothing about the run.
84
+ */
85
+ @Field(() => Boolean)
86
+ IsInFlight: boolean;
87
+
88
+ @Field(() => String, { nullable: true })
89
+ RunID?: string;
90
+
91
+ @Field(() => String, { nullable: true })
92
+ RunStatus?: string;
93
+
94
+ /**
95
+ * The run's answer, returned only once the run is terminal.
96
+ *
97
+ * Sourced from `AIAgentRun.Result`, falling back to the conversation detail's message because
98
+ * `Result` is agent-dependent and null on many successful runs. Still nullable: a caller must
99
+ * decide a message is finished from {@link IsInFlight} / {@link DetailStatus}, never from the
100
+ * presence of this field, or it will wait forever on an agent that writes neither.
101
+ */
102
+ @Field(() => String, { nullable: true })
103
+ FinalPayload?: string;
104
+
105
+ /** `ConversationDetail.Status` — the value the chat UI renders from. */
106
+ @Field(() => String, { nullable: true })
107
+ DetailStatus?: string;
108
+ }
109
+
110
+ /**
111
+ * Every read here sets `BypassCache`. This is the last-resort recovery path, and the rows it
112
+ * judges are written by callers that fire no cache invalidation — `spSweepStaleAIAgentRuns`
113
+ * force-fails a run with a direct set-based UPDATE. `TrustServerCacheCompletely` defaults on, so
114
+ * a cached read would keep reporting a swept run as `Running` and recovery would never fire.
115
+ */
116
+
117
+ /** Ceiling on events returned per call. A client with a gap larger than this pages. */
118
+ const MAX_EVENTS_PER_TAIL = 200;
119
+
120
+ /**
121
+ * Run statuses that mean execution is still in progress. `Paused` is a run parked on a workflow
122
+ * that is still executing; the orphan reconciler and the process panel treat it the same way.
123
+ */
124
+ const IN_FLIGHT_STATUSES: MJAIAgentRunEntity['Status'][] = ['Running', 'Paused'];
125
+
126
+ /**
127
+ * Durable, resumable read of an agent run's progress (MJ #4222).
128
+ *
129
+ * The `statusUpdates` subscription is the only live path a conversation has, and it has no
130
+ * replay: the server topic is an in-memory PubSub and the client subject is explicitly
131
+ * unbuffered. Anything published while a socket is half-open is gone, and the client cannot
132
+ * tell the difference between "nothing happened" and "I missed everything".
133
+ *
134
+ * This query is the durable counterpart, mirroring `IntegrationTailRunEvents` — the same
135
+ * shape the integration subsystem already uses, where the subscription is a latency
136
+ * optimization over a log rather than the only delivery path. A client keeps the `LatestSeq`
137
+ * it has seen and re-reads from there after any interruption.
138
+ *
139
+ * It is a READ MODEL over tables that already exist. `AIAgentRunStep` rows are inserted when
140
+ * a step starts and updated when it finishes, so progress is visible mid-run, and
141
+ * `StepNumber` is already monotonic per run. No new table and no migration.
142
+ *
143
+ * Authorization is delegated to `RunView` executed as the calling user, so MJ's existing
144
+ * row-level security decides what is visible. A caller who cannot read the conversation
145
+ * detail gets the same answer as one asking about a detail that does not exist.
146
+ */
147
+ @Resolver()
148
+ export class ConversationRunEventsResolver {
149
+ @Query(() => ConversationRunEventsOutput)
150
+ async TailConversationEvents(
151
+ @Arg('conversationDetailID', () => String) conversationDetailID: string,
152
+ @Ctx() ctx: AppContext,
153
+ @Arg('sinceSeq', () => Int, { defaultValue: 0 }) sinceSeq?: number
154
+ ): Promise<ConversationRunEventsOutput> {
155
+ const from = Math.max(0, sinceSeq ?? 0);
156
+ const empty = (message: string): ConversationRunEventsOutput => ({
157
+ Success: false,
158
+ Message: message,
159
+ Events: [],
160
+ LatestSeq: from,
161
+ IsInFlight: true,
162
+ });
163
+
164
+ try {
165
+ const user = ctx.userPayload?.userRecord;
166
+ if (!user) {
167
+ return empty('User is not authenticated');
168
+ }
169
+
170
+ const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true });
171
+ const detail = await this.loadDetail(provider, conversationDetailID, user);
172
+ if (!detail) {
173
+ // Not found and not authorized are deliberately indistinguishable — answering
174
+ // "exists but denied" would confirm the id to someone probing for one.
175
+ return empty(`Conversation detail '${conversationDetailID}' not found`);
176
+ }
177
+
178
+ const run = await this.loadLatestRun(provider, conversationDetailID, user);
179
+ if (!run) {
180
+ // The mutation is acknowledged before the run row exists, so this is an
181
+ // ordinary early-poll result, not an error. Reported as success with nothing
182
+ // to show so a client does not treat a race as a failure.
183
+ return {
184
+ Success: true,
185
+ Message: 'No agent run yet for this conversation detail',
186
+ Events: [],
187
+ LatestSeq: from,
188
+ IsInFlight: false,
189
+ DetailStatus: detail.Status ?? undefined,
190
+ };
191
+ }
192
+
193
+ const isInFlight = IN_FLIGHT_STATUSES.includes(run.Status);
194
+ const steps = await this.loadSteps(provider, run.ID, from, user);
195
+ const events = steps.map((s) => this.toEvent(s));
196
+
197
+ return {
198
+ Success: true,
199
+ Message: `${events.length} event(s)`,
200
+ Events: events,
201
+ // Never rewind: a quiet run returns the cursor the caller already held.
202
+ LatestSeq: events.length > 0 ? events[events.length - 1].Seq : from,
203
+ IsInFlight: isInFlight,
204
+ RunID: run.ID,
205
+ RunStatus: run.Status,
206
+ // Only meaningful once terminal; sending it mid-run would hand the client a
207
+ // partial payload it could mistake for the final answer.
208
+ //
209
+ // `AIAgentRun.Result` is agent-dependent — it is null on a large share of
210
+ // genuinely successful runs — so the conversation detail's own message is the
211
+ // fallback. That is the text the chat renders, so it is the answer a caller
212
+ // waiting on this field actually wants.
213
+ FinalPayload: isInFlight ? undefined : (run.Result ?? detail.Message ?? undefined),
214
+ DetailStatus: detail.Status ?? undefined,
215
+ };
216
+ } catch (e) {
217
+ const message = e instanceof Error ? e.message : String(e);
218
+ return empty(`TailConversationEvents failed: ${message}`);
219
+ }
220
+ }
221
+
222
+ /** Loads the conversation detail as the calling user; null when absent or not permitted. */
223
+ private async loadDetail(
224
+ provider: IMetadataProvider,
225
+ conversationDetailID: string,
226
+ user: UserInfo
227
+ ): Promise<MJConversationDetailEntity | undefined> {
228
+ const rv = RunView.FromMetadataProvider(provider);
229
+ const result = await rv.RunView<MJConversationDetailEntity>(
230
+ {
231
+ EntityName: 'MJ: Conversation Details',
232
+ ExtraFilter: `ID='${this.escape(conversationDetailID)}'`,
233
+ MaxRows: 1,
234
+ ResultType: 'entity_object',
235
+ BypassCache: true,
236
+ },
237
+ user
238
+ );
239
+ return result.Success ? result.Results?.[0] : undefined;
240
+ }
241
+
242
+ /**
243
+ * Newest run for the detail. A conversation detail can be retried, and the latest run is
244
+ * the one the UI is showing.
245
+ */
246
+ private async loadLatestRun(
247
+ provider: IMetadataProvider,
248
+ conversationDetailID: string,
249
+ user: UserInfo
250
+ ): Promise<TailRunRow | undefined> {
251
+ const rv = RunView.FromMetadataProvider(provider);
252
+ const result = await rv.RunView<TailRunRow>(
253
+ {
254
+ EntityName: 'MJ: AI Agent Runs',
255
+ ExtraFilter: `ConversationDetailID='${this.escape(conversationDetailID)}'`,
256
+ OrderBy: '__mj_CreatedAt DESC',
257
+ MaxRows: 1,
258
+ Fields: RUN_FIELDS,
259
+ ResultType: 'simple',
260
+ BypassCache: true,
261
+ },
262
+ user
263
+ );
264
+ return result.Success ? result.Results?.[0] : undefined;
265
+ }
266
+
267
+ /** Steps after `sinceSeq`, oldest first, bounded by {@link MAX_EVENTS_PER_TAIL}. */
268
+ private async loadSteps(
269
+ provider: IMetadataProvider,
270
+ agentRunID: string,
271
+ sinceSeq: number,
272
+ user: UserInfo
273
+ ): Promise<TailStepRow[]> {
274
+ const rv = RunView.FromMetadataProvider(provider);
275
+ const result = await rv.RunView<TailStepRow>(
276
+ {
277
+ EntityName: 'MJ: AI Agent Run Steps',
278
+ ExtraFilter: `AgentRunID='${this.escape(agentRunID)}' AND StepNumber > ${Math.floor(sinceSeq)}`,
279
+ OrderBy: 'StepNumber ASC',
280
+ MaxRows: MAX_EVENTS_PER_TAIL,
281
+ Fields: STEP_FIELDS,
282
+ ResultType: 'simple',
283
+ BypassCache: true,
284
+ },
285
+ user
286
+ );
287
+ return result.Success ? (result.Results ?? []) : [];
288
+ }
289
+
290
+ /** Projects a plain step row to an event. Plain rows may carry dates as strings. */
291
+ private toEvent(step: TailStepRow): ConversationRunEvent {
292
+ return {
293
+ Seq: step.StepNumber,
294
+ StepType: step.StepType,
295
+ StepName: step.StepName,
296
+ Status: step.Status,
297
+ Success: step.Success ?? undefined,
298
+ ErrorMessage: step.ErrorMessage ?? undefined,
299
+ StartedAt: new Date(step.StartedAt),
300
+ CompletedAt: step.CompletedAt != null ? new Date(step.CompletedAt) : undefined,
301
+ };
302
+ }
303
+
304
+ /** Escapes a value for the `ExtraFilter` string. Ids are UUIDs, but never trust the shape. */
305
+ private escape(value: string): string {
306
+ return EscapeSQLString(value);
307
+ }
308
+ }
@@ -0,0 +1,232 @@
1
+ import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, Int } from 'type-graphql';
2
+ import { AppContext, UserPayload } from '../types.js';
3
+ import { EntityFieldInfo, EntityInfo, IMetadataProvider, LogError, Metadata } from '@memberjunction/core';
4
+ import { DuplicateRecordDetector } from '@memberjunction/ai-vector-dupe';
5
+ import type { DuplicateEntryCheckResult, DuplicateEntryCheckStatus } from '@memberjunction/ai-vector-dupe';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+ import { GetReadOnlyProvider } from '../util.js';
8
+
9
+ /**
10
+ * The most characters `valuesJSON` may hold. A larger request is refused before it is parsed. The
11
+ * form sends each field's text cut to 500 characters, so a real entry stays well under it; the
12
+ * detector then cuts what reaches the template and the decision model on its own.
13
+ */
14
+ export const DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH = 16 * 1024;
15
+
16
+ /**
17
+ * How a `CheckDuplicateEntry` call ended: a detector status, or `NotAuthorized` when the caller may
18
+ * not run the check (an API key without the scopes, or a user who cannot read the entity). Nothing
19
+ * ran, and a form stops asking about the entity for the session, as it does on `NotConfigured`.
20
+ */
21
+ export type DuplicateEntryCheckOutputStatus = DuplicateEntryCheckStatus | 'NotAuthorized';
22
+
23
+ /** One entered value, as `valuesJSON` may carry it. */
24
+ type EntryValue = string | number | boolean | null;
25
+
26
+ /** One existing record the entry-time check flags as a possible duplicate. */
27
+ @ObjectType()
28
+ export class DuplicateEntryCandidateOutput {
29
+ /** The candidate's primary key as a compact URL segment; `CompositeKey.FromURLSegment` reads it back. */
30
+ @Field()
31
+ RecordID: string;
32
+
33
+ /** The candidate's name, or its key when the caller may not read a name. */
34
+ @Field()
35
+ DisplayName: string;
36
+
37
+ /** The vector similarity score that surfaced the candidate. */
38
+ @Field(() => Float)
39
+ VectorScore: number;
40
+
41
+ /** The decision's probability that the candidate is the same entity; null when it gave no answer. */
42
+ @Field(() => Float, { nullable: true })
43
+ Probability: number | null;
44
+ }
45
+
46
+ /**
47
+ * The result of the `CheckDuplicateEntry` mutation. The mutation never throws to the client: an
48
+ * authorization denial is `Status: 'NotAuthorized'` and any other failure is `Status: 'Failed'`,
49
+ * each with an `ErrorMessage`.
50
+ */
51
+ @ObjectType()
52
+ export class DuplicateEntryCheckOutput {
53
+ /** `Checked`, `NotConfigured`, `NotAuthorized` or `Failed`. */
54
+ @Field(() => String)
55
+ Status: DuplicateEntryCheckOutputStatus;
56
+
57
+ @Field({ nullable: true })
58
+ ErrorMessage?: string;
59
+
60
+ /** The flagged candidates, most probable first. Empty unless `Status` is `Checked`. */
61
+ @Field(() => [DuplicateEntryCandidateOutput])
62
+ Candidates: DuplicateEntryCandidateOutput[];
63
+
64
+ /** How long the check took on the server, in milliseconds. */
65
+ @Field(() => Int)
66
+ ElapsedMs: number;
67
+ }
68
+
69
+ /** What the resolver needs from the detector. `DuplicateRecordDetector` provides it. */
70
+ export type DuplicateEntryChecker = Pick<DuplicateRecordDetector, 'CheckRecordValues'>;
71
+
72
+ /**
73
+ * Checks the values a person is entering for a new record against the entity's existing records,
74
+ * through `DuplicateRecordDetector.CheckRecordValues`, and returns the plausible duplicates to flag.
75
+ * It only flags: it saves, merges and blocks nothing.
76
+ */
77
+ @Resolver()
78
+ export class DuplicateEntryCheckResolver extends ResolverBase {
79
+ /**
80
+ * Flags existing records that may duplicate a new record being entered.
81
+ *
82
+ * Authorization comes before any other work. An API key needs two scopes: `view:run` on the
83
+ * entity, the scope `RunDynamicView` checks, since the check reads the entity's records; and
84
+ * `prompt:execute`, the scope `ExecuteSimplePrompt` checks, since each check pays for an embedding
85
+ * and a decision-model call. A session without an API key skips both. Then the user needs read
86
+ * permission on the entity. A denial is `NotAuthorized`, and is not logged. The detector then
87
+ * narrows the candidates to the rows the user can read.
88
+ *
89
+ * The detector bounds the check's time on the server (`DUPLICATE_ENTRY_CHECK_SERVER_BUDGET_MS` in
90
+ * `@memberjunction/ai-vector-dupe`), a little above the form's own budget.
91
+ *
92
+ * @param entityName The entity the new record belongs to.
93
+ * @param valuesJSON The entered values as the JSON of an object, by field name, at most
94
+ * {@link DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH} characters. Each value is a string,
95
+ * number, boolean or null. Fields the entity does not have, primary keys, read-only fields and
96
+ * MJ system fields are ignored.
97
+ */
98
+ @Mutation(() => DuplicateEntryCheckOutput)
99
+ async CheckDuplicateEntry(
100
+ @Arg('entityName') entityName: string,
101
+ @Arg('valuesJSON') valuesJSON: string,
102
+ @Ctx() { userPayload, providers }: AppContext
103
+ ): Promise<DuplicateEntryCheckOutput> {
104
+ const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
105
+ return this.RunEntryCheck(entityName, valuesJSON, userPayload, provider);
106
+ }
107
+
108
+ /**
109
+ * The check without the GraphQL plumbing: authorizes, reads the values, and runs the detector.
110
+ * Never throws.
111
+ *
112
+ * @param provider the request's metadata provider; the global one when null
113
+ */
114
+ public async RunEntryCheck(
115
+ entityName: string,
116
+ valuesJSON: string,
117
+ userPayload: UserPayload,
118
+ provider: IMetadataProvider | null
119
+ ): Promise<DuplicateEntryCheckOutput> {
120
+ const startTime = Date.now();
121
+ const entity = (provider ?? new Metadata()).EntityByName(entityName);
122
+ if (!entity) {
123
+ return this.failure('Failed', 'Entity not found in metadata', startTime);
124
+ }
125
+ const denial = await this.authorizeEntryCheck(entity, userPayload, provider);
126
+ if (denial) {
127
+ return this.failure('NotAuthorized', denial, startTime);
128
+ }
129
+ try {
130
+ const user = this.GetUserFromPayload(userPayload);
131
+ if (!user) {
132
+ return this.failure('Failed', 'Unable to determine current user', startTime);
133
+ }
134
+ const values = this.parseValues(valuesJSON, entity);
135
+ if ('Error' in values) {
136
+ return this.failure('Failed', values.Error, startTime);
137
+ }
138
+ const result = await this.CreateEntryChecker(provider).CheckRecordValues(entity.Name, values.Values, user);
139
+ return this.mapResult(result);
140
+ } catch (error) {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ LogError(`CheckDuplicateEntry failed for ${entityName}: ${message}`);
143
+ return this.failure('Failed', message || 'Unknown error occurred', startTime);
144
+ }
145
+ }
146
+
147
+ /** The detector a check runs on, bound to the request's provider. */
148
+ protected CreateEntryChecker(provider: IMetadataProvider | null): DuplicateEntryChecker {
149
+ return new DuplicateRecordDetector(provider);
150
+ }
151
+
152
+ /**
153
+ * Why the caller may not run the check, or null when they may: the API-key scopes, then the
154
+ * user's read permission on the entity. A denial is an answer, not a fault, so nothing is logged.
155
+ */
156
+ private async authorizeEntryCheck(
157
+ entity: EntityInfo,
158
+ userPayload: UserPayload,
159
+ provider: IMetadataProvider | null
160
+ ): Promise<string | null> {
161
+ try {
162
+ await this.CheckAPIKeyScopeAuthorization('view:run', entity.Name, userPayload);
163
+ await this.CheckAPIKeyScopeAuthorization('prompt:execute', '*', userPayload);
164
+ this.CheckUserReadPermissions(entity.Name, userPayload, provider ?? undefined);
165
+ return null;
166
+ } catch (error) {
167
+ return error instanceof Error ? error.message : String(error);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Reads the entered values: the JSON of an object of scalar values, at most
173
+ * {@link DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH} characters, kept to the fields a person
174
+ * can enter (matched by name, case-insensitively, and keyed by the field's real name).
175
+ */
176
+ private parseValues(valuesJSON: string, entity: EntityInfo): { Values: Record<string, EntryValue> } | { Error: string } {
177
+ if (valuesJSON.length > DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH) {
178
+ return { Error: `valuesJSON is ${valuesJSON.length} characters; the limit is ${DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH}` };
179
+ }
180
+ let parsed: unknown;
181
+ try {
182
+ parsed = JSON.parse(valuesJSON);
183
+ } catch (e) {
184
+ return { Error: `valuesJSON is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
185
+ }
186
+ if (!this.isPlainObject(parsed)) {
187
+ return { Error: 'valuesJSON must be the JSON of an object of field values' };
188
+ }
189
+ const values: Record<string, EntryValue> = {};
190
+ for (const [name, value] of Object.entries(parsed)) {
191
+ if (!this.isEntryValue(value)) {
192
+ return { Error: `valuesJSON field '${name}' must be a string, number, boolean or null` };
193
+ }
194
+ const field = entity.FieldByName(name);
195
+ if (field && this.isEnterable(field)) {
196
+ values[field.Name] = value;
197
+ }
198
+ }
199
+ return { Values: values };
200
+ }
201
+
202
+ /** Whether a person can enter the field: not a primary key, not read-only, not an MJ system field. */
203
+ private isEnterable(field: EntityFieldInfo): boolean {
204
+ return !field.IsPrimaryKey && !field.ReadOnly && !field.Name.startsWith('__mj_');
205
+ }
206
+
207
+ private isEntryValue(value: unknown): value is EntryValue {
208
+ return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
209
+ }
210
+
211
+ private isPlainObject(value: unknown): value is Record<string, unknown> {
212
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
213
+ }
214
+
215
+ private mapResult(result: DuplicateEntryCheckResult): DuplicateEntryCheckOutput {
216
+ return {
217
+ Status: result.Status,
218
+ ErrorMessage: result.ErrorMessage,
219
+ Candidates: result.Candidates.map(c => ({
220
+ RecordID: c.RecordID,
221
+ DisplayName: c.DisplayName,
222
+ VectorScore: c.VectorScore,
223
+ Probability: c.Probability,
224
+ })),
225
+ ElapsedMs: result.ElapsedMs,
226
+ };
227
+ }
228
+
229
+ private failure(status: DuplicateEntryCheckOutputStatus, errorMessage: string, startTime: number): DuplicateEntryCheckOutput {
230
+ return { Status: status, ErrorMessage: errorMessage, Candidates: [], ElapsedMs: Date.now() - startTime };
231
+ }
232
+ }
@@ -1,4 +1,4 @@
1
- import { CompositeKey, DatabaseProviderBase, EntityInfo, LogDebug, UserInfo } from '@memberjunction/core';
1
+ import { CompositeKey, DatabaseProviderBase } from '@memberjunction/core';
2
2
  import { Arg, Ctx, Field, InputType, ObjectType, Query, Resolver } from 'type-graphql';
3
3
  import { AppContext, UserPayload } from '../types.js';
4
4
  import { CompositeKeyInputType, CompositeKeyOutputType } from '../generic/KeyInputOutputTypes.js';
@@ -36,20 +36,13 @@ export class EntityRecordNameResult {
36
36
  * Resolves a record's display NAME from its primary key — the lookup behind every foreign-key
37
37
  * link, breadcrumb and picker label in the UI.
38
38
  *
39
- * **This is a read of entity data and is gated like one.** It historically took no user context
40
- * at all, which made it an open side channel: a caller denied READ on an entity's name field
41
- * could still obtain that name here, because the name never travelled through the resolver that
42
- * strips denied fields. Field-level security turned that latent gap into a live bypass — the
43
- * form-field FK control falls through to this query precisely WHEN the joined display column is
44
- * denied, so the one case the fallback exists for was the case that leaked.
39
+ * Entity-level read permission is checked here ({@link ResolverBase.CheckUserReadPermissions}).
40
+ * Field- and row-level security are applied by the provider's lookup, for the acting user passed
41
+ * to it: when any field the name is built from is withheld, it answers with no name.
45
42
  *
46
- * Two gates now apply, matching what the generated single-record resolvers do:
47
- * 1. entity-level read permission ({@link ResolverBase.CheckUserReadPermissions});
48
- * 2. field-level read permission on the entity's own name field.
49
- *
50
- * A field-level denial answers `Success: false` with no name rather than throwing. Callers
51
- * already treat "no name" as "show the primary key", which is the intended degradation — and it
52
- * keeps this query from becoming a probe that distinguishes "restricted" from "missing".
43
+ * A withheld name answers `Success: false` with the same status as a record that does not exist.
44
+ * Callers already treat "no name" as "show the primary key", and the identical answer keeps this
45
+ * query from becoming a probe that distinguishes "restricted" from "missing".
53
46
  */
54
47
  @Resolver(EntityRecordNameResult)
55
48
  export class EntityRecordNameResolver extends ResolverBase {
@@ -91,53 +84,18 @@ export class EntityRecordNameResolver extends ResolverBase {
91
84
  const e = md.Entities.find((e) => e.Name === EntityName);
92
85
  if (e) {
93
86
  const contextUser = userPayload ? this.GetUserFromPayload(userPayload) : undefined;
94
- if (this.IsNameFieldDeniedToUser(e, contextUser)) {
95
- LogDebug(
96
- `[FieldSecurity] Suppressed GetEntityRecordName for '${EntityName}': ` +
97
- `the name field is not readable by this user`
98
- );
99
- return {
100
- Success: false,
101
- Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
102
- CompositeKey: pk,
103
- EntityName,
104
- };
105
- }
106
- // Pass the acting user through: the provider signature accepts it, and omitting it ran the
107
- // underlying lookup with no identity at all — so row-level security had nothing to filter on
108
- // either. The field-level gate above is the column half; this is the row half.
87
+ // The acting user is what field- and row-level security are applied for.
109
88
  const recordName = await md.GetEntityRecordName(e.Name, pk, contextUser);
110
89
  if (recordName) return { Success: true, Status: 'OK', CompositeKey: pk, RecordName: recordName, EntityName };
111
90
  else
112
91
  return {
113
92
  Success: false,
114
- Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue if user doesn't have Row Level Access (RLS) if RLS is enabled for this entity`,
93
+ Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
115
94
  CompositeKey: pk,
116
95
  EntityName,
117
96
  };
118
97
  } else return { Success: false, Status: `Entity ${EntityName} not found`, CompositeKey: pk, EntityName };
119
98
  }
120
-
121
- /**
122
- * Whether field security withholds this entity's NAME field from the user.
123
- *
124
- * Resolves the name field the same way the provider's own lookup does — the designated
125
- * `IsNameField`, else a field literally called `Name` — so the check cannot drift from the
126
- * value it is guarding. Fails OPEN only where field security does not apply at all (flag off,
127
- * no user, no name field), matching every other FLS gate.
128
- */
129
- protected IsNameFieldDeniedToUser(entity: EntityInfo, contextUser?: UserInfo): boolean {
130
- if (!entity?.EnableFieldLevelSecurity || !contextUser) {
131
- return false;
132
- }
133
- const nameField =
134
- entity.Fields?.find(f => f.IsNameField) ??
135
- entity.Fields?.find(f => f.Name?.trim().toLowerCase() === 'name');
136
- if (!nameField) {
137
- return false;
138
- }
139
- return entity.GetDeniedReadFields(contextUser).has(nameField.Name.trim().toLowerCase());
140
- }
141
99
  }
142
100
 
143
101
  export default EntityRecordNameResolver;
@@ -5,6 +5,7 @@ import { MJEntityDocumentEntity, MJVectorIndexEntity, MJVectorDatabaseEntity } f
5
5
  import { ResolverBase } from '../generic/ResolverBase.js';
6
6
  import { GetAIAPIKey } from '@memberjunction/ai';
7
7
  import { VectorDBBase } from '@memberjunction/ai-vectordb';
8
+ import { AIEngine } from '@memberjunction/aiengine';
8
9
  import { EscapeSQLString, MJGlobal, UUIDsEqual } from '@memberjunction/global';
9
10
 
10
11
  /* ───── GraphQL types ───── */
@@ -99,7 +100,7 @@ export class FetchEntityVectorsResolver extends ResolverBase {
99
100
  const metadataFilter: Record<string, unknown> = { Entity: { $eq: entityName } };
100
101
 
101
102
  const queryResponse = await vectorDBInstance.QueryIndex({
102
- id: vectorIndex.Name, // index name (stripped before Pinecone query)
103
+ id: AIEngine.Instance.GetProviderIndexName(vectorIndex),
103
104
  vector: uniformVector,
104
105
  topK: limit,
105
106
  includeMetadata: true,
@@ -1,4 +1,5 @@
1
1
  import { Arg, Ctx, Field, InputType, ObjectType, Query } from 'type-graphql';
2
+ import { SQLExpressionValidator } from '@memberjunction/global';
2
3
  import { AppContext } from '../types.js';
3
4
  import { LogError, LogStatus, LogStatusEx, Metadata } from '@memberjunction/core';
4
5
  import { QueryCompositionEngine } from '@memberjunction/generic-database-provider';
@@ -154,6 +155,15 @@ export class GetDataResolver {
154
155
  resolvedSQL = compositionResult.ResolvedSQL;
155
156
  }
156
157
 
158
+ // Defense-in-depth: even system-token holders get SELECT-only SQL here.
159
+ // The read-only pool is the primary control, but if it is ever
160
+ // misconfigured to a read-write login this validator still refuses DML/DDL
161
+ // (mirrors AdhocQueryResolver).
162
+ const validation = SQLExpressionValidator.Instance.validateFullQuery(resolvedSQL);
163
+ if (!validation.valid) {
164
+ return { result: null, error: validation.error || 'SQL validation failed' };
165
+ }
166
+
157
167
  const request = new sql.Request(readOnlyDataSource);
158
168
  const result = await request.query(resolvedSQL);
159
169
  return { result: result.recordset, error: null };