@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.js +69 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
- package/dist/auth/AuthProviderEngine.d.ts +96 -0
- package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
- package/dist/auth/AuthProviderEngine.js +245 -0
- package/dist/auth/AuthProviderEngine.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +56 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +64 -1
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +117 -5
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +52 -35
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +501 -1597
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2711 -17021
- 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 +7 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +31 -0
- 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 +99 -14
- package/dist/index.js.map +1 -1
- package/dist/integration/RSUProgressBridge.d.ts +83 -0
- package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
- package/dist/integration/RSUProgressBridge.js +230 -0
- package/dist/integration/RSUProgressBridge.js.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.js +50 -0
- package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
- package/dist/providerConfigUnits.d.ts +20 -0
- package/dist/providerConfigUnits.d.ts.map +1 -0
- package/dist/providerConfigUnits.js +22 -0
- package/dist/providerConfigUnits.js.map +1 -0
- package/dist/resolvers/FileResolver.d.ts +34 -4
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +218 -28
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +5 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +33 -14
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts +19 -17
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +66 -67
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/currentUserRoles.d.ts +19 -0
- package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
- package/dist/resolvers/currentUserRoles.js +36 -0
- package/dist/resolvers/currentUserRoles.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +17 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
- package/dist/rest/MediaAccessKeys.js +42 -1
- package/dist/rest/MediaAccessKeys.js.map +1 -1
- package/dist/rest/MediaStreamHandler.d.ts +3 -2
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
- package/dist/rest/MediaStreamHandler.js +83 -2
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/rest/UploadTokenManager.d.ts +90 -0
- package/dist/rest/UploadTokenManager.d.ts.map +1 -0
- package/dist/rest/UploadTokenManager.js +156 -0
- package/dist/rest/UploadTokenManager.js.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.js +132 -0
- package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
- package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
- package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +21 -1
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +23 -0
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +91 -91
- package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
- package/src/__tests__/AuthProviderEngine.test.ts +278 -0
- package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
- package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
- package/src/__tests__/RSUProgressBridge.test.ts +250 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
- package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
- package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
- package/src/__tests__/config-units.test.ts +74 -0
- package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
- package/src/__tests__/newUsers.test.ts +726 -0
- package/src/__tests__/uploadTokenManager.test.ts +159 -0
- package/src/auth/AuthProviderCatalogRouter.ts +76 -0
- package/src/auth/AuthProviderEngine.ts +253 -0
- package/src/auth/index.ts +59 -15
- package/src/auth/initializeProviders.ts +128 -7
- package/src/config.ts +55 -36
- package/src/generated/generated.ts +1911 -10551
- package/src/generic/ResolverBase.ts +11 -4
- package/src/generic/RunViewResolver.ts +31 -0
- package/src/index.ts +108 -15
- package/src/integration/RSUProgressBridge.ts +248 -0
- package/src/integration/SchemaRefreshLaunch.ts +77 -0
- package/src/providerConfigUnits.ts +21 -0
- package/src/resolvers/FileResolver.ts +200 -40
- package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
- package/src/resolvers/QueryResolver.ts +25 -11
- package/src/resolvers/QuerySystemUserResolver.ts +28 -13
- package/src/resolvers/RunAIAgentResolver.ts +21 -6
- package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
- package/src/resolvers/UserResolver.ts +99 -60
- package/src/resolvers/currentUserRoles.ts +47 -0
- package/src/rest/MediaAccessKeys.ts +47 -1
- package/src/rest/MediaStreamHandler.ts +98 -3
- package/src/rest/OAuthCallbackHandler.ts +149 -41
- package/src/rest/UploadTokenManager.ts +211 -0
- package/src/services/IntegrationSyncWorkerService.ts +141 -0
- package/src/services/StartTaskGraphDispatcher.ts +21 -1
- package/src/services/TaskGraphAgentRunner.ts +23 -0
- package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
- package/src/types.ts +6 -0
|
@@ -408,7 +408,8 @@ export class ResolverBase {
|
|
|
408
408
|
viewInput.AfterKey
|
|
409
409
|
? CompositeKey.FromKeyValuePairs((viewInput.AfterKey as { KeyValuePairs: { FieldName: string; Value: string }[] }).KeyValuePairs)
|
|
410
410
|
: undefined,
|
|
411
|
-
viewInput.BypassCache
|
|
411
|
+
viewInput.BypassCache,
|
|
412
|
+
viewInput.DataSource
|
|
412
413
|
);
|
|
413
414
|
}
|
|
414
415
|
else {
|
|
@@ -451,7 +452,8 @@ export class ResolverBase {
|
|
|
451
452
|
viewInput.StartRow,
|
|
452
453
|
viewInput.Aggregates,
|
|
453
454
|
undefined,
|
|
454
|
-
viewInput.BypassCache
|
|
455
|
+
viewInput.BypassCache,
|
|
456
|
+
viewInput.DataSource
|
|
455
457
|
);
|
|
456
458
|
} catch (err) {
|
|
457
459
|
console.log(err);
|
|
@@ -497,7 +499,8 @@ export class ResolverBase {
|
|
|
497
499
|
viewInput.StartRow,
|
|
498
500
|
viewInput.Aggregates,
|
|
499
501
|
undefined,
|
|
500
|
-
viewInput.BypassCache
|
|
502
|
+
viewInput.BypassCache,
|
|
503
|
+
viewInput.DataSource
|
|
501
504
|
);
|
|
502
505
|
} catch (err) {
|
|
503
506
|
console.log(err);
|
|
@@ -571,6 +574,7 @@ export class ResolverBase {
|
|
|
571
574
|
userPayload,
|
|
572
575
|
aggregates: viewInput.Aggregates,
|
|
573
576
|
bypassCache: viewInput.BypassCache,
|
|
577
|
+
dataSource: viewInput.DataSource,
|
|
574
578
|
});
|
|
575
579
|
} catch (err) {
|
|
576
580
|
LogError(err);
|
|
@@ -767,7 +771,8 @@ export class ResolverBase {
|
|
|
767
771
|
startRow: number | undefined,
|
|
768
772
|
aggregates?: AggregateExpression[],
|
|
769
773
|
afterKey?: CompositeKey,
|
|
770
|
-
bypassCache?: boolean
|
|
774
|
+
bypassCache?: boolean,
|
|
775
|
+
dataSource?: 'Live' | 'Materialized'
|
|
771
776
|
) {
|
|
772
777
|
try {
|
|
773
778
|
if (!viewInfo || !userPayload) return null;
|
|
@@ -837,6 +842,7 @@ export class ResolverBase {
|
|
|
837
842
|
ResultType: rt,
|
|
838
843
|
Aggregates: aggregates,
|
|
839
844
|
BypassCache: bypassCache,
|
|
845
|
+
DataSource: dataSource,
|
|
840
846
|
},
|
|
841
847
|
user
|
|
842
848
|
);
|
|
@@ -972,6 +978,7 @@ export class ResolverBase {
|
|
|
972
978
|
ResultType: rt,
|
|
973
979
|
Aggregates: param.aggregates,
|
|
974
980
|
BypassCache: param.bypassCache,
|
|
981
|
+
DataSource: param.dataSource,
|
|
975
982
|
});
|
|
976
983
|
}
|
|
977
984
|
|
|
@@ -177,6 +177,13 @@ export class RunViewByIDInput {
|
|
|
177
177
|
'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
|
|
178
178
|
})
|
|
179
179
|
BypassCache?: boolean;
|
|
180
|
+
|
|
181
|
+
@Field(() => String, {
|
|
182
|
+
nullable: true,
|
|
183
|
+
description:
|
|
184
|
+
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
185
|
+
})
|
|
186
|
+
DataSource?: 'Live' | 'Materialized';
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
@InputType()
|
|
@@ -291,6 +298,13 @@ export class RunViewByNameInput {
|
|
|
291
298
|
'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
|
|
292
299
|
})
|
|
293
300
|
BypassCache?: boolean;
|
|
301
|
+
|
|
302
|
+
@Field(() => String, {
|
|
303
|
+
nullable: true,
|
|
304
|
+
description:
|
|
305
|
+
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
306
|
+
})
|
|
307
|
+
DataSource?: 'Live' | 'Materialized';
|
|
294
308
|
}
|
|
295
309
|
|
|
296
310
|
@InputType()
|
|
@@ -391,6 +405,13 @@ export class RunDynamicViewInput {
|
|
|
391
405
|
'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
|
|
392
406
|
})
|
|
393
407
|
BypassCache?: boolean;
|
|
408
|
+
|
|
409
|
+
@Field(() => String, {
|
|
410
|
+
nullable: true,
|
|
411
|
+
description:
|
|
412
|
+
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
413
|
+
})
|
|
414
|
+
DataSource?: 'Live' | 'Materialized';
|
|
394
415
|
}
|
|
395
416
|
|
|
396
417
|
@InputType()
|
|
@@ -520,6 +541,13 @@ export class RunViewGenericInput {
|
|
|
520
541
|
'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
|
|
521
542
|
})
|
|
522
543
|
BypassCache?: boolean;
|
|
544
|
+
|
|
545
|
+
@Field(() => String, {
|
|
546
|
+
nullable: true,
|
|
547
|
+
description:
|
|
548
|
+
"Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
|
|
549
|
+
})
|
|
550
|
+
DataSource?: 'Live' | 'Materialized';
|
|
523
551
|
}
|
|
524
552
|
|
|
525
553
|
//****************************************************************************
|
|
@@ -1082,6 +1110,9 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1082
1110
|
// Forward the aggregate request to the engine (B40) — omitted here as well as in the
|
|
1083
1111
|
// client's input map, so aggregates never reached InternalRunView on this transport.
|
|
1084
1112
|
Aggregates: item.params.Aggregates,
|
|
1113
|
+
// DataSource must be forwarded on this transport too (same as the InternalRunView/RunViews maps):
|
|
1114
|
+
// otherwise a CacheLocal batch's DataSource:'Materialized' request silently reads the live view.
|
|
1115
|
+
DataSource: item.params.DataSource,
|
|
1085
1116
|
AfterKey: item.params.AfterKey
|
|
1086
1117
|
? CompositeKey.FromKeyValuePairs(item.params.AfterKey.KeyValuePairs)
|
|
1087
1118
|
: undefined,
|
package/src/index.ts
CHANGED
|
@@ -57,14 +57,17 @@ import { DataSourceInfo, raiseEvent } from './types.js';
|
|
|
57
57
|
|
|
58
58
|
import { ExternalChangeDetectorEngine } from '@memberjunction/external-change-detection';
|
|
59
59
|
import { ScheduledJobsService } from './services/ScheduledJobsService.js';
|
|
60
|
+
import { IntegrationSyncWorkerService } from './services/IntegrationSyncWorkerService.js';
|
|
60
61
|
import { LocalCacheManager, StartupManager, TelemetryManager, TelemetryLevel, LogStatus, SetVerboseLogging } from '@memberjunction/core';
|
|
61
|
-
import { getSystemUser } from './auth/index.js';
|
|
62
|
+
import { getSystemUser, validateAuthProvidersRegistered } from './auth/index.js';
|
|
63
|
+
import { createAuthProviderCatalogRouter, AUTH_CATALOG_MOUNT_PATH } from './auth/AuthProviderCatalogRouter.js';
|
|
62
64
|
import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
63
65
|
import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
|
|
64
66
|
import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
|
|
65
67
|
import { PubSubManager } from './generic/PubSubManager.js';
|
|
66
68
|
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
67
69
|
import { PublishIntegrationProgress } from './resolvers/IntegrationProgressResolver.js';
|
|
70
|
+
import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
|
|
68
71
|
import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
|
|
69
72
|
import { SessionJanitor } from './agentSessions/index.js';
|
|
70
73
|
import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
|
|
@@ -79,9 +82,12 @@ import {
|
|
|
79
82
|
MJScheduledJobEntity,
|
|
80
83
|
} from '@memberjunction/core-entities';
|
|
81
84
|
import { ServerExtensionLoader, ServerExtensionConfig } from '@memberjunction/server-extensions-core';
|
|
85
|
+
import { MetadataCacheRefreshIntervalSeconds } from './providerConfigUnits.js';
|
|
82
86
|
|
|
83
87
|
const cacheRefreshInterval = configInfo.databaseSettings.metadataCacheRefreshInterval;
|
|
84
88
|
|
|
89
|
+
export { MetadataCacheRefreshIntervalSeconds } from './providerConfigUnits.js';
|
|
90
|
+
|
|
85
91
|
/**
|
|
86
92
|
* Returns the configured database platform from the `DB_PLATFORM` environment
|
|
87
93
|
* variable, falling back to `'sqlserver'` when the env var is unset. An
|
|
@@ -100,6 +106,10 @@ export function getDbType(): DatabasePlatform {
|
|
|
100
106
|
|
|
101
107
|
export { MaxLength } from 'class-validator';
|
|
102
108
|
export * from 'type-graphql';
|
|
109
|
+
// Named re-export so Open App generated resolvers get a live ESM binding for
|
|
110
|
+
// Int/Float/ID. `export *` from type-graphql can leave these undefined for
|
|
111
|
+
// later importers, which makes schema build fail on ViewResult.RowCount.
|
|
112
|
+
export { Int, Float, ID } from 'type-graphql';
|
|
103
113
|
export { NewUserBase } from './auth/newUsers.js';
|
|
104
114
|
export { configInfo, DEFAULT_SERVER_CONFIG } from './config.js';
|
|
105
115
|
export { ServerExtensionLoader, BaseServerExtension } from '@memberjunction/server-extensions-core';
|
|
@@ -204,6 +214,7 @@ import type { RequestHandler, ErrorRequestHandler } from 'express';
|
|
|
204
214
|
import type { ApolloServerPlugin } from '@apollo/server';
|
|
205
215
|
import type { GraphQLSchema } from 'graphql';
|
|
206
216
|
import { BaseServerMiddleware } from './middleware/BaseServerMiddleware.js';
|
|
217
|
+
import { SuppressTaskGraphSubmission } from '@memberjunction/ai-core-plus';
|
|
207
218
|
|
|
208
219
|
export type MJServerOptions = {
|
|
209
220
|
onBeforeServe?: () => void | Promise<void>;
|
|
@@ -339,6 +350,10 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
339
350
|
const pgStartupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
|
|
340
351
|
await StartupManagerImport.Instance.Startup(false, sysUser || backupSysUser, provider, { mode: pgStartupMode.mode });
|
|
341
352
|
|
|
353
|
+
// Both provider sources have now had their turn — config/env at module load, metadata via
|
|
354
|
+
// AuthProviderEngine's startup hook — so "no providers at all" is finally a meaningful check.
|
|
355
|
+
validateAuthProvidersRegistered();
|
|
356
|
+
|
|
342
357
|
// Monkey-patch SQLServerDataProvider.ExecuteSQLWithPool to support PostgreSQL
|
|
343
358
|
// Generated resolvers call this static method with bracket-quoted SQL.
|
|
344
359
|
// When the pool is our PG-compat wrapper, translate and execute via pg.Pool.
|
|
@@ -489,11 +504,16 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
489
504
|
startupLog.LogIf('verbose', 'Read-only Connection Pool has been initialized.');
|
|
490
505
|
}
|
|
491
506
|
|
|
492
|
-
|
|
507
|
+
// cacheRefreshInterval is configured in ms; checkRefreshIntervalSeconds declares seconds — see providerConfigUnits.ts
|
|
508
|
+
const config = new SQLServerProviderConfigData(pool, mj_core_schema, MetadataCacheRefreshIntervalSeconds(cacheRefreshInterval));
|
|
493
509
|
// MJAPI is a long-running server, so entry-point default is 'full' engine pre-warm;
|
|
494
510
|
// MJ_STARTUP_MODE / mj.config.cjs startup.mode can override per the shared precedence chain
|
|
495
511
|
const startupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
|
|
496
512
|
await setupSQLServerClient(config, { mode: startupMode.mode });
|
|
513
|
+
|
|
514
|
+
// See the note on the PostgreSQL path above: this is the first point at which both the
|
|
515
|
+
// config/env providers and the metadata catalog have been registered.
|
|
516
|
+
validateAuthProvidersRegistered();
|
|
497
517
|
lap('Metadata + Provider Setup', tPhase);
|
|
498
518
|
startupLog.BeginPhase('Initializing data provider');
|
|
499
519
|
const md = new Metadata(); // global-provider-ok: bootstrap
|
|
@@ -524,7 +544,8 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
524
544
|
await codegenPool.connect();
|
|
525
545
|
|
|
526
546
|
const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
|
|
527
|
-
|
|
547
|
+
// Same ms→seconds seam as the main provider config above — see providerConfigUnits.ts
|
|
548
|
+
const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, MetadataCacheRefreshIntervalSeconds(cacheRefreshInterval));
|
|
528
549
|
const codegenProvider = new SQLServerDataProvider();
|
|
529
550
|
await codegenProvider.Config(codegenConfig);
|
|
530
551
|
RuntimeSchemaManager.Instance.SetDDLProvider(codegenProvider);
|
|
@@ -835,6 +856,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
835
856
|
})
|
|
836
857
|
);
|
|
837
858
|
|
|
859
|
+
// Give Runtime Schema Update runs the same durable, tailable event stream as syncs and connector
|
|
860
|
+
// builds. `IntegrationRunKind` has always had an 'RSU' kind and RUN_KIND_TO_TOPIC has always
|
|
861
|
+
// mapped it to an 'RSU' channel, but nothing published to it — the only live signal was polling
|
|
862
|
+
// RuntimeSchemaUpdateStatus, which reports the current step and keeps no history, and which goes
|
|
863
|
+
// silent entirely across the API restart the pipeline performs on itself. Registered AFTER the
|
|
864
|
+
// publish hook above so the first RSU event also reaches live subscribers.
|
|
865
|
+
RegisterRSUProgressBridge();
|
|
866
|
+
|
|
838
867
|
// Global listener: broadcast CACHE_INVALIDATION to all browser clients whenever
|
|
839
868
|
// ANY BaseEntity save/delete occurs on this server — regardless of whether it
|
|
840
869
|
// originated from a GraphQL mutation or internal server-side code (agents, actions,
|
|
@@ -1107,7 +1136,9 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1107
1136
|
const { callbackRouter, authenticatedRouter } = createOAuthCallbackHandler({
|
|
1108
1137
|
publicUrl: oauthPublicUrl,
|
|
1109
1138
|
successRedirectUrl: `${oauthPublicUrl}/oauth/success`,
|
|
1110
|
-
errorRedirectUrl: `${oauthPublicUrl}/oauth/error
|
|
1139
|
+
errorRedirectUrl: `${oauthPublicUrl}/oauth/error`,
|
|
1140
|
+
// Constrains where a caller-supplied frontendReturnUrl may point (open-redirect guard).
|
|
1141
|
+
allowedFrontendOrigins: configInfo.cors?.allowedOrigins ?? ['*']
|
|
1111
1142
|
});
|
|
1112
1143
|
oauthAuthenticatedRouter = authenticatedRouter;
|
|
1113
1144
|
|
|
@@ -1256,6 +1287,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1256
1287
|
res.status(allHealthy ? 200 : 503).json({ extensions: results });
|
|
1257
1288
|
});
|
|
1258
1289
|
|
|
1290
|
+
// ─── Public authentication-provider catalog (PUBLIC, before auth mw) ──────
|
|
1291
|
+
// The browser needs the provider list BEFORE it holds a token, so this is necessarily
|
|
1292
|
+
// unauthenticated and must mount ahead of the auth middleware. It publishes only the
|
|
1293
|
+
// non-secret allow-list (see AuthProviderEngine.GetPublicCatalog) — the same values a
|
|
1294
|
+
// single-provider SPA already compiled into its bundle.
|
|
1295
|
+
app.use(AUTH_CATALOG_MOUNT_PATH, cors<cors.CorsRequest>(), createAuthProviderCatalogRouter());
|
|
1296
|
+
startupLog.LogIf('verbose', `[Auth] Public provider catalog registered at ${AUTH_CATALOG_MOUNT_PATH}/providers`);
|
|
1297
|
+
|
|
1259
1298
|
// ─── Unified auth middleware (replaces both REST authMiddleware and contextFunction auth) ─────
|
|
1260
1299
|
app.use(createUnifiedAuthMiddleware(dataSources));
|
|
1261
1300
|
|
|
@@ -1354,6 +1393,20 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1354
1393
|
}
|
|
1355
1394
|
}
|
|
1356
1395
|
|
|
1396
|
+
// Initialize and start the integration sync worker if enabled (PR 1 item 8).
|
|
1397
|
+
// Off by default — deployments that don't opt in keep running syncs inline.
|
|
1398
|
+
let integrationSyncWorker: IntegrationSyncWorkerService | null = null;
|
|
1399
|
+
if (configInfo.integrationSyncWorker?.enabled) {
|
|
1400
|
+
try {
|
|
1401
|
+
integrationSyncWorker = new IntegrationSyncWorkerService(configInfo.integrationSyncWorker);
|
|
1402
|
+
await integrationSyncWorker.Initialize();
|
|
1403
|
+
integrationSyncWorker.Start();
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
console.error('❌ Failed to start integration sync worker:', error);
|
|
1406
|
+
// Don't throw — an unavailable worker must not prevent the API from serving
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1357
1410
|
// Data hooks are now registered via BaseServerMiddleware classes above
|
|
1358
1411
|
// (e.g., MJTenantFilterMiddleware registers PreRunView and PreSave hooks).
|
|
1359
1412
|
// No config-bag hook registration needed.
|
|
@@ -1425,7 +1478,15 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1425
1478
|
const taskGraphPool = dataSources[0]?.dataSource;
|
|
1426
1479
|
const taskGraphDispatcherDisabled = process.env.MJ_DISABLE_TASK_GRAPH_DISPATCHER === '1';
|
|
1427
1480
|
if (taskGraphDispatcherDisabled) {
|
|
1428
|
-
|
|
1481
|
+
// AND REFUSE SUBMISSIONS, not just execution (R3-11). The durable submitter registers through
|
|
1482
|
+
// the generated manifest unconditionally, so without this the host went on ACCEPTING graphs it
|
|
1483
|
+
// had no intention of running: the agent submitted, promised the user a follow-up, and parked
|
|
1484
|
+
// its run `Paused` — with the graph `Pending` and the run parked forever, no per-submission
|
|
1485
|
+
// diagnostics anywhere, and the stale graph executing hours later if anyone unset the flag.
|
|
1486
|
+
// The entity-action seam already had this treatment (its submitter registers inside
|
|
1487
|
+
// StartTaskGraphDispatcher); this gives the agent seam the same.
|
|
1488
|
+
SuppressTaskGraphSubmission('MJ_DISABLE_TASK_GRAPH_DISPATCHER=1 is set on this host');
|
|
1489
|
+
LogStatus('[TaskGraphDispatcher] Disabled by MJ_DISABLE_TASK_GRAPH_DISPATCHER=1 — this process will neither accept nor execute task graphs.');
|
|
1429
1490
|
} else if (resumeUser && taskGraphPool instanceof sql.ConnectionPool) {
|
|
1430
1491
|
StartTaskGraphDispatcher(taskGraphPool, resumeUser)
|
|
1431
1492
|
.catch(err => console.warn(`[TaskGraphDispatcher] Startup failed: ${err}`));
|
|
@@ -1472,6 +1533,17 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1472
1533
|
}
|
|
1473
1534
|
}
|
|
1474
1535
|
|
|
1536
|
+
// Stop the integration sync worker's polling. In-flight runs finish on their own —
|
|
1537
|
+
// their leases are heartbeat-renewed, and killing them here would only strand rows.
|
|
1538
|
+
if (integrationSyncWorker?.IsRunning) {
|
|
1539
|
+
try {
|
|
1540
|
+
integrationSyncWorker.Stop();
|
|
1541
|
+
console.log(`✅ Integration sync worker stopped (${integrationSyncWorker.InFlightCount} run(s) still in flight)`);
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
console.error('❌ Error stopping integration sync worker:', error);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1475
1547
|
// Drain anything self-registered with ShutdownRegistry — QueueManager,
|
|
1476
1548
|
// future engines/services with timers/intervals/listeners. Each is
|
|
1477
1549
|
// responsible for being idempotent and not throwing.
|
|
@@ -1510,15 +1582,32 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1510
1582
|
});
|
|
1511
1583
|
};
|
|
1512
1584
|
|
|
1585
|
+
/**
|
|
1586
|
+
* Age at which an unprocessed `MJ: RSU Pending Works` row is reported as stranded.
|
|
1587
|
+
* A row older than this survived at least one full restart cycle without being completed.
|
|
1588
|
+
*/
|
|
1589
|
+
const RSU_PENDING_WORK_STALE_MINUTES = 30;
|
|
1590
|
+
|
|
1513
1591
|
/**
|
|
1514
1592
|
* Process pending RSU work left from a pre-restart Apply All.
|
|
1515
|
-
* Reads
|
|
1593
|
+
* Reads the durable `MJ: RSU Pending Works` queue, creates entity maps + field maps,
|
|
1594
|
+
* starts sync, and marks each row Completed only AFTER its work actually succeeded —
|
|
1595
|
+
* so a crash mid-processing leaves the row Pending and re-processable on the next boot.
|
|
1516
1596
|
*/
|
|
1517
1597
|
async function processRSUPendingWork(): Promise<void> {
|
|
1518
1598
|
// Dynamic import — schema-engine is not yet published to npm, only exists as a workspace package
|
|
1519
1599
|
const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
|
|
1520
1600
|
const rsm = RuntimeSchemaManager.Instance;
|
|
1521
|
-
|
|
1601
|
+
|
|
1602
|
+
// Get system user for server-side operations — needed to read the queue itself
|
|
1603
|
+
const systemUser = UserCache.Instance.Users.find(u => u.Type?.trim().toLowerCase() === 'owner') ?? UserCache.Instance.Users[0];
|
|
1604
|
+
if (!systemUser) {
|
|
1605
|
+
console.warn(`[RSU] No system user found — cannot process pending work`);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Rows older than this were left behind by an earlier process and are surfaced as stale.
|
|
1610
|
+
const pendingItems = await rsm.ReadPendingWork(systemUser, RSU_PENDING_WORK_STALE_MINUTES);
|
|
1522
1611
|
if (pendingItems.length === 0) return;
|
|
1523
1612
|
|
|
1524
1613
|
console.log(`[RSU] Processing ${pendingItems.length} pending work item(s) from pre-restart...`);
|
|
@@ -1526,15 +1615,11 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1526
1615
|
// Wait a moment for metadata to be fully loaded
|
|
1527
1616
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
1528
1617
|
|
|
1529
|
-
for (const
|
|
1618
|
+
for (const pending of pendingItems) {
|
|
1619
|
+
const pendingWorkID = pending.ID;
|
|
1620
|
+
const item = pending.Work;
|
|
1530
1621
|
try {
|
|
1531
1622
|
const md = new Metadata(); // global-provider-ok: server startup recovery — runs once before any per-request context exists
|
|
1532
|
-
// Get system user for server-side operations
|
|
1533
|
-
const systemUser = UserCache.Instance.Users.find(u => u.Type?.trim().toLowerCase() === 'owner') ?? UserCache.Instance.Users[0];
|
|
1534
|
-
if (!systemUser) {
|
|
1535
|
-
console.warn(`[RSU] No system user found, skipping pending work for ${item.CompanyIntegrationID}`);
|
|
1536
|
-
continue;
|
|
1537
|
-
}
|
|
1538
1623
|
|
|
1539
1624
|
await Metadata.Provider.Refresh(); // global-provider-ok: server startup recovery — one-shot global cache refresh
|
|
1540
1625
|
|
|
@@ -1547,6 +1632,7 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1547
1632
|
}, systemUser);
|
|
1548
1633
|
const companyIntegration = ciResult.Results[0];
|
|
1549
1634
|
if (!companyIntegration) {
|
|
1635
|
+
await rsm.FailPendingWork(pendingWorkID, `CompanyIntegration ${item.CompanyIntegrationID} not found`, systemUser);
|
|
1550
1636
|
console.warn(`[RSU] CompanyIntegration ${item.CompanyIntegrationID} not found`);
|
|
1551
1637
|
continue;
|
|
1552
1638
|
}
|
|
@@ -1559,11 +1645,13 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1559
1645
|
}, systemUser);
|
|
1560
1646
|
const integrationEntity = integrationResult.Results[0];
|
|
1561
1647
|
if (!integrationEntity) {
|
|
1648
|
+
await rsm.FailPendingWork(pendingWorkID, `Integration entity for ${integrationName} not found`, systemUser);
|
|
1562
1649
|
console.warn(`[RSU] Integration entity for ${integrationName} not found`);
|
|
1563
1650
|
continue;
|
|
1564
1651
|
}
|
|
1565
1652
|
const connector = ConnectorFactory.Resolve(integrationEntity);
|
|
1566
1653
|
if (!connector) {
|
|
1654
|
+
await rsm.FailPendingWork(pendingWorkID, `Connector for ${integrationName} not found`, systemUser);
|
|
1567
1655
|
console.warn(`[RSU] Connector for ${integrationName} not found`);
|
|
1568
1656
|
continue;
|
|
1569
1657
|
}
|
|
@@ -1788,8 +1876,13 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1788
1876
|
console.warn(`[RSU] Schedule creation failed: ${schedErr}`);
|
|
1789
1877
|
}
|
|
1790
1878
|
}
|
|
1879
|
+
|
|
1880
|
+
// Only NOW is the work durably done — close the row out.
|
|
1881
|
+
await rsm.CompletePendingWork(pendingWorkID, systemUser);
|
|
1791
1882
|
} catch (err) {
|
|
1792
|
-
|
|
1883
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1884
|
+
console.error(`[RSU] Failed to process pending work for ${item.CompanyIntegrationID}: ${message}`);
|
|
1885
|
+
await rsm.FailPendingWork(pendingWorkID, message, systemUser);
|
|
1793
1886
|
}
|
|
1794
1887
|
}
|
|
1795
1888
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Bridges RSU pipeline lifecycle events onto the integration progress artifact
|
|
3
|
+
* stream, so a Runtime Schema Update is observable the same way a sync or a connector build is.
|
|
4
|
+
*
|
|
5
|
+
* The gap this closes: `IntegrationRunKind` has always included `'RSU'` and
|
|
6
|
+
* `RUN_KIND_TO_TOPIC` has always mapped it to an `'RSU'` subscription channel — but nothing in
|
|
7
|
+
* production ever constructed an emitter with that kind, so the channel carried no traffic and
|
|
8
|
+
* `IntegrationTailRunEvents` had no RSU runs to tail. The only live signal was polling
|
|
9
|
+
* `RuntimeSchemaUpdateStatus`, which reports the CURRENT step and nothing else: no history, no
|
|
10
|
+
* durable record, and nothing at all to read after a mid-run API restart — which the RSU pipeline
|
|
11
|
+
* does to itself, by design, as one of its own steps.
|
|
12
|
+
*
|
|
13
|
+
* Layering: `@memberjunction/schema-engine` must not depend on the progress artifacts package, so
|
|
14
|
+
* it publishes plain {@link RSUObserverEvent}s and this module — one layer up, where both are
|
|
15
|
+
* already dependencies — owns the emitter lifecycle.
|
|
16
|
+
*/
|
|
17
|
+
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
18
|
+
import type { EmitterOptions } from '@memberjunction/integration-progress-artifacts';
|
|
19
|
+
import { RuntimeSchemaManager } from '@memberjunction/schema-engine';
|
|
20
|
+
import type { RSUObserverEvent } from '@memberjunction/schema-engine';
|
|
21
|
+
import { LogError } from '@memberjunction/core';
|
|
22
|
+
import { BaseSingleton } from '@memberjunction/global';
|
|
23
|
+
|
|
24
|
+
/** The emitter stage name used for run-level (non-step) events. */
|
|
25
|
+
const RUN_STAGE = 'RSUPipeline';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How often an in-flight step reports that it is still alive.
|
|
29
|
+
*
|
|
30
|
+
* RSU's expensive steps — CodeGen, TypeScript compile, npm install — run for minutes with no
|
|
31
|
+
* intermediate observer event, so without this the stream goes silent for the entire step and a
|
|
32
|
+
* watcher cannot tell "compiling" from "hung". 30s matches the interval the agent notification
|
|
33
|
+
* conventions ask for on long phases.
|
|
34
|
+
*/
|
|
35
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Owns one emitter per RSU pipeline run and translates observer events into progress events.
|
|
39
|
+
*
|
|
40
|
+
* A {@link BaseSingleton} because the state here is inherently process-wide AND has to be readable
|
|
41
|
+
* from elsewhere. `RuntimeSchemaManager` is itself a singleton and serializes its runs behind the
|
|
42
|
+
* pipeline lock, so at most one RSU run is ever in flight and one current-emitter slot is sufficient
|
|
43
|
+
* — but the reason for the singleton is {@link CurrentRunID}: a resolver that has just triggered an
|
|
44
|
+
* RSU needs to hand its client a run to tail, and it can only do that if it can reach this instance.
|
|
45
|
+
* Constructing the bridge and keeping the only reference inside the observer closure made that
|
|
46
|
+
* accessor unreachable from any caller.
|
|
47
|
+
*
|
|
48
|
+
* NOT a `BaseEngine`: there is no metadata to load and nothing to `Config()`. The distinction is
|
|
49
|
+
* "single shared instance" (this) versus "cached metadata with a load lifecycle" (that).
|
|
50
|
+
*/
|
|
51
|
+
export class RSUProgressBridge extends BaseSingleton<RSUProgressBridge> {
|
|
52
|
+
private emitter: IntegrationProgressEmitter | null = null;
|
|
53
|
+
private currentRunID: string | null = null;
|
|
54
|
+
/** Liveness timer for the step currently in flight — see {@link HEARTBEAT_INTERVAL_MS}. */
|
|
55
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
56
|
+
private emitterOptions: EmitterOptions = {};
|
|
57
|
+
|
|
58
|
+
/** The one bridge for this process. */
|
|
59
|
+
public static get Instance(): RSUProgressBridge {
|
|
60
|
+
return super.getInstance<RSUProgressBridge>();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Options passed through to each run's emitter. Set once, before the first run.
|
|
65
|
+
*
|
|
66
|
+
* The only field that matters in practice is `rootDir` — left unset in production so RSU runs
|
|
67
|
+
* land in the same `logs/integration-runs/` tree as every other run kind and
|
|
68
|
+
* `IntegrationTailRunEvents` finds them without configuration. Tests point it at a temp dir.
|
|
69
|
+
*
|
|
70
|
+
* A settable property rather than a constructor argument because `BaseSingleton.getInstance`
|
|
71
|
+
* requires a zero-argument constructor.
|
|
72
|
+
*/
|
|
73
|
+
public Configure(emitterOptions: EmitterOptions): void {
|
|
74
|
+
this.emitterOptions = emitterOptions;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The run ID of the in-flight RSU run, or null when idle. Lets a caller that triggered an RSU
|
|
79
|
+
* hand a client something to tail via `IntegrationTailRunEvents` / the `RSU` subscription
|
|
80
|
+
* channel.
|
|
81
|
+
*/
|
|
82
|
+
public get CurrentRunID(): string | null {
|
|
83
|
+
return this.currentRunID;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Abandons any in-flight run state and stops its heartbeat, without emitting a terminal event.
|
|
88
|
+
*
|
|
89
|
+
* Needed because singleton state outlives a single caller: a test (or a server tearing down)
|
|
90
|
+
* must be able to return the bridge to idle without the next run inheriting a stale emitter or
|
|
91
|
+
* leaking an interval that keeps the process alive.
|
|
92
|
+
*/
|
|
93
|
+
public Reset(): void {
|
|
94
|
+
this.stopHeartbeat();
|
|
95
|
+
this.emitter = null;
|
|
96
|
+
this.currentRunID = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The observer to hand to {@link RuntimeSchemaManager.PipelineObserver}. */
|
|
100
|
+
public readonly Observe = (event: RSUObserverEvent): void => {
|
|
101
|
+
switch (event.Kind) {
|
|
102
|
+
case 'run.start':
|
|
103
|
+
return this.onRunStart(event);
|
|
104
|
+
case 'step.start':
|
|
105
|
+
return this.onStepStart(event);
|
|
106
|
+
case 'step.end':
|
|
107
|
+
return this.onStepEnd(event);
|
|
108
|
+
case 'run.end':
|
|
109
|
+
return this.onRunEnd(event);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
private onRunStart(event: Extract<RSUObserverEvent, { Kind: 'run.start' }>): void {
|
|
114
|
+
// A retry re-enters RunPipelineBatch, so it legitimately starts a NEW run. If a previous
|
|
115
|
+
// emitter is still open (a throw escaped before run.end), close it rather than leak it.
|
|
116
|
+
if (this.emitter) this.closeCurrent('Superseded by a new RSU run');
|
|
117
|
+
|
|
118
|
+
const runID = IntegrationProgressEmitter.newRunID('rsu');
|
|
119
|
+
this.currentRunID = runID;
|
|
120
|
+
this.emitter = new IntegrationProgressEmitter(
|
|
121
|
+
{
|
|
122
|
+
runID,
|
|
123
|
+
runKind: 'RSU',
|
|
124
|
+
triggerType: 'Pipeline',
|
|
125
|
+
startedAt: new Date().toISOString(),
|
|
126
|
+
context: {
|
|
127
|
+
itemCount: event.ItemCount,
|
|
128
|
+
descriptions: event.Descriptions,
|
|
129
|
+
affectedTables: event.AffectedTables,
|
|
130
|
+
stepTotal: event.StepTotal,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
this.emitterOptions,
|
|
134
|
+
);
|
|
135
|
+
this.emitter.runStart(
|
|
136
|
+
`RSU pipeline started — ${event.ItemCount} migration(s), ${event.StepTotal} expected step(s): ` +
|
|
137
|
+
event.Descriptions.join('; ')
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private onStepStart(event: Extract<RSUObserverEvent, { Kind: 'step.start' }>): void {
|
|
142
|
+
if (!this.emitter) return;
|
|
143
|
+
this.emitter.stageStart(event.Name, this.positionLabel(event.StepIndex, event.StepTotal));
|
|
144
|
+
this.startHeartbeat(event.Name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private onStepEnd(event: Extract<RSUObserverEvent, { Kind: 'step.end' }>): void {
|
|
148
|
+
this.stopHeartbeat();
|
|
149
|
+
if (!this.emitter) return;
|
|
150
|
+
// A failed step is a stage error, not a stage completion — the run may still continue (a
|
|
151
|
+
// per-item migration failure does not abort the batch), so this is deliberately not fatal.
|
|
152
|
+
if (event.Status === 'failed') {
|
|
153
|
+
this.emitter.stageError(event.Name, event.Message, { durationMs: event.DurationMs, code: 'RSU_STEP_FAILED' });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Deliberately no `counts` here. Counts roll up into the run aggregate, and the countable
|
|
157
|
+
// unit of an RSU run is MIGRATIONS, not steps — reporting "processed: 12" for a 12-step
|
|
158
|
+
// single-migration run would read as 12 migrations. The run-level quartet is emitted once,
|
|
159
|
+
// in onRunEnd. Step position lives in the stage name + the step.start message.
|
|
160
|
+
this.emitter.stageComplete(event.Name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private onRunEnd(event: Extract<RSUObserverEvent, { Kind: 'run.end' }>): void {
|
|
164
|
+
this.stopHeartbeat();
|
|
165
|
+
const emitter = this.emitter;
|
|
166
|
+
if (!emitter) return;
|
|
167
|
+
this.emitter = null;
|
|
168
|
+
this.currentRunID = null;
|
|
169
|
+
const summary = `${event.SuccessCount}/${event.TotalCount} migration(s) succeeded`;
|
|
170
|
+
// The one authoritative applied quartet for the run, in migrations — this is what feeds
|
|
171
|
+
// the reader's aggregateCounts.
|
|
172
|
+
emitter.stageComplete(RUN_STAGE, {
|
|
173
|
+
processed: event.TotalCount,
|
|
174
|
+
succeeded: event.SuccessCount,
|
|
175
|
+
failed: event.FailureCount,
|
|
176
|
+
totalKnown: event.TotalCount,
|
|
177
|
+
});
|
|
178
|
+
// Terminal writes are async (they flush the JSONL + write result.json). The observer
|
|
179
|
+
// contract is synchronous, so this is intentionally fire-and-forget with a logged catch —
|
|
180
|
+
// a progress-artifact write must never surface as a pipeline failure.
|
|
181
|
+
const terminal = event.Success
|
|
182
|
+
? emitter.complete(`RSU pipeline complete — ${summary}`)
|
|
183
|
+
: emitter.fail(
|
|
184
|
+
`RSU pipeline failed at '${event.ErrorStep ?? 'unknown step'}' — ${summary}: ` +
|
|
185
|
+
`${event.ErrorMessage ?? 'no error message'}`,
|
|
186
|
+
'rsu-pipeline-failed'
|
|
187
|
+
);
|
|
188
|
+
void terminal.catch(err => LogError(`RSUProgressBridge: terminal write failed — ${err}`));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Starts reporting liveness for the step that just opened. Any previous timer is cleared first,
|
|
193
|
+
* so a missed `step.end` can never leave two timers running.
|
|
194
|
+
*/
|
|
195
|
+
private startHeartbeat(stage: string): void {
|
|
196
|
+
this.stopHeartbeat();
|
|
197
|
+
const startedAt = Date.now();
|
|
198
|
+
this.heartbeat = setInterval(() => {
|
|
199
|
+
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
|
|
200
|
+
this.emitter?.heartbeat(stage, `${stage} still running — ${elapsedSec}s elapsed`);
|
|
201
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
202
|
+
// Never hold the process open on a progress timer.
|
|
203
|
+
this.heartbeat.unref?.();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Stops the liveness timer. Safe to call when none is running. */
|
|
207
|
+
private stopHeartbeat(): void {
|
|
208
|
+
if (!this.heartbeat) return;
|
|
209
|
+
clearInterval(this.heartbeat);
|
|
210
|
+
this.heartbeat = null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Closes an orphaned emitter so a leaked run does not stay in-flight forever. */
|
|
214
|
+
private closeCurrent(reason: string): void {
|
|
215
|
+
this.stopHeartbeat();
|
|
216
|
+
const emitter = this.emitter;
|
|
217
|
+
this.emitter = null;
|
|
218
|
+
this.currentRunID = null;
|
|
219
|
+
void emitter?.fail(reason, 'rsu-run-abandoned')
|
|
220
|
+
.catch(err => LogError(`RSUProgressBridge: abandon write failed — ${err}`));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* "step 4 of 12" — omitted entirely when the determinate counter isn't armed.
|
|
225
|
+
*
|
|
226
|
+
* Explicit null/undefined checks rather than truthiness: a 0 index is falsy, and silently
|
|
227
|
+
* dropping the label for the first step of a 0-based counter would be a bug that only appears
|
|
228
|
+
* if the pipeline's numbering ever changes.
|
|
229
|
+
*/
|
|
230
|
+
private positionLabel(stepIndex?: number, stepTotal?: number): string | undefined {
|
|
231
|
+
if (stepIndex == null || stepTotal == null) return undefined;
|
|
232
|
+
return `step ${stepIndex} of ${stepTotal}`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Attaches a fresh {@link RSUProgressBridge} to the RuntimeSchemaManager singleton. Call once at
|
|
238
|
+
* server startup, after the progress publish hook is registered so the very first RSU event also
|
|
239
|
+
* reaches live subscribers.
|
|
240
|
+
*
|
|
241
|
+
* @returns the bridge, so callers can read {@link RSUProgressBridge.CurrentRunID}.
|
|
242
|
+
*/
|
|
243
|
+
export function RegisterRSUProgressBridge(emitterOptions: EmitterOptions = {}): RSUProgressBridge {
|
|
244
|
+
const bridge = RSUProgressBridge.Instance;
|
|
245
|
+
bridge.Configure(emitterOptions);
|
|
246
|
+
RuntimeSchemaManager.Instance.PipelineObserver = bridge.Observe;
|
|
247
|
+
return bridge;
|
|
248
|
+
}
|