@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.
- package/README.md +2 -1
- package/dist/agents/skip-agent.d.ts +3 -1
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +15 -3
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +11 -3
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -3
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +551 -5
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3277 -120
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +9 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +5 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +33 -2
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -9
- package/dist/index.js.map +1 -1
- package/dist/resolvers/AvailableSearchProvidersResolver.d.ts +26 -0
- package/dist/resolvers/AvailableSearchProvidersResolver.d.ts.map +1 -0
- package/dist/resolvers/AvailableSearchProvidersResolver.js +65 -0
- package/dist/resolvers/AvailableSearchProvidersResolver.js.map +1 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts +11 -25
- package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
- package/dist/resolvers/ComponentRegistryResolver.js +51 -93
- package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +5 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +9 -4
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +6 -0
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +31 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +44 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +217 -7
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +79 -0
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +342 -0
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -0
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +68 -68
- package/src/__tests__/databaseAbstraction.test.ts +123 -61
- package/src/agents/skip-agent.ts +18 -4
- package/src/auth/newUsers.ts +12 -3
- package/src/context.ts +21 -3
- package/src/generated/generated.ts +2229 -80
- package/src/generic/ResolverBase.ts +11 -2
- package/src/generic/RunViewResolver.ts +29 -2
- package/src/index.ts +29 -9
- package/src/resolvers/AvailableSearchProvidersResolver.ts +43 -0
- package/src/resolvers/ComponentRegistryResolver.ts +71 -123
- package/src/resolvers/GetDataResolver.ts +6 -2
- package/src/resolvers/IntegrationDiscoveryResolver.ts +10 -4
- package/src/resolvers/QuerySystemUserResolver.ts +27 -1
- package/src/resolvers/SearchKnowledgeResolver.ts +205 -4
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +338 -0
- 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 {
|
|
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
|
|
62
|
-
*
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
258
|
+
version: resolvedVersion,
|
|
215
259
|
hash: hash,
|
|
216
260
|
userEmail: user.Email
|
|
217
261
|
});
|
|
218
|
-
|
|
219
|
-
// If not modified (304), return
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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:
|
|
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
|
-
|
|
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: `${
|
|
1771
|
-
Connections:
|
|
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) {
|