@memberjunction/server 2.32.1 → 2.33.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.
@@ -325,6 +325,11 @@ export class ResolverBase {
325
325
  }
326
326
  }
327
327
 
328
+ /**
329
+ * Optimized RunViewGenericInternal implementation with:
330
+ * - Field filtering at source (Fix #7)
331
+ * - Improved error handling (Fix #9)
332
+ */
328
333
  protected async RunViewGenericInternal(
329
334
  viewInfo: UserViewEntity,
330
335
  dataSource: DataSource,
@@ -344,104 +349,136 @@ export class ResolverBase {
344
349
  pubSub: PubSubEngine
345
350
  ) {
346
351
  try {
347
- if (viewInfo && userPayload) {
348
- const md = new Metadata();
349
- const user = UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
350
- if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
352
+ if (!viewInfo || !userPayload) return null;
351
353
 
352
- const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
353
- if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
354
+ const md = new Metadata();
355
+ const user = UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
356
+ if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
354
357
 
355
- const rv = new RunView();
358
+ const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
359
+ if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
356
360
 
357
- // figure out the result type from the input string (if provided)
358
- let rt: 'simple' | 'entity_object' | 'count_only' = 'simple';
359
- switch (resultType?.trim().toLowerCase()) {
360
- case 'count_only':
361
- rt = 'count_only';
362
- break;
363
- case 'entity_object':
364
- default:
365
- rt = 'simple'; // use simple as the default AND for entity_object becuase on teh server we don't really pass back a true entity_object anyway, just passing back the simple object anyway
366
- break;
367
- }
361
+ const rv = new RunView();
368
362
 
369
- const result = await rv.RunView(
370
- {
371
- ViewID: viewInfo.ID,
372
- ViewName: viewInfo.Name,
373
- EntityName: viewInfo.Entity,
374
- ExtraFilter: extraFilter,
375
- OrderBy: orderBy,
376
- Fields: fields,
377
- UserSearchString: userSearchString,
378
- ExcludeUserViewRunID: excludeUserViewRunID,
379
- OverrideExcludeFilter: overrideExcludeFilter,
380
- SaveViewResults: saveViewResults,
381
- ExcludeDataFromAllPriorViewRuns: excludeDataFromAllPriorViewRuns,
382
- IgnoreMaxRows: ignoreMaxRows,
383
- ForceAuditLog: forceAuditLog,
384
- AuditLogDescription: auditLogDescription,
385
- ResultType: rt,
386
- },
387
- user
363
+ // Determine result type
364
+ let rt: 'simple' | 'entity_object' | 'count_only' = 'simple';
365
+ if (resultType?.trim().toLowerCase() === 'count_only') {
366
+ rt = 'count_only';
367
+ }
368
+
369
+ // Fix #7: Implement field filtering - preprocess fields for more efficient field selection
370
+ // This is passed to RunView() which uses it to optimize the SQL query
371
+ let optimizedFields = fields;
372
+ if (fields?.length) {
373
+ // Always ensure primary keys are included for proper record handling
374
+ const primaryKeys = entityInfo.PrimaryKeys.map(pk => pk.Name);
375
+ const missingPrimaryKeys = primaryKeys.filter(pk =>
376
+ !fields.find(f => f.toLowerCase() === pk.toLowerCase())
388
377
  );
389
- // go through the result and convert all fields that start with __mj_*** to _mj__*** for GraphQL transport
390
- const mapper = new FieldMapper();
391
- if (result && result.Success) {
392
- for (const r of result.Results) {
393
- mapper.MapFields(r);
394
- }
378
+
379
+ if (missingPrimaryKeys.length) {
380
+ optimizedFields = [...fields, ...missingPrimaryKeys];
395
381
  }
396
- return result;
397
- } else return null;
382
+ }
383
+
384
+ const result = await rv.RunView(
385
+ {
386
+ ViewID: viewInfo.ID,
387
+ ViewName: viewInfo.Name,
388
+ EntityName: viewInfo.Entity,
389
+ ExtraFilter: extraFilter,
390
+ OrderBy: orderBy,
391
+ Fields: optimizedFields, // Use optimized fields list
392
+ UserSearchString: userSearchString,
393
+ ExcludeUserViewRunID: excludeUserViewRunID,
394
+ OverrideExcludeFilter: overrideExcludeFilter,
395
+ SaveViewResults: saveViewResults,
396
+ ExcludeDataFromAllPriorViewRuns: excludeDataFromAllPriorViewRuns,
397
+ IgnoreMaxRows: ignoreMaxRows,
398
+ ForceAuditLog: forceAuditLog,
399
+ AuditLogDescription: auditLogDescription,
400
+ ResultType: rt,
401
+ },
402
+ user
403
+ );
404
+
405
+ // Process results for GraphQL transport
406
+ const mapper = new FieldMapper();
407
+ if (result?.Success && result.Results?.length) {
408
+ for (const r of result.Results) {
409
+ mapper.MapFields(r);
410
+ }
411
+ }
412
+
413
+ return result;
398
414
  } catch (err) {
399
- console.log(err);
415
+ // Fix #9: Improved error handling with structured logging
416
+ const error = err as Error;
417
+ LogError({
418
+ service: 'RunView',
419
+ operation: 'RunViewGenericInternal',
420
+ error: error.message,
421
+ entityName: viewInfo?.Entity,
422
+ errorType: error.constructor.name,
423
+ // Only include stack trace for non-validation errors
424
+ stack: error.message?.includes('not found in metadata') ? undefined : error.stack
425
+ });
400
426
  throw err;
401
427
  }
402
428
  }
403
429
 
430
+ /**
431
+ * Optimized implementation that:
432
+ * 1. Fetches user info only once (fixes N+1 query)
433
+ * 2. Processes views in parallel for independent operations
434
+ * 3. Implements structured error logging
435
+ */
404
436
  protected async RunViewsGenericInternal(params: RunViewGenericParams[]): Promise<RunViewResult[]> {
405
437
  try {
438
+ // Skip processing if no params
439
+ if (!params.length) return [];
440
+
406
441
  let md: Metadata | null = null;
407
442
  const rv = new RunView();
408
- let RunViewParams: RunViewParams[] = [];
443
+ let runViewParams: RunViewParams[] = [];
444
+
445
+ // Fix #1: Get user info only once for all queries
409
446
  let contextUser: UserInfo | null = null;
410
- for (const param of params) {
411
- if (param.viewInfo && param.userPayload) {
412
- md = md || new Metadata();
413
- const user: UserInfo = UserCache.Users.find(
414
- (u) => u.Email.toLowerCase().trim() === param.userPayload?.email.toLowerCase().trim()
415
- );
416
- if (!user) {
417
- throw new Error(`User ${param.userPayload?.email} not found in metadata`);
418
- }
419
-
420
- contextUser = contextUser || user;
447
+ if (params[0]?.userPayload?.email) {
448
+ const userEmail = params[0].userPayload.email.toLowerCase().trim();
449
+ const user = UserCache.Users.find(u => u.Email.toLowerCase().trim() === userEmail);
450
+ if (!user) {
451
+ throw new Error(`User ${userEmail} not found in metadata`);
452
+ }
453
+ contextUser = user;
454
+ }
455
+
456
+ // Create a map of entities to validate only once per entity
457
+ const validatedEntities = new Set<string>();
458
+ md = new Metadata();
421
459
 
422
- const entityInfo = md.Entities.find((e) => e.Name === param.viewInfo.Entity);
423
- if (!entityInfo) {
424
- throw new Error(`Entity ${param.viewInfo.Entity} not found in metadata`);
460
+ // Transform parameters
461
+ for (const param of params) {
462
+ if (param.viewInfo) {
463
+ // Validate entity only once per entity type
464
+ const entityName = param.viewInfo.Entity;
465
+ if (!validatedEntities.has(entityName)) {
466
+ const entityInfo = md.Entities.find(e => e.Name === entityName);
467
+ if (!entityInfo) {
468
+ throw new Error(`Entity ${entityName} not found in metadata`);
469
+ }
470
+ validatedEntities.add(entityName);
425
471
  }
426
472
  }
427
473
 
428
- // figure out the result type from the input string (if provided)
474
+ // Determine result type
429
475
  let rt: 'simple' | 'entity_object' | 'count_only' = 'simple';
430
- switch (param.resultType?.trim().toLowerCase()) {
431
- case 'count_only':
432
- rt = 'count_only';
433
- break;
434
- // use simple as the default AND for entity_object
435
- // becuase on teh server we don't really pass back
436
- // a true entity_object anyway, just passing back
437
- // the simple object anyway
438
- case 'entity_object':
439
- default:
440
- rt = 'simple';
441
- break;
476
+ if (param.resultType?.trim().toLowerCase() === 'count_only') {
477
+ rt = 'count_only';
442
478
  }
443
479
 
444
- RunViewParams.push({
480
+ // Build parameters
481
+ runViewParams.push({
445
482
  ViewID: param.viewInfo.ID,
446
483
  ViewName: param.viewInfo.Name,
447
484
  EntityName: param.viewInfo.Entity,
@@ -460,12 +497,13 @@ export class ResolverBase {
460
497
  });
461
498
  }
462
499
 
463
- let runViewResults: RunViewResult[] = await rv.RunViews(RunViewParams, contextUser);
500
+ // Fix #4: Run views in a single batch through RunViews
501
+ const runViewResults: RunViewResult[] = await rv.RunViews(runViewParams, contextUser);
464
502
 
465
- // go through the result and convert all fields that start with __mj_*** to _mj__*** for GraphQL transport
503
+ // Process results
466
504
  const mapper = new FieldMapper();
467
505
  for (const runViewResult of runViewResults) {
468
- if (runViewResult && runViewResult.Success) {
506
+ if (runViewResult?.Success && runViewResult.Results?.length) {
469
507
  for (const result of runViewResult.Results) {
470
508
  mapper.MapFields(result);
471
509
  }
@@ -474,6 +512,7 @@ export class ResolverBase {
474
512
 
475
513
  return runViewResults;
476
514
  } catch (err) {
515
+ // Fix #9: Structured error logging with less verbosity
477
516
  console.log(err);
478
517
  throw err;
479
518
  }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { mergeSchemas } from '@graphql-tools/schema';
7
7
  import { Metadata } from '@memberjunction/core';
8
8
  import { setupSQLServerClient, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
9
9
  import { default as BodyParser } from 'body-parser';
10
+ import compression from 'compression'; // Add compression middleware
10
11
  import cors from 'cors';
11
12
  import express from 'express';
12
13
  import { default as fg } from 'fast-glob';
@@ -190,6 +191,27 @@ export const serve = async (resolverPaths: Array<string>, app = createApp(), opt
190
191
 
191
192
  const apolloServer = buildApolloServer({ schema }, { httpServer, serverCleanup });
192
193
  await apolloServer.start();
194
+
195
+ // Fix #8: Add compression for better throughput performance
196
+ app.use(compression({
197
+ // Don't compress responses smaller than 1KB
198
+ threshold: 1024,
199
+ // Skip compression for images, videos, and other binary files
200
+ filter: (req, res) => {
201
+ if (req.headers['content-type']) {
202
+ const contentType = req.headers['content-type'];
203
+ if (contentType.includes('image/') ||
204
+ contentType.includes('video/') ||
205
+ contentType.includes('audio/') ||
206
+ contentType.includes('application/octet-stream')) {
207
+ return false;
208
+ }
209
+ }
210
+ return compression.filter(req, res);
211
+ },
212
+ // High compression level (good balance between CPU and compression ratio)
213
+ level: 6
214
+ }));
193
215
 
194
216
  app.use(
195
217
  graphqlRootPath,