@memberjunction/server 6.1.4 → 6.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  4. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkService.js +49 -35
  6. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  7. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  8. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  9. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  10. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +11 -23
  13. package/dist/context.js.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +36 -44
  16. package/dist/index.js.map +1 -1
  17. package/dist/postgresqlCompat.d.ts +8 -0
  18. package/dist/postgresqlCompat.d.ts.map +1 -0
  19. package/dist/postgresqlCompat.js +19 -0
  20. package/dist/postgresqlCompat.js.map +1 -0
  21. package/dist/postgresqlPoolSettings.d.ts +108 -0
  22. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  23. package/dist/postgresqlPoolSettings.js +134 -0
  24. package/dist/postgresqlPoolSettings.js.map +1 -0
  25. package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
  26. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  27. package/dist/resolvers/AdhocQueryResolver.js +54 -174
  28. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  29. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  30. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  31. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  32. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  33. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  34. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  35. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  36. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  37. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  38. package/dist/resolvers/adhocTimeout.js +12 -0
  39. package/dist/resolvers/adhocTimeout.js.map +1 -0
  40. package/dist/services/TaskGraphActionRunner.d.ts +13 -8
  41. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  42. package/dist/services/TaskGraphActionRunner.js +16 -15
  43. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  44. package/package.json +92 -92
  45. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
  46. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  47. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  48. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  49. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
  50. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  51. package/src/__tests__/magicLink.test.ts +15 -76
  52. package/src/__tests__/magicLinkService.consume.test.ts +224 -0
  53. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  54. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  55. package/src/auth/magicLink/MagicLinkService.ts +55 -36
  56. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  57. package/src/context.ts +11 -24
  58. package/src/index.ts +40 -45
  59. package/src/postgresqlCompat.ts +21 -0
  60. package/src/postgresqlPoolSettings.ts +213 -0
  61. package/src/resolvers/AdhocQueryResolver.ts +64 -194
  62. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  63. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  64. package/src/resolvers/adhocTimeout.ts +12 -0
  65. package/src/services/TaskGraphActionRunner.ts +16 -15
  66. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  67. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  68. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  69. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  70. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  71. package/src/resolvers/adhoc-query-helpers.ts +0 -50
package/src/context.ts CHANGED
@@ -10,7 +10,8 @@ import { CloneUserForSessionContext } from './auth/sessionUserClone.js';
10
10
  import { GetAPIKeyActingContextResolver } from './auth/actingContextResolver.js';
11
11
  import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
12
12
  import { authCache } from './cache.js';
13
- import { userEmailMap, apiKey, mj_core_schema } from './config.js';
13
+ import { userEmailMap, apiKey, mj_core_schema, configInfo } from './config.js';
14
+ import { BuildPostgreSQLConnectionConfig, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials } from './postgresqlPoolSettings.js';
14
15
  import { buildBoundaryLogPayload } from './logging/boundaryLogPayload.js';
15
16
  import { StartupLogger } from './logging/StartupLogger.js';
16
17
  import { DataSourceInfo, UserPayload } from './types.js';
@@ -818,15 +819,10 @@ async function createPerRequestProviders(
818
819
  */
819
820
  async function createPostgresProvider(): Promise<DatabaseProviderBase> {
820
821
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
821
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
822
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
823
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
824
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
825
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
826
822
 
827
823
  const pgProvider = new PostgreSQLDataProvider();
828
824
  const pgConfig = new PostgreSQLProviderConfigData(
829
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgUser, Password: pgPass },
825
+ BuildPostgreSQLConnectionConfig(ResolvePostgreSQLEndpoint(), configInfo.databaseSettings, 'api'),
830
826
  mj_core_schema,
831
827
  0,
832
828
  undefined,
@@ -846,39 +842,30 @@ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
846
842
  }
847
843
 
848
844
  /**
849
- * Attempts to create a read-only PostgreSQL provider using DB_READ_ONLY_USERNAME/PASSWORD.
850
- * Shares the connection pool from the primary provider. Returns null if no read-only credentials are configured.
845
+ * Creates a read-only PostgreSQL per-request provider on the read-only pool MJAPI opened at
846
+ * startup with the read-only login. Returns null when no read-only login is configured, so the
847
+ * caller never falls back to the read-write pool.
851
848
  */
852
849
  async function tryCreateReadOnlyPostgresProvider(): Promise<DatabaseProviderBase | null> {
853
- const roUser = process.env.PG_READ_ONLY_USERNAME || process.env.DB_READ_ONLY_USERNAME;
854
- const roPass = process.env.PG_READ_ONLY_PASSWORD || process.env.DB_READ_ONLY_PASSWORD;
855
- if (!roUser || !roPass) {
850
+ const readOnlyPool = PostgreSQLReadOnlyPool.Instance.Pool;
851
+ const credentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
852
+ if (!readOnlyPool || !credentials) {
856
853
  return null;
857
854
  }
858
855
 
859
856
  try {
860
857
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
861
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
862
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
863
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
864
858
 
865
859
  const roProvider = new PostgreSQLDataProvider();
866
860
  const roConfig = new PostgreSQLProviderConfigData(
867
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: roUser, Password: roPass },
861
+ BuildPostgreSQLConnectionConfig({ ...ResolvePostgreSQLEndpoint(), ...credentials }, configInfo.databaseSettings, 'read-only'),
868
862
  mj_core_schema,
869
863
  0,
870
864
  undefined,
871
865
  undefined,
872
866
  false,
873
867
  );
874
-
875
- const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool }; // global-provider-ok: bootstrap (share primary provider's PG pool with the read-only provider)
876
- if (primaryProvider?.DatabaseConnection) {
877
- await roProvider.ConfigWithSharedPool(roConfig, primaryProvider.DatabaseConnection);
878
- } else {
879
- await roProvider.Config(roConfig);
880
- }
881
-
868
+ await roProvider.ConfigWithSharedPool(roConfig, readOnlyPool);
882
869
  return roProvider;
883
870
  } catch (_err) {
884
871
  return null;
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';
@@ -316,25 +318,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
316
318
  startupLog.BeginPhase('Connecting to database');
317
319
  startupLog.LogIf('verbose', 'Database type: PostgreSQL');
318
320
  const pg = await import('pg');
319
- const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
321
+ const { PostgreSQLDataProvider, PostgreSQLProviderConfigData, MJPostgresTypes } = await import('@memberjunction/postgresql-dataprovider');
320
322
 
321
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
322
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
323
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
324
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
325
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
326
-
327
- const pgPool = new pg.default.Pool({
328
- host: pgHost,
329
- port: pgPort,
330
- user: pgUser,
331
- password: pgPass,
332
- database: pgDatabase,
333
- max: configInfo.databaseSettings.connectionPool?.max ?? 50,
334
- min: configInfo.databaseSettings.connectionPool?.min ?? 5,
335
- idleTimeoutMillis: configInfo.databaseSettings.connectionPool?.idleTimeoutMillis ?? 30000,
336
- connectionTimeoutMillis: configInfo.databaseSettings.connectionPool?.acquireTimeoutMillis ?? 30000,
337
- });
323
+ const pgEndpoint = ResolvePostgreSQLEndpoint();
324
+ const { Host: pgHost, Port: pgPort, User: pgUser, Database: pgDatabase } = pgEndpoint;
325
+ // Every API pool carries the statement and idle-in-transaction timeouts from connection #1.
326
+ const pgConnectionConfig = BuildPostgreSQLConnectionConfig(pgEndpoint, configInfo.databaseSettings, 'api');
327
+ const pgPool = new pg.default.Pool(ToPGPoolConfig(pgConnectionConfig));
338
328
 
339
329
  // Verify connection
340
330
  const testClient = await pgPool.connect();
@@ -342,6 +332,28 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
342
332
  testClient.release();
343
333
  startupLog.LogIf('verbose', `PostgreSQL pool connected to ${pgHost}:${pgPort}/${pgDatabase}`);
344
334
 
335
+ // A read-only pool opened with the read-only login, as SQL Server has. Read-only per-request
336
+ // providers (TestQuerySQL and other caller-supplied SQL) share it instead of the primary pool.
337
+ const pgReadOnlyCredentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
338
+ if (pgReadOnlyCredentials) {
339
+ // A pool a provider runs on must carry the provider's type parsers, so BIGINT and NUMERIC
340
+ // come back as numbers here as they do on the provider's own pool.
341
+ const readOnlyPgPool = new pg.default.Pool({
342
+ ...ToPGPoolConfig(BuildPostgreSQLConnectionConfig({ ...pgEndpoint, ...pgReadOnlyCredentials }, configInfo.databaseSettings, 'read-only')),
343
+ types: MJPostgresTypes,
344
+ });
345
+ const readOnlyTestClient = await readOnlyPgPool.connect();
346
+ try {
347
+ for (const warning of await DescribeReadOnlyLoginOverreach(readOnlyTestClient, mj_core_schema)) {
348
+ LogStatus(`WARNING: ${warning}`);
349
+ }
350
+ } finally {
351
+ readOnlyTestClient.release();
352
+ }
353
+ PostgreSQLReadOnlyPool.Instance.Pool = readOnlyPgPool;
354
+ startupLog.LogIf('verbose', 'Read-only PostgreSQL pool has been initialized.');
355
+ }
356
+
345
357
  // Create a DataSourceInfo with a MSSQL-compatible wrapper around pg.Pool
346
358
  // This allows existing code (types, util, context) to work without changes
347
359
  const mssqlCompatPool = createMSSQLCompatPool(pgPool);
@@ -355,15 +367,6 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
355
367
  }));
356
368
 
357
369
  // Set up the PostgreSQL provider
358
- const pgConnectionConfig = {
359
- Host: pgHost,
360
- Port: pgPort,
361
- Database: pgDatabase,
362
- User: pgUser,
363
- Password: pgPass,
364
- MaxConnections: configInfo.databaseSettings.connectionPool?.max ?? 50,
365
- MinConnections: configInfo.databaseSettings.connectionPool?.min ?? 5,
366
- };
367
370
  const pgConfigData = new PostgreSQLProviderConfigData(
368
371
  pgConnectionConfig,
369
372
  mj_core_schema,
@@ -397,7 +400,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
397
400
  if (poolAny._pgPool) {
398
401
  const thePgPool = poolAny._pgPool as import('pg').Pool;
399
402
  // Translate SQL Server bracket syntax to PostgreSQL double-quote syntax
400
- const pgQuery = translateBracketsToPG(query);
403
+ const pgQuery = TranslateBracketsToPG(query);
401
404
  const result = await thePgPool.query(pgQuery);
402
405
  return result.rows;
403
406
  }
@@ -416,21 +419,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
416
419
  const pgCodegenPass = process.env.CODEGEN_DB_PASSWORD;
417
420
  if (pgCodegenUser && pgCodegenPass) {
418
421
  try {
419
- const codegenPgPool = new pg.default.Pool({
420
- host: pgHost,
421
- port: pgPort,
422
- user: pgCodegenUser,
423
- password: pgCodegenPass,
424
- database: pgDatabase,
425
- max: 10,
426
- });
422
+ // CodeGen and DDL work runs long statements across every entity, so this pool gets the
423
+ // long CodeGen timeout rather than the API one, as the SQL Server CodeGen pool does.
424
+ const codegenPgConnectionConfig = BuildPostgreSQLConnectionConfig(
425
+ { ...pgEndpoint, User: pgCodegenUser, Password: pgCodegenPass },
426
+ configInfo.databaseSettings,
427
+ 'codegen',
428
+ );
429
+ const codegenPgPool = new pg.default.Pool(ToPGPoolConfig(codegenPgConnectionConfig));
427
430
  const codegenTestClient = await codegenPgPool.connect();
428
431
  await codegenTestClient.query('SELECT 1');
429
432
  codegenTestClient.release();
430
433
 
431
434
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
432
435
  const codegenPgConfigData = new PostgreSQLProviderConfigData(
433
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgCodegenUser, Password: pgCodegenPass },
436
+ codegenPgConnectionConfig,
434
437
  mj_core_schema,
435
438
  cacheRefreshInterval / 1000, // ms → seconds
436
439
  );
@@ -2079,11 +2082,3 @@ function createMSSQLCompatPool(pgPool: import('pg').Pool): sql.ConnectionPool {
2079
2082
  return wrapper as unknown as sql.ConnectionPool;
2080
2083
  }
2081
2084
 
2082
- /**
2083
- * Translates SQL Server bracket-quoted identifiers to PostgreSQL double-quoted identifiers.
2084
- * Converts [schema].[table] to "schema"."table" and handles common T-SQL patterns.
2085
- */
2086
- function translateBracketsToPG(sql: string): string {
2087
- // Replace [identifier] with "identifier"
2088
- return sql.replace(/\[([^\]]+)\]/g, '"$1"');
2089
- }
@@ -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
+ }