@memberjunction/server 2.50.0 → 2.52.0
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 +133 -0
- package/dist/config.d.ts +264 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -1
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +66 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +849 -517
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +13 -11
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/orm.d.ts.map +1 -1
- package/dist/orm.js +6 -0
- package/dist/orm.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +3 -3
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +13 -10
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.js +1 -1
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +49 -8
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +389 -106
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunAIPromptResolver.d.ts +1 -1
- package/dist/resolvers/RunAIPromptResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIPromptResolver.js +53 -3
- package/dist/resolvers/RunAIPromptResolver.js.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts +61 -0
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -0
- package/dist/resolvers/SqlLoggingConfigResolver.js +477 -0
- package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -0
- package/dist/resolvers/UserFavoriteResolver.d.ts +3 -3
- package/dist/resolvers/UserFavoriteResolver.d.ts.map +1 -1
- package/dist/resolvers/UserFavoriteResolver.js +6 -6
- package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts +3 -3
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +6 -6
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts +4 -4
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +6 -6
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/package.json +25 -25
- package/src/config.ts +28 -0
- package/src/generated/generated.ts +718 -518
- package/src/generic/ResolverBase.ts +17 -10
- package/src/index.ts +2 -1
- package/src/orm.ts +6 -0
- package/src/resolvers/ActionResolver.ts +21 -26
- package/src/resolvers/FileResolver.ts +1 -1
- package/src/resolvers/RunAIAgentResolver.ts +398 -100
- package/src/resolvers/RunAIPromptResolver.ts +52 -2
- package/src/resolvers/SqlLoggingConfigResolver.ts +691 -0
- package/src/resolvers/UserFavoriteResolver.ts +6 -6
- package/src/resolvers/UserResolver.ts +6 -6
- package/src/resolvers/UserViewResolver.ts +6 -6
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { Arg, Ctx, Field, InputType, Mutation, ObjectType, Query, Int, Resolver } from 'type-graphql';
|
|
2
|
+
import { AppContext } from '../types.js';
|
|
3
|
+
import { Metadata, UserInfo } from '@memberjunction/core';
|
|
4
|
+
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
5
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as fs from 'fs/promises';
|
|
9
|
+
import { loadConfig } from '../config.js';
|
|
10
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Configuration options for SQL logging sessions.
|
|
14
|
+
* These options control how SQL statements are captured, formatted, and saved.
|
|
15
|
+
*/
|
|
16
|
+
@ObjectType()
|
|
17
|
+
export class SqlLoggingOptions {
|
|
18
|
+
/** Whether to format SQL output as a database migration file with proper headers */
|
|
19
|
+
@Field(() => Boolean, { nullable: true })
|
|
20
|
+
formatAsMigration?: boolean;
|
|
21
|
+
|
|
22
|
+
/** Optional description or notes for this logging configuration */
|
|
23
|
+
@Field(() => String, { nullable: true })
|
|
24
|
+
description?: string;
|
|
25
|
+
|
|
26
|
+
/** Types of SQL statements to capture: 'queries', 'mutations', or 'both' */
|
|
27
|
+
@Field(() => String, { nullable: true })
|
|
28
|
+
statementTypes?: 'queries' | 'mutations' | 'both';
|
|
29
|
+
|
|
30
|
+
/** String separator to use between SQL statements (e.g., 'GO' for SQL Server) */
|
|
31
|
+
@Field(() => String, { nullable: true })
|
|
32
|
+
batchSeparator?: string;
|
|
33
|
+
|
|
34
|
+
/** Whether to format SQL with proper indentation and line breaks */
|
|
35
|
+
@Field(() => Boolean, { nullable: true })
|
|
36
|
+
prettyPrint?: boolean;
|
|
37
|
+
|
|
38
|
+
/** Whether to include metadata about record changes in the log output */
|
|
39
|
+
@Field(() => Boolean, { nullable: true })
|
|
40
|
+
logRecordChangeMetadata?: boolean;
|
|
41
|
+
|
|
42
|
+
/** Whether to keep log files even if they contain no SQL statements */
|
|
43
|
+
@Field(() => Boolean, { nullable: true })
|
|
44
|
+
retainEmptyLogFiles?: boolean;
|
|
45
|
+
|
|
46
|
+
/** Email address to filter SQL statements by user (when filtering is enabled) */
|
|
47
|
+
@Field(() => String, { nullable: true })
|
|
48
|
+
filterByUserId?: string;
|
|
49
|
+
|
|
50
|
+
/** Human-readable name for the logging session */
|
|
51
|
+
@Field(() => String, { nullable: true })
|
|
52
|
+
sessionName?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@ObjectType()
|
|
56
|
+
export class SqlLoggingSession {
|
|
57
|
+
/** Unique identifier for this logging session */
|
|
58
|
+
@Field(() => String)
|
|
59
|
+
id: string;
|
|
60
|
+
|
|
61
|
+
/** Absolute file path where SQL statements are being logged */
|
|
62
|
+
@Field(() => String)
|
|
63
|
+
filePath: string;
|
|
64
|
+
|
|
65
|
+
/** Timestamp when this logging session was started */
|
|
66
|
+
@Field(() => Date)
|
|
67
|
+
startTime: Date;
|
|
68
|
+
|
|
69
|
+
/** Number of SQL statements captured so far in this session */
|
|
70
|
+
@Field(() => Int)
|
|
71
|
+
statementCount: number;
|
|
72
|
+
|
|
73
|
+
/** Configuration options applied to this logging session */
|
|
74
|
+
@Field(() => SqlLoggingOptions)
|
|
75
|
+
options: SqlLoggingOptions;
|
|
76
|
+
|
|
77
|
+
/** Human-readable name for this logging session */
|
|
78
|
+
@Field(() => String, { nullable: true })
|
|
79
|
+
sessionName?: string;
|
|
80
|
+
|
|
81
|
+
/** Email address of user whose SQL is being filtered (if filtering enabled) */
|
|
82
|
+
@Field(() => String, { nullable: true })
|
|
83
|
+
filterByUserId?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@InputType()
|
|
87
|
+
export class SqlLoggingOptionsInput {
|
|
88
|
+
/** Whether to format SQL output as a database migration file with proper headers */
|
|
89
|
+
@Field(() => Boolean, { nullable: true })
|
|
90
|
+
formatAsMigration?: boolean;
|
|
91
|
+
|
|
92
|
+
/** Optional description or notes for this logging configuration */
|
|
93
|
+
@Field(() => String, { nullable: true })
|
|
94
|
+
description?: string;
|
|
95
|
+
|
|
96
|
+
/** Types of SQL statements to capture: 'queries', 'mutations', or 'both' */
|
|
97
|
+
@Field(() => String, { nullable: true })
|
|
98
|
+
statementTypes?: 'queries' | 'mutations' | 'both';
|
|
99
|
+
|
|
100
|
+
/** String separator to use between SQL statements (e.g., 'GO' for SQL Server) */
|
|
101
|
+
@Field(() => String, { nullable: true })
|
|
102
|
+
batchSeparator?: string;
|
|
103
|
+
|
|
104
|
+
/** Whether to format SQL with proper indentation and line breaks */
|
|
105
|
+
@Field(() => Boolean, { nullable: true })
|
|
106
|
+
prettyPrint?: boolean;
|
|
107
|
+
|
|
108
|
+
/** Whether to include metadata about record changes in the log output */
|
|
109
|
+
@Field(() => Boolean, { nullable: true })
|
|
110
|
+
logRecordChangeMetadata?: boolean;
|
|
111
|
+
|
|
112
|
+
/** Whether to keep log files even if they contain no SQL statements */
|
|
113
|
+
@Field(() => Boolean, { nullable: true })
|
|
114
|
+
retainEmptyLogFiles?: boolean;
|
|
115
|
+
|
|
116
|
+
/** Email address to filter SQL statements by user (when filtering is enabled) */
|
|
117
|
+
@Field(() => String, { nullable: true })
|
|
118
|
+
filterByUserId?: string;
|
|
119
|
+
|
|
120
|
+
/** Human-readable name for the logging session */
|
|
121
|
+
@Field(() => String, { nullable: true })
|
|
122
|
+
sessionName?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@InputType()
|
|
126
|
+
export class StartSqlLoggingInput {
|
|
127
|
+
/** Optional custom filename for the SQL log file (auto-generated if not provided) */
|
|
128
|
+
@Field(() => String, { nullable: true })
|
|
129
|
+
fileName?: string;
|
|
130
|
+
|
|
131
|
+
/** Configuration options for the logging session (merged with server defaults) */
|
|
132
|
+
@Field(() => SqlLoggingOptionsInput, { nullable: true })
|
|
133
|
+
options?: SqlLoggingOptionsInput;
|
|
134
|
+
|
|
135
|
+
/** Whether to filter SQL statements to only those from the current user */
|
|
136
|
+
@Field(() => Boolean, { nullable: true })
|
|
137
|
+
filterToCurrentUser?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@ObjectType()
|
|
141
|
+
export class SqlLoggingConfig {
|
|
142
|
+
/** Whether SQL logging is enabled in the server configuration */
|
|
143
|
+
@Field(() => Boolean)
|
|
144
|
+
enabled: boolean;
|
|
145
|
+
|
|
146
|
+
/** Default logging options applied to new sessions (can be overridden) */
|
|
147
|
+
@Field(() => SqlLoggingOptions)
|
|
148
|
+
defaultOptions: SqlLoggingOptions;
|
|
149
|
+
|
|
150
|
+
/** Directory path where SQL log files are allowed to be created */
|
|
151
|
+
@Field(() => String)
|
|
152
|
+
allowedLogDirectory: string;
|
|
153
|
+
|
|
154
|
+
/** Maximum number of concurrent SQL logging sessions allowed */
|
|
155
|
+
@Field(() => Int)
|
|
156
|
+
maxActiveSessions: number;
|
|
157
|
+
|
|
158
|
+
/** Whether to automatically delete log files that contain no SQL statements */
|
|
159
|
+
@Field(() => Boolean)
|
|
160
|
+
autoCleanupEmptyFiles: boolean;
|
|
161
|
+
|
|
162
|
+
/** Timeout in milliseconds after which inactive sessions are automatically stopped */
|
|
163
|
+
@Field(() => Int)
|
|
164
|
+
sessionTimeout: number;
|
|
165
|
+
|
|
166
|
+
/** Current number of active SQL logging sessions */
|
|
167
|
+
@Field(() => Int)
|
|
168
|
+
activeSessionCount: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* GraphQL resolver for SQL logging configuration and session management.
|
|
173
|
+
* Provides queries and mutations for controlling SQL logging functionality.
|
|
174
|
+
*
|
|
175
|
+
* **Security**: All operations require Owner-level privileges.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```typescript
|
|
179
|
+
* // Start a new logging session
|
|
180
|
+
* const session = await startSqlLogging({
|
|
181
|
+
* fileName: "my-session.sql",
|
|
182
|
+
* filterToCurrentUser: true,
|
|
183
|
+
* options: {
|
|
184
|
+
* prettyPrint: true,
|
|
185
|
+
* statementTypes: "both"
|
|
186
|
+
* }
|
|
187
|
+
* });
|
|
188
|
+
*
|
|
189
|
+
* // Get current configuration
|
|
190
|
+
* const config = await sqlLoggingConfig();
|
|
191
|
+
*
|
|
192
|
+
* // List active sessions
|
|
193
|
+
* const sessions = await activeSqlLoggingSessions();
|
|
194
|
+
*
|
|
195
|
+
* // Stop a session
|
|
196
|
+
* await stopSqlLogging(session.id);
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
@Resolver()
|
|
200
|
+
export class SqlLoggingConfigResolver extends ResolverBase {
|
|
201
|
+
/** Default prefix for auto-generated SQL log filenames */
|
|
202
|
+
private static readonly LOG_FILE_PREFIX = 'sql-log-';
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validates that the current user has Owner-level privileges required for SQL logging operations.
|
|
206
|
+
*
|
|
207
|
+
* This method performs authentication and authorization checks:
|
|
208
|
+
* - Verifies user is authenticated (has email in context)
|
|
209
|
+
* - Looks up user in UserCache by email (case-insensitive)
|
|
210
|
+
* - Checks that user Type field equals 'Owner' (trimmed for nchar padding)
|
|
211
|
+
*
|
|
212
|
+
* @param context - The GraphQL application context containing user authentication data
|
|
213
|
+
* @returns Promise resolving to the authenticated UserInfo object
|
|
214
|
+
* @throws Error if user is not authenticated, not found, or lacks Owner privileges
|
|
215
|
+
*
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
private async checkOwnerAccess(context: AppContext): Promise<UserInfo> {
|
|
219
|
+
const userEmail = context.userPayload?.email;
|
|
220
|
+
if (!userEmail) {
|
|
221
|
+
throw new Error('User not authenticated');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Get the user from cache
|
|
225
|
+
const users = UserCache.Instance.Users;
|
|
226
|
+
const user = users.find(u => u.Email.toLowerCase() === userEmail.toLowerCase());
|
|
227
|
+
if (!user) {
|
|
228
|
+
throw new Error('User not found');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Debug logging
|
|
232
|
+
console.log('SQL Logging access check:', {
|
|
233
|
+
email: user.Email,
|
|
234
|
+
type: user.Type,
|
|
235
|
+
typeLength: user.Type?.length,
|
|
236
|
+
typeTrimmed: user.Type?.trim(),
|
|
237
|
+
isOwner: user.Type?.trim().toLowerCase() === 'owner'
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Check if user has Type = 'Owner' (trim and case-insensitive for nchar fields)
|
|
241
|
+
if (user.Type?.trim().toLowerCase() !== 'owner') {
|
|
242
|
+
throw new Error('Access denied. This feature requires Owner privileges.');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return user;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Retrieves the current SQL logging configuration and status information.
|
|
250
|
+
*
|
|
251
|
+
* Returns comprehensive configuration details including:
|
|
252
|
+
* - Whether SQL logging is enabled in server config
|
|
253
|
+
* - Default logging options (formatting, statement types, etc.)
|
|
254
|
+
* - File system settings (log directory, cleanup options)
|
|
255
|
+
* - Session limits and timeout settings
|
|
256
|
+
* - Count of currently active logging sessions
|
|
257
|
+
*
|
|
258
|
+
* @param context - GraphQL context (requires Owner privileges)
|
|
259
|
+
* @returns Promise resolving to complete SQL logging configuration
|
|
260
|
+
* @throws Error if user lacks Owner privileges
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```graphql
|
|
264
|
+
* query {
|
|
265
|
+
* sqlLoggingConfig {
|
|
266
|
+
* enabled
|
|
267
|
+
* activeSessionCount
|
|
268
|
+
* maxActiveSessions
|
|
269
|
+
* allowedLogDirectory
|
|
270
|
+
* defaultOptions {
|
|
271
|
+
* prettyPrint
|
|
272
|
+
* statementTypes
|
|
273
|
+
* }
|
|
274
|
+
* }
|
|
275
|
+
* }
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
@Query(() => SqlLoggingConfig)
|
|
279
|
+
async sqlLoggingConfig(@Ctx() context: AppContext): Promise<SqlLoggingConfig> {
|
|
280
|
+
await this.checkOwnerAccess(context);
|
|
281
|
+
const config = await loadConfig();
|
|
282
|
+
const provider = Metadata.Provider as SQLServerDataProvider;
|
|
283
|
+
const activeSessions = provider.GetActiveSqlLoggingSessions();
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
enabled: config.sqlLogging?.enabled ?? false,
|
|
287
|
+
defaultOptions: config.sqlLogging?.defaultOptions ?? {
|
|
288
|
+
formatAsMigration: false,
|
|
289
|
+
statementTypes: 'both',
|
|
290
|
+
batchSeparator: 'GO',
|
|
291
|
+
prettyPrint: true,
|
|
292
|
+
logRecordChangeMetadata: false,
|
|
293
|
+
retainEmptyLogFiles: false
|
|
294
|
+
},
|
|
295
|
+
allowedLogDirectory: config.sqlLogging?.allowedLogDirectory ?? './logs/sql',
|
|
296
|
+
maxActiveSessions: config.sqlLogging?.maxActiveSessions ?? 5,
|
|
297
|
+
autoCleanupEmptyFiles: config.sqlLogging?.autoCleanupEmptyFiles ?? true,
|
|
298
|
+
sessionTimeout: config.sqlLogging?.sessionTimeout ?? 3600000,
|
|
299
|
+
activeSessionCount: activeSessions.length
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Retrieves a list of all currently active SQL logging sessions.
|
|
305
|
+
*
|
|
306
|
+
* Returns detailed information for each active session including:
|
|
307
|
+
* - Unique session identifier and file path
|
|
308
|
+
* - Start time and statement count
|
|
309
|
+
* - Session configuration options
|
|
310
|
+
* - User filtering settings
|
|
311
|
+
*
|
|
312
|
+
* @param context - GraphQL context (requires Owner privileges)
|
|
313
|
+
* @returns Promise resolving to array of active SqlLoggingSession objects
|
|
314
|
+
* @throws Error if user lacks Owner privileges
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```graphql
|
|
318
|
+
* query {
|
|
319
|
+
* activeSqlLoggingSessions {
|
|
320
|
+
* id
|
|
321
|
+
* sessionName
|
|
322
|
+
* filePath
|
|
323
|
+
* startTime
|
|
324
|
+
* statementCount
|
|
325
|
+
* filterByUserId
|
|
326
|
+
* options {
|
|
327
|
+
* prettyPrint
|
|
328
|
+
* statementTypes
|
|
329
|
+
* }
|
|
330
|
+
* }
|
|
331
|
+
* }
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
@Query(() => [SqlLoggingSession])
|
|
335
|
+
async activeSqlLoggingSessions(@Ctx() context: AppContext): Promise<SqlLoggingSession[]> {
|
|
336
|
+
await this.checkOwnerAccess(context);
|
|
337
|
+
const provider = Metadata.Provider as SQLServerDataProvider;
|
|
338
|
+
const sessions = provider.GetActiveSqlLoggingSessions();
|
|
339
|
+
|
|
340
|
+
return sessions.map(session => ({
|
|
341
|
+
id: session.id,
|
|
342
|
+
filePath: session.filePath,
|
|
343
|
+
startTime: session.startTime,
|
|
344
|
+
statementCount: session.statementCount,
|
|
345
|
+
options: session.options,
|
|
346
|
+
sessionName: session.options.sessionName,
|
|
347
|
+
filterByUserId: session.options.filterByUserId
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Creates and starts a new SQL logging session with specified configuration.
|
|
353
|
+
*
|
|
354
|
+
* This mutation:
|
|
355
|
+
* - Validates SQL logging is enabled and session limits
|
|
356
|
+
* - Creates a secure file path within the allowed log directory
|
|
357
|
+
* - Configures session options (filtering, formatting, etc.)
|
|
358
|
+
* - Starts the logging session in SQLServerDataProvider
|
|
359
|
+
* - Sets up automatic cleanup after session timeout
|
|
360
|
+
*
|
|
361
|
+
* @param input - Configuration for the new logging session
|
|
362
|
+
* @param input.fileName - Optional custom filename for the log file
|
|
363
|
+
* @param input.filterToCurrentUser - Whether to filter SQL to current user only
|
|
364
|
+
* @param input.options - Logging options (formatting, statement types, etc.)
|
|
365
|
+
* @param context - GraphQL context (requires Owner privileges)
|
|
366
|
+
* @returns Promise resolving to the created SqlLoggingSession object
|
|
367
|
+
* @throws Error if logging disabled, session limit reached, or invalid file path
|
|
368
|
+
*
|
|
369
|
+
* @example
|
|
370
|
+
* ```graphql
|
|
371
|
+
* mutation {
|
|
372
|
+
* startSqlLogging(input: {
|
|
373
|
+
* fileName: "debug-session.sql"
|
|
374
|
+
* filterToCurrentUser: true
|
|
375
|
+
* options: {
|
|
376
|
+
* prettyPrint: true
|
|
377
|
+
* statementTypes: "both"
|
|
378
|
+
* sessionName: "Debug Session"
|
|
379
|
+
* }
|
|
380
|
+
* }) {
|
|
381
|
+
* id
|
|
382
|
+
* filePath
|
|
383
|
+
* sessionName
|
|
384
|
+
* }
|
|
385
|
+
* }
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
@Mutation(() => SqlLoggingSession)
|
|
389
|
+
async startSqlLogging(
|
|
390
|
+
@Arg('input', () => StartSqlLoggingInput) input: StartSqlLoggingInput,
|
|
391
|
+
@Ctx() context: AppContext
|
|
392
|
+
): Promise<SqlLoggingSession> {
|
|
393
|
+
await this.checkOwnerAccess(context);
|
|
394
|
+
const config = await loadConfig();
|
|
395
|
+
|
|
396
|
+
// Check if SQL logging is enabled
|
|
397
|
+
if (!config.sqlLogging?.enabled) {
|
|
398
|
+
throw new Error('SQL logging is not enabled in the server configuration');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Check max active sessions
|
|
402
|
+
const provider = Metadata.Provider as SQLServerDataProvider;
|
|
403
|
+
const activeSessions = provider.GetActiveSqlLoggingSessions();
|
|
404
|
+
if (activeSessions.length >= (config.sqlLogging.maxActiveSessions ?? 5)) {
|
|
405
|
+
throw new Error(`Maximum number of active SQL logging sessions (${config.sqlLogging.maxActiveSessions}) reached`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Prepare file path
|
|
409
|
+
const allowedDir = path.resolve(config.sqlLogging.allowedLogDirectory ?? './logs/sql');
|
|
410
|
+
await this.ensureDirectoryExists(allowedDir);
|
|
411
|
+
|
|
412
|
+
const fileName = input.fileName || `${SqlLoggingConfigResolver.LOG_FILE_PREFIX}${new Date().toISOString().replace(/[:.]/g, '-')}.sql`;
|
|
413
|
+
const filePath = path.join(allowedDir, fileName);
|
|
414
|
+
|
|
415
|
+
// Validate file path is within allowed directory
|
|
416
|
+
const resolvedPath = path.resolve(filePath);
|
|
417
|
+
if (!resolvedPath.startsWith(allowedDir)) {
|
|
418
|
+
throw new Error('Invalid file path - must be within allowed log directory');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Merge options with defaults
|
|
422
|
+
const defaultOptions = config.sqlLogging.defaultOptions || {};
|
|
423
|
+
const userInfo = input.filterToCurrentUser ? this.GetUserFromPayload(context.userPayload) : undefined;
|
|
424
|
+
const sessionOptions = {
|
|
425
|
+
...defaultOptions,
|
|
426
|
+
...input.options,
|
|
427
|
+
sessionName: input.options?.sessionName || `Session started by ${context.userPayload.email}`,
|
|
428
|
+
filterByUserId: input.filterToCurrentUser ? userInfo?.ID : input.options?.filterByUserId
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Create the logging session
|
|
432
|
+
const session = await provider.CreateSqlLogger(filePath, sessionOptions);
|
|
433
|
+
|
|
434
|
+
// Set up auto-cleanup after timeout
|
|
435
|
+
if (config.sqlLogging.sessionTimeout > 0) {
|
|
436
|
+
setTimeout(async () => {
|
|
437
|
+
try {
|
|
438
|
+
await session.dispose();
|
|
439
|
+
} catch (e) {
|
|
440
|
+
// Session might already be disposed
|
|
441
|
+
}
|
|
442
|
+
}, config.sqlLogging.sessionTimeout);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
id: session.id,
|
|
447
|
+
filePath: session.filePath,
|
|
448
|
+
startTime: session.startTime,
|
|
449
|
+
statementCount: session.statementCount,
|
|
450
|
+
options: session.options,
|
|
451
|
+
sessionName: session.options.sessionName,
|
|
452
|
+
filterByUserId: session.options.filterByUserId
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Stops and disposes of a specific SQL logging session.
|
|
458
|
+
*
|
|
459
|
+
* This mutation:
|
|
460
|
+
* - Validates the session exists and user has access
|
|
461
|
+
* - Calls dispose() on the session to close file handles
|
|
462
|
+
* - Removes the session from the active sessions map
|
|
463
|
+
* - Performs any configured cleanup operations
|
|
464
|
+
*
|
|
465
|
+
* @param sessionId - Unique identifier of the session to stop
|
|
466
|
+
* @param context - GraphQL context (requires Owner privileges)
|
|
467
|
+
* @returns Promise resolving to true if session was successfully stopped
|
|
468
|
+
* @throws Error if session not found or user lacks Owner privileges
|
|
469
|
+
*
|
|
470
|
+
* @example
|
|
471
|
+
* ```graphql
|
|
472
|
+
* mutation {
|
|
473
|
+
* stopSqlLogging(sessionId: "session-123-456")
|
|
474
|
+
* }
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
@Mutation(() => Boolean)
|
|
478
|
+
async stopSqlLogging(
|
|
479
|
+
@Arg('sessionId', () => String) sessionId: string,
|
|
480
|
+
@Ctx() context: AppContext
|
|
481
|
+
): Promise<boolean> {
|
|
482
|
+
await this.checkOwnerAccess(context);
|
|
483
|
+
const provider = Metadata.Provider as SQLServerDataProvider;
|
|
484
|
+
|
|
485
|
+
// Get the actual session from the private map to call dispose
|
|
486
|
+
const sessionMap = (provider as any)._sqlLoggingSessions as Map<string, any>;
|
|
487
|
+
const session = sessionMap.get(sessionId);
|
|
488
|
+
|
|
489
|
+
if (!session) {
|
|
490
|
+
throw new Error(`SQL logging session ${sessionId} not found`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
await session.dispose();
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Stops and disposes of all currently active SQL logging sessions.
|
|
499
|
+
*
|
|
500
|
+
* This is a convenience method that:
|
|
501
|
+
* - Calls DisposeAllSqlLoggingSessions() on the data provider
|
|
502
|
+
* - Ensures all file handles are properly closed
|
|
503
|
+
* - Clears the active sessions map
|
|
504
|
+
* - Performs cleanup for all sessions at once
|
|
505
|
+
*
|
|
506
|
+
* @param context - GraphQL context (requires Owner privileges)
|
|
507
|
+
* @returns Promise resolving to true if all sessions were successfully stopped
|
|
508
|
+
* @throws Error if user lacks Owner privileges
|
|
509
|
+
*
|
|
510
|
+
* @example
|
|
511
|
+
* ```graphql
|
|
512
|
+
* mutation {
|
|
513
|
+
* stopAllSqlLogging
|
|
514
|
+
* }
|
|
515
|
+
* ```
|
|
516
|
+
*/
|
|
517
|
+
@Mutation(() => Boolean)
|
|
518
|
+
async stopAllSqlLogging(@Ctx() context: AppContext): Promise<boolean> {
|
|
519
|
+
await this.checkOwnerAccess(context);
|
|
520
|
+
const provider = Metadata.Provider as SQLServerDataProvider;
|
|
521
|
+
await provider.DisposeAllSqlLoggingSessions();
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Updates the default SQL logging options for new sessions.
|
|
527
|
+
*
|
|
528
|
+
* **Note**: This updates runtime configuration only, not the static config file.
|
|
529
|
+
* Changes apply to new sessions but do not persist across server restarts.
|
|
530
|
+
* In a production system, consider persisting changes to a database.
|
|
531
|
+
*
|
|
532
|
+
* @param options - New default options to apply (partial update supported)
|
|
533
|
+
* @param context - GraphQL context (requires Owner privileges)
|
|
534
|
+
* @returns Promise resolving to the updated SqlLoggingOptions object
|
|
535
|
+
* @throws Error if SQL logging not configured or user lacks Owner privileges
|
|
536
|
+
*
|
|
537
|
+
* @example
|
|
538
|
+
* ```graphql
|
|
539
|
+
* mutation {
|
|
540
|
+
* updateSqlLoggingDefaults(options: {
|
|
541
|
+
* prettyPrint: true
|
|
542
|
+
* statementTypes: "both"
|
|
543
|
+
* logRecordChangeMetadata: false
|
|
544
|
+
* }) {
|
|
545
|
+
* prettyPrint
|
|
546
|
+
* statementTypes
|
|
547
|
+
* formatAsMigration
|
|
548
|
+
* }
|
|
549
|
+
* }
|
|
550
|
+
* ```
|
|
551
|
+
*/
|
|
552
|
+
@Mutation(() => SqlLoggingOptions)
|
|
553
|
+
async updateSqlLoggingDefaults(
|
|
554
|
+
@Arg('options', () => SqlLoggingOptionsInput) options: SqlLoggingOptionsInput,
|
|
555
|
+
@Ctx() context: AppContext
|
|
556
|
+
): Promise<SqlLoggingOptions> {
|
|
557
|
+
await this.checkOwnerAccess(context);
|
|
558
|
+
// Note: This updates the runtime configuration only, not the file
|
|
559
|
+
// In a production system, you might want to persist this to a database
|
|
560
|
+
const config = await loadConfig();
|
|
561
|
+
if (!config.sqlLogging) {
|
|
562
|
+
throw new Error('SQL logging configuration not found');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
config.sqlLogging.defaultOptions = {
|
|
566
|
+
...config.sqlLogging.defaultOptions,
|
|
567
|
+
...options
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
return config.sqlLogging.defaultOptions;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Reads the contents of a specific SQL log file.
|
|
575
|
+
*
|
|
576
|
+
* This method:
|
|
577
|
+
* - Validates the session exists and user has access
|
|
578
|
+
* - Ensures the file path is within the allowed log directory
|
|
579
|
+
* - Reads the file content with optional line limits
|
|
580
|
+
* - Returns the content as a string
|
|
581
|
+
*
|
|
582
|
+
* @param sessionId - Unique identifier of the logging session
|
|
583
|
+
* @param maxLines - Maximum number of lines to read (optional, defaults to all)
|
|
584
|
+
* @param context - GraphQL context (requires Owner privileges)
|
|
585
|
+
* @returns Promise resolving to the log file content
|
|
586
|
+
* @throws Error if session not found, file not accessible, or user lacks privileges
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* ```graphql
|
|
590
|
+
* query {
|
|
591
|
+
* readSqlLogFile(sessionId: "session-123", maxLines: 100)
|
|
592
|
+
* }
|
|
593
|
+
* ```
|
|
594
|
+
*/
|
|
595
|
+
@Query(() => String)
|
|
596
|
+
async readSqlLogFile(
|
|
597
|
+
@Arg('sessionId', () => String) sessionId: string,
|
|
598
|
+
@Arg('maxLines', () => Int, { nullable: true }) maxLines: number | null,
|
|
599
|
+
@Ctx() context: AppContext
|
|
600
|
+
): Promise<string> {
|
|
601
|
+
await this.checkOwnerAccess(context);
|
|
602
|
+
const config = await loadConfig();
|
|
603
|
+
|
|
604
|
+
// Check if SQL logging is enabled
|
|
605
|
+
if (!config.sqlLogging?.enabled) {
|
|
606
|
+
throw new Error('SQL logging is not enabled in the server configuration');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Find the session
|
|
610
|
+
const provider = Metadata.Provider as SQLServerDataProvider;
|
|
611
|
+
const sessions = provider.GetActiveSqlLoggingSessions();
|
|
612
|
+
const session = sessions.find(s => s.id === sessionId);
|
|
613
|
+
|
|
614
|
+
if (!session) {
|
|
615
|
+
throw new Error(`SQL logging session ${sessionId} not found`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Validate file path is within allowed directory
|
|
619
|
+
const allowedDir = path.resolve(config.sqlLogging.allowedLogDirectory ?? './logs/sql');
|
|
620
|
+
const resolvedPath = path.resolve(session.filePath);
|
|
621
|
+
if (!resolvedPath.startsWith(allowedDir)) {
|
|
622
|
+
throw new Error('Access denied - file path outside allowed directory');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
// Check if file exists
|
|
627
|
+
await fs.access(session.filePath);
|
|
628
|
+
|
|
629
|
+
// Read file content
|
|
630
|
+
const content = await fs.readFile(session.filePath, 'utf-8');
|
|
631
|
+
|
|
632
|
+
// Apply line limit if specified
|
|
633
|
+
if (maxLines && maxLines > 0) {
|
|
634
|
+
const lines = content.split('\n');
|
|
635
|
+
if (lines.length > maxLines) {
|
|
636
|
+
// Return the last N lines
|
|
637
|
+
return lines.slice(-maxLines).join('\n');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return content;
|
|
642
|
+
} catch (error: any) {
|
|
643
|
+
if (error.code === 'ENOENT') {
|
|
644
|
+
return '-- Log file not yet created or is empty --';
|
|
645
|
+
}
|
|
646
|
+
throw new Error(`Failed to read log file: ${error.message}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Debug query to check what the current user email is in the SQL provider.
|
|
652
|
+
* This helps diagnose user filtering issues when SQL statements aren't being captured.
|
|
653
|
+
*
|
|
654
|
+
* Returns a comparison of the user email stored in the SQLServerDataProvider
|
|
655
|
+
* versus the user email from the GraphQL context, which helps identify mismatches
|
|
656
|
+
* that could prevent SQL filtering from working correctly.
|
|
657
|
+
*
|
|
658
|
+
* @param context - GraphQL context containing user information
|
|
659
|
+
* @returns Formatted string showing both email values and whether they match
|
|
660
|
+
* @throws Error if user doesn't have Owner privileges
|
|
661
|
+
*/
|
|
662
|
+
@Query(() => String)
|
|
663
|
+
async debugCurrentUserEmail(@Ctx() context: AppContext): Promise<string> {
|
|
664
|
+
await this.checkOwnerAccess(context);
|
|
665
|
+
|
|
666
|
+
const contextUserEmail = context.userPayload?.email || 'NOT_SET';
|
|
667
|
+
|
|
668
|
+
return `Context User Email: "${contextUserEmail}" | Note: Provider no longer stores user email - uses contextUser parameter for SQL logging`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Ensures the specified log directory exists, creating it if necessary.
|
|
673
|
+
*
|
|
674
|
+
* This method:
|
|
675
|
+
* - Attempts to access the directory to check if it exists
|
|
676
|
+
* - Creates the directory recursively if it doesn't exist
|
|
677
|
+
* - Handles permission and file system errors gracefully
|
|
678
|
+
*
|
|
679
|
+
* @param dir - Absolute path to the directory to ensure exists
|
|
680
|
+
* @throws Error if directory cannot be created due to permissions or other issues
|
|
681
|
+
*
|
|
682
|
+
* @private
|
|
683
|
+
*/
|
|
684
|
+
private async ensureDirectoryExists(dir: string): Promise<void> {
|
|
685
|
+
try {
|
|
686
|
+
await fs.access(dir);
|
|
687
|
+
} catch {
|
|
688
|
+
await fs.mkdir(dir, { recursive: true });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|