@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.
- package/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +49 -35
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +87 -3
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +118 -3
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/DeleteOptionsInput.d.ts +8 -1
- package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
- package/dist/generic/DeleteOptionsInput.js +1 -1
- package/dist/generic/DeleteOptionsInput.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +4 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +10 -3
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -47
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +54 -174
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/services/TaskGraphActionRunner.d.ts +13 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +16 -15
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/DeleteOptionsInput.wireShape.test.ts +62 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +177 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +224 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/auth/magicLink/MagicLinkService.ts +55 -36
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/config.ts +23 -0
- package/src/context.ts +11 -24
- package/src/generic/CacheInvalidationResolver.ts +139 -3
- package/src/generic/DeleteOptionsInput.ts +9 -2
- package/src/generic/ResolverBase.ts +11 -3
- package/src/index.ts +55 -48
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +64 -194
- package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/services/TaskGraphActionRunner.ts +16 -15
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- 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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
|
37
|
-
* -
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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 |
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
129
|
+
ExecutionTime: executionTimeMs,
|
|
260
130
|
ErrorMessage: errorMessage
|
|
261
131
|
};
|
|
262
132
|
}
|