@memberjunction/server 5.38.0 → 5.40.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 +67 -0
- package/dist/apolloServer/index.d.ts +0 -8
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +61 -0
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.d.ts +58 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -0
- package/dist/auth/magicLink/MagicLinkKeys.js +99 -0
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -0
- package/dist/auth/magicLink/MagicLinkRouter.d.ts +33 -0
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -0
- package/dist/auth/magicLink/MagicLinkRouter.js +184 -0
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -0
- package/dist/auth/magicLink/MagicLinkService.d.ts +123 -0
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -0
- package/dist/auth/magicLink/MagicLinkService.js +605 -0
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -0
- package/dist/auth/magicLink/index.d.ts +6 -0
- package/dist/auth/magicLink/index.d.ts.map +1 -0
- package/dist/auth/magicLink/index.js +6 -0
- package/dist/auth/magicLink/index.js.map +1 -0
- package/dist/auth/magicLink/magicLinkCore.d.ts +82 -0
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -0
- package/dist/auth/magicLink/magicLinkCore.js +164 -0
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -0
- package/dist/auth/magicLink/redeemLanding.d.ts +22 -0
- package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -0
- package/dist/auth/magicLink/redeemLanding.js +61 -0
- package/dist/auth/magicLink/redeemLanding.js.map +1 -0
- package/dist/auth/magicLink/types.d.ts +131 -0
- package/dist/auth/magicLink/types.d.ts.map +1 -0
- package/dist/auth/magicLink/types.js +6 -0
- package/dist/auth/magicLink/types.js.map +1 -0
- package/dist/config.d.ts +479 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +84 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +223 -19
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +952 -4
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +25663 -20321
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
- package/dist/generic/FireAndForgetHeartbeat.js +44 -0
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +35 -7
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +145 -2
- package/dist/index.js.map +1 -1
- package/dist/logging/NoLog.d.ts +50 -0
- package/dist/logging/NoLog.d.ts.map +1 -0
- package/dist/logging/NoLog.js +80 -0
- package/dist/logging/NoLog.js.map +1 -0
- package/dist/logging/bootAudit.d.ts +43 -0
- package/dist/logging/bootAudit.d.ts.map +1 -0
- package/dist/logging/bootAudit.js +83 -0
- package/dist/logging/bootAudit.js.map +1 -0
- package/dist/logging/boundaryLogPayload.d.ts +18 -0
- package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
- package/dist/logging/boundaryLogPayload.js +18 -0
- package/dist/logging/boundaryLogPayload.js.map +1 -0
- package/dist/logging/secretRedactor.d.ts +23 -0
- package/dist/logging/secretRedactor.d.ts.map +1 -0
- package/dist/logging/secretRedactor.js +53 -0
- package/dist/logging/secretRedactor.js.map +1 -0
- package/dist/logging/shortenForLog.d.ts +8 -0
- package/dist/logging/shortenForLog.d.ts.map +1 -0
- package/dist/logging/shortenForLog.js +21 -0
- package/dist/logging/shortenForLog.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.js +127 -0
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
- package/dist/resolvers/CurrentUserContextResolver.d.ts +9 -3
- package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -1
- package/dist/resolvers/CurrentUserContextResolver.js +19 -5
- package/dist/resolvers/CurrentUserContextResolver.js.map +1 -1
- package/dist/resolvers/EntityResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityResolver.js +13 -2
- package/dist/resolvers/EntityResolver.js.map +1 -1
- package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts +28 -0
- package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts.map +1 -0
- package/dist/resolvers/GenerateSeedTaxonomyResolver.js +100 -0
- package/dist/resolvers/GenerateSeedTaxonomyResolver.js.map +1 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +8 -4
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
- package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationProgressResolver.js +196 -0
- package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +19 -14
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +26 -5
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunClusterAnalysisResolver.d.ts +74 -0
- package/dist/resolvers/RunClusterAnalysisResolver.d.ts.map +1 -0
- package/dist/resolvers/RunClusterAnalysisResolver.js +243 -0
- package/dist/resolvers/RunClusterAnalysisResolver.js.map +1 -0
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +12 -2
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
- package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchEntitiesResolver.js +216 -0
- package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
- package/dist/resolvers/UserResolver.d.ts +16 -2
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +45 -2
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -1
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/rest/SignatureWebhookHandler.d.ts +19 -0
- package/dist/rest/SignatureWebhookHandler.d.ts.map +1 -0
- package/dist/rest/SignatureWebhookHandler.js +86 -0
- package/dist/rest/SignatureWebhookHandler.js.map +1 -0
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +14 -2
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +79 -74
- package/src/__tests__/NoLog.test.ts +76 -0
- package/src/__tests__/bootAudit.test.ts +188 -0
- package/src/__tests__/boundaryLogPayload.test.ts +31 -0
- package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
- package/src/__tests__/magicLink.test.ts +387 -0
- package/src/__tests__/secretRedactor.test.ts +163 -0
- package/src/__tests__/subscriptionRedaction.test.ts +217 -0
- package/src/apolloServer/index.ts +58 -0
- package/src/auth/index.ts +2 -2
- package/src/auth/magicLink/MagicLinkKeys.ts +122 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +209 -0
- package/src/auth/magicLink/MagicLinkService.ts +724 -0
- package/src/auth/magicLink/index.ts +17 -0
- package/src/auth/magicLink/magicLinkCore.ts +216 -0
- package/src/auth/magicLink/redeemLanding.ts +62 -0
- package/src/auth/magicLink/types.ts +137 -0
- package/src/config.ts +89 -0
- package/src/context.ts +249 -17
- package/src/generated/generated.ts +12528 -8866
- package/src/generic/FireAndForgetHeartbeat.ts +85 -0
- package/src/generic/ResolverBase.ts +35 -7
- package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
- package/src/index.ts +165 -2
- package/src/logging/NoLog.ts +88 -0
- package/src/logging/bootAudit.ts +117 -0
- package/src/logging/boundaryLogPayload.ts +19 -0
- package/src/logging/secretRedactor.ts +82 -0
- package/src/logging/shortenForLog.ts +17 -0
- package/src/logging/variablesLoggingMiddleware.ts +191 -0
- package/src/resolvers/CurrentUserContextResolver.ts +21 -5
- package/src/resolvers/EntityResolver.ts +17 -5
- package/src/resolvers/GenerateSeedTaxonomyResolver.ts +90 -0
- package/src/resolvers/GetDataResolver.ts +9 -5
- package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
- package/src/resolvers/IntegrationProgressResolver.ts +220 -0
- package/src/resolvers/MCPResolver.ts +18 -14
- package/src/resolvers/RunAIAgentResolver.ts +28 -5
- package/src/resolvers/RunClusterAnalysisResolver.ts +249 -0
- package/src/resolvers/RunTestResolver.ts +14 -2
- package/src/resolvers/SearchEntitiesResolver.ts +173 -0
- package/src/resolvers/UserResolver.ts +38 -2
- package/src/resolvers/VectorizeEntityResolver.ts +14 -1
- package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
- package/src/rest/SignatureWebhookHandler.ts +103 -0
- package/src/services/ScheduledJobsService.ts +15 -2
|
@@ -16,6 +16,7 @@ import { LogError, LogStatus, UserInfo } from '@memberjunction/core';
|
|
|
16
16
|
import { TestEngine } from '@memberjunction/testing-engine';
|
|
17
17
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
18
18
|
import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
|
|
19
|
+
import { startLivenessPulse } from '../generic/FireAndForgetHeartbeat.js';
|
|
19
20
|
import { TestRunVariables, TestLogMessage, TestRunResult as EngineTestRunResult } from '@memberjunction/testing-engine-base';
|
|
20
21
|
|
|
21
22
|
// ===== GraphQL Types =====
|
|
@@ -254,12 +255,17 @@ export class RunTestResolver extends ResolverBase {
|
|
|
254
255
|
userPayload: UserPayload,
|
|
255
256
|
user: UserInfo
|
|
256
257
|
): void {
|
|
258
|
+
// Keep-alive pulse so a long-running test never trips the client idle timeout.
|
|
259
|
+
// The client captures the testRunId from progress events for reconciliation.
|
|
260
|
+
const pulse = startLivenessPulse({ pubSub, sessionId: userPayload.sessionId, resolver: 'RunTestResolver' });
|
|
261
|
+
|
|
257
262
|
this.executeTest(testId, verbose, environment, tags, variables, pubSub, userPayload, user)
|
|
258
263
|
.catch((error: unknown) => {
|
|
259
264
|
const errorMessage = (error instanceof Error) ? error.message : 'Unknown background test execution error';
|
|
260
265
|
LogError(`🔥 Fire-and-forget test execution failed: ${errorMessage}`, undefined, error);
|
|
261
266
|
this.publishFireAndForgetError(pubSub, userPayload, testId, errorMessage);
|
|
262
|
-
})
|
|
267
|
+
})
|
|
268
|
+
.finally(() => pulse.stop());
|
|
263
269
|
}
|
|
264
270
|
|
|
265
271
|
/**
|
|
@@ -343,6 +349,12 @@ export class RunTestResolver extends ResolverBase {
|
|
|
343
349
|
userPayload: UserPayload,
|
|
344
350
|
user: UserInfo
|
|
345
351
|
): void {
|
|
352
|
+
// Keep-alive pulse so a long-running suite never trips the client idle timeout.
|
|
353
|
+
// (Suite reconciliation isn't wired: suite progress carries the per-test run id, not the
|
|
354
|
+
// Test Suite Run id, so the client has no handle to reconcile against — the pulse is the
|
|
355
|
+
// protection here.)
|
|
356
|
+
const pulse = startLivenessPulse({ pubSub, sessionId: userPayload.sessionId, resolver: 'RunTestResolver' });
|
|
357
|
+
|
|
346
358
|
this.executeSuite(
|
|
347
359
|
suiteId, verbose, environment, parallel, tags, variables,
|
|
348
360
|
selectedTestIds, sequenceStart, sequenceEnd, pubSub, userPayload, user
|
|
@@ -350,7 +362,7 @@ export class RunTestResolver extends ResolverBase {
|
|
|
350
362
|
const errorMessage = (error instanceof Error) ? error.message : 'Unknown background suite execution error';
|
|
351
363
|
LogError(`🔥 Fire-and-forget suite execution failed: ${errorMessage}`, undefined, error);
|
|
352
364
|
this.publishFireAndForgetSuiteError(pubSub, userPayload, suiteId, errorMessage);
|
|
353
|
-
});
|
|
365
|
+
}).finally(() => pulse.stop());
|
|
354
366
|
}
|
|
355
367
|
|
|
356
368
|
// ===== Result Building =====
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Arg, Ctx, Field, Float, InputType, Int, ObjectType, Query, Resolver } from 'type-graphql';
|
|
2
|
+
import { EntitySearchResult, SearchEntityParams } from '@memberjunction/core';
|
|
3
|
+
import { AppContext } from '../types.js';
|
|
4
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
5
|
+
import { GetReadOnlyProvider } from '../util.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GraphQL surface for {@link Metadata.Provider.SearchEntity} / `SearchEntities`.
|
|
9
|
+
*
|
|
10
|
+
* Both client forms ({@link GraphQLDataProvider.SearchEntity} and `SearchEntities`)
|
|
11
|
+
* proxy through this single batched resolver — one HTTP round-trip carries N
|
|
12
|
+
* per-entity searches in both directions. The actual ranking (lexical +
|
|
13
|
+
* semantic + RRF blend + permission filter) runs server-side via
|
|
14
|
+
* `GenericDatabaseProvider.SearchEntity`, fanned out concurrently across
|
|
15
|
+
* the input list.
|
|
16
|
+
*
|
|
17
|
+
* Result groups are aligned by input order so the client can map them back
|
|
18
|
+
* to the original `params[i]` slot without needing the entity name.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
@InputType()
|
|
22
|
+
export class SearchEntityInput {
|
|
23
|
+
@Field(() => String)
|
|
24
|
+
declare EntityName: string;
|
|
25
|
+
|
|
26
|
+
@Field(() => String)
|
|
27
|
+
declare SearchText: string;
|
|
28
|
+
|
|
29
|
+
@Field(() => String, { nullable: true })
|
|
30
|
+
Mode?: 'lexical' | 'semantic' | 'hybrid';
|
|
31
|
+
|
|
32
|
+
@Field(() => Int, { nullable: true })
|
|
33
|
+
RrfK?: number;
|
|
34
|
+
|
|
35
|
+
@Field(() => Float, { nullable: true })
|
|
36
|
+
LexicalWeight?: number;
|
|
37
|
+
|
|
38
|
+
@Field(() => Float, { nullable: true })
|
|
39
|
+
SemanticWeight?: number;
|
|
40
|
+
|
|
41
|
+
@Field(() => Int, { nullable: true })
|
|
42
|
+
TopK?: number;
|
|
43
|
+
|
|
44
|
+
@Field(() => Float, { nullable: true })
|
|
45
|
+
MinScore?: number;
|
|
46
|
+
|
|
47
|
+
@Field(() => String, { nullable: true })
|
|
48
|
+
EntityDocumentID?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@ObjectType()
|
|
52
|
+
export class EntitySearchResultType {
|
|
53
|
+
@Field(() => String, { nullable: true })
|
|
54
|
+
declare EntityRecordDocumentID: string | null;
|
|
55
|
+
|
|
56
|
+
@Field(() => String)
|
|
57
|
+
declare RecordID: string;
|
|
58
|
+
|
|
59
|
+
@Field(() => Float)
|
|
60
|
+
declare Score: number;
|
|
61
|
+
|
|
62
|
+
@Field(() => String)
|
|
63
|
+
declare MatchType: 'lexical' | 'semantic' | 'hybrid';
|
|
64
|
+
|
|
65
|
+
@Field(() => Float, { nullable: true })
|
|
66
|
+
LexicalScore?: number | null;
|
|
67
|
+
|
|
68
|
+
@Field(() => Float, { nullable: true })
|
|
69
|
+
SemanticScore?: number | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@ObjectType()
|
|
73
|
+
export class EntitySearchResultGroupType {
|
|
74
|
+
@Field(() => String)
|
|
75
|
+
declare EntityName: string;
|
|
76
|
+
|
|
77
|
+
@Field(() => [EntitySearchResultType])
|
|
78
|
+
declare Results: EntitySearchResultType[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@ObjectType()
|
|
82
|
+
export class SearchEntitiesResponseType {
|
|
83
|
+
@Field(() => Boolean)
|
|
84
|
+
declare Success: boolean;
|
|
85
|
+
|
|
86
|
+
@Field(() => String, { nullable: true })
|
|
87
|
+
ErrorMessage?: string;
|
|
88
|
+
|
|
89
|
+
@Field(() => [EntitySearchResultGroupType])
|
|
90
|
+
declare Groups: EntitySearchResultGroupType[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Per-request cap on how many entities a single `SearchEntities` call may
|
|
95
|
+
* search. Each entity in the batch triggers 2–4 RunViews server-side; an
|
|
96
|
+
* uncapped batch from a malicious or buggy client could fan out into
|
|
97
|
+
* thousands of concurrent DB queries. 20 covers every realistic use case
|
|
98
|
+
* (agent prompt seeding, multi-entity navbar search) with margin to spare.
|
|
99
|
+
*/
|
|
100
|
+
const MAX_SEARCH_ENTITIES_BATCH_SIZE = 20;
|
|
101
|
+
|
|
102
|
+
@Resolver(SearchEntitiesResponseType)
|
|
103
|
+
export class SearchEntitiesResolver {
|
|
104
|
+
@Query(() => SearchEntitiesResponseType)
|
|
105
|
+
async SearchEntities(
|
|
106
|
+
@Ctx() { providers, userPayload }: AppContext,
|
|
107
|
+
@Arg('params', () => [SearchEntityInput]) params: SearchEntityInput[]
|
|
108
|
+
): Promise<SearchEntitiesResponseType> {
|
|
109
|
+
try {
|
|
110
|
+
if (params.length > MAX_SEARCH_ENTITIES_BATCH_SIZE) {
|
|
111
|
+
return {
|
|
112
|
+
Success: false,
|
|
113
|
+
ErrorMessage: `Batch size ${params.length} exceeds the per-request cap of ${MAX_SEARCH_ENTITIES_BATCH_SIZE}. Split the request.`,
|
|
114
|
+
Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const md = GetReadOnlyProvider(providers);
|
|
118
|
+
const user = UserCache.Instance.Users.find(
|
|
119
|
+
(u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase()
|
|
120
|
+
);
|
|
121
|
+
if (!user) {
|
|
122
|
+
return {
|
|
123
|
+
Success: false,
|
|
124
|
+
ErrorMessage: `User ${userPayload.email} not found`,
|
|
125
|
+
Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const callParams: SearchEntityParams[] = params.map(p => ({
|
|
130
|
+
entityName: p.EntityName,
|
|
131
|
+
searchText: p.SearchText,
|
|
132
|
+
options: {
|
|
133
|
+
mode: p.Mode ?? 'hybrid',
|
|
134
|
+
rrfK: p.RrfK ?? undefined,
|
|
135
|
+
weights: {
|
|
136
|
+
lexical: p.LexicalWeight ?? undefined,
|
|
137
|
+
semantic: p.SemanticWeight ?? undefined,
|
|
138
|
+
},
|
|
139
|
+
topK: p.TopK ?? undefined,
|
|
140
|
+
minScore: p.MinScore ?? undefined,
|
|
141
|
+
entityDocumentId: p.EntityDocumentID ?? undefined,
|
|
142
|
+
contextUser: user,
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// Server-side provider fans the batch out via Promise.all under the
|
|
147
|
+
// hood — see ProviderBase.SearchEntities. Results arrive aligned by
|
|
148
|
+
// input order; we just map each group's records to the GraphQL shape.
|
|
149
|
+
const groupedResults: EntitySearchResult[][] = await md.SearchEntities(callParams);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
Success: true,
|
|
153
|
+
Groups: groupedResults.map((results, i) => ({
|
|
154
|
+
EntityName: params[i].EntityName,
|
|
155
|
+
Results: results.map(r => ({
|
|
156
|
+
EntityRecordDocumentID: r.entityRecordDocumentId,
|
|
157
|
+
RecordID: r.recordId,
|
|
158
|
+
Score: r.score,
|
|
159
|
+
MatchType: r.matchType,
|
|
160
|
+
LexicalScore: r.components.lexical ?? null,
|
|
161
|
+
SemanticScore: r.components.semantic ?? null,
|
|
162
|
+
})),
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return {
|
|
167
|
+
Success: false,
|
|
168
|
+
ErrorMessage: e instanceof Error ? e.message : String(e),
|
|
169
|
+
Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -1,9 +1,45 @@
|
|
|
1
|
-
import { AppContext, Arg, Ctx, Int, Query, Resolver } from '@memberjunction/server';
|
|
2
|
-
import {
|
|
1
|
+
import { AppContext, Arg, Ctx, FieldResolver, Int, PubSub, PubSubEngine, Query, Resolver, Root } from '@memberjunction/server';
|
|
2
|
+
import { UUIDsEqual } from '@memberjunction/global';
|
|
3
|
+
import { MJUser_, MJUserRole_, MJUserResolverBase } from '../generated/generated.js';
|
|
3
4
|
import { GetReadOnlyProvider } from '../util.js';
|
|
4
5
|
|
|
5
6
|
@Resolver(MJUser_)
|
|
6
7
|
export class UserResolver extends MJUserResolverBase {
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Roles for the current user row.
|
|
11
|
+
*
|
|
12
|
+
* Overrides the generated field resolver to support ANONYMOUS magic-link sessions: those
|
|
13
|
+
* resolve to a shared Anonymous principal that holds NO roles in the database (by design —
|
|
14
|
+
* persisting them would let anonymous sessions accrete privileges across links). The roles
|
|
15
|
+
* are synthesized per-request in {@link buildMagicLinkSessionUser} and live only on the
|
|
16
|
+
* request's `UserInfo`. For the session's OWN user row we serve those synthesized roles
|
|
17
|
+
* directly, skipping the generated DB query (which returns empty) and its `MJ: User Roles`
|
|
18
|
+
* read-permission check (which the restricted anon role wouldn't pass). Every other case —
|
|
19
|
+
* named users, named magic-link users, listing other users' roles — falls through to the
|
|
20
|
+
* generated resolver unchanged, so there is no behavioral or security change off this path.
|
|
21
|
+
*/
|
|
22
|
+
@FieldResolver(() => [MJUserRole_])
|
|
23
|
+
async MJUserRoles_UserIDArray(@Root() mjuser_: MJUser_, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
|
|
24
|
+
const sessionUser = context.userPayload?.userRecord;
|
|
25
|
+
if (sessionUser?.IsMagicLinkAnonymous && UUIDsEqual(mjuser_.ID, sessionUser.ID)) {
|
|
26
|
+
const now = new Date();
|
|
27
|
+
// Build rows with the entity's RAW field names, then run them through the same
|
|
28
|
+
// name→code-name mapping the generated resolver uses (e.g. __mj_CreatedAt →
|
|
29
|
+
// _mj__CreatedAt). Returning raw names directly trips GraphQL's non-nullable check.
|
|
30
|
+
const rows = (sessionUser.UserRoles ?? []).map((r: { UserID: string; RoleID: string; RoleName?: string; Role?: string }) => ({
|
|
31
|
+
ID: sessionUser.ID,
|
|
32
|
+
UserID: r.UserID,
|
|
33
|
+
RoleID: r.RoleID,
|
|
34
|
+
User: sessionUser.Name,
|
|
35
|
+
Role: r.RoleName ?? r.Role,
|
|
36
|
+
__mj_CreatedAt: now,
|
|
37
|
+
__mj_UpdatedAt: now,
|
|
38
|
+
}));
|
|
39
|
+
return this.ArrayMapFieldNamesToCodeNames('MJ: User Roles', rows, sessionUser);
|
|
40
|
+
}
|
|
41
|
+
return super.MJUserRoles_UserIDArray(mjuser_, context, pubSub);
|
|
42
|
+
}
|
|
7
43
|
@Query(() => MJUser_)
|
|
8
44
|
async CurrentUser(@Ctx() context: AppContext) {
|
|
9
45
|
// Check API key scope authorization for user read (self)
|
|
@@ -90,7 +90,13 @@ export class VectorizeEntityResolver extends ResolverBase {
|
|
|
90
90
|
};
|
|
91
91
|
|
|
92
92
|
const result = await syncer.VectorizeEntity(params, currentUser);
|
|
93
|
-
|
|
93
|
+
if (result.success) {
|
|
94
|
+
LogStatus(`VectorizeEntity pipeline ${pipelineRunID} complete: success=true`);
|
|
95
|
+
} else {
|
|
96
|
+
// The run finished but some/all records failed to render or upsert. Surface it
|
|
97
|
+
// loudly instead of logging a bare "success=false" that reads like a clean finish.
|
|
98
|
+
LogError(`VectorizeEntity pipeline ${pipelineRunID} completed with errors (status=${result.status}): ${result.errorMessage}`);
|
|
99
|
+
}
|
|
94
100
|
} catch (error) {
|
|
95
101
|
const msg = error instanceof Error ? error.message : String(error);
|
|
96
102
|
LogError(`VectorizeEntity pipeline ${pipelineRunID} failed: ${msg}`);
|
|
@@ -110,11 +116,18 @@ export class VectorizeEntityResolver extends ResolverBase {
|
|
|
110
116
|
* Publish a progress update to the PipelineProgress subscription topic.
|
|
111
117
|
*/
|
|
112
118
|
private publishProgress(pipelineRunID: string, update: VectorizeProgressUpdate): void {
|
|
119
|
+
// Carry a short error summary to the client via CurrentItem when the update reports failures,
|
|
120
|
+
// so the subscription reflects that the run didn't cleanly succeed.
|
|
121
|
+
const errorSummary = update.Errors && update.Errors.length > 0
|
|
122
|
+
? `${update.Errors.length} record(s) failed — e.g. ${update.Errors[0].Message}`
|
|
123
|
+
: undefined;
|
|
124
|
+
|
|
113
125
|
const notification: PipelineProgressNotification = {
|
|
114
126
|
PipelineRunID: pipelineRunID,
|
|
115
127
|
Stage: update.Stage,
|
|
116
128
|
TotalItems: update.TotalRecords,
|
|
117
129
|
ProcessedItems: update.ProcessedRecords,
|
|
130
|
+
CurrentItem: errorSummary,
|
|
118
131
|
ElapsedMs: update.ElapsedMs,
|
|
119
132
|
PercentComplete: update.PercentComplete,
|
|
120
133
|
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// type-graphql decorators (`@Field`, `@ObjectType`) call `Reflect.getMetadata`,
|
|
2
|
+
// which only exists once this polyfill is loaded. Vitest doesn't bring it in
|
|
3
|
+
// automatically — it MUST come before any import that pulls in the resolver file.
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
7
|
+
|
|
8
|
+
// Mock the PubSubManager singleton so PublishIntegrationProgress never touches a
|
|
9
|
+
// real PubSubEngine (no network, no resolver-build dependency).
|
|
10
|
+
const publishSpy = vi.fn();
|
|
11
|
+
vi.mock('../../generic/PubSubManager.js', () => ({
|
|
12
|
+
PubSubManager: {
|
|
13
|
+
Instance: {
|
|
14
|
+
Publish: (...args: unknown[]) => publishSpy(...args),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// Silence LogError so a deliberately-failing serialization test doesn't spam output.
|
|
20
|
+
vi.mock('@memberjunction/core', () => ({
|
|
21
|
+
LogError: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
INTEGRATION_PROGRESS_TOPIC,
|
|
26
|
+
IntegrationProgressTopicForKind,
|
|
27
|
+
BuildIntegrationProgressNotification,
|
|
28
|
+
MatchesIntegrationSubscription,
|
|
29
|
+
PublishIntegrationProgress,
|
|
30
|
+
type IntegrationProgressNotification,
|
|
31
|
+
type PublishIntegrationProgressArgs,
|
|
32
|
+
} from '../IntegrationProgressResolver';
|
|
33
|
+
|
|
34
|
+
const baseArgs = (overrides: Partial<PublishIntegrationProgressArgs> = {}): PublishIntegrationProgressArgs => ({
|
|
35
|
+
RunID: 'run-1',
|
|
36
|
+
Kind: 'SyncRun',
|
|
37
|
+
EventType: 'stage.start',
|
|
38
|
+
Seq: 1,
|
|
39
|
+
CompanyIntegrationID: 'ci-1',
|
|
40
|
+
Message: 'starting',
|
|
41
|
+
Stage: 'extract',
|
|
42
|
+
Level: 'info',
|
|
43
|
+
Data: { foo: 'bar' },
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const baseNotification = (overrides: Partial<IntegrationProgressNotification> = {}): IntegrationProgressNotification => ({
|
|
48
|
+
RunID: 'run-1',
|
|
49
|
+
CompanyIntegrationID: 'ci-1',
|
|
50
|
+
Kind: 'SyncRun',
|
|
51
|
+
Topic: 'Sync',
|
|
52
|
+
EventType: 'stage.start',
|
|
53
|
+
Seq: 1,
|
|
54
|
+
Message: 'starting',
|
|
55
|
+
Stage: 'extract',
|
|
56
|
+
Level: 'info',
|
|
57
|
+
DataJSON: '{"foo":"bar"}',
|
|
58
|
+
...overrides,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('IntegrationProgressTopicForKind', () => {
|
|
62
|
+
it('maps each run kind to its logical channel', () => {
|
|
63
|
+
expect(IntegrationProgressTopicForKind('Discovery')).toBe('MetadataRefresh');
|
|
64
|
+
expect(IntegrationProgressTopicForKind('Enrichment')).toBe('MetadataRefresh');
|
|
65
|
+
expect(IntegrationProgressTopicForKind('ConnectorCreation')).toBe('ConnectorCreation');
|
|
66
|
+
expect(IntegrationProgressTopicForKind('SyncRun')).toBe('Sync');
|
|
67
|
+
expect(IntegrationProgressTopicForKind('RSU')).toBe('RSU');
|
|
68
|
+
expect(IntegrationProgressTopicForKind('TableCreation')).toBe('RSU');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns undefined for kinds without a dedicated channel', () => {
|
|
72
|
+
expect(IntegrationProgressTopicForKind('Other')).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('BuildIntegrationProgressNotification', () => {
|
|
77
|
+
it('builds the full notification shape with derived topic and serialized data', () => {
|
|
78
|
+
const n = BuildIntegrationProgressNotification(baseArgs());
|
|
79
|
+
expect(n).toEqual(baseNotification());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('derives ConnectorCreation topic from ConnectorCreation kind', () => {
|
|
83
|
+
const n = BuildIntegrationProgressNotification(baseArgs({ Kind: 'ConnectorCreation' }));
|
|
84
|
+
expect(n.Kind).toBe('ConnectorCreation');
|
|
85
|
+
expect(n.Topic).toBe('ConnectorCreation');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('leaves DataJSON undefined when no data is supplied', () => {
|
|
89
|
+
const n = BuildIntegrationProgressNotification(baseArgs({ Data: undefined }));
|
|
90
|
+
expect(n.DataJSON).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('swallows un-serializable data and leaves DataJSON undefined', () => {
|
|
94
|
+
const cyclic: Record<string, unknown> = {};
|
|
95
|
+
cyclic.self = cyclic;
|
|
96
|
+
const n = BuildIntegrationProgressNotification(baseArgs({ Data: cyclic }));
|
|
97
|
+
expect(n.DataJSON).toBeUndefined();
|
|
98
|
+
// The rest of the payload is unaffected.
|
|
99
|
+
expect(n.RunID).toBe('run-1');
|
|
100
|
+
expect(n.Topic).toBe('Sync');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('omits Topic for an Other-kind run', () => {
|
|
104
|
+
const n = BuildIntegrationProgressNotification(baseArgs({ Kind: 'Other' }));
|
|
105
|
+
expect(n.Topic).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('MatchesIntegrationSubscription', () => {
|
|
110
|
+
it('matches everything when no args are supplied', () => {
|
|
111
|
+
expect(MatchesIntegrationSubscription(baseNotification(), {})).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('filters by runID', () => {
|
|
115
|
+
const n = baseNotification();
|
|
116
|
+
expect(MatchesIntegrationSubscription(n, { runID: 'run-1' })).toBe(true);
|
|
117
|
+
expect(MatchesIntegrationSubscription(n, { runID: 'run-2' })).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('filters by companyIntegrationID', () => {
|
|
121
|
+
const n = baseNotification();
|
|
122
|
+
expect(MatchesIntegrationSubscription(n, { companyIntegrationID: 'ci-1' })).toBe(true);
|
|
123
|
+
expect(MatchesIntegrationSubscription(n, { companyIntegrationID: 'ci-2' })).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('filters by topic', () => {
|
|
127
|
+
const n = baseNotification({ Topic: 'Sync' });
|
|
128
|
+
expect(MatchesIntegrationSubscription(n, { topic: 'Sync' })).toBe(true);
|
|
129
|
+
expect(MatchesIntegrationSubscription(n, { topic: 'RSU' })).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('requires all supplied filters to match', () => {
|
|
133
|
+
const n = baseNotification();
|
|
134
|
+
expect(MatchesIntegrationSubscription(n, { runID: 'run-1', topic: 'Sync', companyIntegrationID: 'ci-1' })).toBe(true);
|
|
135
|
+
expect(MatchesIntegrationSubscription(n, { runID: 'run-1', topic: 'RSU' })).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('PublishIntegrationProgress', () => {
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
publishSpy.mockClear();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('publishes the built notification to the integration topic and returns it', () => {
|
|
145
|
+
const result = PublishIntegrationProgress(baseArgs());
|
|
146
|
+
|
|
147
|
+
expect(result).toEqual(baseNotification());
|
|
148
|
+
expect(publishSpy).toHaveBeenCalledTimes(1);
|
|
149
|
+
|
|
150
|
+
const [topic, payload] = publishSpy.mock.calls[0];
|
|
151
|
+
expect(topic).toBe(INTEGRATION_PROGRESS_TOPIC);
|
|
152
|
+
expect(payload).toEqual(baseNotification());
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('publishes a fresh object copy (not the internal notification reference)', () => {
|
|
156
|
+
const result = PublishIntegrationProgress(baseArgs());
|
|
157
|
+
const [, payload] = publishSpy.mock.calls[0];
|
|
158
|
+
// Spread copy semantics: structurally equal but a distinct instance.
|
|
159
|
+
expect(payload).not.toBe(result);
|
|
160
|
+
expect(payload).toEqual(result);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns undefined and does not throw when publishing fails', () => {
|
|
164
|
+
publishSpy.mockImplementationOnce(() => {
|
|
165
|
+
throw new Error('pubsub down');
|
|
166
|
+
});
|
|
167
|
+
const result = PublishIntegrationProgress(baseArgs());
|
|
168
|
+
expect(result).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Inbound eSignature webhook handler (DocuSign Connect, Adobe events, …).
|
|
3
|
+
*
|
|
4
|
+
* Unauthenticated by design — external signature providers call this endpoint without an MJ bearer
|
|
5
|
+
* token. Payload authenticity is verified by the provider DRIVER's `ParseWebhookEvent` (HMAC /
|
|
6
|
+
* signature), not by MJ auth. Must be registered BEFORE the unified auth middleware.
|
|
7
|
+
*
|
|
8
|
+
* Endpoint:
|
|
9
|
+
* - POST /esignature/webhook/:driverKey
|
|
10
|
+
*
|
|
11
|
+
* @module @memberjunction/server/rest/SignatureWebhookHandler
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import express from 'express';
|
|
15
|
+
import BodyParser from 'body-parser';
|
|
16
|
+
import { LogError, LogStatus, UserInfo } from '@memberjunction/core';
|
|
17
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
18
|
+
import { SignatureEngine } from '@memberjunction/esignature/server';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build an Express router exposing the eSignature webhook. Mount it unauthenticated:
|
|
22
|
+
* `app.use('/esignature', createSignatureWebhookHandler())`.
|
|
23
|
+
*/
|
|
24
|
+
export function createSignatureWebhookHandler(): express.Router {
|
|
25
|
+
const router = express.Router();
|
|
26
|
+
|
|
27
|
+
// Capture the raw body so drivers that HMAC-verify over the exact bytes can do so.
|
|
28
|
+
router.post(
|
|
29
|
+
'/webhook/:driverKey',
|
|
30
|
+
BodyParser.json({ limit: '25mb', verify: captureRawBody }),
|
|
31
|
+
async (req: express.Request, res: express.Response) => {
|
|
32
|
+
await handleWebhook(req, res);
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return router;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stash the raw request bytes on the request for signature verification by the driver. */
|
|
40
|
+
function captureRawBody(req: express.Request, _res: express.Response, buf: Buffer): void {
|
|
41
|
+
(req as RawBodyRequest).rawBody = buf;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function handleWebhook(req: express.Request, res: express.Response): Promise<void> {
|
|
45
|
+
const driverKey = req.params.driverKey;
|
|
46
|
+
if (!driverKey) {
|
|
47
|
+
res.status(400).json({ error: 'Missing driver key in path.' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const contextUser = getSystemUser();
|
|
52
|
+
if (!contextUser) {
|
|
53
|
+
LogError('[eSignature webhook] No system user available; cannot process webhook.');
|
|
54
|
+
res.status(503).json({ error: 'Server not ready to process webhooks.' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await SignatureEngine.Instance.Config(false, contextUser);
|
|
60
|
+
const headers = normalizeHeaders(req.headers);
|
|
61
|
+
const rawBody = (req as RawBodyRequest).rawBody;
|
|
62
|
+
const result = await SignatureEngine.Instance.RecordWebhookEvent(driverKey, req.body, headers, contextUser, rawBody);
|
|
63
|
+
|
|
64
|
+
if (!result.Success) {
|
|
65
|
+
// 202: we received it but couldn't act (e.g. unrecognized payload / no matching envelope).
|
|
66
|
+
// Returning 2xx prevents most providers from aggressively retrying a payload we'll never match.
|
|
67
|
+
LogStatus(`[eSignature webhook] ${driverKey}: not actioned — ${result.ErrorMessage}`);
|
|
68
|
+
res.status(202).json({ received: true, actioned: false, message: result.ErrorMessage });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
res.status(200).json({ received: true, actioned: true, status: result.event?.status });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
LogError(`[eSignature webhook] ${driverKey}: ${e instanceof Error ? e.message : String(e)}`);
|
|
75
|
+
res.status(500).json({ error: 'Failed to process webhook.' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Flatten Express header values to a simple string map for the driver. */
|
|
80
|
+
function normalizeHeaders(headers: express.Request['headers']): Record<string, string> {
|
|
81
|
+
const out: Record<string, string> = {};
|
|
82
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
out[key] = value;
|
|
85
|
+
} else if (Array.isArray(value)) {
|
|
86
|
+
out[key] = value.join(', ');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getSystemUser(): UserInfo | null {
|
|
93
|
+
try {
|
|
94
|
+
return UserCache.Instance.GetSystemUser() ?? null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Express request augmented with the captured raw body. */
|
|
101
|
+
interface RawBodyRequest extends express.Request {
|
|
102
|
+
rawBody?: Buffer;
|
|
103
|
+
}
|
|
@@ -67,7 +67,18 @@ export class ScheduledJobsService {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
try {
|
|
70
|
-
|
|
70
|
+
// Wire engine tunables from MJServer's scheduledJobs config block.
|
|
71
|
+
// Both fields have schema defaults (maxConcurrentJobs=5, defaultLockTimeout=600000ms),
|
|
72
|
+
// but apply explicitly so the values are visible in startup logs and
|
|
73
|
+
// a future config change is picked up without an engine code change.
|
|
74
|
+
if (this.config.maxConcurrentJobs != null) {
|
|
75
|
+
this.engine.MaxConcurrentJobs = this.config.maxConcurrentJobs;
|
|
76
|
+
}
|
|
77
|
+
if (this.config.defaultLockTimeout != null) {
|
|
78
|
+
this.engine.LeaseTimeoutMs = this.config.defaultLockTimeout;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await this.engine.StartPolling(this.systemUser);
|
|
71
82
|
this.isRunning = true;
|
|
72
83
|
|
|
73
84
|
// Single consolidated console message
|
|
@@ -102,7 +113,9 @@ export class ScheduledJobsService {
|
|
|
102
113
|
|
|
103
114
|
try {
|
|
104
115
|
LogStatus('[ScheduledJobsService] Stopping scheduled job polling');
|
|
105
|
-
|
|
116
|
+
// Graceful shutdown: wait for in-flight dispatched jobs to settle,
|
|
117
|
+
// bounded by 30s so a zombie can't hang the shutdown indefinitely.
|
|
118
|
+
await this.engine.StopPolling({ waitForInflight: true, maxWaitMs: 30_000 });
|
|
106
119
|
this.isRunning = false;
|
|
107
120
|
LogStatus('[ScheduledJobsService] Polling stopped successfully');
|
|
108
121
|
} catch (error) {
|