@memberjunction/server 6.2.0-edge.2 → 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 (150) 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 +928 -0
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +8482 -3649
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/ResolverBase.d.ts +43 -3
  15. package/dist/generic/ResolverBase.d.ts.map +1 -1
  16. package/dist/generic/ResolverBase.js +142 -27
  17. package/dist/generic/ResolverBase.js.map +1 -1
  18. package/dist/generic/RunViewResolver.d.ts +4 -0
  19. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  20. package/dist/generic/RunViewResolver.js +33 -1
  21. package/dist/generic/RunViewResolver.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +115 -49
  25. package/dist/index.js.map +1 -1
  26. package/dist/postgresqlCompat.d.ts +8 -0
  27. package/dist/postgresqlCompat.d.ts.map +1 -0
  28. package/dist/postgresqlCompat.js +19 -0
  29. package/dist/postgresqlCompat.js.map +1 -0
  30. package/dist/postgresqlPoolSettings.d.ts +108 -0
  31. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  32. package/dist/postgresqlPoolSettings.js +134 -0
  33. package/dist/postgresqlPoolSettings.js.map +1 -0
  34. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  35. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  36. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  37. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  38. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  39. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  40. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  41. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  42. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  43. package/dist/resolvers/GetDataResolver.js +9 -0
  44. package/dist/resolvers/GetDataResolver.js.map +1 -1
  45. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  46. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  47. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  48. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  49. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  50. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  51. package/dist/resolvers/MeetingResolver.js +381 -0
  52. package/dist/resolvers/MeetingResolver.js.map +1 -0
  53. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  54. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  55. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  56. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  57. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -83
  58. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  59. package/dist/resolvers/RealtimeClientSessionResolver.js +4 -206
  60. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  61. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  62. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  63. package/dist/resolvers/adhocTimeout.js +12 -0
  64. package/dist/resolvers/adhocTimeout.js.map +1 -0
  65. package/dist/resolvers/meetings/index.d.ts +5 -0
  66. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  67. package/dist/resolvers/meetings/index.js +5 -0
  68. package/dist/resolvers/meetings/index.js.map +1 -0
  69. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  70. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  71. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  72. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  73. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  74. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  75. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  76. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  77. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  78. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  79. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  80. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  81. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  82. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  83. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  84. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  85. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  86. package/dist/rest/ViewOperationsHandler.js +8 -5
  87. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  88. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  89. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  90. package/dist/services/TaskGraphActionRunner.js +27 -1
  91. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  92. package/dist/services/WorkQueueHostService.d.ts +36 -0
  93. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  94. package/dist/services/WorkQueueHostService.js +97 -0
  95. package/dist/services/WorkQueueHostService.js.map +1 -0
  96. package/dist/services/workQueueConfig.d.ts +103 -0
  97. package/dist/services/workQueueConfig.d.ts.map +1 -0
  98. package/dist/services/workQueueConfig.js +48 -0
  99. package/dist/services/workQueueConfig.js.map +1 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/package.json +95 -94
  104. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  105. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  106. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  107. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  108. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  109. package/src/__tests__/MeetingResolver.test.ts +374 -0
  110. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  111. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  112. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  113. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  114. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
  115. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  116. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  117. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  118. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  119. package/src/config.ts +130 -3
  120. package/src/context.ts +11 -24
  121. package/src/generated/graphql-schemas/__mj.ts +22138 -18770
  122. package/src/generic/ResolverBase.ts +171 -27
  123. package/src/generic/RunViewResolver.ts +33 -2
  124. package/src/index.ts +127 -50
  125. package/src/postgresqlCompat.ts +21 -0
  126. package/src/postgresqlPoolSettings.ts +213 -0
  127. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  128. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  129. package/src/resolvers/GetDataResolver.ts +10 -0
  130. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  131. package/src/resolvers/MeetingResolver.ts +438 -0
  132. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  133. package/src/resolvers/RealtimeClientSessionResolver.ts +6 -252
  134. package/src/resolvers/adhocTimeout.ts +12 -0
  135. package/src/resolvers/meetings/index.ts +4 -0
  136. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  137. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  138. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  139. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  140. package/src/rest/ViewOperationsHandler.ts +8 -5
  141. package/src/services/TaskGraphActionRunner.ts +29 -1
  142. package/src/services/WorkQueueHostService.ts +129 -0
  143. package/src/services/workQueueConfig.ts +52 -0
  144. package/src/types.ts +5 -0
  145. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  146. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  147. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  148. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  149. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  150. 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';
@@ -67,12 +69,20 @@ import {
67
69
  SetPushStatusPublishHook,
68
70
  ParseReplicatedStatusUpdate,
69
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';
70
79
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
71
80
  import { PublishIntegrationProgress } from './resolvers/IntegrationProgressResolver.js';
72
81
  import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
73
82
  import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
74
83
  import { SessionJanitor } from './agentSessions/index.js';
75
84
  import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
85
+ import { MJServerWorkQueueProviderSource, StartWorkQueueHost } from './services/WorkQueueHostService.js';
76
86
  import { GetAttachmentService } from '@memberjunction/aiengine';
77
87
  import { MJStorageBlobStore } from './services/MJStorageBlobStore.js';
78
88
  import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData, ConfigureRecordDataBroadcast } from './generic/CacheInvalidationResolver.js';
@@ -243,6 +253,7 @@ export * from './resolvers/CurrentUserContextResolver.js';
243
253
  export * from './resolvers/RSUResolver.js';
244
254
  export * from './resolvers/AgentSessionResolver.js';
245
255
  export * from './resolvers/RealtimeClientSessionResolver.js';
256
+ export * from './resolvers/MeetingResolver.js';
246
257
  export * from './resolvers/RemoteBrowserActionResolver.js';
247
258
  export * from './agentSessions/index.js';
248
259
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
@@ -296,7 +307,8 @@ function resolveServerVersion(): string | undefined {
296
307
  const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
297
308
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
298
309
  return pkg.version;
299
- } catch {
310
+ } catch (err) {
311
+ LogError('Failed to resolve server version from package.json', undefined, err);
300
312
  return undefined;
301
313
  }
302
314
  }
@@ -336,8 +348,9 @@ async function wirePushStatusFanOut(redisProvider: RedisLocalStorageProvider, st
336
348
  message: payload.message,
337
349
  SourceServerId: payload.SourceServerId,
338
350
  });
339
- } catch {
351
+ } catch (err) {
340
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);
341
354
  }
342
355
  });
343
356
 
@@ -356,6 +369,46 @@ async function wirePushStatusFanOut(redisProvider: RedisLocalStorageProvider, st
356
369
  }
357
370
  }
358
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
+
359
412
  // Bind MJStorage as the conversation-attachment blob store. The attachment service itself no longer
360
413
  // imports `@memberjunction/storage` — that dependency made it unusable from any browser or React
361
414
  // Native client, which is why the same attachment rules had been reimplemented three times.
@@ -402,25 +455,13 @@ const setupComplete$ = new ReplaySubject(1);
402
455
  startupLog.BeginPhase('Connecting to database');
403
456
  startupLog.LogIf('verbose', 'Database type: PostgreSQL');
404
457
  const pg = await import('pg');
405
- const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
458
+ const { PostgreSQLDataProvider, PostgreSQLProviderConfigData, MJPostgresTypes } = await import('@memberjunction/postgresql-dataprovider');
406
459
 
407
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
408
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
409
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
410
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
411
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
412
-
413
- const pgPool = new pg.default.Pool({
414
- host: pgHost,
415
- port: pgPort,
416
- user: pgUser,
417
- password: pgPass,
418
- database: pgDatabase,
419
- max: configInfo.databaseSettings.connectionPool?.max ?? 50,
420
- min: configInfo.databaseSettings.connectionPool?.min ?? 5,
421
- idleTimeoutMillis: configInfo.databaseSettings.connectionPool?.idleTimeoutMillis ?? 30000,
422
- connectionTimeoutMillis: configInfo.databaseSettings.connectionPool?.acquireTimeoutMillis ?? 30000,
423
- });
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));
424
465
 
425
466
  // Verify connection
426
467
  const testClient = await pgPool.connect();
@@ -428,6 +469,28 @@ const setupComplete$ = new ReplaySubject(1);
428
469
  testClient.release();
429
470
  startupLog.LogIf('verbose', `PostgreSQL pool connected to ${pgHost}:${pgPort}/${pgDatabase}`);
430
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
+
431
494
  // Create a DataSourceInfo with a MSSQL-compatible wrapper around pg.Pool
432
495
  // This allows existing code (types, util, context) to work without changes
433
496
  const mssqlCompatPool = createMSSQLCompatPool(pgPool);
@@ -441,15 +504,6 @@ const setupComplete$ = new ReplaySubject(1);
441
504
  }));
442
505
 
443
506
  // Set up the PostgreSQL provider
444
- const pgConnectionConfig = {
445
- Host: pgHost,
446
- Port: pgPort,
447
- Database: pgDatabase,
448
- User: pgUser,
449
- Password: pgPass,
450
- MaxConnections: configInfo.databaseSettings.connectionPool?.max ?? 50,
451
- MinConnections: configInfo.databaseSettings.connectionPool?.min ?? 5,
452
- };
453
507
  const pgConfigData = new PostgreSQLProviderConfigData(
454
508
  pgConnectionConfig,
455
509
  mj_core_schema,
@@ -483,7 +537,7 @@ const setupComplete$ = new ReplaySubject(1);
483
537
  if (poolAny._pgPool) {
484
538
  const thePgPool = poolAny._pgPool as import('pg').Pool;
485
539
  // Translate SQL Server bracket syntax to PostgreSQL double-quote syntax
486
- const pgQuery = translateBracketsToPG(query);
540
+ const pgQuery = TranslateBracketsToPG(query);
487
541
  const result = await thePgPool.query(pgQuery);
488
542
  return result.rows;
489
543
  }
@@ -502,21 +556,21 @@ const setupComplete$ = new ReplaySubject(1);
502
556
  const pgCodegenPass = process.env.CODEGEN_DB_PASSWORD;
503
557
  if (pgCodegenUser && pgCodegenPass) {
504
558
  try {
505
- const codegenPgPool = new pg.default.Pool({
506
- host: pgHost,
507
- port: pgPort,
508
- user: pgCodegenUser,
509
- password: pgCodegenPass,
510
- database: pgDatabase,
511
- max: 10,
512
- });
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));
513
567
  const codegenTestClient = await codegenPgPool.connect();
514
568
  await codegenTestClient.query('SELECT 1');
515
569
  codegenTestClient.release();
516
570
 
517
571
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
518
572
  const codegenPgConfigData = new PostgreSQLProviderConfigData(
519
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgCodegenUser, Password: pgCodegenPass },
573
+ codegenPgConnectionConfig,
520
574
  mj_core_schema,
521
575
  cacheRefreshInterval / 1000, // ms → seconds
522
576
  );
@@ -792,6 +846,7 @@ const setupComplete$ = new ReplaySubject(1);
792
846
  // forever for an event that was delivered to nobody. Replicating progress and completion over
793
847
  // Redis closes that, and the durable tail query remains the backstop if Redis is down.
794
848
  await wirePushStatusFanOut(redisProvider, startupLog);
849
+ await wireHandoffOfferFanOut(redisProvider, startupLog);
795
850
 
796
851
  startupLog.LogIf('verbose', `Redis cache provider connected: ${process.env.REDIS_URL}`);
797
852
  }
@@ -1367,13 +1422,22 @@ const setupComplete$ = new ReplaySubject(1);
1367
1422
  // Backwards-compatibility shim: synthesize ServerExtensionConfig entries from legacy configInfo.telephony
1368
1423
  const telephonyExtensionConfigs: ServerExtensionConfig[] = [];
1369
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
+ };
1370
1434
  if (configInfo.telephony.twilio) {
1371
1435
  telephonyExtensionConfigs.push({
1372
1436
  Enabled: true,
1373
1437
  DriverClass: 'TwilioTelephonyExtension',
1374
1438
  RootPath: '/telephony/twilio',
1375
1439
  Phase: 'pre-auth',
1376
- Settings: configInfo.telephony.twilio as unknown as Record<string, unknown>,
1440
+ Settings: { ...sharedTelephonySettings, ...configInfo.telephony.twilio },
1377
1441
  });
1378
1442
  }
1379
1443
  if (configInfo.telephony.vonage) {
@@ -1382,7 +1446,7 @@ const setupComplete$ = new ReplaySubject(1);
1382
1446
  DriverClass: 'VonageTelephonyExtension',
1383
1447
  RootPath: '/telephony/vonage',
1384
1448
  Phase: 'pre-auth',
1385
- Settings: configInfo.telephony.vonage as unknown as Record<string, unknown>,
1449
+ Settings: { ...sharedTelephonySettings, ...configInfo.telephony.vonage },
1386
1450
  });
1387
1451
  }
1388
1452
  if (configInfo.telephony.ringcentral) {
@@ -1391,7 +1455,16 @@ const setupComplete$ = new ReplaySubject(1);
1391
1455
  DriverClass: 'RingCentralTelephonyExtension',
1392
1456
  RootPath: '/telephony/ringcentral',
1393
1457
  Phase: 'pre-auth',
1394
- 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 },
1395
1468
  });
1396
1469
  }
1397
1470
  if (configInfo.telephony.teams?.enabled) {
@@ -1723,6 +1796,18 @@ const setupComplete$ = new ReplaySubject(1);
1723
1796
  .catch(err => console.warn(`[TaskGraphDispatcher] Startup failed: ${err}`));
1724
1797
  }
1725
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
+
1726
1811
 
1727
1812
  // Set up graceful shutdown handlers
1728
1813
  const gracefulShutdown = async (signal: string) => {
@@ -2206,11 +2291,3 @@ function createMSSQLCompatPool(pgPool: import('pg').Pool): sql.ConnectionPool {
2206
2291
  return wrapper as unknown as sql.ConnectionPool;
2207
2292
  }
2208
2293
 
2209
- /**
2210
- * Translates SQL Server bracket-quoted identifiers to PostgreSQL double-quoted identifiers.
2211
- * Converts [schema].[table] to "schema"."table" and handles common T-SQL patterns.
2212
- */
2213
- function translateBracketsToPG(sql: string): string {
2214
- // Replace [identifier] with "identifier"
2215
- return sql.replace(/\[([^\]]+)\]/g, '"$1"');
2216
- }
@@ -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
+ }