@memberjunction/server 5.16.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.
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,6 +72,8 @@ 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';
@@ -121,6 +133,7 @@ export * from './resolvers/UserResolver.js';
121
133
  export * from './resolvers/UserViewResolver.js';
122
134
  export * from './resolvers/VersionHistoryResolver.js';
123
135
  export * from './resolvers/CurrentUserContextResolver.js';
136
+ export * from './resolvers/RSUResolver.js';
124
137
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
125
138
 
126
139
  export * from './generated/generated.js';
@@ -303,6 +316,65 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
303
316
  tPhase = lap('Metadata + Provider Setup', tPhase);
304
317
  const md = new Metadata();
305
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
+ }
306
378
  }
307
379
 
308
380
  let tServe = performance.now();
@@ -648,6 +720,23 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
648
720
  // client from reading the error code and triggering token refresh.
649
721
  app.use(cors<cors.CorsRequest>());
650
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
+
651
740
  // ─── Unified auth middleware (replaces both REST authMiddleware and contextFunction auth) ─────
652
741
  app.use(createUnifiedAuthMiddleware(dataSources));
653
742
 
@@ -745,10 +834,23 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
745
834
  console.log(`📦 Connected to database: ${dbHost}:${dbPort}/${dbDatabase}`);
746
835
  console.log(`🚀 Server ready at http://localhost:${graphqlPort}/`);
747
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
+
748
840
  // Set up graceful shutdown handlers
749
841
  const gracefulShutdown = async (signal: string) => {
750
842
  console.log(`\n${signal} received, shutting down gracefully...`);
751
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
+
752
854
  // Stop scheduled jobs service
753
855
  if (scheduledJobsService?.IsRunning) {
754
856
  try {
@@ -784,6 +886,253 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
784
886
  });
785
887
  };
786
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
+
787
1136
  /**
788
1137
  * Creates a MSSQL ConnectionPool-compatible wrapper around a pg.Pool.
789
1138
  * This allows existing code that references DataSourceInfo.dataSource (typed as sql.ConnectionPool)