@memberjunction/server 5.33.0 → 5.34.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.
- 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/generated/generated.d.ts +922 -376
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +5669 -2512
- 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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2
- 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/QuerySystemUserResolver.d.ts +18 -0
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +68 -6
- 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/agents/skip-agent.ts +18 -4
- package/src/generated/generated.ts +4376 -2227
- package/src/generic/ResolverBase.ts +11 -2
- package/src/generic/RunViewResolver.ts +29 -2
- package/src/index.ts +17 -2
- package/src/resolvers/AvailableSearchProvidersResolver.ts +43 -0
- package/src/resolvers/ComponentRegistryResolver.ts +71 -123
- package/src/resolvers/QuerySystemUserResolver.ts +68 -6
- 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
|
@@ -6,7 +6,7 @@ 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
8
|
import { resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
|
|
9
|
-
import { MJGlobal, MJEventType, UUIDsEqual } from '@memberjunction/global';
|
|
9
|
+
import { MJGlobal, MJEventType, UUIDsEqual, ShutdownRegistry } from '@memberjunction/global';
|
|
10
10
|
import { setupSQLServerClient, SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
11
11
|
import { extendConnectionPoolWithQuery } from './util.js';
|
|
12
12
|
import { default as BodyParser } from 'body-parser';
|
|
@@ -101,6 +101,8 @@ export * from './resolvers/RunAIPromptResolver.js';
|
|
|
101
101
|
export * from './resolvers/RunAIAgentResolver.js';
|
|
102
102
|
export * from './resolvers/VectorizeEntityResolver.js';
|
|
103
103
|
export * from './resolvers/SearchKnowledgeResolver.js';
|
|
104
|
+
export * from './resolvers/SearchKnowledgeStreamResolver.js';
|
|
105
|
+
export * from './resolvers/AvailableSearchProvidersResolver.js';
|
|
104
106
|
export * from './resolvers/FetchEntityVectorsResolver.js';
|
|
105
107
|
export * from './resolvers/PipelineProgressResolver.js';
|
|
106
108
|
export * from './resolvers/ClientToolRequestResolver.js';
|
|
@@ -422,7 +424,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
422
424
|
url: process.env.REDIS_URL,
|
|
423
425
|
keyPrefix: process.env.REDIS_KEY_PREFIX || 'mj',
|
|
424
426
|
enablePubSub: true,
|
|
425
|
-
enableLogging:
|
|
427
|
+
enableLogging: configInfo.cacheSettings?.verboseLogging ?? false,
|
|
426
428
|
});
|
|
427
429
|
(Metadata.Provider as GenericDatabaseProvider).SetLocalStorageProvider(redisProvider); // global-provider-ok: bootstrap (Redis cache wiring)
|
|
428
430
|
await redisProvider.StartListening();
|
|
@@ -906,6 +908,19 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
906
908
|
}
|
|
907
909
|
}
|
|
908
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
|
+
|
|
909
924
|
// Close server
|
|
910
925
|
httpServer.close(() => {
|
|
911
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
|
*/
|
|
@@ -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,12 @@ export class CreateQuerySystemUserInput {
|
|
|
98
107
|
|
|
99
108
|
@Field(() => [QueryPermissionInputType], { nullable: true })
|
|
100
109
|
Permissions?: QueryPermissionInputType[];
|
|
110
|
+
|
|
111
|
+
@Field(() => [QueryParameterHintInput], { nullable: true })
|
|
112
|
+
ParameterHints?: QueryParameterHintInput[];
|
|
113
|
+
|
|
114
|
+
@Field(() => Boolean, { nullable: true, defaultValue: false })
|
|
115
|
+
AutoResolveCollision?: boolean;
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
@InputType()
|
|
@@ -158,6 +173,9 @@ export class UpdateQuerySystemUserInput {
|
|
|
158
173
|
|
|
159
174
|
@Field(() => [QueryPermissionInputType], { nullable: true })
|
|
160
175
|
Permissions?: QueryPermissionInputType[];
|
|
176
|
+
|
|
177
|
+
@Field(() => [QueryParameterHintInput], { nullable: true })
|
|
178
|
+
ParameterHints?: QueryParameterHintInput[];
|
|
161
179
|
}
|
|
162
180
|
|
|
163
181
|
/**
|
|
@@ -235,18 +253,24 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
235
253
|
const existingQuery = await this.findExistingQuery(provider, input.Name, finalCategoryID, context.userPayload.userRecord);
|
|
236
254
|
|
|
237
255
|
if (existingQuery) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
256
|
+
if (input.AutoResolveCollision) {
|
|
257
|
+
// Server-side collision resolution: find a unique name using sequential numeric suffixes
|
|
258
|
+
input.Name = await this.findUniqueName(provider, input.Name, finalCategoryID, context.userPayload.userRecord);
|
|
259
|
+
LogStatus(`[CreateQuery] Auto-resolved name collision: "${existingQuery.Name}" -> "${input.Name}"`);
|
|
260
|
+
} else {
|
|
261
|
+
const categoryInfo = input.CategoryPath ? `category path '${input.CategoryPath}'` : `category ID '${finalCategoryID}'`;
|
|
262
|
+
return {
|
|
263
|
+
Success: false,
|
|
264
|
+
ErrorMessage: `Query with name '${input.Name}' already exists in ${categoryInfo}`
|
|
265
|
+
};
|
|
266
|
+
}
|
|
243
267
|
}
|
|
244
268
|
|
|
245
269
|
// Use MJQueryEntityServer which handles AI processing
|
|
246
270
|
const record = await provider.GetEntityObject<MJQueryEntityServer>("MJ: Queries", context.userPayload.userRecord);
|
|
247
271
|
|
|
248
272
|
// Destructure out non-database fields, keep only fields to persist
|
|
249
|
-
const { Permissions: _permissions, CategoryPath: _categoryPath, ...fieldsToSet } = {
|
|
273
|
+
const { Permissions: _permissions, CategoryPath: _categoryPath, ParameterHints: _parameterHints, AutoResolveCollision: _autoResolve, ...fieldsToSet } = {
|
|
250
274
|
...input,
|
|
251
275
|
CategoryID: finalCategoryID || input.CategoryID,
|
|
252
276
|
Status: input.Status || 'Approved',
|
|
@@ -259,6 +283,12 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
259
283
|
};
|
|
260
284
|
|
|
261
285
|
record.SetMany(fieldsToSet, true);
|
|
286
|
+
|
|
287
|
+
// Pass caller-provided parameter sample values to override LLM guesses
|
|
288
|
+
if (input.ParameterHints && input.ParameterHints.length > 0) {
|
|
289
|
+
record.ParameterHints = new Map(input.ParameterHints.map(h => [h.Name, h.Value]));
|
|
290
|
+
}
|
|
291
|
+
|
|
262
292
|
this.ListenForEntityMessages(record, pubSub, context.userPayload.userRecord);
|
|
263
293
|
|
|
264
294
|
// Attempt to save the query
|
|
@@ -480,6 +510,11 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
480
510
|
// Use SetMany to update all fields at once
|
|
481
511
|
queryEntity.SetMany(updateFields);
|
|
482
512
|
|
|
513
|
+
// Pass caller-provided parameter sample values to override LLM guesses
|
|
514
|
+
if (input.ParameterHints && input.ParameterHints.length > 0) {
|
|
515
|
+
queryEntity.ParameterHints = new Map(input.ParameterHints.map(h => [h.Name, h.Value]));
|
|
516
|
+
}
|
|
517
|
+
|
|
483
518
|
// Save the updated query
|
|
484
519
|
const saveResult = await queryEntity.Save();
|
|
485
520
|
if (!saveResult) {
|
|
@@ -708,6 +743,33 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
708
743
|
}
|
|
709
744
|
}
|
|
710
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Finds a unique query name by appending sequential numeric suffixes.
|
|
748
|
+
* Format: "Name (1)", "Name (2)", etc.
|
|
749
|
+
* Uses direct DB queries (not cache) for authoritative uniqueness checks.
|
|
750
|
+
* @param provider - Database provider for direct DB queries
|
|
751
|
+
* @param baseName - The original desired name that is already taken
|
|
752
|
+
* @param categoryID - Category ID to check uniqueness within
|
|
753
|
+
* @param contextUser - User context for database operations
|
|
754
|
+
* @returns A unique name in the format "baseName (N)"
|
|
755
|
+
*/
|
|
756
|
+
private async findUniqueName(
|
|
757
|
+
provider: DatabaseProviderBase,
|
|
758
|
+
baseName: string,
|
|
759
|
+
categoryID: string | null,
|
|
760
|
+
contextUser: UserInfo
|
|
761
|
+
): Promise<string> {
|
|
762
|
+
const MAX_ATTEMPTS = 50;
|
|
763
|
+
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
|
|
764
|
+
const candidateName = `${baseName} (${i})`;
|
|
765
|
+
const existing = await this.findExistingQuery(provider, candidateName, categoryID, contextUser);
|
|
766
|
+
if (!existing) {
|
|
767
|
+
return candidateName;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
throw new Error(`Unable to find unique name for "${baseName}" after ${MAX_ATTEMPTS} attempts`);
|
|
771
|
+
}
|
|
772
|
+
|
|
711
773
|
/**
|
|
712
774
|
* Finds a category by name and parent ID using RunView.
|
|
713
775
|
* Bypasses metadata cache to ensure we get the latest data from database.
|