@memberjunction/server 5.32.0 → 5.34.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 (73) hide show
  1. package/README.md +2 -1
  2. package/dist/agents/skip-agent.d.ts +3 -1
  3. package/dist/agents/skip-agent.d.ts.map +1 -1
  4. package/dist/agents/skip-agent.js +15 -3
  5. package/dist/agents/skip-agent.js.map +1 -1
  6. package/dist/auth/newUsers.d.ts.map +1 -1
  7. package/dist/auth/newUsers.js +11 -3
  8. package/dist/auth/newUsers.js.map +1 -1
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +23 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/generated/generated.d.ts +551 -5
  13. package/dist/generated/generated.d.ts.map +1 -1
  14. package/dist/generated/generated.js +3277 -120
  15. package/dist/generated/generated.js.map +1 -1
  16. package/dist/generic/ResolverBase.d.ts +1 -1
  17. package/dist/generic/ResolverBase.d.ts.map +1 -1
  18. package/dist/generic/ResolverBase.js +9 -2
  19. package/dist/generic/ResolverBase.js.map +1 -1
  20. package/dist/generic/RunViewResolver.d.ts +5 -1
  21. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  22. package/dist/generic/RunViewResolver.js +33 -2
  23. package/dist/generic/RunViewResolver.js.map +1 -1
  24. package/dist/index.d.ts +12 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +29 -9
  27. package/dist/index.js.map +1 -1
  28. package/dist/resolvers/AvailableSearchProvidersResolver.d.ts +26 -0
  29. package/dist/resolvers/AvailableSearchProvidersResolver.d.ts.map +1 -0
  30. package/dist/resolvers/AvailableSearchProvidersResolver.js +65 -0
  31. package/dist/resolvers/AvailableSearchProvidersResolver.js.map +1 -0
  32. package/dist/resolvers/ComponentRegistryResolver.d.ts +11 -25
  33. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  34. package/dist/resolvers/ComponentRegistryResolver.js +51 -93
  35. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  36. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  37. package/dist/resolvers/GetDataResolver.js +5 -1
  38. package/dist/resolvers/GetDataResolver.js.map +1 -1
  39. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  40. package/dist/resolvers/IntegrationDiscoveryResolver.js +9 -4
  41. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  42. package/dist/resolvers/QuerySystemUserResolver.d.ts +6 -0
  43. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  44. package/dist/resolvers/QuerySystemUserResolver.js +31 -1
  45. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  46. package/dist/resolvers/SearchKnowledgeResolver.d.ts +44 -1
  47. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  48. package/dist/resolvers/SearchKnowledgeResolver.js +217 -7
  49. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  50. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +79 -0
  51. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -0
  52. package/dist/resolvers/SearchKnowledgeStreamResolver.js +342 -0
  53. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -0
  54. package/dist/types.d.ts +6 -1
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/package.json +68 -68
  58. package/src/__tests__/databaseAbstraction.test.ts +123 -61
  59. package/src/agents/skip-agent.ts +18 -4
  60. package/src/auth/newUsers.ts +12 -3
  61. package/src/context.ts +21 -3
  62. package/src/generated/generated.ts +2229 -80
  63. package/src/generic/ResolverBase.ts +11 -2
  64. package/src/generic/RunViewResolver.ts +29 -2
  65. package/src/index.ts +29 -9
  66. package/src/resolvers/AvailableSearchProvidersResolver.ts +43 -0
  67. package/src/resolvers/ComponentRegistryResolver.ts +71 -123
  68. package/src/resolvers/GetDataResolver.ts +6 -2
  69. package/src/resolvers/IntegrationDiscoveryResolver.ts +10 -4
  70. package/src/resolvers/QuerySystemUserResolver.ts +27 -1
  71. package/src/resolvers/SearchKnowledgeResolver.ts +205 -4
  72. package/src/resolvers/SearchKnowledgeStreamResolver.ts +338 -0
  73. package/src/types.ts +6 -1
@@ -356,7 +356,10 @@ export class ResolverBase {
356
356
  userPayload,
357
357
  viewInput.MaxRows,
358
358
  viewInput.StartRow,
359
- viewInput.Aggregates
359
+ viewInput.Aggregates,
360
+ viewInput.AfterKey
361
+ ? CompositeKey.FromKeyValuePairs((viewInput.AfterKey as { KeyValuePairs: { FieldName: string; Value: string }[] }).KeyValuePairs)
362
+ : undefined
360
363
  );
361
364
  }
362
365
  else {
@@ -505,6 +508,9 @@ export class ResolverBase {
505
508
  ignoreMaxRows: viewInput.IgnoreMaxRows,
506
509
  maxRows: viewInput.MaxRows,
507
510
  startRow: viewInput.StartRow,
511
+ afterKey: viewInput.AfterKey
512
+ ? CompositeKey.FromKeyValuePairs((viewInput.AfterKey as { KeyValuePairs: { FieldName: string; Value: string }[] }).KeyValuePairs)
513
+ : undefined,
508
514
  excludeDataFromAllPriorViewRuns: viewInput.EntityName ? false : viewInput.ExcludeDataFromAllPriorViewRuns,
509
515
  forceAuditLog: viewInput.ForceAuditLog,
510
516
  auditLogDescription: viewInput.AuditLogDescription,
@@ -686,7 +692,8 @@ export class ResolverBase {
686
692
  userPayload: UserPayload | null,
687
693
  maxRows: number | undefined,
688
694
  startRow: number | undefined,
689
- aggregates?: AggregateExpression[]
695
+ aggregates?: AggregateExpression[],
696
+ afterKey?: CompositeKey
690
697
  ) {
691
698
  try {
692
699
  if (!viewInfo || !userPayload) return null;
@@ -745,6 +752,7 @@ export class ResolverBase {
745
752
  IgnoreMaxRows: ignoreMaxRows,
746
753
  MaxRows: maxRows,
747
754
  StartRow: startRow,
755
+ AfterKey: afterKey,
748
756
  ForceAuditLog: forceAuditLog,
749
757
  AuditLogDescription: auditLogDescription,
750
758
  ResultType: rt,
@@ -857,6 +865,7 @@ export class ResolverBase {
857
865
  IgnoreMaxRows: param.ignoreMaxRows,
858
866
  MaxRows: param.maxRows,
859
867
  StartRow: param.startRow,
868
+ AfterKey: param.afterKey,
860
869
  ForceAuditLog: param.forceAuditLog,
861
870
  AuditLogDescription: param.auditLogDescription,
862
871
  ResultType: rt,
@@ -1,12 +1,12 @@
1
1
  import { Arg, Ctx, Field, InputType, Int, ObjectType, PubSubEngine, Query, Resolver } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
3
  import { ResolverBase } from './ResolverBase.js';
4
- import { LogError, LogStatus, EntityInfo, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult } from '@memberjunction/core';
4
+ import { LogError, LogStatus, EntityInfo, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult, CompositeKey } from '@memberjunction/core';
5
5
  import { UUIDsEqual } from '@memberjunction/global';
6
6
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
7
7
  import { GetReadOnlyProvider } from '../util.js';
8
8
  import { MJUserViewEntityExtended } from '@memberjunction/core-entities';
9
- import { KeyValuePairOutputType } from './KeyInputOutputTypes.js';
9
+ import { CompositeKeyInputType, KeyValuePairOutputType } from './KeyInputOutputTypes.js';
10
10
  import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
11
11
 
12
12
  /********************************************************************************
@@ -159,6 +159,12 @@ export class RunViewByIDInput {
159
159
  })
160
160
  StartRow?: number;
161
161
 
162
+ @Field(() => CompositeKeyInputType, {
163
+ nullable: true,
164
+ description: 'Keyset (seek) pagination cursor. When provided, returns the next page of records after the given PK value, ordered by the PK column. Requires a single-column PK on the entity. Cannot be combined with StartRow.',
165
+ })
166
+ AfterKey?: CompositeKeyInputType;
167
+
162
168
  @Field(() => [AggregateExpressionInput], {
163
169
  nullable: true,
164
170
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
@@ -260,6 +266,12 @@ export class RunViewByNameInput {
260
266
  })
261
267
  StartRow?: number;
262
268
 
269
+ @Field(() => CompositeKeyInputType, {
270
+ nullable: true,
271
+ description: 'Keyset (seek) pagination cursor. When provided, returns the next page of records after the given PK value, ordered by the PK column. Requires a single-column PK on the entity. Cannot be combined with StartRow.',
272
+ })
273
+ AfterKey?: CompositeKeyInputType;
274
+
263
275
  @Field(() => [AggregateExpressionInput], {
264
276
  nullable: true,
265
277
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
@@ -347,6 +359,12 @@ export class RunDynamicViewInput {
347
359
  })
348
360
  StartRow?: number;
349
361
 
362
+ @Field(() => CompositeKeyInputType, {
363
+ nullable: true,
364
+ description: 'Keyset (seek) pagination cursor. When provided, returns the next page of records after the given PK value, ordered by the PK column. Requires a single-column PK on the entity. Cannot be combined with StartRow.',
365
+ })
366
+ AfterKey?: CompositeKeyInputType;
367
+
350
368
  @Field(() => [AggregateExpressionInput], {
351
369
  nullable: true,
352
370
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
@@ -463,6 +481,12 @@ export class RunViewGenericInput {
463
481
  })
464
482
  StartRow?: number;
465
483
 
484
+ @Field(() => CompositeKeyInputType, {
485
+ nullable: true,
486
+ description: 'Keyset (seek) pagination cursor. When provided, returns the next page of records after the given PK value, ordered by the PK column. Requires a single-column PK on the entity. Cannot be combined with StartRow.',
487
+ })
488
+ AfterKey?: CompositeKeyInputType;
489
+
466
490
  @Field(() => [AggregateExpressionInput], {
467
491
  nullable: true,
468
492
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
@@ -1017,6 +1041,9 @@ export class RunViewResolver extends ResolverBase {
1017
1041
  AuditLogDescription: item.params.AuditLogDescription,
1018
1042
  ResultType: (item.params.ResultType || 'simple') as 'simple' | 'entity_object' | 'count_only',
1019
1043
  StartRow: item.params.StartRow,
1044
+ AfterKey: item.params.AfterKey
1045
+ ? CompositeKey.FromKeyValuePairs(item.params.AfterKey.KeyValuePairs)
1046
+ : undefined,
1020
1047
  },
1021
1048
  cacheStatus: item.cacheStatus ? {
1022
1049
  maxUpdatedAt: item.cacheStatus.maxUpdatedAt,
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@ dotenv.config({ quiet: true });
5
5
  import { expressMiddleware } from '@as-integrations/express5';
6
6
  import { mergeSchemas } from '@graphql-tools/schema';
7
7
  import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent, RunView } from '@memberjunction/core';
8
- import { MJGlobal, MJEventType, UUIDsEqual } from '@memberjunction/global';
8
+ import { resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
9
+ import { MJGlobal, MJEventType, UUIDsEqual, ShutdownRegistry } from '@memberjunction/global';
9
10
  import { setupSQLServerClient, SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
10
11
  import { extendConnectionPoolWithQuery } from './util.js';
11
12
  import { default as BodyParser } from 'body-parser';
@@ -58,15 +59,19 @@ import { ServerExtensionLoader, ServerExtensionConfig } from '@memberjunction/se
58
59
  const cacheRefreshInterval = configInfo.databaseSettings.metadataCacheRefreshInterval;
59
60
 
60
61
  /**
61
- * Returns the configured database platform type based on the DB_TYPE environment variable.
62
- * Defaults to 'sqlserver' for backward compatibility.
62
+ * Returns the configured database platform from the `DB_PLATFORM` environment
63
+ * variable, falling back to `'sqlserver'` when the env var is unset. An
64
+ * unrecognized non-empty value (typo, legacy alias) throws — silent fallback
65
+ * is the bug we don't want, because it routes the wrong provider against a
66
+ * real database.
67
+ *
68
+ * Implementation note: the actual env-parsing lives in
69
+ * `@memberjunction/global` (single source of truth across MJCLI, MJServer,
70
+ * CodeGenLib). This wrapper keeps the public `getDbType()` symbol that
71
+ * MJServer consumers (and the broader stack) already import.
63
72
  */
64
73
  export function getDbType(): DatabasePlatform {
65
- const dbType = process.env.DB_TYPE?.toLowerCase();
66
- if (dbType === 'postgresql' || dbType === 'postgres' || dbType === 'pg') {
67
- return 'postgresql';
68
- }
69
- return 'sqlserver';
74
+ return resolveDbPlatformFromEnv() ?? 'sqlserver';
70
75
  }
71
76
 
72
77
  export { MaxLength } from 'class-validator';
@@ -96,6 +101,8 @@ export * from './resolvers/RunAIPromptResolver.js';
96
101
  export * from './resolvers/RunAIAgentResolver.js';
97
102
  export * from './resolvers/VectorizeEntityResolver.js';
98
103
  export * from './resolvers/SearchKnowledgeResolver.js';
104
+ export * from './resolvers/SearchKnowledgeStreamResolver.js';
105
+ export * from './resolvers/AvailableSearchProvidersResolver.js';
99
106
  export * from './resolvers/FetchEntityVectorsResolver.js';
100
107
  export * from './resolvers/PipelineProgressResolver.js';
101
108
  export * from './resolvers/ClientToolRequestResolver.js';
@@ -417,7 +424,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
417
424
  url: process.env.REDIS_URL,
418
425
  keyPrefix: process.env.REDIS_KEY_PREFIX || 'mj',
419
426
  enablePubSub: true,
420
- enableLogging: true,
427
+ enableLogging: configInfo.cacheSettings?.verboseLogging ?? false,
421
428
  });
422
429
  (Metadata.Provider as GenericDatabaseProvider).SetLocalStorageProvider(redisProvider); // global-provider-ok: bootstrap (Redis cache wiring)
423
430
  await redisProvider.StartListening();
@@ -901,6 +908,19 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
901
908
  }
902
909
  }
903
910
 
911
+ // Drain anything self-registered with ShutdownRegistry — QueueManager,
912
+ // future engines/services with timers/intervals/listeners. Each is
913
+ // responsible for being idempotent and not throwing.
914
+ try {
915
+ const count = ShutdownRegistry.Instance.Count;
916
+ if (count > 0) {
917
+ await ShutdownRegistry.Instance.ShutdownAll();
918
+ console.log(`✅ ShutdownRegistry drained ${count} registered service(s)`);
919
+ }
920
+ } catch (error) {
921
+ console.error('❌ Error draining ShutdownRegistry:', error);
922
+ }
923
+
904
924
  // Close server
905
925
  httpServer.close(() => {
906
926
  console.log('✅ HTTP server closed');
@@ -0,0 +1,43 @@
1
+ import { Field, ObjectType, Query, Resolver } from 'type-graphql';
2
+ import { BaseSearchProvider } from '@memberjunction/search-engine';
3
+
4
+ /**
5
+ * Surfaces the runtime ClassFactory registrations of `BaseSearchProvider`
6
+ * subclasses for UI consumers (P5.5 — SearchScope form's provider dropdown).
7
+ *
8
+ * Why a GraphQL query and not a hardcoded client-side list:
9
+ * - `@memberjunction/search-engine` is a Node-only package; the browser
10
+ * can't import it directly, so it can't read `ClassFactory` itself.
11
+ * - Third-party packages can register additional providers at server boot
12
+ * without modifying any UI code; this query keeps the form auto-discovering.
13
+ * - The list is also useful for ops debugging ("which providers does this
14
+ * server build know about?").
15
+ *
16
+ * The resolver is intentionally read-only and does not require system-user
17
+ * privileges — knowing the registered driver-class names on a given server
18
+ * isn't sensitive, and the SearchScope form itself is gated by entity-level
19
+ * permission checks. Adjust if your deployment treats the provider catalog
20
+ * as confidential.
21
+ */
22
+ @ObjectType()
23
+ export class AvailableSearchProviderGQL {
24
+ @Field()
25
+ DriverClass!: string;
26
+
27
+ @Field()
28
+ SourceType!: string;
29
+ }
30
+
31
+ @Resolver()
32
+ export class AvailableSearchProvidersResolver {
33
+ @Query(() => [AvailableSearchProviderGQL])
34
+ AvailableSearchProviders(): AvailableSearchProviderGQL[] {
35
+ const registrations = BaseSearchProvider.GetAvailableProviders();
36
+ return registrations.map(r => {
37
+ const gql = new AvailableSearchProviderGQL();
38
+ gql.DriverClass = r.DriverClass;
39
+ gql.SourceType = r.SourceType;
40
+ return gql;
41
+ });
42
+ }
43
+ }
@@ -1,8 +1,8 @@
1
1
  import { Arg, Ctx, Field, InputType, ObjectType, Query, Mutation, Resolver } from 'type-graphql';
2
- import { UserInfo, IMetadataProvider, Metadata, LogError, LogStatus } from '@memberjunction/core';
3
- import { UUIDsEqual } from '@memberjunction/global';
2
+ import { UserInfo, LogError, LogStatus } from '@memberjunction/core';
3
+ import { UUIDsEqual, MJLruCache } from '@memberjunction/global';
4
4
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
5
- import { MJComponentEntity, MJComponentRegistryEntity, ComponentMetadataEngine } from '@memberjunction/core-entities';
5
+ import { MJComponentRegistryEntity, ComponentMetadataEngine } from '@memberjunction/core-entities';
6
6
  import { ComponentSpec } from '@memberjunction/interactive-component-types';
7
7
  import {
8
8
  ComponentRegistryClient,
@@ -16,7 +16,6 @@ import {
16
16
  } from '@memberjunction/component-registry-client-sdk';
17
17
  import { AppContext } from '../types.js';
18
18
  import { configInfo } from '../config.js';
19
- import { GetReadWriteProvider } from '../util.js';
20
19
 
21
20
  /**
22
21
  * GraphQL types for Component Registry operations
@@ -166,23 +165,43 @@ class ComponentFeedbackResponse {
166
165
  * - REGISTRY_API_KEY_<REGISTRY_NAME>: API key for authenticating with the registry
167
166
  * Example: REGISTRY_API_KEY_MJ_CENTRAL=your-api-key-here
168
167
  */
168
+ /** Server-side cache for registry component specs. Uses MJLruCache for isolated
169
+ * LRU eviction with TTL — avoids sharing eviction budgets with RunView/RunQuery
170
+ * data in LocalCacheManager. */
171
+ const componentSpecCache = new MJLruCache<string, { specJson: string; hash: string }>({
172
+ maxSize: 200,
173
+ ttlMs: 60 * 60 * 1000 // 1 hour — components are immutable; TTL governs "latest" pointer freshness
174
+ });
175
+
169
176
  @Resolver()
170
177
  export class ComponentRegistryExtendedResolver {
171
178
  private componentEngine = ComponentMetadataEngine.Instance;
172
-
179
+
173
180
  constructor() {
174
181
  // No longer pre-initialize clients - create on demand
175
182
  }
176
-
183
+
177
184
  /**
178
- * Get a component from a registry with optional hash for caching
185
+ * Build a deterministic cache key for a registry component.
186
+ */
187
+ private getComponentCacheKey(registryName: string, namespace: string, name: string, version: string): string {
188
+ return `RegistryComponent|${registryName}|${namespace}|${name}|${version}`;
189
+ }
190
+
191
+ /**
192
+ * Get a component from a registry with optional hash for caching.
193
+ *
194
+ * Caching strategy (three tiers):
195
+ * 1. Client sends hash from a previous fetch → server can return 304 if unchanged
196
+ * 2. Server checks in-memory LRU cache for a cached spec from a recent fetch
197
+ * 3. On miss, fetches from the external registry, caches the result, and returns it
179
198
  */
180
199
  @Query(() => ComponentSpecWithHashType)
181
200
  async GetRegistryComponent(
182
201
  @Arg('registryName') registryName: string,
183
202
  @Arg('namespace') namespace: string,
184
203
  @Arg('name') name: string,
185
- @Ctx() { userPayload, providers }: AppContext,
204
+ @Ctx() { userPayload }: AppContext,
186
205
  @Arg('version', { nullable: true }) version?: string,
187
206
  @Arg('hash', { nullable: true }) hash?: string
188
207
  ): Promise<ComponentSpecWithHashType> {
@@ -190,35 +209,59 @@ export class ComponentRegistryExtendedResolver {
190
209
  // Get user from cache
191
210
  const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email?.trim().toLowerCase());
192
211
  if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
193
-
212
+
194
213
  // Get registry from database by name
195
214
  const registry = await this.getRegistryByName(registryName, user);
196
215
  if (!registry) {
197
216
  throw new Error(`Registry not found: ${registryName}`);
198
217
  }
199
-
218
+
200
219
  // Check user permissions (use registry ID for permission check)
201
220
  await this.checkUserAccess(user, registry.ID);
202
-
221
+
203
222
  // Initialize component engine
204
223
  await this.componentEngine.Config(false, user);
205
-
206
- // Create client on-demand for this registry
224
+
225
+ const resolvedVersion = version || 'latest';
226
+ const cacheKey = this.getComponentCacheKey(registry.Name, namespace, name, resolvedVersion);
227
+
228
+ // --- Tier 1: Check server-side cache ---
229
+ const cached = componentSpecCache.Get(cacheKey);
230
+
231
+ if (cached) {
232
+ // If client sent a hash and it matches, return 304
233
+ if (hash && hash === cached.hash) {
234
+ return {
235
+ specification: undefined,
236
+ hash: cached.hash,
237
+ notModified: true,
238
+ message: 'Not modified'
239
+ };
240
+ }
241
+
242
+ // Client has no hash or a different hash — return cached spec
243
+ return {
244
+ specification: cached.specJson,
245
+ hash: cached.hash,
246
+ notModified: false,
247
+ message: undefined
248
+ };
249
+ }
250
+
251
+ // --- Tier 2: Fetch from external registry ---
207
252
  const registryClient = this.createClientForRegistry(registry);
208
-
209
- // Fetch component from registry with hash support
253
+
210
254
  const response = await registryClient.getComponentWithHash({
211
255
  registry: registry.Name,
212
256
  namespace,
213
257
  name,
214
- version: version || 'latest',
258
+ version: resolvedVersion,
215
259
  hash: hash,
216
260
  userEmail: user.Email
217
261
  });
218
-
219
- // If not modified (304), return response with notModified flag
262
+
263
+ // If not modified (304 from registry), return to client
220
264
  if (response.notModified) {
221
- LogStatus(`Component ${namespace}/${name} not modified (hash: ${response.hash})`);
222
265
  return {
223
266
  specification: undefined,
224
267
  hash: response.hash,
@@ -226,22 +269,20 @@ export class ComponentRegistryExtendedResolver {
226
269
  message: response.message || 'Not modified'
227
270
  };
228
271
  }
229
-
272
+
230
273
  // Extract the specification from the response
231
274
  const component = response.specification;
232
275
  if (!component) {
233
276
  throw new Error(`Component ${namespace}/${name} returned without specification`);
234
277
  }
235
-
236
- // Optional: Cache in database if configured
237
- if (this.shouldCache(registry)) {
238
- const writeProvider = GetReadWriteProvider(providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
239
- await this.cacheComponent(component, registry.ID, user, writeProvider);
240
- }
241
-
242
- // Return the ComponentSpec as a JSON string
278
+
279
+ const specJson = JSON.stringify(component);
280
+
281
+ // --- Cache the result for subsequent requests ---
282
+ componentSpecCache.Set(cacheKey, { specJson, hash: response.hash });
283
+
243
284
  return {
244
- specification: JSON.stringify(component),
285
+ specification: specJson,
245
286
  hash: response.hash,
246
287
  notModified: false,
247
288
  message: undefined
@@ -489,100 +530,7 @@ export class ComponentRegistryExtendedResolver {
489
530
  headers: config?.headers
490
531
  });
491
532
  }
492
-
493
- /**
494
- * Check if component should be cached
495
- */
496
- private shouldCache(registry: MJComponentRegistryEntity): boolean {
497
- // Check config for caching settings
498
- const config = configInfo.componentRegistries?.find(r =>
499
- UUIDsEqual(r.id, registry.ID) || r.name === registry.Name
500
- );
501
- return config?.cache !== false; // Cache by default
502
- }
503
-
504
- /**
505
- * Cache component in database
506
- */
507
- private async cacheComponent(
508
- component: ComponentSpec,
509
- registryId: string,
510
- userInfo: UserInfo,
511
- provider?: IMetadataProvider
512
- ): Promise<void> {
513
- try {
514
- // Find or create component entity
515
- const md = provider ?? new Metadata();
516
- const componentEntity = await md.GetEntityObject<MJComponentEntity>('MJ: Components', userInfo);
517
-
518
- // Check if component already exists
519
- const existingComponent = this.componentEngine.Components?.find(
520
- c => c.Name === component.name &&
521
- c.Namespace === component.namespace &&
522
- UUIDsEqual(c.SourceRegistryID, registryId)
523
- );
524
-
525
- if (existingComponent) {
526
- // Update existing component
527
- if (!await componentEntity.Load(existingComponent.ID)) {
528
- throw new Error(`Failed to load component: ${existingComponent.ID}`);
529
- }
530
- } else {
531
- // Create new component
532
- componentEntity.NewRecord();
533
- componentEntity.SourceRegistryID = registryId;
534
- }
535
-
536
- // Update component fields
537
- componentEntity.Name = component.name;
538
- componentEntity.Namespace = component.namespace || '';
539
- componentEntity.Version = component.version || '1.0.0';
540
- componentEntity.Title = component.title;
541
- componentEntity.Description = component.description;
542
- componentEntity.Type = this.mapComponentType(component.type);
543
- componentEntity.FunctionalRequirements = component.functionalRequirements;
544
- componentEntity.TechnicalDesign = component.technicalDesign;
545
- componentEntity.Specification = JSON.stringify(component);
546
- componentEntity.LastSyncedAt = new Date();
547
-
548
- if (!existingComponent) {
549
- componentEntity.ReplicatedAt = new Date();
550
- }
551
-
552
- // Save component
553
- const result = await componentEntity.Save();
554
- if (!result) {
555
- throw new Error(`Failed to cache component: ${component.name}`);
556
- }
557
-
558
- // Refresh metadata cache
559
- await this.componentEngine.Config(true, userInfo);
560
- } catch (error) {
561
- // Log but don't throw - caching failure shouldn't break the operation
562
- LogError('Failed to cache component:');
563
- }
564
- }
565
-
566
- /**
567
- * Map component type string to entity enum
568
- */
569
- private mapComponentType(type: string): MJComponentEntity['Type'] {
570
- const typeMap: Record<string, MJComponentEntity['Type']> = {
571
- 'report': 'Report',
572
- 'dashboard': 'Dashboard',
573
- 'form': 'Form',
574
- 'table': 'Table',
575
- 'chart': 'Chart',
576
- 'navigation': 'Navigation',
577
- 'search': 'Search',
578
- 'widget': 'Widget',
579
- 'utility': 'Utility',
580
- 'other': 'Other'
581
- };
582
-
583
- return typeMap[type.toLowerCase()] || 'Other';
584
- }
585
-
533
+
586
534
  /**
587
535
  * Map search result to GraphQL type
588
536
  */
@@ -216,8 +216,12 @@ export class GetDataResolver {
216
216
  async GetAllEntities(
217
217
  @Ctx() context: AppContext
218
218
  ): Promise<SimpleEntityResultType> {
219
- try {
220
- const md = GetReadOnlyProvider(context.providers);
219
+ try {
220
+ // System-key contexts often only have a read-write provider configured;
221
+ // without the fallback, GetReadOnlyProvider returns null and `md.Entities`
222
+ // throws TypeError. Every other resolver passes this option — adding it
223
+ // here brings the call into line with the rest of the codebase.
224
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
221
225
  const result = md.Entities.map((e) => {
222
226
  return {
223
227
  ID: e.ID,
@@ -1,5 +1,5 @@
1
1
  import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType } from "type-graphql";
2
- import { CompositeKey, LocalCacheManager, Metadata, RunView, UserInfo, LogError, IMetadataProvider } from "@memberjunction/core";
2
+ import { CompositeKey, DatabaseProviderBase, LocalCacheManager, Metadata, RunView, UserInfo, LogError, IMetadataProvider } from "@memberjunction/core";
3
3
  import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
4
4
  import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
5
5
  import {
@@ -1747,9 +1747,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1747
1747
  try {
1748
1748
  const user = this.getAuthenticatedUser(ctx);
1749
1749
  const rv = new RunView();
1750
+ // Boolean literal goes through the active provider's dialect:
1751
+ // SQL Server emits `= 1`, PostgreSQL emits `= TRUE`.
1752
+ // Filter stays server-side; no client-side `.filter()` post-pass.
1753
+ const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as DatabaseProviderBase;
1750
1754
  const filters: string[] = [];
1751
- if (activeOnly) filters.push('IsActive=1');
1752
1755
  if (companyID) filters.push(`CompanyID='${companyID}'`);
1756
+ if (activeOnly) filters.push(`IsActive = ${provider.Dialect.BooleanLiteral(true)}`);
1753
1757
  const filter = filters.join(' AND ');
1754
1758
  const result = await rv.RunView<MJCompanyIntegrationEntity>({
1755
1759
  EntityName: 'MJ: Company Integrations',
@@ -1765,10 +1769,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1765
1769
 
1766
1770
  if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
1767
1771
 
1772
+ const filteredResults = result.Results;
1773
+
1768
1774
  return {
1769
1775
  Success: true,
1770
- Message: `${result.Results.length} connections`,
1771
- Connections: result.Results.map(ci => ({
1776
+ Message: `${filteredResults.length} connections`,
1777
+ Connections: filteredResults.map(ci => ({
1772
1778
  ID: ci.ID,
1773
1779
  IntegrationName: ci.Integration,
1774
1780
  IntegrationID: ci.IntegrationID,
@@ -43,6 +43,15 @@ export class QueryPermissionInputType {
43
43
  RoleID!: string;
44
44
  }
45
45
 
46
+ @InputType()
47
+ export class QueryParameterHintInput {
48
+ @Field(() => String)
49
+ Name!: string;
50
+
51
+ @Field(() => String)
52
+ Value!: string;
53
+ }
54
+
46
55
  @InputType()
47
56
  export class CreateQuerySystemUserInput {
48
57
  @Field(() => String)
@@ -98,6 +107,9 @@ export class CreateQuerySystemUserInput {
98
107
 
99
108
  @Field(() => [QueryPermissionInputType], { nullable: true })
100
109
  Permissions?: QueryPermissionInputType[];
110
+
111
+ @Field(() => [QueryParameterHintInput], { nullable: true })
112
+ ParameterHints?: QueryParameterHintInput[];
101
113
  }
102
114
 
103
115
  @InputType()
@@ -158,6 +170,9 @@ export class UpdateQuerySystemUserInput {
158
170
 
159
171
  @Field(() => [QueryPermissionInputType], { nullable: true })
160
172
  Permissions?: QueryPermissionInputType[];
173
+
174
+ @Field(() => [QueryParameterHintInput], { nullable: true })
175
+ ParameterHints?: QueryParameterHintInput[];
161
176
  }
162
177
 
163
178
  /**
@@ -246,7 +261,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
246
261
  const record = await provider.GetEntityObject<MJQueryEntityServer>("MJ: Queries", context.userPayload.userRecord);
247
262
 
248
263
  // Destructure out non-database fields, keep only fields to persist
249
- const { Permissions: _permissions, CategoryPath: _categoryPath, ...fieldsToSet } = {
264
+ const { Permissions: _permissions, CategoryPath: _categoryPath, ParameterHints: _parameterHints, ...fieldsToSet } = {
250
265
  ...input,
251
266
  CategoryID: finalCategoryID || input.CategoryID,
252
267
  Status: input.Status || 'Approved',
@@ -259,6 +274,12 @@ export class MJQueryResolverExtended extends MJQueryResolver {
259
274
  };
260
275
 
261
276
  record.SetMany(fieldsToSet, true);
277
+
278
+ // Pass caller-provided parameter sample values to override LLM guesses
279
+ if (input.ParameterHints && input.ParameterHints.length > 0) {
280
+ record.ParameterHints = new Map(input.ParameterHints.map(h => [h.Name, h.Value]));
281
+ }
282
+
262
283
  this.ListenForEntityMessages(record, pubSub, context.userPayload.userRecord);
263
284
 
264
285
  // Attempt to save the query
@@ -480,6 +501,11 @@ export class MJQueryResolverExtended extends MJQueryResolver {
480
501
  // Use SetMany to update all fields at once
481
502
  queryEntity.SetMany(updateFields);
482
503
 
504
+ // Pass caller-provided parameter sample values to override LLM guesses
505
+ if (input.ParameterHints && input.ParameterHints.length > 0) {
506
+ queryEntity.ParameterHints = new Map(input.ParameterHints.map(h => [h.Name, h.Value]));
507
+ }
508
+
483
509
  // Save the updated query
484
510
  const saveResult = await queryEntity.Save();
485
511
  if (!saveResult) {