@memberjunction/server 5.37.0 → 5.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +79 -12
  2. package/dist/agents/skip-sdk.d.ts.map +1 -1
  3. package/dist/agents/skip-sdk.js +16 -12
  4. package/dist/agents/skip-sdk.js.map +1 -1
  5. package/dist/apolloServer/index.d.ts +0 -8
  6. package/dist/apolloServer/index.d.ts.map +1 -1
  7. package/dist/apolloServer/index.js +61 -0
  8. package/dist/apolloServer/index.js.map +1 -1
  9. package/dist/config.d.ts +68 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +24 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js +2 -21
  15. package/dist/context.js.map +1 -1
  16. package/dist/generated/generated.d.ts +172 -5
  17. package/dist/generated/generated.d.ts.map +1 -1
  18. package/dist/generated/generated.js +867 -14
  19. package/dist/generated/generated.js.map +1 -1
  20. package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
  21. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
  22. package/dist/generic/FireAndForgetHeartbeat.js +44 -0
  23. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +177 -16
  27. package/dist/index.js.map +1 -1
  28. package/dist/logging/NoLog.d.ts +50 -0
  29. package/dist/logging/NoLog.d.ts.map +1 -0
  30. package/dist/logging/NoLog.js +80 -0
  31. package/dist/logging/NoLog.js.map +1 -0
  32. package/dist/logging/bootAudit.d.ts +43 -0
  33. package/dist/logging/bootAudit.d.ts.map +1 -0
  34. package/dist/logging/bootAudit.js +83 -0
  35. package/dist/logging/bootAudit.js.map +1 -0
  36. package/dist/logging/boundaryLogPayload.d.ts +18 -0
  37. package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
  38. package/dist/logging/boundaryLogPayload.js +18 -0
  39. package/dist/logging/boundaryLogPayload.js.map +1 -0
  40. package/dist/logging/secretRedactor.d.ts +23 -0
  41. package/dist/logging/secretRedactor.d.ts.map +1 -0
  42. package/dist/logging/secretRedactor.js +53 -0
  43. package/dist/logging/secretRedactor.js.map +1 -0
  44. package/dist/logging/shortenForLog.d.ts +8 -0
  45. package/dist/logging/shortenForLog.d.ts.map +1 -0
  46. package/dist/logging/shortenForLog.js +21 -0
  47. package/dist/logging/shortenForLog.js.map +1 -0
  48. package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
  49. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
  50. package/dist/logging/variablesLoggingMiddleware.js +127 -0
  51. package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
  52. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  53. package/dist/resolvers/GetDataResolver.js +8 -4
  54. package/dist/resolvers/GetDataResolver.js.map +1 -1
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
  56. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
  58. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  59. package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
  60. package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
  61. package/dist/resolvers/IntegrationProgressResolver.js +196 -0
  62. package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
  63. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  64. package/dist/resolvers/MCPResolver.js +19 -14
  65. package/dist/resolvers/MCPResolver.js.map +1 -1
  66. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  67. package/dist/resolvers/QueryResolver.js +5 -4
  68. package/dist/resolvers/QueryResolver.js.map +1 -1
  69. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  70. package/dist/resolvers/QuerySystemUserResolver.js +6 -18
  71. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  72. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  73. package/dist/resolvers/RunAIAgentResolver.js +32 -64
  74. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  75. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  76. package/dist/resolvers/RunTestResolver.js +12 -2
  77. package/dist/resolvers/RunTestResolver.js.map +1 -1
  78. package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
  79. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
  80. package/dist/resolvers/SearchEntitiesResolver.js +216 -0
  81. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
  82. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  83. package/dist/resolvers/VectorizeEntityResolver.js +14 -1
  84. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  85. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  86. package/dist/services/ScheduledJobsService.js +14 -2
  87. package/dist/services/ScheduledJobsService.js.map +1 -1
  88. package/package.json +75 -74
  89. package/src/__tests__/NoLog.test.ts +76 -0
  90. package/src/__tests__/bootAudit.test.ts +188 -0
  91. package/src/__tests__/boundaryLogPayload.test.ts +31 -0
  92. package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
  93. package/src/__tests__/resolverBase.rls.test.ts +224 -0
  94. package/src/__tests__/secretRedactor.test.ts +163 -0
  95. package/src/__tests__/subscriptionRedaction.test.ts +217 -0
  96. package/src/agents/skip-sdk.ts +16 -13
  97. package/src/apolloServer/index.ts +58 -0
  98. package/src/config.ts +27 -0
  99. package/src/context.ts +2 -19
  100. package/src/generated/generated.ts +627 -15
  101. package/src/generic/FireAndForgetHeartbeat.ts +85 -0
  102. package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
  103. package/src/index.ts +199 -19
  104. package/src/logging/NoLog.ts +88 -0
  105. package/src/logging/bootAudit.ts +117 -0
  106. package/src/logging/boundaryLogPayload.ts +19 -0
  107. package/src/logging/secretRedactor.ts +82 -0
  108. package/src/logging/shortenForLog.ts +17 -0
  109. package/src/logging/variablesLoggingMiddleware.ts +191 -0
  110. package/src/resolvers/GetDataResolver.ts +9 -5
  111. package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
  112. package/src/resolvers/IntegrationProgressResolver.ts +220 -0
  113. package/src/resolvers/MCPResolver.ts +18 -14
  114. package/src/resolvers/QueryResolver.ts +7 -6
  115. package/src/resolvers/QuerySystemUserResolver.ts +11 -25
  116. package/src/resolvers/RunAIAgentResolver.ts +34 -71
  117. package/src/resolvers/RunTestResolver.ts +14 -2
  118. package/src/resolvers/SearchEntitiesResolver.ts +173 -0
  119. package/src/resolvers/VectorizeEntityResolver.ts +14 -1
  120. package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
  121. package/src/services/ScheduledJobsService.ts +15 -2
@@ -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
+ }
@@ -90,7 +90,13 @@ export class VectorizeEntityResolver extends ResolverBase {
90
90
  };
91
91
 
92
92
  const result = await syncer.VectorizeEntity(params, currentUser);
93
- LogStatus(`VectorizeEntity pipeline ${pipelineRunID} complete: success=${result.success}`);
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
+ });
@@ -67,7 +67,18 @@ export class ScheduledJobsService {
67
67
  }
68
68
 
69
69
  try {
70
- this.engine.StartPolling(this.systemUser);
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
- this.engine.StopPolling();
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) {