@memberjunction/server 5.15.0 → 5.17.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.
Files changed (91) hide show
  1. package/README.md +66 -3
  2. package/dist/auth/index.d.ts +0 -3
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +5 -7
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/auth/initializeProviders.js +2 -2
  7. package/dist/auth/initializeProviders.js.map +1 -1
  8. package/dist/config.d.ts +51 -0
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +7 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +3 -3
  14. package/dist/context.js.map +1 -1
  15. package/dist/generated/generated.d.ts +46 -46
  16. package/dist/generated/generated.d.ts.map +1 -1
  17. package/dist/generated/generated.js +332 -332
  18. package/dist/generated/generated.js.map +1 -1
  19. package/dist/index.d.ts +4 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +327 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/resolvers/DatasetResolver.d.ts +5 -0
  24. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  25. package/dist/resolvers/DatasetResolver.js +35 -0
  26. package/dist/resolvers/DatasetResolver.js.map +1 -1
  27. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +484 -0
  28. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  29. package/dist/resolvers/IntegrationDiscoveryResolver.js +3867 -328
  30. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  31. package/dist/resolvers/RSUResolver.d.ts +89 -0
  32. package/dist/resolvers/RSUResolver.d.ts.map +1 -0
  33. package/dist/resolvers/RSUResolver.js +424 -0
  34. package/dist/resolvers/RSUResolver.js.map +1 -0
  35. package/package.json +63 -60
  36. package/src/__tests__/unifiedAuth.test.ts +3 -2
  37. package/src/auth/__tests__/backward-compatibility.test.ts +2 -3
  38. package/src/auth/index.ts +5 -8
  39. package/src/auth/initializeProviders.ts +2 -2
  40. package/src/config.ts +9 -0
  41. package/src/context.ts +3 -3
  42. package/src/generated/generated.ts +269 -269
  43. package/src/index.ts +371 -4
  44. package/src/resolvers/DatasetResolver.ts +36 -0
  45. package/src/resolvers/IntegrationDiscoveryResolver.ts +2970 -39
  46. package/src/resolvers/RSUResolver.ts +351 -0
  47. package/dist/auth/AuthProviderFactory.d.ts +0 -68
  48. package/dist/auth/AuthProviderFactory.d.ts.map +0 -1
  49. package/dist/auth/AuthProviderFactory.js +0 -155
  50. package/dist/auth/AuthProviderFactory.js.map +0 -1
  51. package/dist/auth/BaseAuthProvider.d.ts +0 -41
  52. package/dist/auth/BaseAuthProvider.d.ts.map +0 -1
  53. package/dist/auth/BaseAuthProvider.js +0 -102
  54. package/dist/auth/BaseAuthProvider.js.map +0 -1
  55. package/dist/auth/IAuthProvider.d.ts +0 -46
  56. package/dist/auth/IAuthProvider.d.ts.map +0 -1
  57. package/dist/auth/IAuthProvider.js +0 -2
  58. package/dist/auth/IAuthProvider.js.map +0 -1
  59. package/dist/auth/providers/Auth0Provider.d.ts +0 -18
  60. package/dist/auth/providers/Auth0Provider.d.ts.map +0 -1
  61. package/dist/auth/providers/Auth0Provider.js +0 -52
  62. package/dist/auth/providers/Auth0Provider.js.map +0 -1
  63. package/dist/auth/providers/CognitoProvider.d.ts +0 -18
  64. package/dist/auth/providers/CognitoProvider.d.ts.map +0 -1
  65. package/dist/auth/providers/CognitoProvider.js +0 -56
  66. package/dist/auth/providers/CognitoProvider.js.map +0 -1
  67. package/dist/auth/providers/GoogleProvider.d.ts +0 -18
  68. package/dist/auth/providers/GoogleProvider.d.ts.map +0 -1
  69. package/dist/auth/providers/GoogleProvider.js +0 -51
  70. package/dist/auth/providers/GoogleProvider.js.map +0 -1
  71. package/dist/auth/providers/MSALProvider.d.ts +0 -18
  72. package/dist/auth/providers/MSALProvider.d.ts.map +0 -1
  73. package/dist/auth/providers/MSALProvider.js +0 -52
  74. package/dist/auth/providers/MSALProvider.js.map +0 -1
  75. package/dist/auth/providers/OktaProvider.d.ts +0 -18
  76. package/dist/auth/providers/OktaProvider.d.ts.map +0 -1
  77. package/dist/auth/providers/OktaProvider.js +0 -52
  78. package/dist/auth/providers/OktaProvider.js.map +0 -1
  79. package/dist/auth/tokenExpiredError.d.ts +0 -5
  80. package/dist/auth/tokenExpiredError.d.ts.map +0 -1
  81. package/dist/auth/tokenExpiredError.js +0 -12
  82. package/dist/auth/tokenExpiredError.js.map +0 -1
  83. package/src/auth/AuthProviderFactory.ts +0 -182
  84. package/src/auth/BaseAuthProvider.ts +0 -137
  85. package/src/auth/IAuthProvider.ts +0 -54
  86. package/src/auth/providers/Auth0Provider.ts +0 -45
  87. package/src/auth/providers/CognitoProvider.ts +0 -50
  88. package/src/auth/providers/GoogleProvider.ts +0 -45
  89. package/src/auth/providers/MSALProvider.ts +0 -45
  90. package/src/auth/providers/OktaProvider.ts +0 -46
  91. package/src/auth/tokenExpiredError.ts +0 -12
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ dotenv.config({ quiet: true });
4
4
 
5
5
  import { expressMiddleware } from '@as-integrations/express5';
6
6
  import { mergeSchemas } from '@graphql-tools/schema';
7
- import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent } from '@memberjunction/core';
7
+ import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent, RunView } from '@memberjunction/core';
8
8
  import { MJGlobal, MJEventType, UUIDsEqual } from '@memberjunction/global';
9
9
  import { setupSQLServerClient, SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
10
10
  import { extendConnectionPoolWithQuery } from './util.js';
@@ -43,6 +43,16 @@ import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
43
43
  import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
44
44
  import { PubSubManager } from './generic/PubSubManager.js';
45
45
  import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
46
+ import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
47
+ import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
48
+ import {
49
+ MJCompanyIntegrationEntity,
50
+ MJIntegrationEntity,
51
+ MJCompanyIntegrationEntityMapEntity,
52
+ MJCompanyIntegrationFieldMapEntity,
53
+ MJScheduledJobEntity,
54
+ } from '@memberjunction/core-entities';
55
+ import { ServerExtensionLoader, ServerExtensionConfig } from '@memberjunction/server-extensions-core';
46
56
 
47
57
  const cacheRefreshInterval = configInfo.databaseSettings.metadataCacheRefreshInterval;
48
58
 
@@ -62,17 +72,16 @@ export { MaxLength } from 'class-validator';
62
72
  export * from 'type-graphql';
63
73
  export { NewUserBase } from './auth/newUsers.js';
64
74
  export { configInfo, DEFAULT_SERVER_CONFIG } from './config.js';
75
+ export { ServerExtensionLoader, BaseServerExtension } from '@memberjunction/server-extensions-core';
76
+ export type { ServerExtensionConfig, ExtensionInitResult, ExtensionHealthResult } from '@memberjunction/server-extensions-core';
65
77
  export * from './directives/index.js';
66
78
  export * from './entitySubclasses/MJEntityPermissionEntityServer.server.js';
67
79
  export * from './types.js';
68
80
  export {
69
- TokenExpiredError,
70
81
  getSystemUser,
71
82
  getSigningKeys,
72
83
  extractUserInfoFromPayload,
73
84
  verifyUserRecord,
74
- AuthProviderFactory,
75
- IAuthProvider,
76
85
  } from './auth/index.js';
77
86
  export * from './auth/APIKeyScopeAuth.js';
78
87
 
@@ -124,6 +133,7 @@ export * from './resolvers/UserResolver.js';
124
133
  export * from './resolvers/UserViewResolver.js';
125
134
  export * from './resolvers/VersionHistoryResolver.js';
126
135
  export * from './resolvers/CurrentUserContextResolver.js';
136
+ export * from './resolvers/RSUResolver.js';
127
137
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
128
138
 
129
139
  export * from './generated/generated.js';
@@ -152,6 +162,13 @@ const localPath = (p: string) => {
152
162
  export const createApp = (): Application => express();
153
163
 
154
164
  export const serve = async (resolverPaths: Array<string>, app: Application = createApp(), options?: MJServerOptions): Promise<void> => {
165
+ const t0 = performance.now();
166
+ const lap = (label: string, since: number) => {
167
+ const ms = performance.now() - since;
168
+ console.log(`⏱️ [Startup] ${label}: ${ms.toFixed(0)}ms`);
169
+ return performance.now();
170
+ };
171
+
155
172
  const localResolverPaths = ['resolvers/**/*Resolver.{js,ts}', 'generic/*Resolver.{js,ts}', 'generated/generated.{js,ts}'].map(localPath);
156
173
 
157
174
  const combinedResolverPaths = [...resolverPaths, ...localResolverPaths];
@@ -260,6 +277,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
260
277
  } else {
261
278
  // ─── SQL Server Path (existing behavior) ───────────────────────
262
279
  console.log('Database type: SQL Server');
280
+ let tPhase = performance.now();
263
281
  const pool = new sql.ConnectionPool(createMSSQLConfig());
264
282
 
265
283
  // Handle connection-level errors from dead/stale connections in the pool.
@@ -270,6 +288,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
270
288
  });
271
289
 
272
290
  await pool.connect();
291
+ tPhase = lap('DB Pool Connect', tPhase);
273
292
 
274
293
  dataSources.push(new DataSourceInfo({dataSource: pool, type: 'Read-Write', host: dbHost, port: dbPort, database: dbDatabase, userName: dbUsername}));
275
294
 
@@ -294,10 +313,72 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
294
313
 
295
314
  const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
296
315
  await setupSQLServerClient(config);
316
+ tPhase = lap('Metadata + Provider Setup', tPhase);
297
317
  const md = new Metadata();
298
318
  console.log(`Data Source has been initialized. ${md?.Entities ? md.Entities.length : 0} entities loaded.`);
319
+
320
+ // Set up CodeGen-credentialed provider for RSU DDL operations (CREATE TABLE, CREATE SCHEMA, etc.)
321
+ const codegenUser = process.env.CODEGEN_DB_USERNAME;
322
+ const codegenPass = process.env.CODEGEN_DB_PASSWORD;
323
+ if (codegenUser && codegenPass) {
324
+ try {
325
+ const codegenPool = new sql.ConnectionPool({
326
+ ...createMSSQLConfig(),
327
+ user: codegenUser,
328
+ password: codegenPass,
329
+ });
330
+ codegenPool.on('error', (err) => {
331
+ console.error('[ConnectionPool] CodeGen pool connection error:', err.message);
332
+ });
333
+ await codegenPool.connect();
334
+
335
+ const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
336
+ const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, cacheRefreshInterval);
337
+ const codegenProvider = new SQLServerDataProvider();
338
+ await codegenProvider.Config(codegenConfig);
339
+ RuntimeSchemaManager.Instance.SetDDLProvider(codegenProvider);
340
+ console.log('RSU DDL provider initialized with CodeGen credentials.');
341
+
342
+ // Set up in-process CodeGen runner for RSU
343
+ try {
344
+ const { RunCodeGenBase } = await import('@memberjunction/codegen-lib');
345
+ const { SQLServerCodeGenConnection } = await import('@memberjunction/codegen-lib/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.js');
346
+
347
+ const codegenConnection = new SQLServerCodeGenConnection(codegenPool);
348
+ const codegenCurrentUser = UserCache.Instance.Users.find(u => u.Type?.trim().toLowerCase() === 'owner') ?? UserCache.Instance.Users[0];
349
+
350
+ const codegenDataSource = {
351
+ provider: codegenProvider,
352
+ connection: codegenConnection,
353
+ currentUser: codegenCurrentUser,
354
+ connectionInfo: `${configInfo.dbHost}:${configInfo.dbPort}/${configInfo.dbDatabase} (CodeGen)`,
355
+ };
356
+
357
+ const runObject = MJGlobal.Instance.ClassFactory.CreateInstance(RunCodeGenBase) as InstanceType<typeof RunCodeGenBase>;
358
+
359
+ const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
360
+ RuntimeSchemaManager.Instance.SetCodeGenRunner({
361
+ RunInProcess: (skipDB) => runObject.RunInProcess(codegenDataSource, skipDB, rsuWorkDir),
362
+ });
363
+ console.log('RSU in-process CodeGen runner initialized.');
364
+
365
+ // Inject CodeGen output paths for targeted git staging
366
+ const { initializeConfig } = await import('@memberjunction/codegen-lib');
367
+ const codegenConfig = initializeConfig(rsuWorkDir);
368
+ const outputPaths = (codegenConfig.output ?? []).map((o: { directory: string }) => o.directory);
369
+ RuntimeSchemaManager.Instance.SetCodeGenOutputPaths(outputPaths);
370
+ console.log(`RSU CodeGen output paths: ${outputPaths.length} directories configured.`);
371
+ } catch (codegenErr) {
372
+ console.warn(`RSU in-process CodeGen runner setup failed (will fall back to child process): ${(codegenErr as Error).message}`);
373
+ }
374
+ } catch (err) {
375
+ console.warn(`RSU DDL provider setup failed (RSU will fall back to default provider): ${(err as Error).message}`);
376
+ }
377
+ }
299
378
  }
300
379
 
380
+ let tServe = performance.now();
381
+
301
382
  // Store queryDialects config in GlobalObjectStore so MJQueryEntityServer can
302
383
  // read it without a circular dependency on MJServer
303
384
  if (configInfo.queryDialects) {
@@ -387,6 +468,8 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
387
468
  /******TEST HARNESS FOR CHANGE DETECTION */
388
469
  /******TEST HARNESS FOR CHANGE DETECTION */
389
470
 
471
+ tServe = lap('Telemetry + Cache + APIKey Init', tServe);
472
+
390
473
  const dynamicModules = await Promise.all(
391
474
  paths.map((modulePath) => {
392
475
  try {
@@ -518,6 +601,8 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
518
601
  }
519
602
  });
520
603
 
604
+ tServe = lap('Resolver + Middleware Discovery', tServe);
605
+
521
606
  let schema = mergeSchemas({
522
607
  schemas: [
523
608
  buildSchemaSync({
@@ -538,6 +623,8 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
538
623
  schema = transformer(schema);
539
624
  }
540
625
 
626
+ tServe = lap('Schema Build', tServe);
627
+
541
628
  const httpServer = createServer(app);
542
629
 
543
630
  const webSocketServer = new WebSocketServer({ server: httpServer, path: graphqlRootPath });
@@ -633,6 +720,23 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
633
720
  // client from reading the error code and triggering token refresh.
634
721
  app.use(cors<cors.CorsRequest>());
635
722
 
723
+ // ─── Server extensions (before auth — extensions handle their own auth) ─────
724
+ // Slack uses HMAC signature verification, Teams uses Bot Framework JWT validation.
725
+ // These must be registered before the unified auth middleware so webhook
726
+ // requests aren't rejected for lacking an MJ bearer token.
727
+ const extensionLoader = new ServerExtensionLoader();
728
+ const extensionConfigs = (configInfo.serverExtensions ?? []) as ServerExtensionConfig[];
729
+ if (extensionConfigs.length > 0) {
730
+ await extensionLoader.LoadExtensions(app, extensionConfigs);
731
+ }
732
+
733
+ // Extension health endpoint (always available, returns empty array if no extensions)
734
+ app.get('/health/extensions', async (_req, res) => {
735
+ const results = await extensionLoader.HealthCheckAll();
736
+ const allHealthy = results.length === 0 || results.every(r => r.Healthy);
737
+ res.status(allHealthy ? 200 : 503).json({ extensions: results });
738
+ });
739
+
636
740
  // ─── Unified auth middleware (replaces both REST authMiddleware and contextFunction auth) ─────
637
741
  app.use(createUnifiedAuthMiddleware(dataSources));
638
742
 
@@ -723,14 +827,30 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
723
827
  await Promise.resolve(options.onBeforeServe());
724
828
  }
725
829
 
830
+ tServe = lap('Apollo + Express Setup', tServe);
831
+
726
832
  await new Promise<void>((resolve) => httpServer.listen({ port: graphqlPort }, resolve));
833
+ lap('Total Startup', t0);
727
834
  console.log(`📦 Connected to database: ${dbHost}:${dbPort}/${dbDatabase}`);
728
835
  console.log(`🚀 Server ready at http://localhost:${graphqlPort}/`);
729
836
 
837
+ // Process pending RSU work from pre-restart (entity maps, field maps, sync)
838
+ processRSUPendingWork().catch(err => console.warn(`RSU pending work processing failed: ${err}`));
839
+
730
840
  // Set up graceful shutdown handlers
731
841
  const gracefulShutdown = async (signal: string) => {
732
842
  console.log(`\n${signal} received, shutting down gracefully...`);
733
843
 
844
+ // Stop server extensions
845
+ if (extensionLoader.ExtensionCount > 0) {
846
+ try {
847
+ await extensionLoader.ShutdownAll();
848
+ console.log('✅ Server extensions shut down');
849
+ } catch (error) {
850
+ console.error('❌ Error shutting down server extensions:', error);
851
+ }
852
+ }
853
+
734
854
  // Stop scheduled jobs service
735
855
  if (scheduledJobsService?.IsRunning) {
736
856
  try {
@@ -766,6 +886,253 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
766
886
  });
767
887
  };
768
888
 
889
+ /**
890
+ * Process pending RSU work left from a pre-restart Apply All.
891
+ * Reads pending work files, creates entity maps + field maps, starts sync.
892
+ */
893
+ async function processRSUPendingWork(): Promise<void> {
894
+ // Dynamic import — schema-engine is not yet published to npm, only exists as a workspace package
895
+ const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
896
+ const rsm = RuntimeSchemaManager.Instance;
897
+ const pendingItems = await rsm.ReadAndClearPendingWork();
898
+ if (pendingItems.length === 0) return;
899
+
900
+ console.log(`[RSU] Processing ${pendingItems.length} pending work item(s) from pre-restart...`);
901
+
902
+ // Wait a moment for metadata to be fully loaded
903
+ await new Promise(resolve => setTimeout(resolve, 3000));
904
+
905
+ for (const item of pendingItems) {
906
+ try {
907
+ const md = new Metadata();
908
+
909
+ // Get system user for server-side operations
910
+ const systemUser = UserCache.Instance.Users.find(u => u.Type?.trim().toLowerCase() === 'owner') ?? UserCache.Instance.Users[0];
911
+ if (!systemUser) {
912
+ console.warn(`[RSU] No system user found, skipping pending work for ${item.CompanyIntegrationID}`);
913
+ continue;
914
+ }
915
+
916
+ await Metadata.Provider.Refresh();
917
+
918
+ // Resolve connector
919
+ const rv = new RunView();
920
+ const ciResult = await rv.RunView<MJCompanyIntegrationEntity>({
921
+ EntityName: 'MJ: Company Integrations',
922
+ ExtraFilter: `ID='${item.CompanyIntegrationID}'`,
923
+ ResultType: 'entity_object',
924
+ }, systemUser);
925
+ const companyIntegration = ciResult.Results[0];
926
+ if (!companyIntegration) {
927
+ console.warn(`[RSU] CompanyIntegration ${item.CompanyIntegrationID} not found`);
928
+ continue;
929
+ }
930
+
931
+ const integrationName = companyIntegration.Integration;
932
+ const integrationResult = await rv.RunView<MJIntegrationEntity>({
933
+ EntityName: 'MJ: Integrations',
934
+ ExtraFilter: `Name='${integrationName}'`,
935
+ ResultType: 'entity_object',
936
+ }, systemUser);
937
+ const integrationEntity = integrationResult.Results[0];
938
+ if (!integrationEntity) {
939
+ console.warn(`[RSU] Integration entity for ${integrationName} not found`);
940
+ continue;
941
+ }
942
+ const connector = ConnectorFactory.Resolve(integrationEntity);
943
+ if (!connector) {
944
+ console.warn(`[RSU] Connector for ${integrationName} not found`);
945
+ continue;
946
+ }
947
+
948
+ // Create entity maps + field maps for each source object
949
+ const createdEntityMapIDs: string[] = [];
950
+ const rvPending = new RunView();
951
+ const sourceObjectFields: Record<string, string[] | null> = item.SourceObjectFields ?? {};
952
+
953
+ for (const objName of item.SourceObjectNames) {
954
+ const tableName = objName.replace(/[^A-Za-z0-9_]/g, '_').toLowerCase();
955
+ const entity = md.Entities.find(
956
+ e => e.SchemaName.toLowerCase() === item.SchemaName.toLowerCase() &&
957
+ e.BaseTable.toLowerCase() === tableName
958
+ );
959
+ if (!entity) {
960
+ console.warn(`[RSU] Entity not found for ${item.SchemaName}.${tableName}`);
961
+ continue;
962
+ }
963
+
964
+ // Check if entity map already exists for this connector + entity
965
+ const existingMapResult = await rvPending.RunView<MJCompanyIntegrationEntityMapEntity>({
966
+ EntityName: 'MJ: Company Integration Entity Maps',
967
+ ExtraFilter: `CompanyIntegrationID='${item.CompanyIntegrationID}' AND EntityID='${entity.ID}'`,
968
+ ResultType: 'entity_object',
969
+ }, systemUser);
970
+
971
+ let entityMapID: string;
972
+ let isNewMap = false;
973
+
974
+ if (existingMapResult.Success && existingMapResult.Results.length > 0) {
975
+ entityMapID = existingMapResult.Results[0].ID;
976
+ console.log(`[RSU] Entity map already exists for ${objName} → ${entity.Name} (${entityMapID})`);
977
+ } else {
978
+ const entityMap = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>(
979
+ 'MJ: Company Integration Entity Maps', systemUser
980
+ );
981
+ entityMap.NewRecord();
982
+ entityMap.CompanyIntegrationID = item.CompanyIntegrationID;
983
+ entityMap.EntityID = entity.ID;
984
+ entityMap.ExternalObjectName = objName;
985
+ entityMap.SyncDirection = 'Pull';
986
+ entityMap.Status = 'Active';
987
+ entityMap.SyncEnabled = true;
988
+ const mapSaved = await entityMap.Save();
989
+ if (!mapSaved) {
990
+ console.warn(`[RSU] Failed to save entity map for ${objName}`);
991
+ continue;
992
+ }
993
+ entityMapID = entityMap.ID;
994
+ isNewMap = true;
995
+ }
996
+
997
+ if (isNewMap) createdEntityMapIDs.push(entityMapID);
998
+
999
+ // Create field maps — filter by SourceObjectFields (null = all)
1000
+ try {
1001
+ const introspect = connector.IntrospectSchema.bind(connector) as
1002
+ (ci: unknown, u: unknown) => Promise<{ Objects: Array<{ ExternalName: string; Fields: Array<{ Name: string }> }> }>;
1003
+ const schema = await introspect(companyIntegration, systemUser);
1004
+ const sourceObj = schema.Objects.find(o => o.ExternalName.toLowerCase() === objName.toLowerCase());
1005
+
1006
+ const selectedFields = sourceObjectFields[objName]; // null = all, string[] = specific
1007
+ const fieldsToMap = selectedFields
1008
+ ? (sourceObj?.Fields ?? []).filter(f => selectedFields.some(sf => sf.toLowerCase() === f.Name.toLowerCase()))
1009
+ : (sourceObj?.Fields ?? []);
1010
+
1011
+ // Load existing field maps to avoid duplicates
1012
+ const existingFieldMaps = await rvPending.RunView<MJCompanyIntegrationFieldMapEntity>({
1013
+ EntityName: 'MJ: Company Integration Field Maps',
1014
+ ExtraFilter: `EntityMapID='${entityMapID}'`,
1015
+ ResultType: 'simple',
1016
+ Fields: ['SourceFieldName'],
1017
+ }, systemUser);
1018
+ const existingFieldNames = new Set(
1019
+ (existingFieldMaps.Success ? existingFieldMaps.Results : []).map((fm: { SourceFieldName: string }) => fm.SourceFieldName.toLowerCase())
1020
+ );
1021
+
1022
+ let fieldCount = 0;
1023
+ for (const field of fieldsToMap) {
1024
+ if (existingFieldNames.has(field.Name.toLowerCase())) continue;
1025
+ const fieldMap = await md.GetEntityObject<MJCompanyIntegrationFieldMapEntity>(
1026
+ 'MJ: Company Integration Field Maps', systemUser
1027
+ );
1028
+ fieldMap.NewRecord();
1029
+ fieldMap.EntityMapID = entityMapID;
1030
+ fieldMap.SourceFieldName = field.Name;
1031
+ fieldMap.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
1032
+ fieldMap.Status = 'Active';
1033
+ if (await fieldMap.Save()) fieldCount++;
1034
+ }
1035
+ console.log(`[RSU] Created entity map for ${objName} → ${entity.Name} with ${fieldCount} field maps${isNewMap ? '' : ' (existing map, new fields only)'}`);
1036
+ } catch (fieldErr) {
1037
+ console.warn(`[RSU] Field map creation failed for ${objName}: ${fieldErr}`);
1038
+ }
1039
+ }
1040
+
1041
+ // Start sync if requested
1042
+ if (item.StartSync !== false) {
1043
+ try {
1044
+ await IntegrationEngine.Instance.Config(false, systemUser);
1045
+ const syncOptions: IntegrationSyncOptions = {};
1046
+ if (item.SyncScope !== 'all' && createdEntityMapIDs.length > 0) syncOptions.EntityMapIDs = createdEntityMapIDs;
1047
+ if (item.FullSync) syncOptions.FullSync = true;
1048
+ const opts = Object.keys(syncOptions).length > 0 ? syncOptions : undefined;
1049
+ IntegrationEngine.Instance.RunSync(item.CompanyIntegrationID, systemUser, 'Manual', undefined, undefined, opts);
1050
+ console.log(`[RSU] Sync started for ${item.CompanyIntegrationID} (EntityMaps: ${createdEntityMapIDs.length}, FullSync: ${!!item.FullSync})`);
1051
+ } catch (syncErr) {
1052
+ console.warn(`[RSU] Sync start failed: ${syncErr}`);
1053
+ }
1054
+ } else {
1055
+ console.log(`[RSU] Sync skipped for ${item.CompanyIntegrationID} (StartSync=false)`);
1056
+ }
1057
+
1058
+ // Create or update schedule if CronExpression provided
1059
+ if (item.CronExpression) {
1060
+ try {
1061
+ const rvSched = new RunView();
1062
+
1063
+ // Find existing schedule by loading all integration sync jobs and matching Configuration JSON exactly
1064
+ const allJobsResult = await rvSched.RunView<MJScheduledJobEntity>({
1065
+ EntityName: 'MJ: Scheduled Jobs',
1066
+ ExtraFilter: `Status IN ('Active', 'Paused')`,
1067
+ ResultType: 'entity_object',
1068
+ }, systemUser);
1069
+
1070
+ let existingJob: MJScheduledJobEntity | null = null;
1071
+ if (allJobsResult.Success) {
1072
+ for (const j of allJobsResult.Results) {
1073
+ try {
1074
+ const config = JSON.parse(j.Configuration || '{}') as Record<string, unknown>;
1075
+ if (config.CompanyIntegrationID === item.CompanyIntegrationID) {
1076
+ existingJob = j;
1077
+ break;
1078
+ }
1079
+ } catch { /* skip invalid JSON */ }
1080
+ }
1081
+ }
1082
+
1083
+ let job: MJScheduledJobEntity;
1084
+ let isUpdate = false;
1085
+
1086
+ if (existingJob) {
1087
+ job = existingJob;
1088
+ isUpdate = true;
1089
+ } else {
1090
+ const jobTypeResult = await rvSched.RunView<{ ID: string }>({
1091
+ EntityName: 'MJ: Scheduled Job Types',
1092
+ ExtraFilter: `DriverClass='IntegrationSyncScheduledJobDriver'`,
1093
+ MaxRows: 1,
1094
+ ResultType: 'simple',
1095
+ Fields: ['ID']
1096
+ }, systemUser);
1097
+
1098
+ if (!jobTypeResult.Success || jobTypeResult.Results.length === 0) {
1099
+ console.warn(`[RSU] IntegrationSyncScheduledJobDriver job type not found`);
1100
+ throw new Error('Job type not found');
1101
+ }
1102
+
1103
+ job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', systemUser);
1104
+ job.NewRecord();
1105
+ job.JobTypeID = jobTypeResult.Results[0].ID;
1106
+ job.OwnerUserID = systemUser.ID;
1107
+ job.Configuration = JSON.stringify({ CompanyIntegrationID: item.CompanyIntegrationID });
1108
+ }
1109
+
1110
+ job.Name = `${integrationName} Scheduled Sync`;
1111
+ job.CronExpression = item.CronExpression;
1112
+ job.Timezone = item.ScheduleTimezone || 'UTC';
1113
+ job.Status = 'Active';
1114
+ job.NextRunAt = CronExpressionHelper.GetNextRunTime(item.CronExpression, item.ScheduleTimezone || 'UTC');
1115
+ if (await job.Save()) {
1116
+ console.log(`[RSU] ${isUpdate ? 'Updated' : 'Created'} schedule "${job.Name}" (${item.CronExpression}, NextRunAt=${job.NextRunAt.toISOString()}) for ${item.CompanyIntegrationID}`);
1117
+ companyIntegration.ScheduleEnabled = true;
1118
+ companyIntegration.ScheduleType = 'Cron';
1119
+ companyIntegration.CronExpression = item.CronExpression;
1120
+ await companyIntegration.Save();
1121
+ } else {
1122
+ console.warn(`[RSU] Failed to save schedule for ${item.CompanyIntegrationID}`);
1123
+ }
1124
+ } catch (schedErr) {
1125
+ console.warn(`[RSU] Schedule creation failed: ${schedErr}`);
1126
+ }
1127
+ }
1128
+ } catch (err) {
1129
+ console.error(`[RSU] Failed to process pending work for ${item.CompanyIntegrationID}: ${err}`);
1130
+ }
1131
+ }
1132
+
1133
+ console.log(`[RSU] Pending work processing complete`);
1134
+ }
1135
+
769
1136
  /**
770
1137
  * Creates a MSSQL ConnectionPool-compatible wrapper around a pg.Pool.
771
1138
  * This allows existing code that references DataSourceInfo.dataSource (typed as sql.ConnectionPool)
@@ -120,4 +120,40 @@ export class DatasetStatusResolver extends ResolverBase {
120
120
  throw new Error('Error retrieving Dataset Status: ' + DatasetName + '\n\n' + err);
121
121
  }
122
122
  }
123
+
124
+ /**
125
+ * Batch version: fetch status for multiple datasets in a single round-trip.
126
+ * Reduces N separate GetDatasetStatusByName calls to 1 network request.
127
+ */
128
+ @Query(() => [DatasetStatusResultType])
129
+ async GetMultipleDatasetStatusByName(
130
+ @Arg('DatasetNames', () => [String]) DatasetNames: string[],
131
+ @Ctx() { providers, userPayload }: AppContext,
132
+ ): Promise<DatasetStatusResultType[]> {
133
+ const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
134
+ const results: DatasetStatusResultType[] = [];
135
+
136
+ // Execute all status checks in parallel
137
+ const statusPromises = DatasetNames.map(async (name) => {
138
+ await this.CheckAPIKeyScopeAuthorization('dataset:read', name, userPayload);
139
+ return md.GetDatasetStatusByName(name);
140
+ });
141
+
142
+ const statuses = await Promise.all(statusPromises);
143
+
144
+ for (const result of statuses) {
145
+ if (result) {
146
+ results.push({
147
+ DatasetID: result.DatasetID,
148
+ DatasetName: result.DatasetName,
149
+ Success: result.Success,
150
+ Status: result.Status,
151
+ LatestUpdateDate: result.LatestUpdateDate,
152
+ EntityUpdateDates: JSON.stringify(result.EntityUpdateDates),
153
+ } as DatasetStatusResultType);
154
+ }
155
+ }
156
+
157
+ return results;
158
+ }
123
159
  }