@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
package/src/index.ts CHANGED
@@ -31,6 +31,8 @@ import { WebSocketServer } from 'ws';
31
31
  import { RealtimeProxyServer } from './realtimeProxy/RealtimeProxyServer.js';
32
32
  import buildApolloServer from './apolloServer/index.js';
33
33
  import { configInfo, configFilePath, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage, RESTApiOptions } from './config.js';
34
+ import { TranslateBracketsToPG } from './postgresqlCompat.js';
35
+ import { BuildPostgreSQLConnectionConfig, DescribeReadOnlyLoginOverreach, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials, ToPGPoolConfig } from './postgresqlPoolSettings.js';
34
36
  import { default as jwt } from 'jsonwebtoken';
35
37
  import { contextFunction, CreateUnifiedAuthMiddleware, getUserPayload } from './context.js';
36
38
  import { UserPayload } from './types.js';
@@ -61,12 +63,26 @@ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
61
63
  import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
62
64
  import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
63
65
  import { PubSubManager } from './generic/PubSubManager.js';
66
+ import { ReconcileOrphanedConversationDetails } from './generic/OrphanedConversationDetailReconciler.js';
67
+ import {
68
+ PUSH_STATUS_UPDATES_TOPIC,
69
+ SetPushStatusPublishHook,
70
+ ParseReplicatedStatusUpdate,
71
+ } from './generic/PushStatusResolver.js';
72
+ import {
73
+ HANDOFF_OFFER_FANOUT_CHANNEL,
74
+ HANDOFF_OFFER_TOPIC,
75
+ SetHandoffOfferPublishHook,
76
+ ParseReplicatedHandoffOfferUpdate,
77
+ } from './resolvers/HumanHandoffResolver.js';
78
+ import { RoomHandoffEngine } from '@memberjunction/livekit-room-server';
64
79
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
65
80
  import { PublishIntegrationProgress } from './resolvers/IntegrationProgressResolver.js';
66
81
  import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
67
82
  import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
68
83
  import { SessionJanitor } from './agentSessions/index.js';
69
84
  import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
85
+ import { MJServerWorkQueueProviderSource, StartWorkQueueHost } from './services/WorkQueueHostService.js';
70
86
  import { GetAttachmentService } from '@memberjunction/aiengine';
71
87
  import { MJStorageBlobStore } from './services/MJStorageBlobStore.js';
72
88
  import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData, ConfigureRecordDataBroadcast } from './generic/CacheInvalidationResolver.js';
@@ -167,7 +183,9 @@ export * from './generic/refusalExtensions.js';
167
183
  export * from './generic/RunViewResolver.js';
168
184
  export * from './resolvers/RunTemplateResolver.js';
169
185
  export * from './resolvers/RunAIPromptResolver.js';
186
+ export * from './resolvers/RunDecisionResolver.js';
170
187
  export * from './resolvers/RunAIAgentResolver.js';
188
+ export { AgentRunStatusPublisher } from './resolvers/AgentRunStatusPublisher.js';
171
189
  export * from './resolvers/VectorizeEntityResolver.js';
172
190
  export * from './resolvers/SearchKnowledgeResolver.js';
173
191
  export * from './resolvers/SearchKnowledgeStreamResolver.js';
@@ -224,6 +242,7 @@ export * from './rest/MediaAccessKeys.js';
224
242
  export * from './rest/MediaStreamHandler.js';
225
243
  export * from './resolvers/InfoResolver.js';
226
244
  export * from './resolvers/PotentialDuplicateRecordResolver.js';
245
+ export * from './resolvers/DuplicateEntryCheckResolver.js';
227
246
  export * from './resolvers/RunTestResolver.js';
228
247
  export * from './resolvers/SearchEntitiesResolver.js';
229
248
  export * from './resolvers/UserFavoriteResolver.js';
@@ -234,6 +253,7 @@ export * from './resolvers/CurrentUserContextResolver.js';
234
253
  export * from './resolvers/RSUResolver.js';
235
254
  export * from './resolvers/AgentSessionResolver.js';
236
255
  export * from './resolvers/RealtimeClientSessionResolver.js';
256
+ export * from './resolvers/MeetingResolver.js';
237
257
  export * from './resolvers/RemoteBrowserActionResolver.js';
238
258
  export * from './agentSessions/index.js';
239
259
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
@@ -287,11 +307,108 @@ function resolveServerVersion(): string | undefined {
287
307
  const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
288
308
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
289
309
  return pkg.version;
290
- } catch {
310
+ } catch (err) {
311
+ LogError('Failed to resolve server version from package.json', undefined, err);
291
312
  return undefined;
292
313
  }
293
314
  }
294
315
 
316
+ /** How often to re-check for conversation details left behind by finished runs. */
317
+ const ORPHAN_DETAIL_SWEEP_INTERVAL_MS = 5 * 60_000;
318
+
319
+ /** Redis channel carrying replicated push-status updates between server instances. */
320
+ const PUSH_STATUS_FANOUT_CHANNEL = 'push-status-updates';
321
+
322
+ /**
323
+ * Replicate push-status updates across server instances over Redis (MJ #4222).
324
+ *
325
+ * Outbound: every locally-published update is forwarded on a shared channel. Inbound: a message
326
+ * from another instance is republished onto THIS instance's local topic, where the normal
327
+ * subscription filter decides who receives it — so the identity gate (`ownerUserId` vs. the
328
+ * connection's authenticated user) still applies to a replicated message exactly as it does to a
329
+ * local one. The replica has no say in who sees what.
330
+ *
331
+ * Republishing goes straight to `PubSubManager`, never back through `publishStatusUpdate`, so an
332
+ * inbound message cannot be re-broadcast and loop. `SourceServerId` guards the remaining case: a
333
+ * publisher also receives its own message from Redis.
334
+ */
335
+ async function wirePushStatusFanOut(redisProvider: RedisLocalStorageProvider, startupLog: StartupLogger): Promise<void> {
336
+ try {
337
+ await redisProvider.SubscribeToChannel(PUSH_STATUS_FANOUT_CHANNEL, (raw: string) => {
338
+ try {
339
+ const payload = ParseReplicatedStatusUpdate(raw, MJGlobal.Instance.ProcessUUID);
340
+ if (!payload) {
341
+ return;
342
+ }
343
+ // Rebuilt as a plain record: the topic's publish signature takes an index-signature type,
344
+ // and listing the fields keeps the wire shape explicit at the one place it crosses hosts.
345
+ PubSubManager.Instance.Publish(PUSH_STATUS_UPDATES_TOPIC, {
346
+ sessionId: payload.sessionId,
347
+ ownerUserId: payload.ownerUserId,
348
+ message: payload.message,
349
+ SourceServerId: payload.SourceServerId,
350
+ });
351
+ } catch (err) {
352
+ // A malformed message on a shared channel must not take down the subscriber.
353
+ LogError('Error processing push-status fan-out message', undefined, err);
354
+ }
355
+ });
356
+
357
+ SetPushStatusPublishHook((payload) => {
358
+ redisProvider.PublishMessage(PUSH_STATUS_FANOUT_CHANNEL, JSON.stringify(payload));
359
+ });
360
+
361
+ // Printed unconditionally, not verbose-gated. "Is fan-out actually on?" is the first question
362
+ // anyone debugging a hung conversation behind a load balancer asks, and a silent default left
363
+ // no way to answer it.
364
+ console.log('[MJAPI] Push-status updates: cross-instance fan-out enabled via Redis');
365
+ } catch (err) {
366
+ // Single-instance delivery still works, and the durable tail query covers the rest. Degraded,
367
+ // not broken — so this must not stop the server from starting.
368
+ console.warn(`Push-status fan-out unavailable: ${(err as Error).message}`);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Replicate handoff-offer updates across server instances over Redis.
374
+ *
375
+ * Outbound: every locally-published offer change is forwarded on a shared Redis channel.
376
+ * Inbound: a message from another instance is republished onto THIS instance's local GraphQL
377
+ * topic (where the subscription filter scopes to the target user) and delivered to RoomHandoffEngine
378
+ * so the call-hosting instance can transition its local flow (e.g. wait for the user to join the room).
379
+ */
380
+ async function wireHandoffOfferFanOut(redisProvider: RedisLocalStorageProvider, startupLog: StartupLogger): Promise<void> {
381
+ try {
382
+ await redisProvider.SubscribeToChannel(HANDOFF_OFFER_FANOUT_CHANNEL, (raw: string) => {
383
+ try {
384
+ const payload = ParseReplicatedHandoffOfferUpdate(raw, MJGlobal.Instance.ProcessUUID);
385
+ if (!payload) {
386
+ return;
387
+ }
388
+ PubSubManager.Instance.Publish(HANDOFF_OFFER_TOPIC, {
389
+ UserID: payload.UserID,
390
+ Kind: payload.Kind,
391
+ Offer: payload.Offer,
392
+ });
393
+ RoomHandoffEngine.Instance.OnRemoteOfferChange(payload);
394
+ } catch (err) {
395
+ // A malformed message on a shared channel must not take down the subscriber.
396
+ LogError('Error processing handoff-offer fan-out message', undefined, err);
397
+ }
398
+ });
399
+
400
+ SetHandoffOfferPublishHook((payload) => {
401
+ redisProvider.PublishMessage(HANDOFF_OFFER_FANOUT_CHANNEL, JSON.stringify(payload));
402
+ });
403
+
404
+ console.log('[MJAPI] Handoff-offer updates: cross-instance fan-out enabled via Redis');
405
+ startupLog.LogIf('verbose', 'Handoff-offer updates: cross-instance fan-out enabled via Redis');
406
+ } catch (err) {
407
+ console.warn(`Handoff-offer fan-out unavailable: ${err instanceof Error ? err.message : String(err)}`);
408
+ }
409
+ }
410
+
411
+
295
412
  // Bind MJStorage as the conversation-attachment blob store. The attachment service itself no longer
296
413
  // imports `@memberjunction/storage` — that dependency made it unusable from any browser or React
297
414
  // Native client, which is why the same attachment rules had been reimplemented three times.
@@ -338,25 +455,13 @@ const setupComplete$ = new ReplaySubject(1);
338
455
  startupLog.BeginPhase('Connecting to database');
339
456
  startupLog.LogIf('verbose', 'Database type: PostgreSQL');
340
457
  const pg = await import('pg');
341
- const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
342
-
343
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
344
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
345
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
346
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
347
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
458
+ const { PostgreSQLDataProvider, PostgreSQLProviderConfigData, MJPostgresTypes } = await import('@memberjunction/postgresql-dataprovider');
348
459
 
349
- const pgPool = new pg.default.Pool({
350
- host: pgHost,
351
- port: pgPort,
352
- user: pgUser,
353
- password: pgPass,
354
- database: pgDatabase,
355
- max: configInfo.databaseSettings.connectionPool?.max ?? 50,
356
- min: configInfo.databaseSettings.connectionPool?.min ?? 5,
357
- idleTimeoutMillis: configInfo.databaseSettings.connectionPool?.idleTimeoutMillis ?? 30000,
358
- connectionTimeoutMillis: configInfo.databaseSettings.connectionPool?.acquireTimeoutMillis ?? 30000,
359
- });
460
+ const pgEndpoint = ResolvePostgreSQLEndpoint();
461
+ const { Host: pgHost, Port: pgPort, User: pgUser, Database: pgDatabase } = pgEndpoint;
462
+ // Every API pool carries the statement and idle-in-transaction timeouts from connection #1.
463
+ const pgConnectionConfig = BuildPostgreSQLConnectionConfig(pgEndpoint, configInfo.databaseSettings, 'api');
464
+ const pgPool = new pg.default.Pool(ToPGPoolConfig(pgConnectionConfig));
360
465
 
361
466
  // Verify connection
362
467
  const testClient = await pgPool.connect();
@@ -364,6 +469,28 @@ const setupComplete$ = new ReplaySubject(1);
364
469
  testClient.release();
365
470
  startupLog.LogIf('verbose', `PostgreSQL pool connected to ${pgHost}:${pgPort}/${pgDatabase}`);
366
471
 
472
+ // A read-only pool opened with the read-only login, as SQL Server has. Read-only per-request
473
+ // providers (TestQuerySQL and other caller-supplied SQL) share it instead of the primary pool.
474
+ const pgReadOnlyCredentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
475
+ if (pgReadOnlyCredentials) {
476
+ // A pool a provider runs on must carry the provider's type parsers, so BIGINT and NUMERIC
477
+ // come back as numbers here as they do on the provider's own pool.
478
+ const readOnlyPgPool = new pg.default.Pool({
479
+ ...ToPGPoolConfig(BuildPostgreSQLConnectionConfig({ ...pgEndpoint, ...pgReadOnlyCredentials }, configInfo.databaseSettings, 'read-only')),
480
+ types: MJPostgresTypes,
481
+ });
482
+ const readOnlyTestClient = await readOnlyPgPool.connect();
483
+ try {
484
+ for (const warning of await DescribeReadOnlyLoginOverreach(readOnlyTestClient, mj_core_schema)) {
485
+ LogStatus(`WARNING: ${warning}`);
486
+ }
487
+ } finally {
488
+ readOnlyTestClient.release();
489
+ }
490
+ PostgreSQLReadOnlyPool.Instance.Pool = readOnlyPgPool;
491
+ startupLog.LogIf('verbose', 'Read-only PostgreSQL pool has been initialized.');
492
+ }
493
+
367
494
  // Create a DataSourceInfo with a MSSQL-compatible wrapper around pg.Pool
368
495
  // This allows existing code (types, util, context) to work without changes
369
496
  const mssqlCompatPool = createMSSQLCompatPool(pgPool);
@@ -377,15 +504,6 @@ const setupComplete$ = new ReplaySubject(1);
377
504
  }));
378
505
 
379
506
  // Set up the PostgreSQL provider
380
- const pgConnectionConfig = {
381
- Host: pgHost,
382
- Port: pgPort,
383
- Database: pgDatabase,
384
- User: pgUser,
385
- Password: pgPass,
386
- MaxConnections: configInfo.databaseSettings.connectionPool?.max ?? 50,
387
- MinConnections: configInfo.databaseSettings.connectionPool?.min ?? 5,
388
- };
389
507
  const pgConfigData = new PostgreSQLProviderConfigData(
390
508
  pgConnectionConfig,
391
509
  mj_core_schema,
@@ -419,7 +537,7 @@ const setupComplete$ = new ReplaySubject(1);
419
537
  if (poolAny._pgPool) {
420
538
  const thePgPool = poolAny._pgPool as import('pg').Pool;
421
539
  // Translate SQL Server bracket syntax to PostgreSQL double-quote syntax
422
- const pgQuery = translateBracketsToPG(query);
540
+ const pgQuery = TranslateBracketsToPG(query);
423
541
  const result = await thePgPool.query(pgQuery);
424
542
  return result.rows;
425
543
  }
@@ -438,21 +556,21 @@ const setupComplete$ = new ReplaySubject(1);
438
556
  const pgCodegenPass = process.env.CODEGEN_DB_PASSWORD;
439
557
  if (pgCodegenUser && pgCodegenPass) {
440
558
  try {
441
- const codegenPgPool = new pg.default.Pool({
442
- host: pgHost,
443
- port: pgPort,
444
- user: pgCodegenUser,
445
- password: pgCodegenPass,
446
- database: pgDatabase,
447
- max: 10,
448
- });
559
+ // CodeGen and DDL work runs long statements across every entity, so this pool gets the
560
+ // long CodeGen timeout rather than the API one, as the SQL Server CodeGen pool does.
561
+ const codegenPgConnectionConfig = BuildPostgreSQLConnectionConfig(
562
+ { ...pgEndpoint, User: pgCodegenUser, Password: pgCodegenPass },
563
+ configInfo.databaseSettings,
564
+ 'codegen',
565
+ );
566
+ const codegenPgPool = new pg.default.Pool(ToPGPoolConfig(codegenPgConnectionConfig));
449
567
  const codegenTestClient = await codegenPgPool.connect();
450
568
  await codegenTestClient.query('SELECT 1');
451
569
  codegenTestClient.release();
452
570
 
453
571
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
454
572
  const codegenPgConfigData = new PostgreSQLProviderConfigData(
455
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgCodegenUser, Password: pgCodegenPass },
573
+ codegenPgConnectionConfig,
456
574
  mj_core_schema,
457
575
  cacheRefreshInterval / 1000, // ms → seconds
458
576
  );
@@ -720,6 +838,16 @@ const setupComplete$ = new ReplaySubject(1);
720
838
  }
721
839
  });
722
840
 
841
+ // Fan push-status updates across server instances (MJ #4222).
842
+ //
843
+ // Behind a load balancer the browser's WebSocket lives on one replica while the mutation that
844
+ // drives the agent can be handled by another. The push topic is an in-process PubSub, so a
845
+ // completion published on replica B never reaches a subscriber on replica A — the browser waits
846
+ // forever for an event that was delivered to nobody. Replicating progress and completion over
847
+ // Redis closes that, and the durable tail query remains the backstop if Redis is down.
848
+ await wirePushStatusFanOut(redisProvider, startupLog);
849
+ await wireHandoffOfferFanOut(redisProvider, startupLog);
850
+
723
851
  startupLog.LogIf('verbose', `Redis cache provider connected: ${process.env.REDIS_URL}`);
724
852
  }
725
853
 
@@ -1075,6 +1203,15 @@ const setupComplete$ = new ReplaySubject(1);
1075
1203
  }
1076
1204
  });
1077
1205
 
1206
+ // graphql-ws's ws integration takes its keepalive as a THIRD POSITIONAL argument to useServer,
1207
+ // defaulting to 12_000 when omitted. That default was doing real work here while being invisible
1208
+ // at the call site: the server pings every 12s and terminates the socket after an unanswered
1209
+ // pong, which is why MJAPI notices a half-open link in ~12-24s while the browser — whose
1210
+ // graphql-ws client does nothing on an unanswered pong — noticed nothing at all (MJ #4222).
1211
+ // Stated explicitly so the behaviour is visible and tunable, and so the next reader does not
1212
+ // conclude from the call site that no server-side heartbeat exists. Value unchanged.
1213
+ const WS_SERVER_KEEPALIVE_MS = 12_000;
1214
+
1078
1215
  // Track per-connection expiry timers so we can clean them up on close
1079
1216
  const expiryTimers = new WeakMap<object, ReturnType<typeof setTimeout>>();
1080
1217
 
@@ -1149,7 +1286,8 @@ const setupComplete$ = new ReplaySubject(1);
1149
1286
  console.error('WebSocket error:', errors);
1150
1287
  },
1151
1288
  },
1152
- webSocketServer
1289
+ webSocketServer,
1290
+ WS_SERVER_KEEPALIVE_MS
1153
1291
  );
1154
1292
 
1155
1293
  const apolloServer = buildApolloServer(
@@ -1284,13 +1422,22 @@ const setupComplete$ = new ReplaySubject(1);
1284
1422
  // Backwards-compatibility shim: synthesize ServerExtensionConfig entries from legacy configInfo.telephony
1285
1423
  const telephonyExtensionConfigs: ServerExtensionConfig[] = [];
1286
1424
  if (configInfo.telephony?.enabled) {
1425
+ // Settings every carrier shares (inbound run-as user, call cap, outbound policy). Each carrier's own block is
1426
+ // spread AFTER them, so a carrier can override one explicitly.
1427
+ const sharedTelephonySettings: Record<string, unknown> = {
1428
+ inboundRunAsUserEmail: configInfo.telephony.inboundRunAsUserEmail,
1429
+ maxCallSeconds: configInfo.telephony.maxCallSeconds,
1430
+ maxConcurrentCalls: configInfo.telephony.maxConcurrentCalls,
1431
+ transferTargets: configInfo.telephony.transferTargets,
1432
+ outbound: configInfo.telephony.outbound,
1433
+ };
1287
1434
  if (configInfo.telephony.twilio) {
1288
1435
  telephonyExtensionConfigs.push({
1289
1436
  Enabled: true,
1290
1437
  DriverClass: 'TwilioTelephonyExtension',
1291
1438
  RootPath: '/telephony/twilio',
1292
1439
  Phase: 'pre-auth',
1293
- Settings: configInfo.telephony.twilio as unknown as Record<string, unknown>,
1440
+ Settings: { ...sharedTelephonySettings, ...configInfo.telephony.twilio },
1294
1441
  });
1295
1442
  }
1296
1443
  if (configInfo.telephony.vonage) {
@@ -1299,7 +1446,7 @@ const setupComplete$ = new ReplaySubject(1);
1299
1446
  DriverClass: 'VonageTelephonyExtension',
1300
1447
  RootPath: '/telephony/vonage',
1301
1448
  Phase: 'pre-auth',
1302
- Settings: configInfo.telephony.vonage as unknown as Record<string, unknown>,
1449
+ Settings: { ...sharedTelephonySettings, ...configInfo.telephony.vonage },
1303
1450
  });
1304
1451
  }
1305
1452
  if (configInfo.telephony.ringcentral) {
@@ -1308,7 +1455,16 @@ const setupComplete$ = new ReplaySubject(1);
1308
1455
  DriverClass: 'RingCentralTelephonyExtension',
1309
1456
  RootPath: '/telephony/ringcentral',
1310
1457
  Phase: 'pre-auth',
1311
- Settings: configInfo.telephony.ringcentral as unknown as Record<string, unknown>,
1458
+ Settings: { ...sharedTelephonySettings, ...configInfo.telephony.ringcentral },
1459
+ });
1460
+ }
1461
+ if (configInfo.telephony.livekitSip) {
1462
+ telephonyExtensionConfigs.push({
1463
+ Enabled: true,
1464
+ DriverClass: 'LiveKitSipExtension',
1465
+ RootPath: '/telephony/livekit-sip',
1466
+ Phase: 'pre-auth',
1467
+ Settings: { ...sharedTelephonySettings, ...configInfo.telephony.livekitSip },
1312
1468
  });
1313
1469
  }
1314
1470
  if (configInfo.telephony.teams?.enabled) {
@@ -1580,8 +1736,25 @@ const setupComplete$ = new ReplaySubject(1);
1580
1736
  // instance is still heart-beating. The watchdog also self-registers for graceful-shutdown
1581
1737
  // cancellation (via ShutdownRegistry) once it begins tracking this process's first live run.
1582
1738
  if (resumeUser && Metadata.Provider instanceof DatabaseProviderBase) { // global-provider-ok: server startup recovery — one-shot orphaned-run sweep at boot
1583
- AgentRunWatchdog.SweepOrphanedRuns(Metadata.Provider, resumeUser) // global-provider-ok: server startup recovery — one-shot orphaned-run sweep at boot
1739
+ const sweepUser = resumeUser;
1740
+ const sweepProvider = Metadata.Provider; // global-provider-ok: server startup recovery — one-shot orphaned-run sweep at boot
1741
+ AgentRunWatchdog.SweepOrphanedRuns(sweepProvider, sweepUser)
1584
1742
  .catch(err => console.warn(`[AgentRunWatchdog] Startup sweep failed: ${err}`));
1743
+
1744
+ // The watchdog repairs the RUN; nothing repaired the conversation detail, which is the row the
1745
+ // chat actually renders from (MJ #4222). A process that dies mid-run leaves a terminal run
1746
+ // beside a message that still claims to be generating, and it spins forever for anyone who
1747
+ // opens it. Runs at boot (closes restart orphans) and on a timer (closes mid-life orphans),
1748
+ // mirroring the watchdog's own two-phase shape.
1749
+ const reconcileOrphans = () =>
1750
+ ReconcileOrphanedConversationDetails(sweepProvider, sweepUser)
1751
+ .catch(err => console.warn(`[OrphanDetailReconciler] Pass failed: ${err}`));
1752
+ void reconcileOrphans();
1753
+ const orphanDetailTimer = setInterval(() => void reconcileOrphans(), ORPHAN_DETAIL_SWEEP_INTERVAL_MS);
1754
+ ShutdownRegistry.Instance.Register({
1755
+ ShutdownName: 'OrphanDetailReconciler',
1756
+ Shutdown: () => { clearInterval(orphanDetailTimer); },
1757
+ });
1585
1758
  }
1586
1759
 
1587
1760
  // Launch the AI Agent Session janitor: run own-host orphan recovery once at boot, then keep a
@@ -1623,6 +1796,18 @@ const setupComplete$ = new ReplaySubject(1);
1623
1796
  .catch(err => console.warn(`[TaskGraphDispatcher] Startup failed: ${err}`));
1624
1797
  }
1625
1798
 
1799
+ // Start the durable work-queue host where enabled. It plans which subscriptions this instance runs,
1800
+ // re-plans on a timer, and self-registers with ShutdownRegistry, so gracefulShutdown's awaited
1801
+ // ShutdownAll() drains it (up to 2 × shutdownDrainMs) before the HTTP server closes.
1802
+ // Not awaited: a slow engine load must not delay readiness, and a failure never stops the API.
1803
+ const workQueueProvider = Metadata.Provider; // global-provider-ok: server startup — the work-queue host runs on the server's own provider
1804
+ if (configInfo.workQueue?.enabled && workQueueProvider instanceof DatabaseProviderBase) {
1805
+ const workQueuePool = dataSources[0]?.dataSource;
1806
+ const providerSource = new MJServerWorkQueueProviderSource(workQueuePool instanceof sql.ConnectionPool ? workQueuePool : null, workQueueProvider);
1807
+ StartWorkQueueHost(configInfo.workQueue, workQueueProvider, providerSource)
1808
+ .catch(error => console.error('❌ Failed to start the work queue host:', error));
1809
+ }
1810
+
1626
1811
 
1627
1812
  // Set up graceful shutdown handlers
1628
1813
  const gracefulShutdown = async (signal: string) => {
@@ -2106,11 +2291,3 @@ function createMSSQLCompatPool(pgPool: import('pg').Pool): sql.ConnectionPool {
2106
2291
  return wrapper as unknown as sql.ConnectionPool;
2107
2292
  }
2108
2293
 
2109
- /**
2110
- * Translates SQL Server bracket-quoted identifiers to PostgreSQL double-quoted identifiers.
2111
- * Converts [schema].[table] to "schema"."table" and handles common T-SQL patterns.
2112
- */
2113
- function translateBracketsToPG(sql: string): string {
2114
- // Replace [identifier] with "identifier"
2115
- return sql.replace(/\[([^\]]+)\]/g, '"$1"');
2116
- }
@@ -0,0 +1,21 @@
1
+ import { LexSQL } from '@memberjunction/sql-parser';
2
+ import { SQLServerDialect } from '@memberjunction/sql-dialect';
3
+
4
+ const sqlServer = new SQLServerDialect();
5
+
6
+ /**
7
+ * Translates SQL Server bracket-quoted identifiers (`[Name]`) to PostgreSQL double-quoted ones
8
+ * (`"Name"`), for SQL written in SQL Server syntax that runs on PostgreSQL. Only identifiers
9
+ * change: brackets inside string literals and comments are left as written. A `]]` in a name
10
+ * becomes `]`, and a `"` in a name is doubled.
11
+ */
12
+ export function TranslateBracketsToPG(sql: string): string {
13
+ return LexSQL(sql, sqlServer)
14
+ .map(t => t.Kind === 'identifier' && t.Text.startsWith('[') ? toDoubleQuoted(t.Text) : t.Text)
15
+ .join('');
16
+ }
17
+
18
+ function toDoubleQuoted(bracketed: string): string {
19
+ const name = bracketed.slice(1, bracketed.endsWith(']') ? -1 : undefined).replace(/]]/g, ']');
20
+ return `"${name.replace(/"/g, '""')}"`;
21
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Connection settings for MJAPI's PostgreSQL pools, derived from the same `databaseSettings`
3
+ * values SQL Server reads, with the same defaults.
4
+ *
5
+ * | Setting | SQL Server | PostgreSQL |
6
+ * |--------------------------------------|----------------------------------|----------------------------------------------|
7
+ * | `requestTimeout` | driver request timeout | `statement_timeout` on every backend |
8
+ * | `connectionPool.max` / `min` | pool size | pool size |
9
+ * | `connectionPool.idleTimeoutMillis` | idle connection eviction | idle connection eviction |
10
+ * | `connectionPool.acquireTimeoutMillis`| wait for a pooled connection | `connectionTimeoutMillis` (see below) |
11
+ * | `connectionTimeout` | time to open a connection | not used |
12
+ *
13
+ * Where the two cannot match exactly:
14
+ * - The `pg` pool has one timeout covering both waiting for a free slot and opening a connection.
15
+ * It is fed from `acquireTimeoutMillis`; `connectionTimeout` is not used on PostgreSQL.
16
+ * - SQL Server's `requestTimeout` is a client-side timer over the whole request, including reading
17
+ * rows. PostgreSQL's `statement_timeout` is server-side execution time per statement, so a query
18
+ * that executes quickly and returns its rows slowly is not cut off on PostgreSQL.
19
+ * - `idle_in_transaction_session_timeout` has no SQL Server counterpart. It is derived as twice the
20
+ * statement timeout, so a transaction left open by an abandoned request is ended and its locks
21
+ * released.
22
+ * - MJAPI opens a raw compatibility pool and a provider pool, each up to `connectionPool.max`, plus
23
+ * a CodeGen pool of 10 and, when a read-only login is configured, a read-only pool of at most 10,
24
+ * where SQL Server opens one pool (and a read-only one). PostgreSQL's default `max_connections`
25
+ * is 100, so the read-only and CodeGen pools are kept small.
26
+ */
27
+ import type { PGConnectionConfig } from '@memberjunction/postgresql-dataprovider';
28
+ import type { Pool, PoolConfig } from 'pg';
29
+ import { BaseSingleton } from '@memberjunction/global';
30
+ import type { DatabaseSettingsInfo } from './config.js';
31
+
32
+ /** Statement timeout for the CodeGen/DDL pool, matching the SQL Server CodeGen pool. */
33
+ export const CODEGEN_STATEMENT_TIMEOUT_MS = 600000;
34
+
35
+ /** Pool size for the CodeGen/DDL pool. */
36
+ const CODEGEN_MAX_CONNECTIONS = 10;
37
+
38
+ /** Largest pool size for the read-only pool, which serves only caller-supplied SQL. */
39
+ const READ_ONLY_MAX_CONNECTIONS = 10;
40
+
41
+ /**
42
+ * Which pool the settings are for: API traffic, the read-only pool for caller-supplied SQL, or
43
+ * CodeGen and DDL work.
44
+ */
45
+ export type PostgreSQLPoolPurpose = 'api' | 'read-only' | 'codegen';
46
+
47
+ /** Where a PostgreSQL pool connects and as whom. */
48
+ export interface PostgreSQLEndpoint {
49
+ Host: string;
50
+ Port: number;
51
+ Database: string;
52
+ User: string;
53
+ Password: string;
54
+ }
55
+
56
+ /** Reads the PostgreSQL endpoint from `PG_*` environment variables, then `DB_*` ones. */
57
+ export function ResolvePostgreSQLEndpoint(env: NodeJS.ProcessEnv = process.env): PostgreSQLEndpoint {
58
+ return {
59
+ Host: env.PG_HOST || env.DB_HOST || 'localhost',
60
+ Port: parseInt(env.PG_PORT || env.DB_PORT || '5432', 10),
61
+ Database: env.PG_DATABASE || env.DB_DATABASE || '',
62
+ User: env.PG_USERNAME || env.DB_USERNAME || 'postgres',
63
+ Password: env.PG_PASSWORD || env.DB_PASSWORD || ''
64
+ };
65
+ }
66
+
67
+ /** The idle-in-transaction limit for a given statement timeout; 0 means none. */
68
+ export function IdleInTransactionTimeoutFor(statementTimeoutMs: number): number {
69
+ return statementTimeoutMs > 0 ? statementTimeoutMs * 2 : 0;
70
+ }
71
+
72
+ /** Builds the provider connection config for one of MJAPI's PostgreSQL pools. */
73
+ export function BuildPostgreSQLConnectionConfig(
74
+ endpoint: PostgreSQLEndpoint,
75
+ settings: DatabaseSettingsInfo,
76
+ purpose: PostgreSQLPoolPurpose
77
+ ): PGConnectionConfig {
78
+ const pool = settings.connectionPool;
79
+ if (purpose === 'codegen') {
80
+ return {
81
+ ...endpoint,
82
+ MaxConnections: CODEGEN_MAX_CONNECTIONS,
83
+ StatementTimeoutMs: CODEGEN_STATEMENT_TIMEOUT_MS,
84
+ IdleInTransactionSessionTimeoutMs: 0
85
+ };
86
+ }
87
+ const statementTimeoutMs = Math.max(0, settings.requestTimeout);
88
+ const apiMax = pool?.max ?? 50;
89
+ const apiMin = pool?.min ?? 5;
90
+ const readOnly = purpose === 'read-only';
91
+ return {
92
+ ...endpoint,
93
+ MaxConnections: readOnly ? Math.min(READ_ONLY_MAX_CONNECTIONS, apiMax) : apiMax,
94
+ MinConnections: readOnly ? 0 : apiMin,
95
+ IdleTimeoutMillis: pool?.idleTimeoutMillis ?? 30000,
96
+ ConnectionTimeoutMillis: pool?.acquireTimeoutMillis ?? 30000,
97
+ StatementTimeoutMs: statementTimeoutMs,
98
+ IdleInTransactionSessionTimeoutMs: IdleInTransactionTimeoutFor(statementTimeoutMs)
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Maps a provider connection config onto `pg.Pool` options, for the pools MJAPI creates directly
104
+ * rather than through the provider. Timeouts that are unset or 0 are left out.
105
+ */
106
+ export function ToPGPoolConfig(config: PGConnectionConfig): PoolConfig {
107
+ const pool: PoolConfig = {
108
+ host: config.Host,
109
+ port: config.Port,
110
+ database: config.Database,
111
+ user: config.User,
112
+ password: config.Password,
113
+ max: config.MaxConnections
114
+ };
115
+ if (config.MinConnections !== undefined) pool.min = config.MinConnections;
116
+ if (config.IdleTimeoutMillis !== undefined) pool.idleTimeoutMillis = config.IdleTimeoutMillis;
117
+ if (config.ConnectionTimeoutMillis !== undefined) pool.connectionTimeoutMillis = config.ConnectionTimeoutMillis;
118
+ if (config.StatementTimeoutMs) pool.statement_timeout = config.StatementTimeoutMs;
119
+ if (config.IdleInTransactionSessionTimeoutMs) pool.idle_in_transaction_session_timeout = config.IdleInTransactionSessionTimeoutMs;
120
+ return pool;
121
+ }
122
+
123
+ /** Credentials for a read-only database login. */
124
+ export interface ReadOnlyCredentials {
125
+ User: string;
126
+ Password: string;
127
+ }
128
+
129
+ /**
130
+ * The read-only login for PostgreSQL: the `dbReadOnlyUsername` / `dbReadOnlyPassword` settings
131
+ * SQL Server uses (which default from `DB_READ_ONLY_USERNAME` / `DB_READ_ONLY_PASSWORD`), then
132
+ * `PG_READ_ONLY_USERNAME` / `PG_READ_ONLY_PASSWORD`. `null` when neither pair is complete.
133
+ */
134
+ export function ResolvePostgreSQLReadOnlyCredentials(
135
+ configured: { dbReadOnlyUsername?: string; dbReadOnlyPassword?: string },
136
+ env: NodeJS.ProcessEnv = process.env
137
+ ): ReadOnlyCredentials | null {
138
+ if (configured.dbReadOnlyUsername && configured.dbReadOnlyPassword) {
139
+ return { User: configured.dbReadOnlyUsername, Password: configured.dbReadOnlyPassword };
140
+ }
141
+ if (env.PG_READ_ONLY_USERNAME && env.PG_READ_ONLY_PASSWORD) {
142
+ return { User: env.PG_READ_ONLY_USERNAME, Password: env.PG_READ_ONLY_PASSWORD };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Holds MJAPI's PostgreSQL read-only pool, opened once at startup with the read-only login.
149
+ * Per-request read-only providers share it, so they connect as the read-only user rather than
150
+ * through the read-write pool.
151
+ */
152
+ export class PostgreSQLReadOnlyPool extends BaseSingleton<PostgreSQLReadOnlyPool> {
153
+ private _pool: Pool | null = null;
154
+
155
+ /** Use {@link PostgreSQLReadOnlyPool.Instance}. */
156
+ public constructor() {
157
+ super();
158
+ }
159
+
160
+ public static get Instance(): PostgreSQLReadOnlyPool {
161
+ return PostgreSQLReadOnlyPool.getInstance<PostgreSQLReadOnlyPool>();
162
+ }
163
+
164
+ /** The read-only pool, or `null` when no read-only login is configured. */
165
+ public get Pool(): Pool | null {
166
+ return this._pool;
167
+ }
168
+
169
+ public set Pool(pool: Pool | null) {
170
+ this._pool = pool;
171
+ }
172
+ }
173
+
174
+ /** What {@link DescribeReadOnlyLoginOverreach} reads about the read-only login. */
175
+ export interface ReadOnlyLoginGrants {
176
+ ReadsServerFiles: boolean;
177
+ ReadableBaseTables: number;
178
+ }
179
+
180
+ /** The one method {@link DescribeReadOnlyLoginOverreach} needs from a connection; a `pg` client has it. */
181
+ export interface ReadOnlyLoginCheckClient {
182
+ query(sql: string, params: unknown[]): Promise<{ rows: ReadOnlyLoginGrants[] }>; // case-violation-ok-legacy-back-compat: mirrors the pg client's query method, which a pg client must satisfy as-is
183
+ }
184
+
185
+ /**
186
+ * What the PostgreSQL read-only login can do beyond reading entity base views, as warnings to log at
187
+ * startup. Ad-hoc SQL and `TestQuerySQL` run as this login, and SQL can reach data through functions
188
+ * that no check of its table references sees (`query_to_xml`, file readers), so the login's own
189
+ * grants are the only complete limit. It should hold `SELECT` on the entity base views and nothing
190
+ * else: not on the base tables, and not membership of `pg_read_server_files`. Empty when the login
191
+ * is that narrow.
192
+ *
193
+ * @param client - A connection opened as the read-only login.
194
+ * @param coreSchema - The MJ core schema, whose base tables the login should not read.
195
+ */
196
+ export async function DescribeReadOnlyLoginOverreach(client: ReadOnlyLoginCheckClient, coreSchema: string): Promise<string[]> {
197
+ const result = await client.query(
198
+ `SELECT pg_has_role(current_user, 'pg_read_server_files', 'MEMBER') AS "ReadsServerFiles",
199
+ (SELECT count(*)::int FROM pg_catalog.pg_tables t
200
+ WHERE t.schemaname = $1
201
+ AND has_table_privilege(current_user, quote_ident(t.schemaname) || '.' || quote_ident(t.tablename), 'SELECT')) AS "ReadableBaseTables"`,
202
+ [coreSchema],
203
+ );
204
+ const row = result.rows[0];
205
+ const warnings: string[] = [];
206
+ if (row?.ReadableBaseTables > 0) {
207
+ warnings.push(`The PostgreSQL read-only login can read ${row.ReadableBaseTables} base table(s) in schema "${coreSchema}". Ad-hoc SQL and TestQuerySQL run as this login; grant it SELECT on the entity base views only.`);
208
+ }
209
+ if (row?.ReadsServerFiles) {
210
+ warnings.push('The PostgreSQL read-only login is a member of pg_read_server_files, so SQL run as it can read files on the database server. Revoke that membership.');
211
+ }
212
+ return warnings;
213
+ }