@memberjunction/server 5.48.0 → 5.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +9 -0
  2. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  3. package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
  4. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
  6. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  7. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.js +4 -0
  9. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  10. package/dist/auth/newUsers.d.ts.map +1 -1
  11. package/dist/auth/newUsers.js +6 -4
  12. package/dist/auth/newUsers.js.map +1 -1
  13. package/dist/config.d.ts +40 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +12 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts.map +1 -1
  18. package/dist/context.js +28 -2
  19. package/dist/context.js.map +1 -1
  20. package/dist/generated/generated.d.ts +373 -6
  21. package/dist/generated/generated.d.ts.map +1 -1
  22. package/dist/generated/generated.js +1975 -39
  23. package/dist/generated/generated.js.map +1 -1
  24. package/dist/generic/ResolverBase.d.ts.map +1 -1
  25. package/dist/generic/ResolverBase.js +4 -2
  26. package/dist/generic/ResolverBase.js.map +1 -1
  27. package/dist/generic/RunViewResolver.d.ts +1 -0
  28. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  29. package/dist/generic/RunViewResolver.js +12 -0
  30. package/dist/generic/RunViewResolver.js.map +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +73 -11
  33. package/dist/index.js.map +1 -1
  34. package/dist/integration/CustomColumnPromoter.d.ts +15 -2
  35. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  36. package/dist/integration/CustomColumnPromoter.js +113 -11
  37. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  38. package/dist/integration/EntityMapLifecycle.d.ts +66 -0
  39. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
  40. package/dist/integration/EntityMapLifecycle.js +189 -0
  41. package/dist/integration/EntityMapLifecycle.js.map +1 -0
  42. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
  43. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  44. package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
  45. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  46. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
  47. package/dist/realtimeWidget/WidgetRouter.js +6 -2
  48. package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
  49. package/dist/realtimeWidget/WidgetSessionService.d.ts +12 -1
  50. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  51. package/dist/realtimeWidget/WidgetSessionService.js +23 -8
  52. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  53. package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
  54. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  55. package/dist/resolvers/AdhocQueryResolver.js +81 -25
  56. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  57. package/dist/resolvers/GetDataResolver.d.ts +16 -0
  58. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  59. package/dist/resolvers/GetDataResolver.js +26 -1
  60. package/dist/resolvers/GetDataResolver.js.map +1 -1
  61. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
  62. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  63. package/dist/resolvers/IntegrationDiscoveryResolver.js +531 -64
  64. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  65. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  66. package/dist/resolvers/QueryResolver.js +0 -6
  67. package/dist/resolvers/QueryResolver.js.map +1 -1
  68. package/dist/resolvers/RSUResolver.d.ts +10 -0
  69. package/dist/resolvers/RSUResolver.d.ts.map +1 -1
  70. package/dist/resolvers/RSUResolver.js +28 -0
  71. package/dist/resolvers/RSUResolver.js.map +1 -1
  72. package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
  73. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  74. package/dist/resolvers/RunAIAgentResolver.js +135 -132
  75. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  76. package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
  77. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  78. package/dist/resolvers/TransactionGroupResolver.js +29 -1
  79. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  80. package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
  81. package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
  82. package/dist/resolvers/adhoc-query-helpers.js +42 -0
  83. package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
  84. package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
  85. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  86. package/dist/rest/OAuthCallbackHandler.js +21 -4
  87. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  88. package/dist/telephony/media-upgrade-router.d.ts +14 -0
  89. package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
  90. package/dist/telephony/media-upgrade-router.js +17 -1
  91. package/dist/telephony/media-upgrade-router.js.map +1 -1
  92. package/package.json +91 -89
  93. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
  94. package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
  95. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
  96. package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
  97. package/src/__tests__/WidgetSessionService.test.ts +96 -0
  98. package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
  99. package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
  100. package/src/__tests__/media-upgrade-router.test.ts +43 -0
  101. package/src/__tests__/unifiedAuth.test.ts +18 -3
  102. package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
  103. package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
  104. package/src/auth/magicLink/MagicLinkService.ts +4 -0
  105. package/src/auth/newUsers.ts +6 -5
  106. package/src/config.ts +14 -0
  107. package/src/context.ts +28 -2
  108. package/src/generated/generated.ts +1385 -28
  109. package/src/generic/ResolverBase.ts +5 -2
  110. package/src/generic/RunViewResolver.ts +11 -0
  111. package/src/index.ts +75 -11
  112. package/src/integration/CustomColumnPromoter.ts +123 -6
  113. package/src/integration/EntityMapLifecycle.ts +221 -0
  114. package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
  115. package/src/realtimeWidget/WidgetRouter.ts +6 -2
  116. package/src/realtimeWidget/WidgetSessionService.ts +24 -8
  117. package/src/resolvers/AdhocQueryResolver.ts +97 -30
  118. package/src/resolvers/GetDataResolver.ts +30 -1
  119. package/src/resolvers/IntegrationDiscoveryResolver.ts +523 -64
  120. package/src/resolvers/QueryResolver.ts +1 -7
  121. package/src/resolvers/RSUResolver.ts +28 -0
  122. package/src/resolvers/RunAIAgentResolver.ts +74 -70
  123. package/src/resolvers/TransactionGroupResolver.ts +36 -2
  124. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
  125. package/src/resolvers/adhoc-query-helpers.ts +50 -0
  126. package/src/rest/OAuthCallbackHandler.ts +22 -4
  127. package/src/telephony/media-upgrade-router.ts +18 -1
@@ -673,14 +673,17 @@ export class ResolverBase {
673
673
 
674
674
  const apiKeyEngine = GetAPIKeyEngine();
675
675
 
676
- // Check for full_access scope first (god power - bypasses all other checks)
676
+ // Check for full_access scope first (god power - bypasses all other checks).
677
+ // Use skipLogging to avoid polluting the usage log with expected denials —
678
+ // this is a fast-path optimization, not the real authorization decision.
677
679
  const fullAccessResult = await apiKeyEngine.Authorize(
678
680
  userPayload.apiKeyHash,
679
681
  'MJAPI',
680
682
  'full_access',
681
683
  '*',
682
684
  systemUser,
683
- { endpoint: '/graphql', method: 'POST' }
685
+ { endpoint: '/graphql', method: 'POST' },
686
+ { skipLogging: true }
684
687
  );
685
688
 
686
689
  if (fullAccessResult.Allowed) {
@@ -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
- await StartupManagerImport.Instance.Startup(false, sysUser || backupSysUser, provider);
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
- await setupSQLServerClient(config);
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 === graphqlRootPath) {
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
- const token = String(ctx.connectionParams?.Authorization);
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
- entityMapID = existingMapResult.Results[0].ID;
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
- entityMap.Status = 'Active';
1491
- entityMap.SyncEnabled = true;
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
- const promoter = new IntegrationCustomColumnPromoter(user, provider);
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 passing = planPromotions(buildOverflowStats(overflowJson), {});
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
+ }