@memberjunction/server 2.98.0 → 2.100.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 (34) hide show
  1. package/dist/config.d.ts +213 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +15 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/generated/generated.d.ts +6 -0
  6. package/dist/generated/generated.d.ts.map +1 -1
  7. package/dist/generated/generated.js +32 -0
  8. package/dist/generated/generated.js.map +1 -1
  9. package/dist/generic/RunViewResolver.d.ts +5 -3
  10. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  11. package/dist/generic/RunViewResolver.js +30 -18
  12. package/dist/generic/RunViewResolver.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/resolvers/AskSkipResolver.d.ts +4 -0
  18. package/dist/resolvers/AskSkipResolver.d.ts.map +1 -1
  19. package/dist/resolvers/AskSkipResolver.js +11 -5
  20. package/dist/resolvers/AskSkipResolver.js.map +1 -1
  21. package/dist/resolvers/ComponentRegistryResolver.d.ts +49 -0
  22. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -0
  23. package/dist/resolvers/ComponentRegistryResolver.js +448 -0
  24. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -0
  25. package/dist/resolvers/FileResolver.js +2 -2
  26. package/dist/resolvers/FileResolver.js.map +1 -1
  27. package/package.json +40 -40
  28. package/src/config.ts +17 -0
  29. package/src/generated/generated.ts +20 -0
  30. package/src/generic/RunViewResolver.ts +30 -15
  31. package/src/index.ts +1 -0
  32. package/src/resolvers/AskSkipResolver.ts +15 -6
  33. package/src/resolvers/ComponentRegistryResolver.ts +535 -0
  34. package/src/resolvers/FileResolver.ts +2 -2
@@ -0,0 +1,535 @@
1
+ import { Arg, Ctx, Field, InputType, ObjectType, Query, Resolver } from 'type-graphql';
2
+ import { UserInfo, Metadata, LogError, LogStatus } from '@memberjunction/core';
3
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
4
+ import { ComponentEntity, ComponentRegistryEntity, ComponentMetadataEngine } from '@memberjunction/core-entities';
5
+ import { ComponentSpec } from '@memberjunction/interactive-component-types';
6
+ import {
7
+ ComponentRegistryClient,
8
+ ComponentResponse,
9
+ ComponentSearchResult,
10
+ DependencyTree,
11
+ RegistryError,
12
+ RegistryErrorCode
13
+ } from '@memberjunction/component-registry-client-sdk';
14
+ import { AppContext } from '../types';
15
+ import { configInfo } from '../config';
16
+
17
+ /**
18
+ * GraphQL types for Component Registry operations
19
+ */
20
+
21
+ @ObjectType()
22
+ class ComponentSpecWithHashType {
23
+ @Field(() => String, { nullable: true })
24
+ specification?: string; // JSON string of ComponentSpec
25
+
26
+ @Field(() => String)
27
+ hash: string;
28
+
29
+ @Field(() => Boolean)
30
+ notModified: boolean;
31
+
32
+ @Field(() => String, { nullable: true })
33
+ message?: string;
34
+ }
35
+
36
+ @InputType()
37
+ class SearchRegistryComponentsInput {
38
+ @Field({ nullable: true })
39
+ registryId?: string;
40
+
41
+ @Field({ nullable: true })
42
+ namespace?: string;
43
+
44
+ @Field({ nullable: true })
45
+ query?: string;
46
+
47
+ @Field({ nullable: true })
48
+ type?: string;
49
+
50
+ @Field(() => [String], { nullable: true })
51
+ tags?: string[];
52
+
53
+ @Field({ nullable: true })
54
+ limit?: number;
55
+
56
+ @Field({ nullable: true })
57
+ offset?: number;
58
+ }
59
+
60
+ @ObjectType()
61
+ class RegistryComponentSearchResultType {
62
+ @Field(() => [String])
63
+ components: string[]; // Array of JSON strings of ComponentSpec
64
+
65
+ @Field()
66
+ total: number;
67
+
68
+ @Field()
69
+ offset: number;
70
+
71
+ @Field()
72
+ limit: number;
73
+ }
74
+
75
+ @ObjectType()
76
+ class ComponentDependencyTreeType {
77
+ @Field()
78
+ componentId: string;
79
+
80
+ @Field({ nullable: true })
81
+ name?: string;
82
+
83
+ @Field({ nullable: true })
84
+ namespace?: string;
85
+
86
+ @Field({ nullable: true })
87
+ version?: string;
88
+
89
+ @Field({ nullable: true })
90
+ circular?: boolean;
91
+
92
+ @Field({ nullable: true })
93
+ totalCount?: number;
94
+
95
+ @Field(() => [ComponentDependencyTreeType], { nullable: true })
96
+ dependencies?: ComponentDependencyTreeType[];
97
+ }
98
+
99
+ /**
100
+ * Resolver for Component Registry operations
101
+ *
102
+ * Environment Variables for Development:
103
+ * - REGISTRY_URI_OVERRIDE_<REGISTRY_NAME>: Override the URI for a specific registry
104
+ * Example: REGISTRY_URI_OVERRIDE_MJ_CENTRAL=http://localhost:8080
105
+ * Registry names are converted to uppercase with non-alphanumeric chars replaced by underscores
106
+ *
107
+ * - REGISTRY_API_KEY_<REGISTRY_NAME>: API key for authenticating with the registry
108
+ * Example: REGISTRY_API_KEY_MJ_CENTRAL=your-api-key-here
109
+ */
110
+ @Resolver()
111
+ export class ComponentRegistryExtendedResolver {
112
+ private componentEngine = ComponentMetadataEngine.Instance;
113
+
114
+ constructor() {
115
+ // No longer pre-initialize clients - create on demand
116
+ }
117
+
118
+ /**
119
+ * Get a component from a registry with optional hash for caching
120
+ */
121
+ @Query(() => ComponentSpecWithHashType)
122
+ async GetRegistryComponent(
123
+ @Arg('registryName') registryName: string,
124
+ @Arg('namespace') namespace: string,
125
+ @Arg('name') name: string,
126
+ @Ctx() { userPayload }: AppContext,
127
+ @Arg('version', { nullable: true }) version?: string,
128
+ @Arg('hash', { nullable: true }) hash?: string
129
+ ): Promise<ComponentSpecWithHashType> {
130
+ try {
131
+ // Get user from cache
132
+ const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email?.trim().toLowerCase());
133
+ if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
134
+
135
+ // Get registry from database by name
136
+ const registry = await this.getRegistryByName(registryName, user);
137
+ if (!registry) {
138
+ throw new Error(`Registry not found: ${registryName}`);
139
+ }
140
+
141
+ // Check user permissions (use registry ID for permission check)
142
+ await this.checkUserAccess(user, registry.ID);
143
+
144
+ // Initialize component engine
145
+ await this.componentEngine.Config(false, user);
146
+
147
+ // Create client on-demand for this registry
148
+ const registryClient = this.createClientForRegistry(registry);
149
+
150
+ // Fetch component from registry with hash support
151
+ const response = await registryClient.getComponentWithHash({
152
+ registry: registry.Name,
153
+ namespace,
154
+ name,
155
+ version: version || 'latest',
156
+ hash: hash
157
+ });
158
+
159
+ // If not modified (304), return response with notModified flag
160
+ if (response.notModified) {
161
+ LogStatus(`Component ${namespace}/${name} not modified (hash: ${response.hash})`);
162
+ return {
163
+ specification: undefined,
164
+ hash: response.hash,
165
+ notModified: true,
166
+ message: response.message || 'Not modified'
167
+ };
168
+ }
169
+
170
+ // Extract the specification from the response
171
+ const component = response.specification;
172
+ if (!component) {
173
+ throw new Error(`Component ${namespace}/${name} returned without specification`);
174
+ }
175
+
176
+ // Optional: Cache in database if configured
177
+ if (this.shouldCache(registry)) {
178
+ await this.cacheComponent(component, registryName, user);
179
+ }
180
+
181
+ // Return the ComponentSpec as a JSON string
182
+ return {
183
+ specification: JSON.stringify(component),
184
+ hash: response.hash,
185
+ notModified: false,
186
+ message: undefined
187
+ };
188
+ } catch (error) {
189
+ if (error instanceof RegistryError) {
190
+ // Log specific registry errors
191
+ LogError(`Registry error [${error.code}]: ${error.message}`);
192
+ if (error.code === RegistryErrorCode.COMPONENT_NOT_FOUND) {
193
+ // Return an error response structure
194
+ return {
195
+ specification: undefined,
196
+ hash: '',
197
+ notModified: false,
198
+ message: 'Component not found'
199
+ };
200
+ }
201
+ }
202
+ LogError(error);
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Search for components in registries
209
+ */
210
+ @Query(() => RegistryComponentSearchResultType)
211
+ async SearchRegistryComponents(
212
+ @Arg('params') params: SearchRegistryComponentsInput,
213
+ @Ctx() { userPayload }: AppContext
214
+ ): Promise<RegistryComponentSearchResultType> {
215
+ try {
216
+ // Get user from cache
217
+ const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email?.trim().toLowerCase());
218
+ if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
219
+
220
+ // If a specific registry is specified, use only that one
221
+ if (params.registryId) {
222
+ await this.checkUserAccess(user, params.registryId);
223
+
224
+ // Get registry and create client on-demand
225
+ const registry = await this.getRegistry(params.registryId, user);
226
+ if (!registry) {
227
+ throw new Error(`Registry not found: ${params.registryId}`);
228
+ }
229
+
230
+ const client = this.createClientForRegistry(registry);
231
+
232
+ const result = await client.searchComponents({
233
+ namespace: params.namespace,
234
+ query: params.query,
235
+ type: params.type,
236
+ tags: params.tags,
237
+ limit: params.limit || 10,
238
+ offset: params.offset || 0
239
+ });
240
+
241
+ return this.mapSearchResult(result);
242
+ }
243
+
244
+ // Otherwise, search across all active registries
245
+ const allResults: ComponentSpec[] = [];
246
+
247
+ // Get all active registries from database
248
+ await this.componentEngine.Config(false, user);
249
+ const activeRegistries = this.componentEngine.ComponentRegistries?.filter(
250
+ r => r.Status === 'Active'
251
+ ) || [];
252
+
253
+ for (const registry of activeRegistries) {
254
+ try {
255
+ await this.checkUserAccess(user, registry.ID);
256
+
257
+ const client = this.createClientForRegistry(registry);
258
+ const result = await client.searchComponents({
259
+ namespace: params.namespace,
260
+ query: params.query,
261
+ type: params.type,
262
+ tags: params.tags,
263
+ limit: params.limit || 10,
264
+ offset: 0 // Reset offset for each registry
265
+ });
266
+
267
+ allResults.push(...result.components);
268
+ } catch (error) {
269
+ // Log but continue with other registries
270
+ LogError(`Failed to search registry ${registry.Name}: ${error}`);
271
+ }
272
+ }
273
+
274
+ // Apply pagination to combined results
275
+ const offset = params.offset || 0;
276
+ const limit = params.limit || 10;
277
+ const paginatedResults = allResults.slice(offset, offset + limit);
278
+
279
+ return {
280
+ components: paginatedResults.map(spec => JSON.stringify(spec)),
281
+ total: allResults.length,
282
+ offset,
283
+ limit
284
+ };
285
+ } catch (error) {
286
+ LogError(error);
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Resolve component dependencies
293
+ */
294
+ @Query(() => ComponentDependencyTreeType, { nullable: true })
295
+ async ResolveComponentDependencies(
296
+ @Arg('registryName') registryName: string,
297
+ @Arg('componentId') componentId: string,
298
+ @Ctx() { userPayload }: AppContext
299
+ ): Promise<ComponentDependencyTreeType | null> {
300
+ try {
301
+ // Get user from cache
302
+ const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email?.trim().toLowerCase());
303
+ if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
304
+
305
+ // Get registry to find its ID for permission check
306
+ const registry = await this.getRegistryByName(registryName, user);
307
+ if (!registry) {
308
+ throw new Error(`Registry not found: ${registryName}`);
309
+ }
310
+
311
+ await this.checkUserAccess(user, registry.ID);
312
+
313
+ // Create client on-demand
314
+ const client = this.createClientForRegistry(registry);
315
+
316
+ const tree = await client.resolveDependencies(componentId);
317
+ return tree as ComponentDependencyTreeType;
318
+ } catch (error) {
319
+ LogError(error);
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Check if user has access to a registry
326
+ */
327
+ private async checkUserAccess(userInfo: UserInfo | undefined, registryId: string): Promise<void> {
328
+ // TODO: Implement actual permission checking
329
+ // For now, just ensure user is authenticated
330
+ if (!userInfo) {
331
+ throw new Error('User must be authenticated to access component registries');
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Get registry entity from database by ID
337
+ */
338
+ private async getRegistry(registryId: string, userInfo: UserInfo): Promise<ComponentRegistryEntity | null> {
339
+ try {
340
+ await this.componentEngine.Config(false, userInfo);
341
+
342
+ const registry = this.componentEngine.ComponentRegistries?.find(
343
+ r => r.ID === registryId
344
+ );
345
+
346
+ return registry || null;
347
+ } catch (error) {
348
+ LogError(error);
349
+ return null;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Get registry entity from database by Name
355
+ */
356
+ private async getRegistryByName(registryName: string, userInfo: UserInfo): Promise<ComponentRegistryEntity | null> {
357
+ try {
358
+ await this.componentEngine.Config(false, userInfo);
359
+
360
+ const registry = this.componentEngine.ComponentRegistries?.find(
361
+ r => r.Name === registryName && r.Status === 'Active'
362
+ );
363
+
364
+ return registry || null;
365
+ } catch (error) {
366
+ LogError(error);
367
+ return null;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Get the registry URI, checking for environment variable override first
373
+ * Environment variable format: REGISTRY_URI_OVERRIDE_<REGISTRY_NAME>
374
+ * Example: REGISTRY_URI_OVERRIDE_MJ_CENTRAL=http://localhost:8080
375
+ */
376
+ private getRegistryUri(registry: ComponentRegistryEntity): string {
377
+ if (!registry.Name) {
378
+ return registry.URI || '';
379
+ }
380
+
381
+ // Convert registry name to environment variable format
382
+ // Replace spaces, hyphens, and other non-alphanumeric chars with underscores
383
+ const envVarName = `REGISTRY_URI_OVERRIDE_${registry.Name.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`;
384
+
385
+ // Check for environment variable override
386
+ const override = process.env[envVarName];
387
+ if (override) {
388
+ LogStatus(`Using URI override for registry ${registry.Name}: ${override}`);
389
+ return override;
390
+ }
391
+
392
+ // Use production URI from database
393
+ return registry.URI || '';
394
+ }
395
+
396
+ /**
397
+ * Create a client for a registry on-demand
398
+ * Checks configuration first, then falls back to default settings
399
+ */
400
+ private createClientForRegistry(registry: ComponentRegistryEntity): ComponentRegistryClient {
401
+ // Check if there's a configuration for this registry
402
+ const config = configInfo.componentRegistries?.find(r =>
403
+ r.id === registry.ID || r.name === registry.Name
404
+ );
405
+
406
+ // Get API key from environment or config
407
+ const apiKey = process.env[`REGISTRY_API_KEY_${registry.ID.replace(/-/g, '_').toUpperCase()}`] ||
408
+ process.env[`REGISTRY_API_KEY_${registry.Name?.replace(/-/g, '_').toUpperCase()}`] ||
409
+ config?.apiKey;
410
+
411
+ // Get the registry URI (with possible override)
412
+ const baseUrl = this.getRegistryUri(registry);
413
+
414
+ // Build retry policy with defaults
415
+ const retryPolicy = {
416
+ maxRetries: config?.retryPolicy?.maxRetries ?? 3,
417
+ initialDelay: config?.retryPolicy?.initialDelay ?? 1000,
418
+ maxDelay: config?.retryPolicy?.maxDelay ?? 10000,
419
+ backoffMultiplier: config?.retryPolicy?.backoffMultiplier ?? 2
420
+ };
421
+
422
+ // Use config settings if available, otherwise defaults
423
+ return new ComponentRegistryClient({
424
+ baseUrl: baseUrl,
425
+ apiKey: apiKey,
426
+ timeout: config?.timeout || 30000,
427
+ retryPolicy: retryPolicy,
428
+ headers: config?.headers
429
+ });
430
+ }
431
+
432
+ /**
433
+ * Check if component should be cached
434
+ */
435
+ private shouldCache(registry: ComponentRegistryEntity): boolean {
436
+ // Check config for caching settings
437
+ const config = configInfo.componentRegistries?.find(r =>
438
+ r.id === registry.ID || r.name === registry.Name
439
+ );
440
+ return config?.cache !== false; // Cache by default
441
+ }
442
+
443
+ /**
444
+ * Cache component in database
445
+ */
446
+ private async cacheComponent(
447
+ component: ComponentSpec,
448
+ registryId: string,
449
+ userInfo: UserInfo
450
+ ): Promise<void> {
451
+ try {
452
+ // Find or create component entity
453
+ const md = new Metadata();
454
+ const componentEntity = await md.GetEntityObject<ComponentEntity>('MJ: Components', userInfo);
455
+
456
+ // Check if component already exists
457
+ const existingComponent = this.componentEngine.Components?.find(
458
+ c => c.Name === component.name &&
459
+ c.Namespace === component.namespace &&
460
+ c.SourceRegistryID === registryId
461
+ );
462
+
463
+ if (existingComponent) {
464
+ // Update existing component
465
+ if (!await componentEntity.Load(existingComponent.ID)) {
466
+ throw new Error(`Failed to load component: ${existingComponent.ID}`);
467
+ }
468
+ } else {
469
+ // Create new component
470
+ componentEntity.NewRecord();
471
+ componentEntity.SourceRegistryID = registryId;
472
+ }
473
+
474
+ // Update component fields
475
+ componentEntity.Name = component.name;
476
+ componentEntity.Namespace = component.namespace || '';
477
+ componentEntity.Version = component.version || '1.0.0';
478
+ componentEntity.Title = component.title;
479
+ componentEntity.Description = component.description;
480
+ componentEntity.Type = this.mapComponentType(component.type);
481
+ componentEntity.FunctionalRequirements = component.functionalRequirements;
482
+ componentEntity.TechnicalDesign = component.technicalDesign;
483
+ componentEntity.Specification = JSON.stringify(component);
484
+ componentEntity.LastSyncedAt = new Date();
485
+
486
+ if (!existingComponent) {
487
+ componentEntity.ReplicatedAt = new Date();
488
+ }
489
+
490
+ // Save component
491
+ const result = await componentEntity.Save();
492
+ if (!result) {
493
+ throw new Error(`Failed to cache component: ${component.name}`);
494
+ }
495
+
496
+ // Refresh metadata cache
497
+ await this.componentEngine.Config(true, userInfo);
498
+ } catch (error) {
499
+ // Log but don't throw - caching failure shouldn't break the operation
500
+ LogError('Failed to cache component:');
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Map component type string to entity enum
506
+ */
507
+ private mapComponentType(type: string): ComponentEntity['Type'] {
508
+ const typeMap: Record<string, ComponentEntity['Type']> = {
509
+ 'report': 'Report',
510
+ 'dashboard': 'Dashboard',
511
+ 'form': 'Form',
512
+ 'table': 'Table',
513
+ 'chart': 'Chart',
514
+ 'navigation': 'Navigation',
515
+ 'search': 'Search',
516
+ 'widget': 'Widget',
517
+ 'utility': 'Utility',
518
+ 'other': 'Other'
519
+ };
520
+
521
+ return typeMap[type.toLowerCase()] || 'Other';
522
+ }
523
+
524
+ /**
525
+ * Map search result to GraphQL type
526
+ */
527
+ private mapSearchResult(result: ComponentSearchResult): RegistryComponentSearchResultType {
528
+ return {
529
+ components: result.components.map(spec => JSON.stringify(spec)),
530
+ total: result.total,
531
+ offset: result.offset,
532
+ limit: result.limit
533
+ };
534
+ }
535
+ }
@@ -107,7 +107,7 @@ export class FileResolver extends FileResolverBase {
107
107
  @PubSub() pubSub: PubSubEngine
108
108
  ) {
109
109
  // if the name is changing, rename the target object as well
110
- const md = GetReadOnlyProvider(context.providers);
110
+ const md = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
111
111
  const user = this.GetUserFromPayload(context.userPayload);
112
112
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
113
113
  fileEntity.CheckPermissions(EntityPermissionType.Update, true);
@@ -135,7 +135,7 @@ export class FileResolver extends FileResolverBase {
135
135
  @Ctx() context: AppContext,
136
136
  @PubSub() pubSub: PubSubEngine
137
137
  ) {
138
- const md = GetReadOnlyProvider(context.providers);
138
+ const md = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
139
139
  const userInfo = this.GetUserFromPayload(context.userPayload);
140
140
 
141
141
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', userInfo);