@memberjunction/server 5.47.0 → 5.49.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 +9 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +4 -0
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +65 -3
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +122 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +629 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +4 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +1 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -11
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +15 -2
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +113 -11
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/EntityMapLifecycle.d.ts +66 -0
- package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
- package/dist/integration/EntityMapLifecycle.js +189 -0
- package/dist/integration/EntityMapLifecycle.js.map +1 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts +58 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -0
- package/dist/realtimeProxy/RealtimeProxyServer.js +246 -0
- package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -0
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.js +6 -2
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +81 -25
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts +16 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +26 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +522 -60
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +0 -6
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts +10 -0
- package/dist/resolvers/RSUResolver.d.ts.map +1 -1
- package/dist/resolvers/RSUResolver.js +28 -0
- package/dist/resolvers/RSUResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +135 -132
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts +1 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +5 -0
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +29 -1
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
- package/dist/resolvers/adhoc-query-helpers.js +42 -0
- package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
- package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +21 -4
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/telephony/media-upgrade-router.d.ts +14 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
- package/dist/telephony/media-upgrade-router.js +17 -1
- package/dist/telephony/media-upgrade-router.js.map +1 -1
- package/package.json +88 -88
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
- package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
- package/src/__tests__/RealtimeProxyServer.test.ts +122 -0
- package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
- package/src/__tests__/media-upgrade-router.test.ts +43 -0
- package/src/__tests__/unifiedAuth.test.ts +18 -3
- package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
- package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +4 -0
- package/src/config.ts +14 -0
- package/src/context.ts +74 -3
- package/src/generated/generated.ts +446 -1
- package/src/generic/ResolverBase.ts +5 -2
- package/src/generic/RunViewResolver.ts +11 -0
- package/src/index.ts +100 -11
- package/src/integration/CustomColumnPromoter.ts +123 -6
- package/src/integration/EntityMapLifecycle.ts +221 -0
- package/src/realtimeProxy/RealtimeProxyServer.ts +265 -0
- package/src/realtimeWidget/WidgetRouter.ts +6 -2
- package/src/resolvers/AdhocQueryResolver.ts +97 -30
- package/src/resolvers/GetDataResolver.ts +30 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +514 -60
- package/src/resolvers/QueryResolver.ts +1 -7
- package/src/resolvers/RSUResolver.ts +28 -0
- package/src/resolvers/RunAIAgentResolver.ts +74 -70
- package/src/resolvers/TestQuerySQLResolver.ts +4 -0
- package/src/resolvers/TransactionGroupResolver.ts +36 -2
- package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
- package/src/resolvers/adhoc-query-helpers.ts +50 -0
- package/src/rest/OAuthCallbackHandler.ts +22 -4
- package/src/telephony/media-upgrade-router.ts +18 -1
|
@@ -588,6 +588,12 @@ export class RunViewWithCacheCheckResultOutput {
|
|
|
588
588
|
|
|
589
589
|
@Field(() => String, { nullable: true, description: 'Error message if status is error' })
|
|
590
590
|
errorMessage?: string;
|
|
591
|
+
|
|
592
|
+
@Field(() => [AggregateResultOutput], {
|
|
593
|
+
nullable: true,
|
|
594
|
+
description: 'Aggregate results when status is stale and aggregates were requested (B40 — previously never marshalled, so CacheLocal callers got no aggregates at all)'
|
|
595
|
+
})
|
|
596
|
+
aggregateResults?: AggregateResultOutput[];
|
|
591
597
|
}
|
|
592
598
|
|
|
593
599
|
@ObjectType()
|
|
@@ -1073,6 +1079,9 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1073
1079
|
AuditLogDescription: item.params.AuditLogDescription,
|
|
1074
1080
|
ResultType: (item.params.ResultType || 'simple') as 'simple' | 'entity_object' | 'count_only',
|
|
1075
1081
|
StartRow: item.params.StartRow,
|
|
1082
|
+
// Forward the aggregate request to the engine (B40) — omitted here as well as in the
|
|
1083
|
+
// client's input map, so aggregates never reached InternalRunView on this transport.
|
|
1084
|
+
Aggregates: item.params.Aggregates,
|
|
1076
1085
|
AfterKey: item.params.AfterKey
|
|
1077
1086
|
? CompositeKey.FromKeyValuePairs(item.params.AfterKey.KeyValuePairs)
|
|
1078
1087
|
: undefined,
|
|
@@ -1130,6 +1139,8 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1130
1139
|
maxUpdatedAt: result.maxUpdatedAt,
|
|
1131
1140
|
rowCount: result.rowCount,
|
|
1132
1141
|
errorMessage: result.errorMessage,
|
|
1142
|
+
// Same serializer the regular RunView path uses (value → JSON string).
|
|
1143
|
+
aggregateResults: this.processAggregateResults(result.aggregateResults),
|
|
1133
1144
|
};
|
|
1134
1145
|
}
|
|
1135
1146
|
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,13 @@ dotenv.config({ quiet: true });
|
|
|
4
4
|
|
|
5
5
|
import { expressMiddleware } from '@as-integrations/express5';
|
|
6
6
|
import { mergeSchemas } from '@graphql-tools/schema';
|
|
7
|
-
import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent, RunView, DatabaseProviderBase } from '@memberjunction/core';
|
|
7
|
+
import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent, RunView, DatabaseProviderBase, ResolveStartupMode } from '@memberjunction/core';
|
|
8
8
|
import { resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
|
|
9
9
|
import { MJGlobal, MJEventType, UUIDsEqual, ShutdownRegistry } from '@memberjunction/global';
|
|
10
10
|
import { setupSQLServerClient, SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
11
11
|
import { extendConnectionPoolWithQuery } from './util.js';
|
|
12
12
|
import { registerIntegrationCustomColumnPromoter } from './integration/CustomColumnPromoter.js';
|
|
13
|
+
import { DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap } from './integration/EntityMapLifecycle.js';
|
|
13
14
|
import { default as BodyParser } from 'body-parser';
|
|
14
15
|
import compression from 'compression'; // Add compression middleware
|
|
15
16
|
import cors from 'cors';
|
|
@@ -26,6 +27,7 @@ import { BuildSchemaOptions, buildSchemaSync, GraphQLTimestamp, PubSubEngine } f
|
|
|
26
27
|
import { PubSub } from 'graphql-subscriptions';
|
|
27
28
|
import sql from 'mssql';
|
|
28
29
|
import { WebSocketServer } from 'ws';
|
|
30
|
+
import { RealtimeProxyServer } from './realtimeProxy/RealtimeProxyServer.js';
|
|
29
31
|
import buildApolloServer from './apolloServer/index.js';
|
|
30
32
|
import { configInfo, configFilePath, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage, RESTApiOptions } from './config.js';
|
|
31
33
|
import { default as jwt } from 'jsonwebtoken';
|
|
@@ -47,7 +49,7 @@ import { createTwilioTelephonyHandler, TWILIO_TELEPHONY_MOUNT_PATH, SetTwilioTel
|
|
|
47
49
|
import { createVonageTelephonyHandler, VONAGE_TELEPHONY_MOUNT_PATH, SetVonageTelephonyService } from './telephony/index.js';
|
|
48
50
|
import { RingCentralTelephonyService, SetRingCentralTelephonyService } from './telephony/index.js';
|
|
49
51
|
import { createTeamsMeetingsHandler, TEAMS_MEETINGS_MOUNT_PATH, SetTeamsMeetingsService, GetTeamsMeetingsService, StartCalendarScheduler } from './telephony/index.js';
|
|
50
|
-
import { InstallMediaUpgradeDispatcher } from './telephony/index.js';
|
|
52
|
+
import { InstallMediaUpgradeDispatcher, IsGraphQLWsPath } from './telephony/index.js';
|
|
51
53
|
|
|
52
54
|
import { resolve } from 'node:path';
|
|
53
55
|
import { DataSourceInfo, raiseEvent } from './types.js';
|
|
@@ -323,10 +325,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
323
325
|
// Refresh user cache using PostgreSQL
|
|
324
326
|
await refreshUserCacheFromPG(pgPool, mj_core_schema);
|
|
325
327
|
|
|
326
|
-
// Run startup actions
|
|
328
|
+
// Run startup actions — same 'full' entry-point default as the SQL Server path
|
|
327
329
|
const sysUser = UserCache.Instance.GetSystemUser();
|
|
328
330
|
const backupSysUser = UserCache.Instance.Users.find(u => u.IsActive && u.Type === 'Owner');
|
|
329
|
-
|
|
331
|
+
const pgStartupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
|
|
332
|
+
await StartupManagerImport.Instance.Startup(false, sysUser || backupSysUser, provider, { mode: pgStartupMode.mode });
|
|
330
333
|
|
|
331
334
|
// Monkey-patch SQLServerDataProvider.ExecuteSQLWithPool to support PostgreSQL
|
|
332
335
|
// Generated resolvers call this static method with bracket-quoted SQL.
|
|
@@ -416,6 +419,17 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
416
419
|
if (cgConfig.additionalSchemaInfo) {
|
|
417
420
|
RuntimeSchemaManager.Instance.SetAdditionalSchemaInfoPath(cgConfig.additionalSchemaInfo);
|
|
418
421
|
startupLog.LogIf('verbose', `RSU additionalSchemaInfo path: ${cgConfig.additionalSchemaInfo}`);
|
|
422
|
+
} else if (RuntimeSchemaManager.Instance.IsEnabled) {
|
|
423
|
+
// U5 — boot-time assert: RSU is enabled but CodeGen has NO additionalSchemaInfo path
|
|
424
|
+
// configured, so RSU's soft-PK/FK writes land in a file CodeGen never reads. Every
|
|
425
|
+
// resolved soft PK would be silently lost ("No primary key found" per table). LOUD,
|
|
426
|
+
// named, and actionable — grep for RSU_ADDITIONAL_SCHEMA_INFO_DIVERGENCE.
|
|
427
|
+
console.error(
|
|
428
|
+
`[RSU_ADDITIONAL_SCHEMA_INFO_DIVERGENCE] Runtime Schema Update is ENABLED but mj.config.cjs has no ` +
|
|
429
|
+
`'additionalSchemaInfo' setting — RSU writes soft PK/FK definitions to '${process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json'}' ` +
|
|
430
|
+
`while CodeGen reads none, so resolved soft primary keys will be silently LOST at codegen time. ` +
|
|
431
|
+
`Set 'additionalSchemaInfo' in mj.config.cjs to the same file so the write path and read path agree.`
|
|
432
|
+
);
|
|
419
433
|
}
|
|
420
434
|
} catch (codegenErr) {
|
|
421
435
|
console.warn(`RSU in-process CodeGen runner setup failed (will fall back to child process): ${(codegenErr as Error).message}`);
|
|
@@ -468,7 +482,10 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
468
482
|
}
|
|
469
483
|
|
|
470
484
|
const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
|
|
471
|
-
|
|
485
|
+
// MJAPI is a long-running server, so entry-point default is 'full' engine pre-warm;
|
|
486
|
+
// MJ_STARTUP_MODE / mj.config.cjs startup.mode can override per the shared precedence chain
|
|
487
|
+
const startupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
|
|
488
|
+
await setupSQLServerClient(config, { mode: startupMode.mode });
|
|
472
489
|
lap('Metadata + Provider Setup', tPhase);
|
|
473
490
|
startupLog.BeginPhase('Initializing data provider');
|
|
474
491
|
const md = new Metadata(); // global-provider-ok: bootstrap
|
|
@@ -541,6 +558,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
541
558
|
if (codegenConfig.additionalSchemaInfo) {
|
|
542
559
|
RuntimeSchemaManager.Instance.SetAdditionalSchemaInfoPath(codegenConfig.additionalSchemaInfo);
|
|
543
560
|
startupLog.LogIf('verbose', `RSU additionalSchemaInfo path: ${codegenConfig.additionalSchemaInfo}`);
|
|
561
|
+
} else if (RuntimeSchemaManager.Instance.IsEnabled) {
|
|
562
|
+
// U5 — boot-time assert: write path ≠ read path (see the PostgreSQL branch for detail).
|
|
563
|
+
console.error(
|
|
564
|
+
`[RSU_ADDITIONAL_SCHEMA_INFO_DIVERGENCE] Runtime Schema Update is ENABLED but mj.config.cjs has no ` +
|
|
565
|
+
`'additionalSchemaInfo' setting — RSU writes soft PK/FK definitions to '${process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json'}' ` +
|
|
566
|
+
`while CodeGen reads none, so resolved soft primary keys will be silently LOST at codegen time. ` +
|
|
567
|
+
`Set 'additionalSchemaInfo' in mj.config.cjs to the same file so the write path and read path agree.`
|
|
568
|
+
);
|
|
544
569
|
}
|
|
545
570
|
} catch (codegenErr) {
|
|
546
571
|
console.warn(`RSU in-process CodeGen runner setup failed (will fall back to child process): ${(codegenErr as Error).message}`);
|
|
@@ -857,7 +882,34 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
857
882
|
|
|
858
883
|
const httpServer = createServer(app);
|
|
859
884
|
|
|
860
|
-
|
|
885
|
+
// `noServer` so we own the HTTP `upgrade` routing ourselves (below) and can dispatch by path between
|
|
886
|
+
// the graphql-ws server and the realtime proxy. (In `{ server, path }` mode, ws destroys any socket whose
|
|
887
|
+
// path doesn't match, which would kill the proxy's `/realtime-proxy` upgrades — hence the explicit router.)
|
|
888
|
+
const webSocketServer = new WebSocketServer({ noServer: true });
|
|
889
|
+
|
|
890
|
+
// Single upgrade router: the realtime proxy claims its own path first (an authenticated byte-tunnel that
|
|
891
|
+
// lets self-hosted realtime providers — e.g. HuggingFace speech-to-speech — run the shipped client-direct
|
|
892
|
+
// audio topology without the internal endpoint ever being exposed to the browser); everything on the
|
|
893
|
+
// graphql path goes to graphql-ws; anything else is rejected. Must be registered before httpServer.listen().
|
|
894
|
+
// Path acceptance (including the bare-root/`/graphql` alias) is centralized in `IsGraphQLWsPath` — see
|
|
895
|
+
// its doc comment for why the alias exists — so this listener and `InstallMediaUpgradeDispatcher`'s
|
|
896
|
+
// telephony-active replacement listener can never disagree on what counts as "the GraphQL path."
|
|
897
|
+
httpServer.on('upgrade', (request, socket, head) => {
|
|
898
|
+
if (RealtimeProxyServer.Instance.TryHandleUpgrade(request, socket, head as Buffer)) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
let pathname = graphqlRootPath;
|
|
902
|
+
try {
|
|
903
|
+
pathname = new URL(request.url ?? graphqlRootPath, 'http://internal').pathname;
|
|
904
|
+
} catch {
|
|
905
|
+
/* unparseable — fall through to the graphql-path check, which will reject it */
|
|
906
|
+
}
|
|
907
|
+
if (IsGraphQLWsPath(pathname, graphqlRootPath)) {
|
|
908
|
+
webSocketServer.handleUpgrade(request, socket, head, (ws) => webSocketServer.emit('connection', ws, request));
|
|
909
|
+
} else {
|
|
910
|
+
socket.destroy();
|
|
911
|
+
}
|
|
912
|
+
});
|
|
861
913
|
|
|
862
914
|
// Track per-connection expiry timers so we can clean them up on close
|
|
863
915
|
const expiryTimers = new WeakMap<object, ReturnType<typeof setTimeout>>();
|
|
@@ -869,7 +921,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
869
921
|
// This prevents re-validating (and re-logging) on every subscription message.
|
|
870
922
|
onConnect: async (ctx) => {
|
|
871
923
|
try {
|
|
872
|
-
|
|
924
|
+
// Only coerce a value that's actually present — `String(undefined)` yields the
|
|
925
|
+
// literal string "undefined" (truthy, 9 chars), which slips past getUserPayload's
|
|
926
|
+
// `!token` guard and fails later as 'Invalid token payload' (a full stack-trace log)
|
|
927
|
+
// instead of the quiet, routine 'Missing token' path. A WS client that connects
|
|
928
|
+
// before its auth handshake (extremely common) must land on the quiet path.
|
|
929
|
+
const token = ctx.connectionParams?.Authorization ? String(ctx.connectionParams.Authorization) : '';
|
|
873
930
|
// Carry API keys from connectionParams so API-key / MCP / Node clients can authenticate the socket
|
|
874
931
|
// (validated the same way as the HTTP x-mj-api-key / x-mj-user-api-key headers).
|
|
875
932
|
const systemApiKey = ctx.connectionParams?.['x-mj-api-key'] ? String(ctx.connectionParams['x-mj-api-key']) : undefined;
|
|
@@ -1451,7 +1508,22 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1451
1508
|
let isNewMap = false;
|
|
1452
1509
|
|
|
1453
1510
|
if (existingMapResult.Success && existingMapResult.Results.length > 0) {
|
|
1454
|
-
|
|
1511
|
+
const existingMap = existingMapResult.Results[0];
|
|
1512
|
+
entityMapID = existingMap.ID;
|
|
1513
|
+
// Re-add re-enables: a previously-removed (disabled) object that is
|
|
1514
|
+
// re-selected comes back Active WITH its field maps re-enabled. Skipped in
|
|
1515
|
+
// CreateDisabled (schema-evolution) mode — a refresh must never resurrect a
|
|
1516
|
+
// map the user turned off.
|
|
1517
|
+
if (!item.CreateDisabled && (existingMap.Status !== 'Active' || existingMap.SyncEnabled === false)) {
|
|
1518
|
+
existingMap.Status = 'Active';
|
|
1519
|
+
existingMap.SyncEnabled = true;
|
|
1520
|
+
if (await existingMap.Save()) {
|
|
1521
|
+
await ReenableFieldMapsForEntityMap(entityMapID, systemUser, Metadata.Provider); // global-provider-ok: server startup recovery — runs once before any per-request context exists
|
|
1522
|
+
console.log(`[RSU] Re-enabled previously-disabled entity map for ${objName} → ${entity.Name} (${entityMapID})`);
|
|
1523
|
+
} else {
|
|
1524
|
+
console.warn(`[RSU] Failed to re-enable entity map for ${objName}: ${existingMap.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1455
1527
|
console.log(`[RSU] Entity map already exists for ${objName} → ${entity.Name} (${entityMapID})`);
|
|
1456
1528
|
} else {
|
|
1457
1529
|
const entityMap = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>(
|
|
@@ -1462,8 +1534,10 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1462
1534
|
entityMap.EntityID = entity.ID;
|
|
1463
1535
|
entityMap.ExternalObjectName = objName;
|
|
1464
1536
|
entityMap.SyncDirection = 'Pull';
|
|
1465
|
-
|
|
1466
|
-
|
|
1537
|
+
// Refresh diff: schema-evolution-born maps arrive DISABLED (the user
|
|
1538
|
+
// enables them after the refresh); the first-apply/selection path stays Active.
|
|
1539
|
+
entityMap.Status = item.CreateDisabled ? 'Inactive' : 'Active';
|
|
1540
|
+
entityMap.SyncEnabled = !item.CreateDisabled;
|
|
1467
1541
|
const mapSaved = await entityMap.Save();
|
|
1468
1542
|
if (!mapSaved) {
|
|
1469
1543
|
console.warn(`[RSU] Failed to save entity map for ${objName}`);
|
|
@@ -1508,7 +1582,7 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1508
1582
|
fieldMap.IsKeyField = field.IsPrimaryKey ?? false;
|
|
1509
1583
|
fieldMap.IsRequired = field.IsRequired ?? false;
|
|
1510
1584
|
fieldMap.Direction = 'SourceToDest';
|
|
1511
|
-
fieldMap.Status = 'Active';
|
|
1585
|
+
fieldMap.Status = item.CreateDisabled ? 'Inactive' : 'Active';
|
|
1512
1586
|
if (await fieldMap.Save()) fieldCount++;
|
|
1513
1587
|
}
|
|
1514
1588
|
console.log(`[RSU] Created entity map for ${objName} → ${entity.Name} with ${fieldCount} field maps${isNewMap ? '' : ' (existing map, new fields only)'}`);
|
|
@@ -1517,6 +1591,21 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1517
1591
|
}
|
|
1518
1592
|
}
|
|
1519
1593
|
|
|
1594
|
+
// Remove-as-disable: entity maps whose object is NOT in this apply's selection
|
|
1595
|
+
// are disabled (data kept; re-selection re-enables). 'ignore' opts out for subset applies.
|
|
1596
|
+
if ((item.UnselectedAction ?? 'disable') !== 'ignore') {
|
|
1597
|
+
try {
|
|
1598
|
+
const disabledObjects = await DisableUnselectedEntityMaps(
|
|
1599
|
+
item.CompanyIntegrationID, item.SourceObjectNames, systemUser, Metadata.Provider // global-provider-ok: server startup recovery — runs once before any per-request context exists
|
|
1600
|
+
);
|
|
1601
|
+
if (disabledObjects.length > 0) {
|
|
1602
|
+
console.log(`[RSU] Disabled ${disabledObjects.length} unselected entity map(s): ${disabledObjects.join(', ')}`);
|
|
1603
|
+
}
|
|
1604
|
+
} catch (disableErr) {
|
|
1605
|
+
console.warn(`[RSU] Unselected-map disable failed: ${disableErr}`);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1520
1609
|
// Start sync if requested
|
|
1521
1610
|
if (item.StartSync !== false) {
|
|
1522
1611
|
try {
|
|
@@ -40,9 +40,26 @@ import {
|
|
|
40
40
|
buildOverflowStats,
|
|
41
41
|
planPromotions,
|
|
42
42
|
sanitizeColumnName,
|
|
43
|
+
inferColumnTypeFromStats,
|
|
43
44
|
type SchemaPromotionResult,
|
|
44
45
|
type PromotionCandidate,
|
|
46
|
+
type CustomKeyStat,
|
|
47
|
+
type InferredColumnType,
|
|
45
48
|
} from '@memberjunction/integration-engine';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A custom-key candidate persisted to CompanyIntegration.Configuration.customKeyCandidates
|
|
52
|
+
* (out-of-band capture): key identity + sizing statistics + the inferred column
|
|
53
|
+
* type, NEVER raw sample values (PII-safe — the config row is operator-visible). Written
|
|
54
|
+
* with REPLACE semantics per synced entity on every sync, so keys that vanish evict.
|
|
55
|
+
*/
|
|
56
|
+
interface PersistedCustomKeyCandidate {
|
|
57
|
+
Key: string;
|
|
58
|
+
Occurrences: number;
|
|
59
|
+
TotalRecords: number;
|
|
60
|
+
MaxLength: number;
|
|
61
|
+
Inferred: InferredColumnType;
|
|
62
|
+
}
|
|
46
63
|
import type { BaseEntity } from '@memberjunction/core';
|
|
47
64
|
import { DDLGenerator, type TargetColumnConfig, type DatabasePlatform } from '@memberjunction/integration-schema-builder';
|
|
48
65
|
import { RuntimeSchemaManager, type RSUPipelineInput } from '@memberjunction/schema-engine';
|
|
@@ -82,6 +99,14 @@ export function registerIntegrationCustomColumnPromoter(): void {
|
|
|
82
99
|
IntegrationEngine.Instance.SetPostSyncSchemaPromotionCallback(async (ctx) => {
|
|
83
100
|
const user = ctx.ContextUser as UserInfo;
|
|
84
101
|
const provider = ctx.Provider as IMetadataProvider | undefined;
|
|
102
|
+
const promoter = new IntegrationCustomColumnPromoter(user, provider);
|
|
103
|
+
// out-of-band candidates: persist the sync's in-memory custom-key statistics
|
|
104
|
+
// (REPLACE semantics per synced entity — vanished keys evict) so on-demand
|
|
105
|
+
// IntegrationListCustomColumnCandidates sees them even when the content-hash fast path
|
|
106
|
+
// skipped every row (the hash basis excludes overflow, so skipped rows never write it).
|
|
107
|
+
// Unconditional — runs whether or not auto-promote is on. PII-safe: names/lengths/types
|
|
108
|
+
// only, never raw values.
|
|
109
|
+
await promoter.PersistCandidateStats(ctx.CompanyIntegrationID, ctx.SyncedEntityNames, ctx.CustomKeyStats);
|
|
85
110
|
// GATE — promotion runs RSU (ADD COLUMN + register EntityField) + restart, which is disruptive.
|
|
86
111
|
// It is OPT-IN per connection (DEFAULT OFF): by default a sync only CAPTURES unmapped fields into
|
|
87
112
|
// the overflow column; the user triggers promotion on demand via IntegrationPromoteCustomColumns
|
|
@@ -94,8 +119,7 @@ export function registerIntegrationCustomColumnPromoter(): void {
|
|
|
94
119
|
);
|
|
95
120
|
return { Promoted: false, ColumnsAdded: [], SchemaUpdatePending: false };
|
|
96
121
|
}
|
|
97
|
-
|
|
98
|
-
return promoter.PromoteForSync(ctx.CompanyIntegrationID, ctx.SyncedEntityNames);
|
|
122
|
+
return promoter.PromoteForSync(ctx.CompanyIntegrationID, ctx.SyncedEntityNames, ctx.CustomKeyStats);
|
|
99
123
|
});
|
|
100
124
|
// Verbose-only: this is a boot-time registration confirmation, not operator-actionable at
|
|
101
125
|
// standard level. Routes through the global verbose gate (set from the server's telemetry.level).
|
|
@@ -136,6 +160,7 @@ export class IntegrationCustomColumnPromoter {
|
|
|
136
160
|
public async PromoteForSync(
|
|
137
161
|
companyIntegrationID: string,
|
|
138
162
|
syncedEntityNames: string[],
|
|
163
|
+
customKeyStats?: Record<string, CustomKeyStat[]>,
|
|
139
164
|
): Promise<SchemaPromotionResult> {
|
|
140
165
|
const integrationID = await this.resolveIntegrationID(companyIntegrationID);
|
|
141
166
|
if (!integrationID) return NOT_PROMOTED;
|
|
@@ -143,7 +168,7 @@ export class IntegrationCustomColumnPromoter {
|
|
|
143
168
|
const columnsAdded: Array<{ EntityName: string; ColumnName: string }> = [];
|
|
144
169
|
for (const entityName of syncedEntityNames) {
|
|
145
170
|
try {
|
|
146
|
-
const added = await this.promoteEntity(companyIntegrationID, integrationID, entityName);
|
|
171
|
+
const added = await this.promoteEntity(companyIntegrationID, integrationID, entityName, customKeyStats?.[entityName]);
|
|
147
172
|
columnsAdded.push(...added);
|
|
148
173
|
} catch (err) {
|
|
149
174
|
// One entity's promotion failure must not abort the others, and never the sync.
|
|
@@ -164,8 +189,9 @@ export class IntegrationCustomColumnPromoter {
|
|
|
164
189
|
companyIntegrationID: string,
|
|
165
190
|
integrationID: string,
|
|
166
191
|
entityName: string,
|
|
192
|
+
inRunStats?: CustomKeyStat[],
|
|
167
193
|
): Promise<Array<{ EntityName: string; ColumnName: string }>> {
|
|
168
|
-
const planned = await this.planWorkForEntity(companyIntegrationID, entityName);
|
|
194
|
+
const planned = await this.planWorkForEntity(companyIntegrationID, entityName, inRunStats);
|
|
169
195
|
if (!planned || planned.work.length === 0) return []; // no overflow / no entity map / already converged
|
|
170
196
|
const { entityInfo, entityMap } = planned;
|
|
171
197
|
let work = planned.work;
|
|
@@ -208,14 +234,19 @@ export class IntegrationCustomColumnPromoter {
|
|
|
208
234
|
private async planWorkForEntity(
|
|
209
235
|
companyIntegrationID: string,
|
|
210
236
|
entityName: string,
|
|
237
|
+
inRunStats?: CustomKeyStat[],
|
|
211
238
|
): Promise<{ entityInfo: EntityInfo; entityMap: { ID: string; ExternalObjectName: string }; work: WorkItem[] } | null> {
|
|
212
239
|
const entityInfo = this.provider.EntityByName(entityName);
|
|
213
240
|
if (!entityInfo?.SchemaName || !entityInfo.BaseTable) return null;
|
|
214
241
|
// No overflow column on this table (predates the feature) → nothing to promote.
|
|
215
242
|
if (!entityInfo.Fields.some(f => f.Name === CUSTOM_OVERFLOW_COLUMN)) return null;
|
|
216
243
|
|
|
244
|
+
// Candidate sources, most-authoritative first (dedup by key):
|
|
245
|
+
// 1. live overflow-column scan (rows that were actually written),
|
|
246
|
+
// 2. THIS run's in-memory stats (rows the content-hash fast path skipped —
|
|
247
|
+
// out-of-band capture; the hash basis excludes overflow so skips never write it),
|
|
248
|
+
// 3. candidates persisted from prior runs (survive restarts for on-demand listing).
|
|
217
249
|
const overflowJson = await this.scanOverflow(entityName);
|
|
218
|
-
if (overflowJson.length === 0) return null; // no customs captured
|
|
219
250
|
|
|
220
251
|
// U3 note (rkihm-BC review, #3061): this in-repo promotion path passes no `LockUntilFullSync`, so it
|
|
221
252
|
// does NOT yet enforce "hold promotion until a full sync since the last schema change." The lever
|
|
@@ -223,7 +254,11 @@ export class IntegrationCustomColumnPromoter {
|
|
|
223
254
|
// sync has completed post-rediscovery — DEFERRED and tracked as a follow-up. The engine ships the
|
|
224
255
|
// gate; wiring MJServer's own consumer to set it is a separate change. Until then, this path retains
|
|
225
256
|
// the pre-U3 behavior for a rediscover-then-incremental sequence.
|
|
226
|
-
const
|
|
257
|
+
const byKey = new Map<string, PromotionCandidate>();
|
|
258
|
+
for (const c of planPromotions(buildOverflowStats(overflowJson), {})) byKey.set(c.Key, c);
|
|
259
|
+
for (const c of this.candidatesFromStats(inRunStats)) if (!byKey.has(c.Key)) byKey.set(c.Key, c);
|
|
260
|
+
for (const c of await this.loadPersistedCandidates(companyIntegrationID, entityName)) if (!byKey.has(c.Key)) byKey.set(c.Key, c);
|
|
261
|
+
const passing = [...byKey.values()];
|
|
227
262
|
if (passing.length === 0) return null;
|
|
228
263
|
|
|
229
264
|
const entityMap = await this.findEntityMap(companyIntegrationID, entityName);
|
|
@@ -258,6 +293,88 @@ export class IntegrationCustomColumnPromoter {
|
|
|
258
293
|
}));
|
|
259
294
|
}
|
|
260
295
|
|
|
296
|
+
/** Maps THIS run's in-memory custom-key stats to promotion candidates (out-of-band capture). */
|
|
297
|
+
private candidatesFromStats(stats?: CustomKeyStat[]): PromotionCandidate[] {
|
|
298
|
+
if (!stats || stats.length === 0) return [];
|
|
299
|
+
return stats
|
|
300
|
+
.filter(s => s.TotalRecords > 0 && s.Occurrences > 0)
|
|
301
|
+
.map(s => ({
|
|
302
|
+
Key: s.Key,
|
|
303
|
+
Coverage: s.Occurrences / s.TotalRecords,
|
|
304
|
+
Inferred: inferColumnTypeFromStats(s.SampleValues, s.MaxLength),
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Loads candidates persisted by {@link PersistCandidateStats} for one entity (empty on any gap). */
|
|
309
|
+
private async loadPersistedCandidates(companyIntegrationID: string, entityName: string): Promise<PromotionCandidate[]> {
|
|
310
|
+
try {
|
|
311
|
+
const ci = await this.provider.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', this.user);
|
|
312
|
+
if (!(await ci.Load(companyIntegrationID)) || !ci.Configuration) return [];
|
|
313
|
+
const cfg = JSON.parse(ci.Configuration) as { customKeyCandidates?: Record<string, PersistedCustomKeyCandidate[]> };
|
|
314
|
+
const persisted = cfg.customKeyCandidates?.[entityName];
|
|
315
|
+
if (!Array.isArray(persisted)) return [];
|
|
316
|
+
return persisted
|
|
317
|
+
.filter(p => p && typeof p.Key === 'string' && p.TotalRecords > 0)
|
|
318
|
+
.map(p => ({ Key: p.Key, Coverage: p.Occurrences / p.TotalRecords, Inferred: p.Inferred }));
|
|
319
|
+
} catch {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Persists the sync's custom-key statistics onto CompanyIntegration.Configuration
|
|
326
|
+
* (`customKeyCandidates`, keyed by entity name) with REPLACE semantics per SYNCED entity:
|
|
327
|
+
* an entity synced with stats gets exactly this run's key set (vanished keys evict); an
|
|
328
|
+
* entity synced with NO custom keys has its entry removed. Unsynced entities keep theirs.
|
|
329
|
+
* PII-safe — key names, counts, lengths and inferred types only, never raw values. Caps
|
|
330
|
+
* the stored set per entity so a pathological source can't bloat the config row.
|
|
331
|
+
*/
|
|
332
|
+
public async PersistCandidateStats(
|
|
333
|
+
companyIntegrationID: string,
|
|
334
|
+
syncedEntityNames: string[],
|
|
335
|
+
statsByEntity?: Record<string, CustomKeyStat[]>,
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
const MAX_PERSISTED_KEYS_PER_ENTITY = 200;
|
|
338
|
+
try {
|
|
339
|
+
const ci = await this.provider.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', this.user);
|
|
340
|
+
if (!(await ci.Load(companyIntegrationID))) return;
|
|
341
|
+
const cfg = (ci.Configuration ? JSON.parse(ci.Configuration) : {}) as Record<string, unknown> & {
|
|
342
|
+
customKeyCandidates?: Record<string, PersistedCustomKeyCandidate[]>;
|
|
343
|
+
};
|
|
344
|
+
const candidates = { ...(cfg.customKeyCandidates ?? {}) };
|
|
345
|
+
let changed = false;
|
|
346
|
+
for (const entityName of syncedEntityNames) {
|
|
347
|
+
const stats = statsByEntity?.[entityName];
|
|
348
|
+
if (stats && stats.length > 0) {
|
|
349
|
+
candidates[entityName] = stats
|
|
350
|
+
.filter(s => s.TotalRecords > 0 && s.Occurrences > 0)
|
|
351
|
+
.slice(0, MAX_PERSISTED_KEYS_PER_ENTITY)
|
|
352
|
+
.map(s => ({
|
|
353
|
+
Key: s.Key,
|
|
354
|
+
Occurrences: s.Occurrences,
|
|
355
|
+
TotalRecords: s.TotalRecords,
|
|
356
|
+
MaxLength: s.MaxLength,
|
|
357
|
+
Inferred: inferColumnTypeFromStats(s.SampleValues, s.MaxLength),
|
|
358
|
+
}));
|
|
359
|
+
changed = true;
|
|
360
|
+
} else if (candidates[entityName]) {
|
|
361
|
+
delete candidates[entityName]; // synced clean → evict stale candidates
|
|
362
|
+
changed = true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (!changed) return;
|
|
366
|
+
if (Object.keys(candidates).length > 0) cfg.customKeyCandidates = candidates;
|
|
367
|
+
else delete cfg.customKeyCandidates;
|
|
368
|
+
ci.Configuration = JSON.stringify(cfg);
|
|
369
|
+
if (!await ci.Save()) {
|
|
370
|
+
LogError(`[CustomColumnPromoter] Failed to persist custom-key candidates for CI ${companyIntegrationID}: ${ci.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
371
|
+
}
|
|
372
|
+
} catch (err) {
|
|
373
|
+
// Best-effort — candidate persistence must never fail the sync.
|
|
374
|
+
LogError(`[CustomColumnPromoter] PersistCandidateStats failed for CI ${companyIntegrationID}: ${this.msg(err)}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
261
378
|
/** Samples the overflow column (rows where it is non-null) — dialect-agnostic via RunView. */
|
|
262
379
|
private async scanOverflow(entityName: string): Promise<Array<string | null>> {
|
|
263
380
|
const rv = new RunView();
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Entity-map / field-map lifecycle helpers shared by the ApplyAll selection
|
|
3
|
+
* path, the post-restart RSU pending-work consumer, and the schema-evolution refresh diff
|
|
4
|
+
* Selection removal DISABLES (never deletes — data is kept), re-selection
|
|
5
|
+
* re-enables, and a refresh's new objects arrive disabled-by-default.
|
|
6
|
+
*/
|
|
7
|
+
import { CompositeKey, IMetadataProvider, LogError, RunView, UserInfo } from '@memberjunction/core';
|
|
8
|
+
import type {
|
|
9
|
+
MJCompanyIntegrationEntityMapEntity,
|
|
10
|
+
MJCompanyIntegrationFieldMapEntity,
|
|
11
|
+
MJCompanyIntegrationSyncWatermarkEntity,
|
|
12
|
+
} from '@memberjunction/core-entities';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Remove-as-disable: entity maps for this CompanyIntegration whose
|
|
16
|
+
* ExternalObjectName is NOT in the selected set are set `Status='Inactive'` +
|
|
17
|
+
* `SyncEnabled=false`, and their Active field maps are disabled with them. Data and
|
|
18
|
+
* tables are KEPT — a later re-selection re-enables everything (see
|
|
19
|
+
* {@link ReenableFieldMapsForEntityMap} + the ApplyAll create-or-reuse path).
|
|
20
|
+
*
|
|
21
|
+
* @returns the ExternalObjectNames actually disabled this call.
|
|
22
|
+
*/
|
|
23
|
+
export async function DisableUnselectedEntityMaps(
|
|
24
|
+
companyIntegrationID: string,
|
|
25
|
+
selectedExternalObjectNames: string[],
|
|
26
|
+
contextUser: UserInfo,
|
|
27
|
+
md: IMetadataProvider,
|
|
28
|
+
): Promise<string[]> {
|
|
29
|
+
const selected = new Set(selectedExternalObjectNames.map(n => n.toLowerCase()));
|
|
30
|
+
const disabled: string[] = [];
|
|
31
|
+
|
|
32
|
+
const maps = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
33
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
34
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID.replace(/'/g, "''")}'`,
|
|
35
|
+
ResultType: 'entity_object',
|
|
36
|
+
BypassCache: true, // lifecycle decisions must read committed state
|
|
37
|
+
}, contextUser);
|
|
38
|
+
if (!maps.Success) return disabled;
|
|
39
|
+
|
|
40
|
+
for (const em of maps.Results) {
|
|
41
|
+
const name = (em.ExternalObjectName ?? '').toLowerCase();
|
|
42
|
+
if (!name || selected.has(name)) continue;
|
|
43
|
+
if (em.Status === 'Inactive' && em.SyncEnabled === false) continue; // already off
|
|
44
|
+
em.Status = 'Inactive';
|
|
45
|
+
em.SyncEnabled = false;
|
|
46
|
+
if (!await em.Save()) {
|
|
47
|
+
LogError(`[EntityMapLifecycle] Failed to disable entity map for '${em.ExternalObjectName}': ${em.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
await SetFieldMapsStatus(em.ID, 'Inactive', contextUser, md);
|
|
51
|
+
disabled.push(em.ExternalObjectName ?? em.ID);
|
|
52
|
+
}
|
|
53
|
+
return disabled;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sets one entity map's enabled state (Status + SyncEnabled) and cascades the same status to
|
|
58
|
+
* its field maps. Used by the schema-evolution diff: NEW objects arrive
|
|
59
|
+
* `enabled=false` by default (the user turns them on after the refresh; autoEnableNewObjects
|
|
60
|
+
* opts into enabled=true).
|
|
61
|
+
*/
|
|
62
|
+
export async function SetEntityMapEnabled(
|
|
63
|
+
entityMapID: string,
|
|
64
|
+
enabled: boolean,
|
|
65
|
+
contextUser: UserInfo,
|
|
66
|
+
md: IMetadataProvider,
|
|
67
|
+
): Promise<boolean> {
|
|
68
|
+
const em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', contextUser);
|
|
69
|
+
if (!await em.InnerLoad(CompositeKey.FromID(entityMapID))) return false;
|
|
70
|
+
em.Status = enabled ? 'Active' : 'Inactive';
|
|
71
|
+
em.SyncEnabled = enabled;
|
|
72
|
+
if (!await em.Save()) {
|
|
73
|
+
LogError(`[EntityMapLifecycle] Failed to set entity map ${entityMapID} enabled=${enabled}: ${em.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
await SetFieldMapsStatus(entityMapID, enabled ? 'Active' : 'Inactive', contextUser, md);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Re-add re-enables: flips every non-Active field map of an entity map back to
|
|
82
|
+
* Active. Called when a previously-removed (disabled) object is re-selected — the entity
|
|
83
|
+
* map itself is re-activated by the caller's create-or-reuse; this brings its field maps
|
|
84
|
+
* back with it.
|
|
85
|
+
*/
|
|
86
|
+
export async function ReenableFieldMapsForEntityMap(
|
|
87
|
+
entityMapID: string,
|
|
88
|
+
contextUser: UserInfo,
|
|
89
|
+
md: IMetadataProvider,
|
|
90
|
+
): Promise<number> {
|
|
91
|
+
return SetFieldMapsStatus(entityMapID, 'Active', contextUser, md);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Sets every field map of an entity map to the given status (skips rows already there). */
|
|
95
|
+
async function SetFieldMapsStatus(
|
|
96
|
+
entityMapID: string,
|
|
97
|
+
status: 'Active' | 'Inactive',
|
|
98
|
+
contextUser: UserInfo,
|
|
99
|
+
md: IMetadataProvider,
|
|
100
|
+
): Promise<number> {
|
|
101
|
+
const fms = await new RunView().RunView<MJCompanyIntegrationFieldMapEntity>({
|
|
102
|
+
EntityName: 'MJ: Company Integration Field Maps',
|
|
103
|
+
ExtraFilter: `EntityMapID='${entityMapID.replace(/'/g, "''")}'`,
|
|
104
|
+
ResultType: 'entity_object',
|
|
105
|
+
BypassCache: true,
|
|
106
|
+
}, contextUser);
|
|
107
|
+
if (!fms.Success) return 0;
|
|
108
|
+
let changed = 0;
|
|
109
|
+
for (const fm of fms.Results) {
|
|
110
|
+
if (fm.Status === status) continue;
|
|
111
|
+
fm.Status = status;
|
|
112
|
+
if (await fm.Save()) changed++;
|
|
113
|
+
else LogError(`[EntityMapLifecycle] Failed to set field map '${fm.SourceFieldName}' to ${status}: ${fm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
114
|
+
}
|
|
115
|
+
return changed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* DAG-aware removal advisory (rsuplan "adding and removing tables"): when a removal
|
|
120
|
+
* disables object X but a STILL-ACTIVE object depends on X (hard-FK or soft-FK parent edge —
|
|
121
|
+
* the same edges the sync engine layers by), the dependents are NOT auto-removed; instead each
|
|
122
|
+
* broken dependency is surfaced as a warning so a consumer can prompt the user to force-remove
|
|
123
|
+
* (or re-add) them. Pure + deterministic for unit testing: callers supply the parent map.
|
|
124
|
+
*
|
|
125
|
+
* @param activeObjectNames objects remaining active after the removal
|
|
126
|
+
* @param removedObjectNames objects being removed/disabled this pass
|
|
127
|
+
* @param parentsByLowerName object name (lowercased) → its parent object names (the DAG edges)
|
|
128
|
+
* @returns one warning per active object that depends on ≥1 removed object
|
|
129
|
+
*/
|
|
130
|
+
export function ComputeRemovedDependencyWarnings(
|
|
131
|
+
activeObjectNames: string[],
|
|
132
|
+
removedObjectNames: string[],
|
|
133
|
+
parentsByLowerName: Map<string, string[]>,
|
|
134
|
+
): string[] {
|
|
135
|
+
if (removedObjectNames.length === 0 || activeObjectNames.length === 0) return [];
|
|
136
|
+
const removedLower = new Set(removedObjectNames.map(n => n.toLowerCase()));
|
|
137
|
+
const warnings: string[] = [];
|
|
138
|
+
for (const name of activeObjectNames) {
|
|
139
|
+
const parents = parentsByLowerName.get(name.toLowerCase()) ?? [];
|
|
140
|
+
const cut = parents.filter(p => removedLower.has(p.toLowerCase()));
|
|
141
|
+
if (cut.length > 0) {
|
|
142
|
+
warnings.push(
|
|
143
|
+
`Object '${name}' remains active but depends on removed object(s) ${cut.map(c => `'${c}'`).join(', ')} — ` +
|
|
144
|
+
`its parent references cannot resolve until they return; review whether '${name}' should be removed too (force-remove) or the parent re-added.`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return warnings;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* DAG-aware FORCE-REMOVE (rsuplan "adding and removing tables": "a consumer may want the user
|
|
153
|
+
* to force remove those too"): computes the TRANSITIVE closure of still-active objects that
|
|
154
|
+
* depend — directly or through other dependents — on a removed object. Callers opting into
|
|
155
|
+
* cascade removal disable this set alongside the removed objects (still remove-as-disable,
|
|
156
|
+
* never delete; a re-add re-enables). Pure + deterministic for unit testing.
|
|
157
|
+
*
|
|
158
|
+
* @param activeObjectNames objects remaining active after the (non-cascaded) removal
|
|
159
|
+
* @param removedObjectNames objects being removed/disabled this pass
|
|
160
|
+
* @param parentsByLowerName object name (lowercased) → its parent object names (the DAG edges)
|
|
161
|
+
* @returns the active objects to cascade-disable, in dependency-discovery order
|
|
162
|
+
*/
|
|
163
|
+
export function ComputeCascadeRemovalSet(
|
|
164
|
+
activeObjectNames: string[],
|
|
165
|
+
removedObjectNames: string[],
|
|
166
|
+
parentsByLowerName: Map<string, string[]>,
|
|
167
|
+
): string[] {
|
|
168
|
+
if (removedObjectNames.length === 0 || activeObjectNames.length === 0) return [];
|
|
169
|
+
const gone = new Set(removedObjectNames.map(n => n.toLowerCase()));
|
|
170
|
+
const cascade: string[] = [];
|
|
171
|
+
let grew = true;
|
|
172
|
+
while (grew) {
|
|
173
|
+
grew = false;
|
|
174
|
+
for (const name of activeObjectNames) {
|
|
175
|
+
const lower = name.toLowerCase();
|
|
176
|
+
if (gone.has(lower)) continue; // already removed or already cascaded
|
|
177
|
+
const parents = parentsByLowerName.get(lower) ?? [];
|
|
178
|
+
if (parents.some(p => gone.has(p.toLowerCase()))) {
|
|
179
|
+
gone.add(lower);
|
|
180
|
+
cascade.push(name);
|
|
181
|
+
grew = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return cascade;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* U10 schema-change: resets the PULL watermark for the given entity maps so the
|
|
190
|
+
* next sync ignores the incremental cursor, re-fetches the full object, and BACKFILLS rows
|
|
191
|
+
* for newly-added columns (content-hash keeps genuinely-unchanged mapped values write-free
|
|
192
|
+
* only when the mapped set is unchanged — a schema change alters the mapped set, so changed
|
|
193
|
+
* rows rewrite exactly once). Nulling the value (not deleting the row) keeps bookkeeping.
|
|
194
|
+
*/
|
|
195
|
+
export async function ResetPullWatermarks(
|
|
196
|
+
entityMapIDs: string[],
|
|
197
|
+
contextUser: UserInfo,
|
|
198
|
+
md: IMetadataProvider,
|
|
199
|
+
): Promise<string[]> {
|
|
200
|
+
const reset: string[] = [];
|
|
201
|
+
if (entityMapIDs.length === 0) return reset;
|
|
202
|
+
const inList = entityMapIDs.map(id => `'${id.replace(/'/g, "''")}'`).join(',');
|
|
203
|
+
const wms = await new RunView().RunView<{ ID: string; EntityMapID: string }>({
|
|
204
|
+
EntityName: 'MJ: Company Integration Sync Watermarks',
|
|
205
|
+
ExtraFilter: `EntityMapID IN (${inList}) AND Direction='Pull'`,
|
|
206
|
+
Fields: ['ID', 'EntityMapID'],
|
|
207
|
+
ResultType: 'simple',
|
|
208
|
+
BypassCache: true,
|
|
209
|
+
}, contextUser);
|
|
210
|
+
if (!wms.Success) return reset;
|
|
211
|
+
for (const row of wms.Results) {
|
|
212
|
+
const wm = await md.GetEntityObject<MJCompanyIntegrationSyncWatermarkEntity>(
|
|
213
|
+
'MJ: Company Integration Sync Watermarks', contextUser
|
|
214
|
+
);
|
|
215
|
+
if (!await wm.InnerLoad(CompositeKey.FromID(row.ID))) continue;
|
|
216
|
+
wm.WatermarkValue = null;
|
|
217
|
+
if (await wm.Save()) reset.push(row.EntityMapID);
|
|
218
|
+
else LogError(`[EntityMapLifecycle] Failed to reset watermark for entity map ${row.EntityMapID}: ${wm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
219
|
+
}
|
|
220
|
+
return reset;
|
|
221
|
+
}
|