@memberjunction/server 5.48.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 +28 -2
- 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 +73 -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 +10 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
- 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/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 +53 -1
- 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 +28 -2
- 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 +75 -11
- package/src/integration/CustomColumnPromoter.ts +123 -6
- package/src/integration/EntityMapLifecycle.ts +221 -0
- package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
- 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/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';
|
|
@@ -48,7 +49,7 @@ import { createTwilioTelephonyHandler, TWILIO_TELEPHONY_MOUNT_PATH, SetTwilioTel
|
|
|
48
49
|
import { createVonageTelephonyHandler, VONAGE_TELEPHONY_MOUNT_PATH, SetVonageTelephonyService } from './telephony/index.js';
|
|
49
50
|
import { RingCentralTelephonyService, SetRingCentralTelephonyService } from './telephony/index.js';
|
|
50
51
|
import { createTeamsMeetingsHandler, TEAMS_MEETINGS_MOUNT_PATH, SetTeamsMeetingsService, GetTeamsMeetingsService, StartCalendarScheduler } from './telephony/index.js';
|
|
51
|
-
import { InstallMediaUpgradeDispatcher } from './telephony/index.js';
|
|
52
|
+
import { InstallMediaUpgradeDispatcher, IsGraphQLWsPath } from './telephony/index.js';
|
|
52
53
|
|
|
53
54
|
import { resolve } from 'node:path';
|
|
54
55
|
import { DataSourceInfo, raiseEvent } from './types.js';
|
|
@@ -324,10 +325,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
324
325
|
// Refresh user cache using PostgreSQL
|
|
325
326
|
await refreshUserCacheFromPG(pgPool, mj_core_schema);
|
|
326
327
|
|
|
327
|
-
// Run startup actions
|
|
328
|
+
// Run startup actions — same 'full' entry-point default as the SQL Server path
|
|
328
329
|
const sysUser = UserCache.Instance.GetSystemUser();
|
|
329
330
|
const backupSysUser = UserCache.Instance.Users.find(u => u.IsActive && u.Type === 'Owner');
|
|
330
|
-
|
|
331
|
+
const pgStartupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
|
|
332
|
+
await StartupManagerImport.Instance.Startup(false, sysUser || backupSysUser, provider, { mode: pgStartupMode.mode });
|
|
331
333
|
|
|
332
334
|
// Monkey-patch SQLServerDataProvider.ExecuteSQLWithPool to support PostgreSQL
|
|
333
335
|
// Generated resolvers call this static method with bracket-quoted SQL.
|
|
@@ -417,6 +419,17 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
417
419
|
if (cgConfig.additionalSchemaInfo) {
|
|
418
420
|
RuntimeSchemaManager.Instance.SetAdditionalSchemaInfoPath(cgConfig.additionalSchemaInfo);
|
|
419
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
|
+
);
|
|
420
433
|
}
|
|
421
434
|
} catch (codegenErr) {
|
|
422
435
|
console.warn(`RSU in-process CodeGen runner setup failed (will fall back to child process): ${(codegenErr as Error).message}`);
|
|
@@ -469,7 +482,10 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
469
482
|
}
|
|
470
483
|
|
|
471
484
|
const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
|
|
472
|
-
|
|
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 });
|
|
473
489
|
lap('Metadata + Provider Setup', tPhase);
|
|
474
490
|
startupLog.BeginPhase('Initializing data provider');
|
|
475
491
|
const md = new Metadata(); // global-provider-ok: bootstrap
|
|
@@ -542,6 +558,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
542
558
|
if (codegenConfig.additionalSchemaInfo) {
|
|
543
559
|
RuntimeSchemaManager.Instance.SetAdditionalSchemaInfoPath(codegenConfig.additionalSchemaInfo);
|
|
544
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
|
+
);
|
|
545
569
|
}
|
|
546
570
|
} catch (codegenErr) {
|
|
547
571
|
console.warn(`RSU in-process CodeGen runner setup failed (will fall back to child process): ${(codegenErr as Error).message}`);
|
|
@@ -867,6 +891,9 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
867
891
|
// lets self-hosted realtime providers — e.g. HuggingFace speech-to-speech — run the shipped client-direct
|
|
868
892
|
// audio topology without the internal endpoint ever being exposed to the browser); everything on the
|
|
869
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."
|
|
870
897
|
httpServer.on('upgrade', (request, socket, head) => {
|
|
871
898
|
if (RealtimeProxyServer.Instance.TryHandleUpgrade(request, socket, head as Buffer)) {
|
|
872
899
|
return;
|
|
@@ -877,7 +904,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
877
904
|
} catch {
|
|
878
905
|
/* unparseable — fall through to the graphql-path check, which will reject it */
|
|
879
906
|
}
|
|
880
|
-
if (pathname
|
|
907
|
+
if (IsGraphQLWsPath(pathname, graphqlRootPath)) {
|
|
881
908
|
webSocketServer.handleUpgrade(request, socket, head, (ws) => webSocketServer.emit('connection', ws, request));
|
|
882
909
|
} else {
|
|
883
910
|
socket.destroy();
|
|
@@ -894,7 +921,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
894
921
|
// This prevents re-validating (and re-logging) on every subscription message.
|
|
895
922
|
onConnect: async (ctx) => {
|
|
896
923
|
try {
|
|
897
|
-
|
|
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) : '';
|
|
898
930
|
// Carry API keys from connectionParams so API-key / MCP / Node clients can authenticate the socket
|
|
899
931
|
// (validated the same way as the HTTP x-mj-api-key / x-mj-user-api-key headers).
|
|
900
932
|
const systemApiKey = ctx.connectionParams?.['x-mj-api-key'] ? String(ctx.connectionParams['x-mj-api-key']) : undefined;
|
|
@@ -1476,7 +1508,22 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1476
1508
|
let isNewMap = false;
|
|
1477
1509
|
|
|
1478
1510
|
if (existingMapResult.Success && existingMapResult.Results.length > 0) {
|
|
1479
|
-
|
|
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
|
+
}
|
|
1480
1527
|
console.log(`[RSU] Entity map already exists for ${objName} → ${entity.Name} (${entityMapID})`);
|
|
1481
1528
|
} else {
|
|
1482
1529
|
const entityMap = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>(
|
|
@@ -1487,8 +1534,10 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1487
1534
|
entityMap.EntityID = entity.ID;
|
|
1488
1535
|
entityMap.ExternalObjectName = objName;
|
|
1489
1536
|
entityMap.SyncDirection = 'Pull';
|
|
1490
|
-
|
|
1491
|
-
|
|
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;
|
|
1492
1541
|
const mapSaved = await entityMap.Save();
|
|
1493
1542
|
if (!mapSaved) {
|
|
1494
1543
|
console.warn(`[RSU] Failed to save entity map for ${objName}`);
|
|
@@ -1533,7 +1582,7 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1533
1582
|
fieldMap.IsKeyField = field.IsPrimaryKey ?? false;
|
|
1534
1583
|
fieldMap.IsRequired = field.IsRequired ?? false;
|
|
1535
1584
|
fieldMap.Direction = 'SourceToDest';
|
|
1536
|
-
fieldMap.Status = 'Active';
|
|
1585
|
+
fieldMap.Status = item.CreateDisabled ? 'Inactive' : 'Active';
|
|
1537
1586
|
if (await fieldMap.Save()) fieldCount++;
|
|
1538
1587
|
}
|
|
1539
1588
|
console.log(`[RSU] Created entity map for ${objName} → ${entity.Name} with ${fieldCount} field maps${isNewMap ? '' : ' (existing map, new fields only)'}`);
|
|
@@ -1542,6 +1591,21 @@ async function processRSUPendingWork(): Promise<void> {
|
|
|
1542
1591
|
}
|
|
1543
1592
|
}
|
|
1544
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
|
+
|
|
1545
1609
|
// Start sync if requested
|
|
1546
1610
|
if (item.StartSync !== false) {
|
|
1547
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
|
+
}
|