@memberjunction/server 6.1.3 → 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 (93) 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/config.d.ts +53 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +23 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/context.d.ts.map +1 -1
  16. package/dist/context.js +11 -23
  17. package/dist/context.js.map +1 -1
  18. package/dist/generic/CacheInvalidationResolver.d.ts +87 -3
  19. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  20. package/dist/generic/CacheInvalidationResolver.js +118 -3
  21. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  22. package/dist/generic/DeleteOptionsInput.d.ts +8 -1
  23. package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
  24. package/dist/generic/DeleteOptionsInput.js +1 -1
  25. package/dist/generic/DeleteOptionsInput.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +4 -0
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +10 -3
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +49 -47
  32. package/dist/index.js.map +1 -1
  33. package/dist/postgresqlCompat.d.ts +8 -0
  34. package/dist/postgresqlCompat.d.ts.map +1 -0
  35. package/dist/postgresqlCompat.js +19 -0
  36. package/dist/postgresqlCompat.js.map +1 -0
  37. package/dist/postgresqlPoolSettings.d.ts +108 -0
  38. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  39. package/dist/postgresqlPoolSettings.js +134 -0
  40. package/dist/postgresqlPoolSettings.js.map +1 -0
  41. package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
  42. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  43. package/dist/resolvers/AdhocQueryResolver.js +54 -174
  44. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  45. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  46. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  48. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  49. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  50. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  51. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  52. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  53. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  54. package/dist/resolvers/adhocTimeout.js +12 -0
  55. package/dist/resolvers/adhocTimeout.js.map +1 -0
  56. package/dist/services/TaskGraphActionRunner.d.ts +13 -8
  57. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  58. package/dist/services/TaskGraphActionRunner.js +16 -15
  59. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  60. package/package.json +92 -92
  61. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
  62. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  63. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  64. package/src/__tests__/DeleteOptionsInput.wireShape.test.ts +62 -0
  65. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  66. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
  67. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  68. package/src/__tests__/cacheInvalidationRecordData.test.ts +177 -0
  69. package/src/__tests__/magicLink.test.ts +15 -76
  70. package/src/__tests__/magicLinkService.consume.test.ts +224 -0
  71. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  72. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  73. package/src/auth/magicLink/MagicLinkService.ts +55 -36
  74. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  75. package/src/config.ts +23 -0
  76. package/src/context.ts +11 -24
  77. package/src/generic/CacheInvalidationResolver.ts +139 -3
  78. package/src/generic/DeleteOptionsInput.ts +9 -2
  79. package/src/generic/ResolverBase.ts +11 -3
  80. package/src/index.ts +55 -48
  81. package/src/postgresqlCompat.ts +21 -0
  82. package/src/postgresqlPoolSettings.ts +213 -0
  83. package/src/resolvers/AdhocQueryResolver.ts +64 -194
  84. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  85. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  86. package/src/resolvers/adhocTimeout.ts +12 -0
  87. package/src/services/TaskGraphActionRunner.ts +16 -15
  88. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  89. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  90. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  91. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  92. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  93. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -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
+ }
@@ -1,14 +1,13 @@
1
1
  import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
- import { DatabasePlatform, LogError } from '@memberjunction/core';
3
- import { SQLExpressionValidator } from '@memberjunction/global';
4
- import { RenderPipeline } from '@memberjunction/generic-database-provider';
2
+ import { LogError } from '@memberjunction/core';
3
+ import type { RunQueryResult } from '@memberjunction/core';
5
4
  import { AppContext } from '../types.js';
6
- import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
5
+ import { configInfo } from '../config.js';
6
+ import { GetReadOnlyProvider } from '../util.js';
7
7
  import { ResolverBase } from '../generic/ResolverBase.js';
8
8
  import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
9
9
  import { RunQueryResultType } from './QueryResolver.js';
10
- import { exactTotalFromPage, resolveAdhocTotalRowCount } from './adhoc-query-helpers.js';
11
- import sql from 'mssql';
10
+ import { ClampAdhocTimeoutSeconds } from './adhocTimeout.js';
12
11
 
13
12
  /**
14
13
  * Input type for executing ad-hoc SQL queries directly.
@@ -32,10 +31,15 @@ class AdhocQueryInput {
32
31
  /**
33
32
  * Resolver for executing ad-hoc (unsaved) SQL queries.
34
33
  *
34
+ * The query runs through the read-only provider's own ad-hoc path (`RunQuery` with `SQL`), the
35
+ * same one in-process callers use, so it behaves identically on every database platform: the SQL
36
+ * is validated and rendered there, paged in the database with a count, run under the caller's
37
+ * timeout, and on PostgreSQL inside a read-only transaction that is rolled back.
38
+ *
35
39
  * Security:
36
- * - SQL validated via SQLExpressionValidator (full_query context) — blocks mutations, dangerous operations
37
- * - Executes on read-only connection pool only (no fallback to read-write)
38
- * - Configurable timeout (default 30s)
40
+ * - SQL validated by the provider (SQLExpressionValidator, single read query) — blocks mutations
41
+ * - Read-only provider only (no fallback to read-write)
42
+ * - Configurable timeout (default 30s), enforced by the database
39
43
  * - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
40
44
  * - Refuses scope-limited principals (see {@link IsScopeLimitedPrincipal}). Raw SQL bypasses
41
45
  * RunView, entity permissions and row-level security, so the RLS scope tokens that confine a
@@ -52,201 +56,67 @@ export class AdhocQueryResolver extends ResolverBase {
52
56
  @Ctx() context: AppContext
53
57
  ): Promise<RunQueryResultType> {
54
58
  const startTime = Date.now();
55
-
59
+ // SECURITY: refuse principals whose read authority is narrower than their role grant.
60
+ // Ad-hoc SQL does not go through RunView, entity permissions or row-level security, so the
61
+ // RLS scope tokens that confine a magic-link session are never substituted. There is no
62
+ // filter to narrow, so the only correct answer for such a principal is no.
63
+ if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
64
+ return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
65
+ }
66
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
67
+ if (!provider) {
68
+ return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
69
+ }
70
+ const contextUser = context.userPayload?.userRecord;
71
+ const timeoutSeconds = ClampAdhocTimeoutSeconds(input.TimeoutSeconds, configInfo.databaseSettings.requestTimeout);
72
+ // A negative offset must not reach paging, where it would turn paging off.
73
+ const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
56
74
  try {
57
- // 1. SECURITY: refuse principals whose read authority is narrower than their role
58
- // grant. This resolver runs a raw SELECT on the read-only pool — it does NOT go
59
- // through RunView, entity permissions or row-level security, so the RLS scope tokens
60
- // that confine a magic-link session are never substituted here. There is no filter
61
- // to narrow, so the only correct answer for such a principal is no. Checked before
62
- // the SQL is even validated: authorization gates the work, it does not race it.
63
- if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
64
- return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
65
- }
66
-
67
- // 2. Security: validate SQL using SQLExpressionValidator
68
- const validator = SQLExpressionValidator.Instance;
69
- const validation = validator.validateFullQuery(input.SQL);
70
- if (!validation.valid) {
71
- return this.buildErrorResult(validation.error || 'SQL validation failed');
72
- }
73
-
74
- // 3. Get READ-ONLY data source (no fallback to read-write)
75
- let readOnlyDS: sql.ConnectionPool;
76
- try {
77
- readOnlyDS = GetReadOnlyDataSource(context.dataSources, { allowFallbackToReadWrite: false });
78
- } catch {
79
- return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
80
- }
81
-
82
- // 4. Resolve platform from the read-only provider for the render pipeline.
83
- let platform: DatabasePlatform = 'sqlserver';
84
- try {
85
- const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
86
- if (provider?.PlatformKey) platform = provider.PlatformKey;
87
- } catch {
88
- // Provider not configured — keep the default platform.
89
- }
90
- const contextUser = context.userPayload?.userRecord;
91
-
92
- // 5. Route the SQL through RenderPipeline so composition tokens
93
- // resolve, comments and templates are processed, and the row cap
94
- // is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
95
- //
96
- // Page whenever a positive MaxRows is requested — INCLUDING the first
97
- // page (StartRow 0). This yields OFFSET/FETCH data SQL *and* a
98
- // COUNT(*) query so the response reports the true total row count
99
- // rather than just the returned page size. It mirrors the saved-query
100
- // path (GenericDatabaseProvider.InternalRunQuery → WrapWithPaging).
101
- // (Previously paging was gated on StartRow > 0, so page 1 fell back to
102
- // a TOP-N cap with TotalRowCount = page size — hiding the pager.)
103
- // Clamp StartRow to a non-negative integer. A negative offset must not slip
104
- // past the paging gate (which would then run the query with no row cap at all).
105
- const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
106
- const maxRows = input.MaxRows;
107
- const usePaging = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
108
-
109
- let dataSQL: string;
110
- let countSQL: string | null = null;
111
- try {
112
- const rendered = RenderPipeline.Run(input.SQL, {
113
- Platform: platform,
114
- ContextUser: contextUser,
115
- ...(usePaging ? { Paging: { StartRow: startRow, MaxRows: maxRows! } } : {}),
116
- });
117
- dataSQL = rendered.FinalSQL;
118
- countSQL = rendered.PagingResult?.CountSQL ?? null;
119
- } catch (renderErr) {
120
- const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
121
- return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
122
- }
123
-
124
- // 6. Execute the page (and, only when a full page needs it, the count) under
125
- // a shared wall-clock deadline derived from the request's timeout budget.
126
- const deadline = startTime + (input.TimeoutSeconds ?? 30) * 1000;
127
- const { recordset, totalRowCount } = await this.executeDataAndCount(
128
- readOnlyDS, dataSQL, countSQL, startRow, usePaging ? maxRows! : null, deadline
129
- );
130
- const executionTimeMs = Date.now() - startTime;
131
-
132
- // 7. Return as RunQueryResultType
133
- return {
134
- QueryID: '',
135
- QueryName: 'Ad-Hoc Query',
136
- Success: true,
137
- Results: JSON.stringify(recordset),
138
- RowCount: recordset.length,
139
- TotalRowCount: totalRowCount,
140
- PageNumber: usePaging ? Math.floor(startRow / maxRows!) + 1 : undefined,
141
- PageSize: usePaging ? maxRows! : undefined,
142
- ExecutionTime: executionTimeMs,
143
- ErrorMessage: ''
144
- };
75
+ const result = await provider.RunQuery({
76
+ SQL: input.SQL,
77
+ MaxRows: input.MaxRows,
78
+ StartRow: startRow,
79
+ TimeoutSeconds: timeoutSeconds,
80
+ }, contextUser);
81
+ return this.toResultType(result, startRow, input.MaxRows, timeoutSeconds, Date.now() - startTime);
145
82
  } catch (err: unknown) {
146
- const executionTimeMs = Date.now() - startTime;
147
83
  const errorMessage = err instanceof Error ? err.message : String(err);
148
-
149
- // Handle timeout
150
- if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
151
- return {
152
- QueryID: '',
153
- QueryName: 'Ad-Hoc Query',
154
- Success: false,
155
- Results: '[]',
156
- RowCount: 0,
157
- TotalRowCount: 0,
158
- PageNumber: undefined,
159
- PageSize: undefined,
160
- ExecutionTime: executionTimeMs,
161
- ErrorMessage: `Query execution exceeded ${input.TimeoutSeconds ?? 30} second timeout`
162
- };
163
- }
164
-
165
84
  LogError(`Ad-hoc query execution failed: ${errorMessage}`);
166
- return {
167
- QueryID: '',
168
- QueryName: 'Ad-Hoc Query',
169
- Success: false,
170
- Results: '[]',
171
- RowCount: 0,
172
- TotalRowCount: 0,
173
- PageNumber: undefined,
174
- PageSize: undefined,
175
- ExecutionTime: executionTimeMs,
176
- ErrorMessage: `Query execution failed: ${errorMessage}`
177
- };
85
+ return this.buildErrorResult(`Query execution failed: ${errorMessage}`, Date.now() - startTime);
178
86
  }
179
87
  }
180
88
 
181
- /**
182
- * Runs the page's data SQL, then — only when the page is FULL (so more rows may
183
- * exist) — a COUNT(*) for the true total. A short (or unpaged) page needs no
184
- * count: the exact total is `startRow + rowsReturned` (see {@link exactTotalFromPage}).
185
- *
186
- * The count is NON-FATAL: some queries page fine but cannot be counted — e.g.
187
- * duplicate column names are legal in a result set but rejected inside the COUNT
188
- * CTE wrap. A count failure must never sink the whole result, so we log it and
189
- * report a lower-bound total (`startRow + rowsReturned`) and let the data render.
190
- */
191
- private async executeDataAndCount(
192
- ds: sql.ConnectionPool,
193
- dataSQL: string,
194
- countSQL: string | null,
89
+ /** Maps the provider's result to the GraphQL shape, naming a timeout plainly. */
90
+ private toResultType(
91
+ result: RunQueryResult,
195
92
  startRow: number,
196
- maxRows: number | null,
197
- deadline: number,
198
- ): Promise<{ recordset: Record<string, unknown>[]; totalRowCount: number }> {
199
- const dataResult = await this.runSqlWithDeadline<Record<string, unknown>>(ds, dataSQL, deadline);
200
- const recordset = (dataResult.recordset ?? []) as Record<string, unknown>[];
201
-
202
- // Total already known from the page alone (unpaged, or a short page)? Skip the count.
203
- const exact = exactTotalFromPage(startRow, recordset.length, maxRows);
204
- if (exact != null || !countSQL) {
205
- return { recordset, totalRowCount: exact ?? recordset.length };
206
- }
207
-
208
- // Full page — a COUNT(*) is required to know the true total.
209
- const lowerBound = startRow + recordset.length;
210
- try {
211
- const countResult = await this.runSqlWithDeadline<{ TotalRowCount: number }>(ds, countSQL, deadline);
212
- return { recordset, totalRowCount: resolveAdhocTotalRowCount(countResult.recordset, lowerBound) };
213
- } catch (countErr) {
214
- const msg = countErr instanceof Error ? countErr.message : String(countErr);
215
- LogError(`Ad-hoc query row-count failed; reporting a lower-bound total (${lowerBound}). ${msg}`);
216
- return { recordset, totalRowCount: lowerBound };
217
- }
218
- }
219
-
220
- /**
221
- * Executes one SQL statement on the read-only pool, racing it against the shared
222
- * wall-clock `deadline`. The timer is always cleared on completion so a settled
223
- * query never leaves a dangling timeout armed.
224
- */
225
- private async runSqlWithDeadline<T>(
226
- ds: sql.ConnectionPool,
227
- sqlText: string,
228
- deadline: number,
229
- ): Promise<sql.IResult<T>> {
230
- const remaining = deadline - Date.now();
231
- if (remaining <= 0) {
232
- throw new Error('Query timeout exceeded');
233
- }
234
- let timer: ReturnType<typeof setTimeout> | undefined;
235
- try {
236
- return await Promise.race([
237
- new sql.Request(ds).query<T>(sqlText),
238
- new Promise<never>((_, reject) => {
239
- timer = setTimeout(() => reject(new Error('Query timeout exceeded')), remaining);
240
- }),
241
- ]);
242
- } finally {
243
- if (timer) {
244
- clearTimeout(timer);
245
- }
93
+ maxRows: number | undefined,
94
+ timeoutSeconds: number,
95
+ executionTimeMs: number,
96
+ ): RunQueryResultType {
97
+ if (!result.Success) {
98
+ const message = result.ErrorMessage ?? 'Ad-hoc query failed';
99
+ return this.buildErrorResult(
100
+ /timeout/i.test(message) ? `Query execution exceeded ${timeoutSeconds} second timeout` : message,
101
+ executionTimeMs,
102
+ );
246
103
  }
104
+ const paged = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
105
+ return {
106
+ QueryID: '',
107
+ QueryName: 'Ad-Hoc Query',
108
+ Success: true,
109
+ Results: JSON.stringify(result.Results),
110
+ RowCount: result.RowCount,
111
+ TotalRowCount: result.TotalRowCount,
112
+ PageNumber: paged ? Math.floor(startRow / maxRows!) + 1 : undefined,
113
+ PageSize: paged ? maxRows : undefined,
114
+ ExecutionTime: executionTimeMs,
115
+ ErrorMessage: ''
116
+ };
247
117
  }
248
118
 
249
- private buildErrorResult(errorMessage: string): RunQueryResultType {
119
+ private buildErrorResult(errorMessage: string, executionTimeMs = 0): RunQueryResultType {
250
120
  return {
251
121
  QueryID: '',
252
122
  QueryName: 'Ad-Hoc Query',
@@ -256,7 +126,7 @@ export class AdhocQueryResolver extends ResolverBase {
256
126
  TotalRowCount: 0,
257
127
  PageNumber: undefined,
258
128
  PageSize: undefined,
259
- ExecutionTime: 0,
129
+ ExecutionTime: executionTimeMs,
260
130
  ErrorMessage: errorMessage
261
131
  };
262
132
  }