@memberjunction/server 2.22.2 → 2.23.1

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 (54) hide show
  1. package/dist/config.d.ts +27 -1
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +5 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/context.d.ts +5 -3
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +9 -6
  8. package/dist/context.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +22 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/resolvers/FileResolver.d.ts +3 -3
  14. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  15. package/dist/resolvers/FileResolver.js +10 -10
  16. package/dist/resolvers/FileResolver.js.map +1 -1
  17. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  18. package/dist/resolvers/GetDataResolver.js +6 -1
  19. package/dist/resolvers/GetDataResolver.js.map +1 -1
  20. package/dist/resolvers/SyncDataResolver.d.ts +2 -0
  21. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  22. package/dist/resolvers/SyncDataResolver.js +64 -0
  23. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  24. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  25. package/dist/resolvers/SyncRolesUsersResolver.js +7 -2
  26. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  27. package/dist/resolvers/UserResolver.d.ts +1 -1
  28. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  29. package/dist/resolvers/UserResolver.js +2 -2
  30. package/dist/resolvers/UserResolver.js.map +1 -1
  31. package/dist/resolvers/UserViewResolver.d.ts +4 -5
  32. package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
  33. package/dist/resolvers/UserViewResolver.js +7 -7
  34. package/dist/resolvers/UserViewResolver.js.map +1 -1
  35. package/dist/types.d.ts +18 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +18 -1
  38. package/dist/types.js.map +1 -1
  39. package/dist/util.d.ts +6 -0
  40. package/dist/util.d.ts.map +1 -1
  41. package/dist/util.js +20 -0
  42. package/dist/util.js.map +1 -1
  43. package/package.json +22 -22
  44. package/src/config.ts +6 -0
  45. package/src/context.ts +11 -7
  46. package/src/index.ts +30 -3
  47. package/src/resolvers/FileResolver.ts +10 -10
  48. package/src/resolvers/GetDataResolver.ts +10 -3
  49. package/src/resolvers/SyncDataResolver.ts +76 -0
  50. package/src/resolvers/SyncRolesUsersResolver.ts +12 -3
  51. package/src/resolvers/UserResolver.ts +2 -2
  52. package/src/resolvers/UserViewResolver.ts +7 -7
  53. package/src/types.ts +29 -0
  54. package/src/util.ts +38 -0
package/src/context.ts CHANGED
@@ -8,8 +8,9 @@ import { DataSource } from 'typeorm';
8
8
  import { getSigningKeys, getSystemUser, validationOptions, verifyUserRecord } from './auth/index.js';
9
9
  import { authCache } from './cache.js';
10
10
  import { userEmailMap, apiKey } from './config.js';
11
- import { UserPayload } from './types.js';
11
+ import { DataSourceInfo, UserPayload } from './types.js';
12
12
  import { TokenExpiredError } from './auth/index.js';
13
+ import { GetReadOnlyDataSource, GetReadWriteDataSource } from './util.js';
13
14
 
14
15
  const verifyAsync = async (issuer: string, options: jwt.VerifyOptions, token: string): Promise<jwt.JwtPayload> =>
15
16
  new Promise((resolve, reject) => {
@@ -29,15 +30,18 @@ const verifyAsync = async (issuer: string, options: jwt.VerifyOptions, token: st
29
30
  export const getUserPayload = async (
30
31
  bearerToken: string,
31
32
  sessionId = 'default',
32
- dataSource: DataSource,
33
+ dataSources: DataSourceInfo[],
33
34
  requestDomain?: string,
34
35
  requestApiKey?: string
35
36
  ): Promise<UserPayload> => {
36
37
  try {
38
+ const readOnlyDataSource = GetReadOnlyDataSource(dataSources, { allowFallbackToReadWrite: true });
39
+ const readWriteDataSource = GetReadWriteDataSource(dataSources);
40
+
37
41
  if (requestApiKey && requestApiKey != String(undefined)) {
38
42
  // use requestApiKey for auth
39
43
  if (requestApiKey === apiKey) {
40
- const systemUser = await getSystemUser(dataSource);
44
+ const systemUser = await getSystemUser(readOnlyDataSource);
41
45
  return {
42
46
  userRecord: systemUser,
43
47
  email: systemUser.Email,
@@ -81,7 +85,7 @@ export const getUserPayload = async (
81
85
  const fullName = payload?.name;
82
86
  const firstName = payload?.given_name || fullName?.split(' ')[0];
83
87
  const lastName = payload?.family_name || fullName?.split(' ')[1] || fullName?.split(' ')[0];
84
- const userRecord = await verifyUserRecord(email, firstName, lastName, requestDomain, dataSource);
88
+ const userRecord = await verifyUserRecord(email, firstName, lastName, requestDomain, readWriteDataSource);
85
89
 
86
90
  if (!userRecord) {
87
91
  console.error(`User ${email} not found`);
@@ -101,7 +105,7 @@ export const getUserPayload = async (
101
105
  };
102
106
 
103
107
  export const contextFunction =
104
- ({ setupComplete$, dataSource }: { setupComplete$: Subject<unknown>; dataSource: DataSource }) =>
108
+ ({ setupComplete$, dataSource, dataSources }: { setupComplete$: Subject<unknown>; dataSource: DataSource, dataSources: DataSourceInfo[] }) =>
105
109
  async ({ req }: { req: IncomingMessage }) => {
106
110
  await firstValueFrom(setupComplete$); // wait for setup to complete before processing the request
107
111
 
@@ -115,7 +119,7 @@ export const contextFunction =
115
119
  const userPayload = await getUserPayload(
116
120
  bearerToken,
117
121
  sessionId,
118
- dataSource,
122
+ dataSources,
119
123
  requestDomain?.hostname ? requestDomain.hostname : undefined,
120
124
  apiKey
121
125
  );
@@ -126,5 +130,5 @@ export const contextFunction =
126
130
  console.log({ operationName });
127
131
  }
128
132
 
129
- return { dataSource, userPayload };
133
+ return { dataSource, dataSources, userPayload };
130
134
  };
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ import { BuildSchemaOptions, buildSchemaSync, GraphQLTimestamp } from 'type-grap
20
20
  import { DataSource } from 'typeorm';
21
21
  import { WebSocketServer } from 'ws';
22
22
  import buildApolloServer from './apolloServer/index.js';
23
- import { configInfo, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage } from './config.js';
23
+ import { configInfo, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage } from './config.js';
24
24
  import { contextFunction, getUserPayload } from './context.js';
25
25
  import { requireSystemUserDirective, publicDirective } from './directives/index.js';
26
26
  import orm from './orm.js';
@@ -64,9 +64,12 @@ export * from './resolvers/SyncRolesUsersResolver.js';
64
64
  export * from './resolvers/SyncDataResolver.js';
65
65
  export * from './resolvers/GetDataResolver.js';
66
66
 
67
+ export { GetReadOnlyDataSource, GetReadWriteDataSource } from './util.js';
68
+
67
69
  export * from './generated/generated.js';
68
70
 
69
71
  import { resolve } from 'node:path';
72
+ import { DataSourceInfo } from './types.js';
70
73
 
71
74
  export type MJServerOptions = {
72
75
  onBeforeServe?: () => void | Promise<void>;
@@ -103,6 +106,26 @@ export const serve = async (resolverPaths: Array<string>, app = createApp(), opt
103
106
  await setupSQLServerClient(config); // datasource is already initialized, so we can setup the client right away
104
107
  const md = new Metadata();
105
108
  console.log(`Data Source has been initialized. ${md?.Entities ? md.Entities.length : 0} entities loaded.`);
109
+
110
+ const dataSources = [new DataSourceInfo({dataSource, type: 'Read-Write', host: dbHost, port: dbPort, database: dbDatabase, userName: dbUsername})];
111
+
112
+ // Establish a second read-only connection to the database if dbReadOnlyUsername and dbReadOnlyPassword exist
113
+ let readOnlyDataSource: DataSource | null = null;
114
+ if (configInfo.dbReadOnlyUsername && configInfo.dbReadOnlyPassword) {
115
+ const readOnlyConfig = {
116
+ ...orm(paths),
117
+ username: configInfo.dbReadOnlyUsername,
118
+ password: configInfo.dbReadOnlyPassword,
119
+ };
120
+ readOnlyDataSource = new DataSource(readOnlyConfig);
121
+ await readOnlyDataSource.initialize();
122
+
123
+ // since we created a read-only data source, add it to the list of data sources
124
+ dataSources.push(new DataSourceInfo({dataSource: readOnlyDataSource, type: 'Read-Only', host: dbHost, port: dbPort, database: dbDatabase, userName: configInfo.dbReadOnlyUsername}));
125
+ console.log('Read-only Data Source has been initialized.');
126
+ }
127
+
128
+
106
129
  setupComplete$.next(true);
107
130
 
108
131
  /******TEST HARNESS FOR CHANGE DETECTION */
@@ -156,7 +179,7 @@ export const serve = async (resolverPaths: Array<string>, app = createApp(), opt
156
179
  {
157
180
  schema,
158
181
  context: async ({ connectionParams }) => {
159
- const userPayload = await getUserPayload(String(connectionParams?.Authorization), undefined, dataSource);
182
+ const userPayload = await getUserPayload(String(connectionParams?.Authorization), undefined, dataSources);
160
183
  return { userPayload };
161
184
  },
162
185
  },
@@ -171,7 +194,11 @@ export const serve = async (resolverPaths: Array<string>, app = createApp(), opt
171
194
  cors<cors.CorsRequest>(),
172
195
  BodyParser.json({ limit: '50mb' }),
173
196
  expressMiddleware(apolloServer, {
174
- context: contextFunction({ setupComplete$, dataSource }),
197
+ context: contextFunction({
198
+ setupComplete$,
199
+ dataSource, // default read-write data source
200
+ dataSources // all data source
201
+ }),
175
202
  })
176
203
  );
177
204
 
@@ -47,20 +47,20 @@ export class FileResolver extends FileResolverBase {
47
47
  @Mutation(() => CreateFilePayload)
48
48
  async CreateFile(
49
49
  @Arg('input', () => CreateFileInput) input: CreateFileInput,
50
- @Ctx() { dataSource, userPayload }: AppContext,
50
+ @Ctx() context: AppContext,
51
51
  @PubSub() pubSub: PubSubEngine
52
52
  ) {
53
53
  const md = new Metadata();
54
- const user = this.GetUserFromPayload(userPayload);
54
+ const user = this.GetUserFromPayload(context.userPayload);
55
55
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
56
56
  const providerEntity = await md.GetEntityObject<FileStorageProviderEntity>('File Storage Providers', user);
57
57
  fileEntity.CheckPermissions(EntityPermissionType.Create, true);
58
58
 
59
59
  // Check to see if there's already an object with that name
60
- const [sameName] = await this.findBy(dataSource, 'Files', { Name: input.Name, ProviderID: input.ProviderID });
60
+ const [sameName] = await this.findBy(context.dataSource, 'Files', { Name: input.Name, ProviderID: input.ProviderID });
61
61
  const NameExists = Boolean(sameName);
62
62
 
63
- const fileRecord = (await super.CreateFile({ ...input, Status: 'Pending' }, { dataSource, userPayload }, pubSub)) as File_;
63
+ const fileRecord = (await super.CreateFile({ ...input, Status: 'Pending' }, context, pubSub)) as File_;
64
64
 
65
65
  // If there's a problem creating the file record, the base resolver will return null
66
66
  if (!fileRecord) {
@@ -98,12 +98,12 @@ export class FileResolver extends FileResolverBase {
98
98
  @Mutation(() => File_)
99
99
  async UpdateFile(
100
100
  @Arg('input', () => UpdateFileInput) input: UpdateFileInput,
101
- @Ctx() { dataSource, userPayload }: AppContext,
101
+ @Ctx() context: AppContext,
102
102
  @PubSub() pubSub: PubSubEngine
103
103
  ) {
104
104
  // if the name is changing, rename the target object as well
105
105
  const md = new Metadata();
106
- const user = this.GetUserFromPayload(userPayload);
106
+ const user = this.GetUserFromPayload(context.userPayload);
107
107
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
108
108
  fileEntity.CheckPermissions(EntityPermissionType.Update, true);
109
109
 
@@ -119,7 +119,7 @@ export class FileResolver extends FileResolverBase {
119
119
  }
120
120
  }
121
121
 
122
- const updatedFile = await super.UpdateFile(input, { dataSource, userPayload }, pubSub);
122
+ const updatedFile = await super.UpdateFile(input, context, pubSub);
123
123
  return updatedFile;
124
124
  }
125
125
 
@@ -127,11 +127,11 @@ export class FileResolver extends FileResolverBase {
127
127
  async DeleteFile(
128
128
  @Arg('ID', () => String) ID: string,
129
129
  @Arg('options___', () => DeleteOptionsInput) options: DeleteOptionsInput,
130
- @Ctx() { dataSource, userPayload }: AppContext,
130
+ @Ctx() context: AppContext,
131
131
  @PubSub() pubSub: PubSubEngine
132
132
  ) {
133
133
  const md = new Metadata();
134
- const userInfo = this.GetUserFromPayload(userPayload);
134
+ const userInfo = this.GetUserFromPayload(context.userPayload);
135
135
 
136
136
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', userInfo);
137
137
  await fileEntity.Load(ID);
@@ -147,6 +147,6 @@ export class FileResolver extends FileResolverBase {
147
147
  await deleteObject(providerEntity, fileEntity.ProviderKey ?? fileEntity.Name);
148
148
  }
149
149
 
150
- return super.DeleteFile(ID, options, { dataSource, userPayload }, pubSub);
150
+ return super.DeleteFile(ID, options, context, pubSub);
151
151
  }
152
152
  }
@@ -3,6 +3,7 @@ import { AppContext } from '../types.js';
3
3
  import { LogError, LogStatus, Metadata } from '@memberjunction/core';
4
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
+ import { GetReadOnlyDataSource } from '../util.js';
6
7
 
7
8
  @InputType()
8
9
  export class GetDataInputType {
@@ -98,7 +99,7 @@ export class SimpleEntityFieldOutputType {
98
99
 
99
100
  export class GetDataResolver {
100
101
  /**
101
- * This mutation will sync the specified items with the existing system. Items will be processed in order and the results of each operation will be returned in the Results array within the return value.
102
+ * This query will sync the specified items with the existing system. Items will be processed in order and the results of each operation will be returned in the Results array within the return value.
102
103
  * @param items - an array of ActionItemInputType objects that specify the action to be taken on the specified entity with the specified primary key and the JSON representation of the field values.
103
104
  * @param token - the short-lived access token that is required to perform this operation.
104
105
  */
@@ -118,12 +119,18 @@ export class GetDataResolver {
118
119
  throw new Error(`Token ${input.Token} is not valid or has expired`);
119
120
  }
120
121
 
122
+ // Use the read-only connection for executing queries
123
+ const readOnlyDataSource = GetReadOnlyDataSource(context.dataSources, {allowFallbackToReadWrite: false})
124
+ if (!readOnlyDataSource) {
125
+ throw new Error('Read-only data source not found');
126
+ }
127
+
121
128
  // Execute all queries in parallel, but execute each individual query in its own try catch block so that if one fails, the others can still be processed
122
129
  // and also so that we can capture the error message for each query and return it
123
130
  const results = await Promise.allSettled(
124
131
  input.Queries.map(async (query) => {
125
132
  try {
126
- const result = await context.dataSource.query(query);
133
+ const result = await readOnlyDataSource.query(query);
127
134
  return { result, error: null };
128
135
  } catch (err) {
129
136
  return { result: null, error: err };
@@ -258,4 +265,4 @@ export function recordTokenUse(token: string, usePayload: any) {
258
265
  else {
259
266
  throw new Error(`Token ${token} does not exist`);
260
267
  }
261
- }
268
+ }
@@ -3,6 +3,7 @@ import { AppContext } from '../types.js';
3
3
  import { BaseEntity, CompositeKey, LogError, Metadata, RunView, UserInfo } from '@memberjunction/core';
4
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
5
  import { CompositeKeyInputType, CompositeKeyOutputType } from '../generic/KeyInputOutputTypes.js';
6
+ import { DatasetItemEntity } from '@memberjunction/core-entities';
6
7
 
7
8
 
8
9
 
@@ -95,6 +96,9 @@ export class SyncDataResultType {
95
96
  Results: ActionItemOutputType[] = [];
96
97
  }
97
98
 
99
+
100
+ const __metadata_DatasetItems: string[] = [];
101
+
98
102
  export class SyncDataResolver {
99
103
  /**
100
104
  * This mutation will sync the specified items with the existing system. Items will be processed in order and the results of each operation will be returned in the Results array within the return value.
@@ -114,6 +118,10 @@ export class SyncDataResolver {
114
118
  results.push(await this.SyncSingleItem(item, context, md));
115
119
  }
116
120
 
121
+ if (await this.DoSyncItemsAffectMetadata(items)) {
122
+ await md.Refresh(); // force refesh the metadata which will cause a reload from the DB
123
+ }
124
+
117
125
  const overallSuccess = !results.some((r) => !r.Success); // if any element in the array of results has a Success value of false, then the overall success is false
118
126
  return { Success: overallSuccess, Results: results };
119
127
  }
@@ -123,6 +131,35 @@ export class SyncDataResolver {
123
131
  }
124
132
  }
125
133
 
134
+ protected async GetLowercaseMetadatEntitiesList(forceRefresh: boolean = false): Promise<string[]> {
135
+ if (forceRefresh || __metadata_DatasetItems.length === 0) {
136
+ const rv = new RunView(); // cache this, veyr simple - should use an engine for this stuff later
137
+ const result = await rv.RunView<DatasetItemEntity>({
138
+ EntityName: "Dataset Items",
139
+ ExtraFilter: "Dataset = 'MJ_Metadata'",
140
+ })
141
+ if (result && result.Success) {
142
+ __metadata_DatasetItems.length = 0;
143
+ __metadata_DatasetItems.push(...result.Results.map((r) => {
144
+ return r.Entity.trim().toLowerCase();
145
+ }));
146
+ }
147
+ }
148
+ // now return the list of entities
149
+ return __metadata_DatasetItems;
150
+ }
151
+
152
+ protected async DoSyncItemsAffectMetadata(items: ActionItemInputType[]): Promise<boolean> {
153
+ // check to see if any of the items affect any of these entities:
154
+ const entitiesToCheck = await this.GetLowercaseMetadatEntitiesList(false);
155
+ for (const item of items) {
156
+ if (entitiesToCheck.find(e => e === item.EntityName.trim().toLowerCase()) ) {
157
+ return true;
158
+ }
159
+ }
160
+ return false; // didn't find any
161
+ }
162
+
126
163
  protected async SyncSingleItem(item: ActionItemInputType, context: AppContext, md: Metadata): Promise<ActionItemOutputType> {
127
164
  const result = new ActionItemOutputType();
128
165
  result.AlternateKey = item.AlternateKey;
@@ -266,6 +303,15 @@ export class SyncDataResolver {
266
303
  result.ErrorMessage = 'Failed to load the item, it is possible the record with the specified primary key does not exist';
267
304
  }
268
305
  else {
306
+ // pass back the full record as it was JUST BEFORE the delete, often quite useful on the other end
307
+ result.RecordJSON = await altKeyResult.GetDataObjectJSON({
308
+ includeRelatedEntityData: false,
309
+ excludeFields: [],
310
+ omitEmptyStrings: false,
311
+ relatedEntityList: [],
312
+ omitNullValues: false,
313
+ oldValues: false
314
+ });
269
315
  if (await altKeyResult.Delete()) {
270
316
  result.Success = true;
271
317
  }
@@ -275,6 +321,15 @@ export class SyncDataResolver {
275
321
  }
276
322
  }
277
323
  else if (await entityObject.InnerLoad(pk)) {
324
+ // pass back the full record as it was JUST BEFORE the delete, often quite useful on the other end
325
+ result.RecordJSON = await entityObject.GetDataObjectJSON({
326
+ includeRelatedEntityData: false,
327
+ excludeFields: [],
328
+ omitEmptyStrings: false,
329
+ relatedEntityList: [],
330
+ omitNullValues: false,
331
+ oldValues: false
332
+ });
278
333
  if (await entityObject.Delete()) {
279
334
  result.Success = true;
280
335
  }
@@ -297,6 +352,15 @@ export class SyncDataResolver {
297
352
  if (await entityObject.Save()) {
298
353
  result.Success = true;
299
354
  result.PrimaryKey = new CompositeKey(entityObject.PrimaryKeys.map((pk) => ({FieldName: pk.Name, Value: pk.Value})));
355
+ // pass back the full record AFTER the sync, that's often quite useful on the other end
356
+ result.RecordJSON = await entityObject.GetDataObjectJSON({
357
+ includeRelatedEntityData: false,
358
+ excludeFields: [],
359
+ omitEmptyStrings: false,
360
+ relatedEntityList: [],
361
+ omitNullValues: false,
362
+ oldValues: false
363
+ });
300
364
  }
301
365
  else {
302
366
  result.ErrorMessage = 'Failed to create the item :' + entityObject.LatestResult.Message;
@@ -328,6 +392,18 @@ export class SyncDataResolver {
328
392
  entityObject.SetMany(fieldValues);
329
393
  if (await entityObject.Save()) {
330
394
  result.Success = true;
395
+ if (!result.PrimaryKey || result.PrimaryKey.KeyValuePairs.length === 0) {
396
+ result.PrimaryKey = new CompositeKey(entityObject.PrimaryKeys.map((pk) => ({FieldName: pk.Name, Value: pk.Value})));
397
+ }
398
+ // pass back the full record AFTER the sync, that's often quite useful on the other end
399
+ result.RecordJSON = await entityObject.GetDataObjectJSON({
400
+ includeRelatedEntityData: false,
401
+ excludeFields: [],
402
+ omitEmptyStrings: false,
403
+ relatedEntityList: [],
404
+ omitNullValues: false,
405
+ oldValues: false
406
+ });
331
407
  }
332
408
  else {
333
409
  result.ErrorMessage = 'Failed to update the item :' + entityObject.LatestResult.Message;
@@ -3,6 +3,7 @@ import { AppContext } from '../types.js';
3
3
  import { LogError, Metadata, RunView, UserInfo } from '@memberjunction/core';
4
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
5
  import { RoleEntity, UserEntity, UserRoleEntity } from '@memberjunction/core-entities';
6
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
6
7
 
7
8
  @ObjectType()
8
9
  export class SyncRolesAndUsersResultType {
@@ -88,8 +89,16 @@ export class SyncRolesAndUsersResolver {
88
89
  try {
89
90
  // first we sync the roles, then the users
90
91
  const roleResult = await this.SyncRoles(data.Roles, context);
91
- if (roleResult.Success) {
92
- return await this.SyncUsers(data.Users, context);
92
+ if (roleResult?.Success) {
93
+ const usersResult = await this.SyncUsers(data.Users, context);
94
+ if (usersResult?.Success) {
95
+ // refresh the user cache, don't set an auto-refresh
96
+ // interval here becuase that is alreayd done at startup
97
+ // and will keep going on its own as per the config. This is a
98
+ // special one-time refresh since we made changes here.
99
+ await UserCache.Instance.Refresh(context.dataSource);
100
+ }
101
+ return usersResult;
93
102
  }
94
103
  else {
95
104
  return roleResult;
@@ -398,4 +407,4 @@ export class SyncRolesAndUsersResolver {
398
407
  }
399
408
  }
400
409
  }
401
-
410
+
@@ -4,8 +4,8 @@ import { User_, UserResolverBase } from '../generated/generated.js';
4
4
  @Resolver(User_)
5
5
  export class UserResolver extends UserResolverBase {
6
6
  @Query(() => User_)
7
- async CurrentUser(@Ctx() { dataSource, userPayload }: AppContext) {
8
- const result = await this.UserByEmail(userPayload.email, { dataSource, userPayload });
7
+ async CurrentUser(@Ctx() context: AppContext) {
8
+ const result = await this.UserByEmail(context.userPayload.email, context);
9
9
  console.log('CurrentUser', result);
10
10
  return result;
11
11
  }
@@ -23,23 +23,23 @@ export class UserViewResolver extends UserViewResolverBase {
23
23
  }
24
24
 
25
25
  @Query(() => [UserView_])
26
- async CurrentUserDefaultViewByEntityID(@Arg('EntityID', () => Int) EntityID: number, @Ctx() { dataSource, userPayload }: AppContext) {
27
- return await this.findBy(dataSource, 'User Views', {
28
- UserID: await this.getCurrentUserID(dataSource, userPayload),
26
+ async CurrentUserDefaultViewByEntityID(@Arg('EntityID', () => Int) EntityID: number, @Ctx() context: AppContext) {
27
+ return await this.findBy(context.dataSource, 'User Views', {
28
+ UserID: await this.getCurrentUserID(context),
29
29
  EntityID,
30
30
  IsDefault: true,
31
31
  });
32
32
  }
33
33
 
34
- protected async getCurrentUserID(dataSource: DataSource, userPayload: UserPayload): Promise<number> {
34
+ protected async getCurrentUserID(context: AppContext): Promise<number> {
35
35
  const userResolver = new UserResolver();
36
- const user = await userResolver.UserByEmail(userPayload.email, { dataSource, userPayload });
36
+ const user = await userResolver.UserByEmail(context.userPayload.email, context);
37
37
  return user.ID;
38
38
  }
39
39
 
40
40
  @Query(() => [UserView_])
41
- async CurrentUserUserViewsByEntityID(@Arg('EntityID', () => Int) EntityID: number, @Ctx() { dataSource, userPayload }: AppContext) {
42
- return this.findBy(dataSource, 'User Views', { UserID: await this.getCurrentUserID(dataSource, userPayload), EntityID });
41
+ async CurrentUserUserViewsByEntityID(@Arg('EntityID', () => Int) EntityID: number, @Ctx() context: AppContext) {
42
+ return this.findBy(context.dataSource, 'User Views', { UserID: await this.getCurrentUserID(context), EntityID });
43
43
  }
44
44
 
45
45
  @Query(() => [UserView_])
package/src/types.ts CHANGED
@@ -11,10 +11,39 @@ export type UserPayload = {
11
11
  apiKey?: string;
12
12
  };
13
13
 
14
+ /**
15
+ * AppContext is the context object that is passed to all resolvers.
16
+ */
14
17
  export type AppContext = {
18
+ /**
19
+ * The default and backwards compatible data source.
20
+ */
15
21
  dataSource: DataSource;
16
22
  userPayload: UserPayload;
17
23
  queryRunner?: QueryRunner;
24
+ /**
25
+ * Array of data sources that have additional information about their intended use e.g. Admin, Read-Write, Read-Only.
26
+ */
27
+ dataSources: DataSourceInfo[];
28
+ };
29
+
30
+ export class DataSourceInfo {
31
+ dataSource: DataSource;
32
+ host: string;
33
+ port: number;
34
+ instance?: string;
35
+ database: string;
36
+ userName: string;
37
+ type: "Admin" | "Read-Write" | "Read-Only" | "Other";
38
+
39
+ constructor(init: {dataSource: DataSource, type: "Admin" | "Read-Write" | "Read-Only" | "Other", host: string, port: number, database: string, userName: string} ) {
40
+ this.dataSource = init.dataSource;
41
+ this.host = init.host;
42
+ this.port = init.port;
43
+ this.database = init.database;
44
+ this.userName = init.userName;
45
+ this.type = init.type;
46
+ }
18
47
  };
19
48
 
20
49
  export type DirectiveBuilder = {
package/src/util.ts CHANGED
@@ -4,6 +4,8 @@ import { gzip as gzipCallback, createGunzip } from 'zlib';
4
4
  import { promisify } from 'util';
5
5
  import { URL } from 'url';
6
6
  import { z } from 'zod';
7
+ import { DataSourceInfo } from './types';
8
+ import { DataSource } from 'typeorm';
7
9
 
8
10
  const gzip = promisify(gzipCallback);
9
11
 
@@ -110,3 +112,39 @@ export async function sendPostRequest(url: string, payload: any, useCompression:
110
112
  }
111
113
  );
112
114
  }
115
+
116
+
117
+ /**
118
+ * Returns the read-only data source if it exists, otherwise returns the read-write data source if options is not provided or if options.allowFallbackToReadWrite is true.
119
+ * @param dataSources
120
+ * @param options
121
+ * @returns
122
+ */
123
+ export function GetReadOnlyDataSource(dataSources: DataSourceInfo[], options?: {allowFallbackToReadWrite: boolean}): DataSource {
124
+ const readOnlyDataSource = dataSources.find((ds) => ds.type === 'Read-Only');
125
+ if (readOnlyDataSource) {
126
+ return readOnlyDataSource.dataSource;
127
+ }
128
+ else if (!options || options.allowFallbackToReadWrite) {
129
+ // default behavior for backward compatibility prior to MJ 2.22.3 where we introduced this functionality was to have a single
130
+ // connection, so for back-compatability, if we don't have a read-only data source, we'll fall back to the read-write data source
131
+ const readWriteDataSource = dataSources.find((ds) => ds.type === 'Read-Write');
132
+ if (readWriteDataSource) {
133
+ return readWriteDataSource.dataSource;
134
+ }
135
+ }
136
+ throw new Error('No suitable data source found');
137
+ }
138
+
139
+ /**
140
+ * Returns the read-write data source if it exists, otherwise throws an error.
141
+ * @param dataSources
142
+ * @returns
143
+ */
144
+ export function GetReadWriteDataSource(dataSources: DataSourceInfo[]): DataSource {
145
+ const readWriteDataSource = dataSources.find((ds) => ds.type === 'Read-Write');
146
+ if (readWriteDataSource) {
147
+ return readWriteDataSource.dataSource;
148
+ }
149
+ throw new Error('No suitable read-write data source found');
150
+ }