@memberjunction/server 5.41.0 → 5.42.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 (76) hide show
  1. package/dist/agentSessions/index.d.ts +1 -0
  2. package/dist/agentSessions/index.d.ts.map +1 -1
  3. package/dist/agentSessions/index.js +5 -0
  4. package/dist/agentSessions/index.js.map +1 -1
  5. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
  6. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
  7. package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
  8. package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
  9. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
  10. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
  11. package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
  12. package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
  13. package/dist/auth/initializeProviders.d.ts.map +1 -1
  14. package/dist/auth/initializeProviders.js +4 -7
  15. package/dist/auth/initializeProviders.js.map +1 -1
  16. package/dist/config.d.ts +7 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +12 -2
  19. package/dist/config.js.map +1 -1
  20. package/dist/generated/generated.d.ts +591 -0
  21. package/dist/generated/generated.d.ts.map +1 -1
  22. package/dist/generated/generated.js +6832 -3610
  23. package/dist/generated/generated.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +33 -16
  26. package/dist/index.js.map +1 -1
  27. package/dist/integration/CustomColumnPromoter.d.ts +108 -0
  28. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
  29. package/dist/integration/CustomColumnPromoter.js +508 -0
  30. package/dist/integration/CustomColumnPromoter.js.map +1 -0
  31. package/dist/logging/StartupLogger.d.ts +57 -1
  32. package/dist/logging/StartupLogger.d.ts.map +1 -1
  33. package/dist/logging/StartupLogger.js +115 -6
  34. package/dist/logging/StartupLogger.js.map +1 -1
  35. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +27 -0
  36. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
  37. package/dist/resolvers/ExecuteRemoteOperationResolver.js +136 -0
  38. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
  39. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
  40. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  41. package/dist/resolvers/IntegrationDiscoveryResolver.js +544 -42
  42. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  43. package/dist/resolvers/RealtimeBridgeResolver.d.ts +121 -0
  44. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
  45. package/dist/resolvers/RealtimeBridgeResolver.js +533 -0
  46. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
  47. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +78 -5
  48. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  49. package/dist/resolvers/RemoteBrowserActionResolver.js +227 -15
  50. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  51. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  52. package/dist/resolvers/RunAIAgentResolver.js +0 -7
  53. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  54. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  55. package/dist/rest/setupRESTEndpoints.js +5 -3
  56. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  57. package/package.json +83 -78
  58. package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
  59. package/src/__tests__/RemoteBrowserAudioStream.test.ts +8 -1
  60. package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
  61. package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
  62. package/src/agentSessions/index.ts +5 -0
  63. package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
  64. package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
  65. package/src/auth/initializeProviders.ts +4 -7
  66. package/src/config.ts +13 -2
  67. package/src/generated/generated.ts +2444 -205
  68. package/src/index.ts +33 -16
  69. package/src/integration/CustomColumnPromoter.ts +597 -0
  70. package/src/logging/StartupLogger.ts +130 -6
  71. package/src/resolvers/ExecuteRemoteOperationResolver.ts +108 -0
  72. package/src/resolvers/IntegrationDiscoveryResolver.ts +396 -37
  73. package/src/resolvers/RealtimeBridgeResolver.ts +450 -0
  74. package/src/resolvers/RemoteBrowserActionResolver.ts +232 -17
  75. package/src/resolvers/RunAIAgentResolver.ts +1 -9
  76. package/src/rest/setupRESTEndpoints.ts +5 -3
@@ -1,4 +1,4 @@
1
- import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType } from "type-graphql";
1
+ import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType, Int, Float } from "type-graphql";
2
2
  import { CompositeKey, DatabaseProviderBase, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
3
3
  import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
4
4
  import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
@@ -28,6 +28,7 @@ import {
28
28
  IntegrationSyncOptions,
29
29
  SourceSchemaInfo,
30
30
  IntegrationSchemaSync,
31
+ decideSchemaLimitViolations,
31
32
  IntegrationConnectorCreationPipeline,
32
33
  IntegrationActionGenerator
33
34
  } from "@memberjunction/integration-engine";
@@ -50,6 +51,7 @@ import type { SchemaBuilderOutput } from "@memberjunction/integration-schema-bui
50
51
  import { IntegrationProgressReader } from "@memberjunction/integration-progress-artifacts";
51
52
  import type { IntegrationRunSnapshot, IntegrationRunKind } from "@memberjunction/integration-progress-artifacts";
52
53
  import { ResolverBase } from "../generic/ResolverBase.js";
54
+ import { IntegrationCustomColumnPromoter } from "../integration/CustomColumnPromoter.js";
53
55
  import { AppContext } from "../types.js";
54
56
  import { RequireSystemUser } from "../directives/RequireSystemUser.js";
55
57
  import { UserCache } from "@memberjunction/sqlserver-dataprovider";
@@ -570,6 +572,78 @@ class MutationResultOutput {
570
572
  @Field() Message: string;
571
573
  }
572
574
 
575
+ // ─── Typed sync-config (rate-limit / concurrency / time-budget as STRUCTURED fields, not a raw
576
+ // Configuration JSON blob). These map to the CompanyIntegration.Configuration keys the engine
577
+ // reads at runtime, so they are customizable per-connection via the API instead of hidden code
578
+ // constants. Set merges (preserves other Configuration keys); Get reads them back typed. ──────
579
+ @InputType()
580
+ class IntegrationSyncConfigInput {
581
+ @Field(() => Int, { nullable: true, description: 'Entity maps processed concurrently within a dependency layer (clamped 1-16). Default 1 (sequential).' }) SyncConcurrency?: number;
582
+ @Field(() => Int, { nullable: true, description: 'Upper bound the per-layer AIMD controller ramps toward. Default = connector MaxConcurrencyHint.' }) MaxConcurrency?: number;
583
+ @Field(() => Float, { nullable: true, description: 'Override the source rate limit (requests/sec). Default = connector RateLimitPolicy / derived.' }) RateLimitTokensPerSec?: number;
584
+ @Field(() => Int, { nullable: true, description: 'Override the rate-limiter burst capacity (tokens).' }) RateLimitBurst?: number;
585
+ @Field(() => Boolean, { nullable: true, description: '§4 cross-layer pipelining: a child map starts when ITS parents finish, not the whole layer.' }) CrossLayerPipeline?: boolean;
586
+ @Field(() => Boolean, { nullable: true, description: 'Merkle/partition hash-diff reconcile for watermark-less change detection (buffers the set in RAM).' }) PartitionReconcile?: boolean;
587
+ @Field(() => Int, { nullable: true, description: 'Time budget (ms) for stage-2 streaming field discovery before it stops and uses what it gathered.' }) DiscoveryTimeBudgetMs?: number;
588
+ @Field(() => Int, { nullable: true, description: 'Batch size for stage-2 streaming field discovery (records per FetchChanges page). Default 500.' }) DiscoveryBatchSize?: number;
589
+ @Field(() => Int, { nullable: true, description: 'Max records sampled in stage-2 streaming field discovery (a column corpus + PK guess; NOT a full scan). Default 500.' }) DiscoveryMaxRecords?: number;
590
+ @Field(() => Boolean, { nullable: true, description: '§7 Comprehensive refresh deactivates Declared objects/fields ABSENT from an AUTHORITATIVE discovery (reversible). Default false.' }) DeactivateAbsent?: boolean;
591
+ // NOTE: §B table/column caps (MJ_INTEGRATION_MAX_TABLES / _MAX_COLUMNS_PER_TABLE) are DELIBERATELY NOT here —
592
+ // they are operator/env guardrails, not per-connection user settings (a user-raisable cap is toothless).
593
+ }
594
+
595
+ @ObjectType()
596
+ class IntegrationSyncConfigOutput {
597
+ @Field() Success: boolean;
598
+ @Field() Message: string;
599
+ @Field(() => Int, { nullable: true }) SyncConcurrency?: number;
600
+ @Field(() => Int, { nullable: true }) MaxConcurrency?: number;
601
+ @Field(() => Float, { nullable: true }) RateLimitTokensPerSec?: number;
602
+ @Field(() => Int, { nullable: true }) RateLimitBurst?: number;
603
+ @Field(() => Boolean, { nullable: true }) CrossLayerPipeline?: boolean;
604
+ @Field(() => Boolean, { nullable: true }) PartitionReconcile?: boolean;
605
+ @Field(() => Int, { nullable: true }) DiscoveryTimeBudgetMs?: number;
606
+ @Field(() => Int, { nullable: true }) DiscoveryBatchSize?: number;
607
+ @Field(() => Int, { nullable: true }) DiscoveryMaxRecords?: number;
608
+ @Field(() => Boolean, { nullable: true }) DeactivateAbsent?: boolean;
609
+ }
610
+
611
+ @ObjectType()
612
+ class CustomColumnCandidate {
613
+ @Field() EntityName: string;
614
+ /** The source field key as captured in the overflow column. */
615
+ @Field() SourceKey: string;
616
+ /** The sanitized, collision-resolved column name that would be created. */
617
+ @Field() ColumnName: string;
618
+ /** Inferred schema-field type family ('string' | 'number' | 'boolean' | 'datetime'). */
619
+ @Field() InferredType: string;
620
+ /** true = the real column does not exist yet (ADD COLUMN); false = recovery (column exists, mapping missing). */
621
+ @Field() NeedsColumn: boolean;
622
+ }
623
+
624
+ @ObjectType()
625
+ class CustomColumnCandidatesOutput {
626
+ @Field() Success: boolean;
627
+ @Field() Message: string;
628
+ @Field(() => [CustomColumnCandidate]) Candidates: CustomColumnCandidate[];
629
+ }
630
+
631
+ @ObjectType()
632
+ class PromotedColumn {
633
+ @Field() EntityName: string;
634
+ @Field() ColumnName: string;
635
+ }
636
+
637
+ @ObjectType()
638
+ class PromoteCustomColumnsOutput {
639
+ @Field() Success: boolean;
640
+ @Field() Message: string;
641
+ @Field() Promoted: boolean;
642
+ @Field(() => [PromotedColumn]) ColumnsAdded: PromotedColumn[];
643
+ /** true when an RSU schema change ran (a server restart may be pending to expose the new columns). */
644
+ @Field() SchemaUpdatePending: boolean;
645
+ }
646
+
573
647
  @InputType()
574
648
  class FieldMapInput {
575
649
  @Field() SourceFieldName: string;
@@ -628,6 +702,10 @@ class CreateScheduleInput {
628
702
  @Field({ nullable: true }) Description?: string;
629
703
  @Field({ nullable: true }) SyncDirection?: string;
630
704
  @Field({ nullable: true }) FullSync?: boolean;
705
+ /** §13 — 'sync' (default; moves data via RunSync) or 'discovery' (schema-only RefreshConnectorSchema on cron, evolving the IO/IOF catalog — no RSU, no data sync). */
706
+ @Field({ nullable: true, defaultValue: 'sync' }) JobKind?: string;
707
+ /** Discovery-job only: deactivate objects/fields absent from an authoritative refresh (reversible). Default true. */
708
+ @Field({ nullable: true }) DeactivateAbsent?: boolean;
631
709
  }
632
710
 
633
711
  @ObjectType()
@@ -1181,9 +1259,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1181
1259
  * `<cwd>/logs/integration-runs/<runID>/progress.jsonl` artifact.
1182
1260
  */
1183
1261
  @Mutation(() => RefreshConnectorSchemaOutput)
1262
+ @RequireSystemUser()
1184
1263
  async IntegrationRefreshConnectorSchema(
1185
1264
  @Arg("companyIntegrationID") companyIntegrationID: string,
1186
1265
  @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK convention hint (e.g. 'id' for HubSpot)" }) universalPKConvention: string | undefined,
1266
+ @Arg("deactivateAbsent", { nullable: true, description: "Comprehensive refresh (default true): objects/fields ABSENT from this discovery are deactivated (Status='Disabled', never deleted, reversible on a later rediscovery). Pass false for a scoped/partial discovery so it never disables what it didn't probe." }) deactivateAbsent: boolean | undefined,
1187
1267
  @Ctx() ctx: AppContext
1188
1268
  ): Promise<RefreshConnectorSchemaOutput> {
1189
1269
  try {
@@ -1204,6 +1284,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1204
1284
  UniversalPKConvention: universalPKConvention || undefined,
1205
1285
  ConsoleMirror: true,
1206
1286
  TriggerType: 'Manual' as const,
1287
+ // §7 — explicit RefreshConnectorSchema is a comprehensive re-discovery: default to
1288
+ // deactivating objects/fields the source no longer exposes (reversible). Precedence:
1289
+ // explicit arg > persisted Configuration.deactivateAbsent (set via IntegrationSetSyncConfig) >
1290
+ // comprehensive default (true). Caller can opt out per-call or per-connection.
1291
+ DeactivateAbsent: deactivateAbsent ?? this.readConfigBool(companyIntegration.Configuration, 'deactivateAbsent') ?? true,
1207
1292
  };
1208
1293
  const result = await pipeline.Run(runOpts as unknown as Parameters<typeof pipeline.Run>[0]);
1209
1294
 
@@ -1260,6 +1345,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1260
1345
  * the IntegrationActionExecutor (CoreActions) is the single runtime dispatcher.
1261
1346
  */
1262
1347
  @Mutation(() => IntegrationGenerateActionOutput)
1348
+ @RequireSystemUser()
1263
1349
  async IntegrationGenerateAction(
1264
1350
  @Arg("integrationName") integrationName: string,
1265
1351
  @Arg("objectName") objectName: string,
@@ -1553,14 +1639,20 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1553
1639
  SourceFieldName: f.Name,
1554
1640
  TargetColumnName: f.Name.replace(/[^A-Za-z0-9_]/g, '_'),
1555
1641
  TargetSqlType: targetSqlType,
1556
- // Synced shadow tables must NOT enforce NOT NULL on non-PK
1557
- // columns. The external system (SF, HubSpot, etc.) is the
1558
- // source of truth for business data, not for MJ's schema
1559
- // constraints — and its describe output often declares
1560
- // fields required when real records actually have nulls
1561
- // (deprecated, calculated, or edge-case fields). Enforcing
1562
- // NOT NULL here just aborts entire batches on one bad row.
1563
- IsNullable: !f.IsPrimaryKey,
1642
+ // Synced shadow tables must NOT enforce NOT NULL on ANY
1643
+ // column — including the PK. The external system (SF, HubSpot,
1644
+ // GrowthZone, etc.) is the source of truth for business data,
1645
+ // not for MJ's schema constraints — and its describe output
1646
+ // often declares fields required when real records actually
1647
+ // have nulls (deprecated, calculated, or edge-case fields).
1648
+ // Integration PKs are SOFT (tracked via SchemaBuilder.SoftPrimaryKeys
1649
+ // for upsert/dedup; identity falls back to a content-hash when the
1650
+ // PK is null/partial — §4). Emitting the PK column NOT NULL breaks
1651
+ // that fallback: a source row with a null PK (e.g. nested/derived
1652
+ // records like event sponsors, contact phones) aborts the insert
1653
+ // before content-hash can save it. So the soft-PK column is nullable
1654
+ // too; uniqueness/identity is enforced logically, not by the DDL.
1655
+ IsNullable: true,
1564
1656
  MaxLength: f.MaxLength,
1565
1657
  Precision: f.Precision,
1566
1658
  Scale: f.Scale,
@@ -1581,12 +1673,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1581
1673
  continue;
1582
1674
  }
1583
1675
 
1584
- // If columns exist but no PK was found, log diagnostic info and skip rather than
1585
- // generating broken DDL with UNIQUE ([ID]) on a non-existent column.
1676
+ // Provable-only: no PK we could prove from the streamed data (single OR composite) means
1677
+ // the object is NOT added and we say so clearly. We never fabricate a key (e.g. "all
1678
+ // columns as the PK"); a wrong identity is worse than an honest omission. The fix for a
1679
+ // missing PK is to STREAM MORE DATA at discovery time so the stats can prove one, not to
1680
+ // invent a key here.
1586
1681
  if (primaryKeyFields.length === 0 && columns.length > 0) {
1587
1682
  droppedNoPrimaryKey.push(obj.SourceObjectName);
1588
1683
  const fieldNames = sourceObj.Fields.map(f => `${f.Name}(pk=${f.IsPrimaryKey})`).join(', ');
1589
- LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — ${columns.length} columns but NO primary key field found. Fields: [${fieldNames}]`);
1684
+ LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — ${columns.length} columns but NO provable primary key. Fields: [${fieldNames}]`);
1590
1685
  continue;
1591
1686
  }
1592
1687
 
@@ -2124,7 +2219,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2124
2219
  requestedNames?: string[],
2125
2220
  ): SourceSchemaInfo {
2126
2221
  const engine = IntegrationEngineBase.Instance;
2127
- const ios = engine.GetIntegrationObjectsByIntegrationID(integrationID);
2222
+ // ACTIVE-only materialization: an object/field a given tenant doesn't expose is marked
2223
+ // Status='Inactive' by discovery (the phantom-skip), and MUST NOT be materialized — creating
2224
+ // empty tables/columns for absent objects wastes storage AND, more importantly, blows up the
2225
+ // per-entity CodeGen + advancedGen (AI form-layout) time on every ApplyAll. The sync path
2226
+ // already filters active (GetActiveIntegrationObjects); this build site now matches it.
2227
+ const ios = engine.GetActiveIntegrationObjects(integrationID);
2128
2228
  const filter = requestedNames && requestedNames.length > 0
2129
2229
  ? new Set(requestedNames.map(n => n.toLowerCase()))
2130
2230
  : null;
@@ -2137,7 +2237,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2137
2237
  const result: SourceSchemaInfo = { Objects: [] };
2138
2238
  for (const io of ios) {
2139
2239
  if (filter && !filter.has(io.Name.toLowerCase())) continue;
2140
- const iofs = engine.GetIntegrationObjectFields(io.ID);
2240
+ // Active fields only — an inactive (source-absent / deactivated) field is not materialized.
2241
+ const iofs = engine.GetIntegrationObjectFields(io.ID).filter(iof => iof.Status === 'Active');
2141
2242
 
2142
2243
  const fields = iofs.map(iof => {
2143
2244
  const targetIOName = iof.RelatedIntegrationObjectID
@@ -2388,6 +2489,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2388
2489
  * Creates a CompanyIntegration with a linked Credential entity for encrypted credential storage.
2389
2490
  */
2390
2491
  @Mutation(() => CreateConnectionOutput)
2492
+ @RequireSystemUser()
2391
2493
  async IntegrationCreateConnection(
2392
2494
  @Arg("input") input: CreateConnectionInput,
2393
2495
  @Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
@@ -2573,6 +2675,174 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2573
2675
  }
2574
2676
  }
2575
2677
 
2678
+ /**
2679
+ * Sets the per-connection sync tuning (rate limit, concurrency, time budget, pipeline flags) as
2680
+ * STRUCTURED typed fields, merged into CompanyIntegration.Configuration (other keys preserved).
2681
+ * These are the exact keys the IntegrationEngine reads at runtime, so they become customizable
2682
+ * via the API instead of hidden code constants. Returns the merged config typed.
2683
+ */
2684
+ @Mutation(() => IntegrationSyncConfigOutput)
2685
+ async IntegrationSetSyncConfig(
2686
+ @Arg("companyIntegrationID") companyIntegrationID: string,
2687
+ @Arg("config", () => IntegrationSyncConfigInput) config: IntegrationSyncConfigInput,
2688
+ @Ctx() ctx: AppContext
2689
+ ): Promise<IntegrationSyncConfigOutput> {
2690
+ try {
2691
+ const user = this.getAuthenticatedUser(ctx);
2692
+ const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
2693
+ const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
2694
+ if (!await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID))) {
2695
+ return { Success: false, Message: 'CompanyIntegration not found' };
2696
+ }
2697
+ let cfg: Record<string, unknown> = {};
2698
+ try { if (ci.Configuration) cfg = JSON.parse(ci.Configuration) as Record<string, unknown>; } catch { cfg = {}; }
2699
+ const set = (key: string, val: unknown) => { if (val !== undefined && val !== null) cfg[key] = val; };
2700
+ set('syncConcurrency', config.SyncConcurrency);
2701
+ set('maxConcurrency', config.MaxConcurrency);
2702
+ set('rateLimitTokensPerSec', config.RateLimitTokensPerSec);
2703
+ set('rateLimitBurst', config.RateLimitBurst);
2704
+ set('crossLayerPipeline', config.CrossLayerPipeline);
2705
+ set('partitionReconcile', config.PartitionReconcile);
2706
+ set('discoveryTimeBudgetMs', config.DiscoveryTimeBudgetMs);
2707
+ set('discoveryBatchSize', config.DiscoveryBatchSize);
2708
+ set('discoveryMaxRecords', config.DiscoveryMaxRecords);
2709
+ set('deactivateAbsent', config.DeactivateAbsent);
2710
+ ci.Configuration = JSON.stringify(cfg);
2711
+ if (!await ci.Save()) return { Success: false, Message: `Failed to save: ${ci.LatestResult?.CompleteMessage ?? 'unknown'}` };
2712
+ return { Success: true, Message: 'Sync config updated', ...this.readSyncConfig(cfg) };
2713
+ } catch (e) {
2714
+ LogError(`IntegrationSetSyncConfig error: ${e}`);
2715
+ return { Success: false, Message: this.formatError(e) };
2716
+ }
2717
+ }
2718
+
2719
+ /** Reads the per-connection sync tuning back as STRUCTURED typed fields. */
2720
+ @Query(() => IntegrationSyncConfigOutput)
2721
+ async IntegrationGetSyncConfig(
2722
+ @Arg("companyIntegrationID") companyIntegrationID: string,
2723
+ @Ctx() ctx: AppContext
2724
+ ): Promise<IntegrationSyncConfigOutput> {
2725
+ try {
2726
+ const user = this.getAuthenticatedUser(ctx);
2727
+ const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
2728
+ const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
2729
+ if (!await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID))) {
2730
+ return { Success: false, Message: 'CompanyIntegration not found' };
2731
+ }
2732
+ let cfg: Record<string, unknown> = {};
2733
+ try { if (ci.Configuration) cfg = JSON.parse(ci.Configuration) as Record<string, unknown>; } catch { cfg = {}; }
2734
+ return { Success: true, Message: 'OK', ...this.readSyncConfig(cfg) };
2735
+ } catch (e) {
2736
+ LogError(`IntegrationGetSyncConfig error: ${e}`);
2737
+ return { Success: false, Message: this.formatError(e) };
2738
+ }
2739
+ }
2740
+
2741
+ /** Reads a single boolean key from a CompanyIntegration.Configuration JSON string (undefined if absent/malformed). */
2742
+ private readConfigBool(configuration: string | null | undefined, key: string): boolean | undefined {
2743
+ try {
2744
+ if (!configuration) return undefined;
2745
+ const v = (JSON.parse(configuration) as Record<string, unknown>)[key];
2746
+ return typeof v === 'boolean' ? v : undefined;
2747
+ } catch { return undefined; }
2748
+ }
2749
+
2750
+ /** Extracts the typed sync-config fields from a parsed Configuration object (type-guarded). */
2751
+ private readSyncConfig(cfg: Record<string, unknown>): Partial<IntegrationSyncConfigOutput> {
2752
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
2753
+ const bool = (v: unknown) => (typeof v === 'boolean' ? v : undefined);
2754
+ return {
2755
+ SyncConcurrency: num(cfg.syncConcurrency),
2756
+ MaxConcurrency: num(cfg.maxConcurrency),
2757
+ RateLimitTokensPerSec: num(cfg.rateLimitTokensPerSec),
2758
+ RateLimitBurst: num(cfg.rateLimitBurst),
2759
+ CrossLayerPipeline: bool(cfg.crossLayerPipeline),
2760
+ PartitionReconcile: bool(cfg.partitionReconcile),
2761
+ DiscoveryTimeBudgetMs: num(cfg.discoveryTimeBudgetMs),
2762
+ DiscoveryBatchSize: num(cfg.discoveryBatchSize),
2763
+ DiscoveryMaxRecords: num(cfg.discoveryMaxRecords),
2764
+ DeactivateAbsent: bool(cfg.deactivateAbsent),
2765
+ };
2766
+ }
2767
+
2768
+ /** The MJ entity names this connection has entity maps for (for whole-connection scope). */
2769
+ private async getMappedEntityNames(companyIntegrationID: string, user: UserInfo): Promise<string[]> {
2770
+ const rv = new RunView();
2771
+ const res = await rv.RunView<{ Entity: string }>({
2772
+ EntityName: 'MJ: Company Integration Entity Maps',
2773
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
2774
+ Fields: ['Entity'],
2775
+ ResultType: 'simple',
2776
+ }, user);
2777
+ if (!res.Success) return [];
2778
+ return Array.from(new Set((res.Results ?? []).map(r => r.Entity).filter((e): e is string => !!e)));
2779
+ }
2780
+
2781
+ /**
2782
+ * Lists the custom-column CANDIDATES captured in the overflow column awaiting promotion — the "new
2783
+ * columns found" for a connection. READ-ONLY (no schema change, no RSU). Scope to one entity via
2784
+ * entityName, or omit to list across all the connection's mapped entities. Computed live (overflow keys
2785
+ * minus already-mapped/already-a-column), so it is inherently deduped against anything a concurrent
2786
+ * discovery already promoted.
2787
+ */
2788
+ @Query(() => CustomColumnCandidatesOutput)
2789
+ async IntegrationListCustomColumnCandidates(
2790
+ @Arg("companyIntegrationID") companyIntegrationID: string,
2791
+ @Arg("entityName", { nullable: true }) entityName: string | undefined,
2792
+ @Ctx() ctx: AppContext
2793
+ ): Promise<CustomColumnCandidatesOutput> {
2794
+ try {
2795
+ const user = this.getAuthenticatedUser(ctx);
2796
+ const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
2797
+ const promoter = new IntegrationCustomColumnPromoter(user, provider);
2798
+ const entityNames = entityName ? [entityName] : await this.getMappedEntityNames(companyIntegrationID, user);
2799
+ const candidates: CustomColumnCandidate[] = [];
2800
+ for (const en of entityNames) {
2801
+ candidates.push(...await promoter.ListCandidates(companyIntegrationID, en));
2802
+ }
2803
+ return { Success: true, Message: `${candidates.length} candidate column(s) found`, Candidates: candidates };
2804
+ } catch (e) {
2805
+ LogError(`IntegrationListCustomColumnCandidates error: ${e}`);
2806
+ return { Success: false, Message: this.formatError(e), Candidates: [] };
2807
+ }
2808
+ }
2809
+
2810
+ /**
2811
+ * On-demand promotion of captured custom columns: runs RSU (ADD COLUMN + register EntityField + field map),
2812
+ * which may require a server restart to expose them over GraphQL. This is the USER-ACCEPTED trigger — by
2813
+ * default a sync only CAPTURES to the overflow column (auto-promote is opt-in per connection via
2814
+ * Configuration.autoPromoteCustomColumns). Scope via entityNames, or omit to promote across all mapped
2815
+ * entities. Idempotent: already-promoted/mapped keys are skipped (safe to re-run / run alongside discovery).
2816
+ */
2817
+ @Mutation(() => PromoteCustomColumnsOutput)
2818
+ @RequireSystemUser()
2819
+ async IntegrationPromoteCustomColumns(
2820
+ @Arg("companyIntegrationID") companyIntegrationID: string,
2821
+ @Arg("entityNames", () => [String], { nullable: true }) entityNames: string[] | undefined,
2822
+ @Ctx() ctx: AppContext
2823
+ ): Promise<PromoteCustomColumnsOutput> {
2824
+ try {
2825
+ const user = this.getAuthenticatedUser(ctx);
2826
+ const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
2827
+ const targets = (entityNames && entityNames.length > 0) ? entityNames : await this.getMappedEntityNames(companyIntegrationID, user);
2828
+ if (targets.length === 0) {
2829
+ return { Success: true, Message: 'No mapped entities to promote', Promoted: false, ColumnsAdded: [], SchemaUpdatePending: false };
2830
+ }
2831
+ const promoter = new IntegrationCustomColumnPromoter(user, provider);
2832
+ const result = await promoter.PromoteForSync(companyIntegrationID, targets);
2833
+ return {
2834
+ Success: true,
2835
+ Message: result.Promoted ? `Promoted ${result.ColumnsAdded.length} column(s)` : 'No columns required promotion',
2836
+ Promoted: result.Promoted,
2837
+ ColumnsAdded: result.ColumnsAdded.map(c => ({ EntityName: c.EntityName, ColumnName: c.ColumnName })),
2838
+ SchemaUpdatePending: result.SchemaUpdatePending,
2839
+ };
2840
+ } catch (e) {
2841
+ LogError(`IntegrationPromoteCustomColumns error: ${e}`);
2842
+ return { Success: false, Message: this.formatError(e), Promoted: false, ColumnsAdded: [], SchemaUpdatePending: false };
2843
+ }
2844
+ }
2845
+
2576
2846
  /**
2577
2847
  * Soft-deletes a CompanyIntegration by setting IsActive=false.
2578
2848
  */
@@ -2751,6 +3021,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2751
3021
  * pattern. Use IntegrationSchemaPreview to preview generated SQL without applying.
2752
3022
  */
2753
3023
  @Mutation(() => ApplySchemaOutput)
3024
+ @RequireSystemUser()
2754
3025
  async IntegrationApplySchema(
2755
3026
  @Arg("companyIntegrationID") companyIntegrationID: string,
2756
3027
  @Arg("objects", () => [SchemaPreviewObjectInput]) objects: SchemaPreviewObjectInput[],
@@ -2841,6 +3112,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2841
3112
  * All migrations run sequentially, then ONE CodeGen, ONE compile, ONE git PR, ONE restart.
2842
3113
  */
2843
3114
  @Mutation(() => ApplySchemaBatchOutput)
3115
+ @RequireSystemUser()
2844
3116
  async IntegrationApplySchemaBatch(
2845
3117
  @Arg("items", () => [ApplySchemaBatchItemInput]) items: ApplySchemaBatchItemInput[],
2846
3118
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,
@@ -2913,6 +3185,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2913
3185
  * 6. Starts sync for the integration
2914
3186
  */
2915
3187
  @Mutation(() => ApplyAllOutput)
3188
+ @RequireSystemUser()
2916
3189
  async IntegrationApplyAll(
2917
3190
  @Arg("input") input: ApplyAllInput,
2918
3191
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,
@@ -3351,6 +3624,38 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3351
3624
  * Build schema artifacts for a single connector's objects.
3352
3625
  * Shared by IntegrationApplySchema (single) and IntegrationApplySchemaBatch (batch).
3353
3626
  */
3627
+ /**
3628
+ * §B — enforce OPTIONAL table/column caps at the create-tables (RSU) gate. These are OPERATOR
3629
+ * (deployment) guardrails read from ENV — `MJ_INTEGRATION_MAX_TABLES` / `MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE`
3630
+ * (absent or ≤0 = unbounded, the common case). They are DELIBERATELY env-only, NOT per-connection
3631
+ * `Configuration`/GraphQL: a guardrail a user can raise via the same API they apply with is toothless.
3632
+ * THROWS a clear error when the selection exceeds a cap so NOTHING partial is created — the caller surfaces
3633
+ * it and the user narrows the selection (the cap itself is an operator concern). NEVER truncates. Discovery
3634
+ * still surfaces every object/field; only materialization is capped. Per-table column count = the selected
3635
+ * field subset, or the object's full discovered field count when all fields are selected.
3636
+ */
3637
+ private enforceSchemaLimits(
3638
+ objects: SchemaPreviewObjectInput[],
3639
+ filteredSchema: SourceSchemaInfo,
3640
+ ): void {
3641
+ const envInt = (name: string): number | null => {
3642
+ const v = parseInt(process.env[name] ?? '', 10);
3643
+ return Number.isFinite(v) && v > 0 ? v : null;
3644
+ };
3645
+ const fullCountByName = new Map(filteredSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.Fields.length]));
3646
+ // Delegate the cap decision to the engine's pure, unit-tested decideSchemaLimitViolations.
3647
+ const violations = decideSchemaLimitViolations({
3648
+ TableCount: objects.length,
3649
+ ColumnCountByTable: objects.map(o => ({
3650
+ Name: o.SourceObjectName,
3651
+ ColumnCount: o.Fields?.length ?? fullCountByName.get(o.SourceObjectName.toLowerCase()) ?? 0,
3652
+ })),
3653
+ MaxTables: envInt('MJ_INTEGRATION_MAX_TABLES'),
3654
+ MaxColumnsPerTable: envInt('MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE'),
3655
+ });
3656
+ if (violations.length > 0) throw new Error(violations.join(' '));
3657
+ }
3658
+
3354
3659
  private async buildSchemaForConnector(
3355
3660
  companyIntegrationID: string,
3356
3661
  objects: SchemaPreviewObjectInput[],
@@ -3409,6 +3714,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3409
3714
  Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
3410
3715
  };
3411
3716
 
3717
+ // §B — reject an over-limit table/column SELECTION before ANY table is materialized. This is the
3718
+ // single shared gate for ApplyAll / ApplyAllBatch / ApplySchemaBatch (all route through here).
3719
+ // Caps are operator/env guardrails (MJ_INTEGRATION_MAX_TABLES / _MAX_COLUMNS_PER_TABLE).
3720
+ this.enforceSchemaLimits(objects, filteredSchema);
3721
+
3412
3722
  const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform, connector);
3413
3723
 
3414
3724
  const input: SchemaBuilderInput = {
@@ -3442,6 +3752,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3442
3752
  * Sends a webhook to the registered callback when complete.
3443
3753
  */
3444
3754
  @Mutation(() => StartSyncOutput)
3755
+ @RequireSystemUser()
3445
3756
  async IntegrationStartSync(
3446
3757
  @Arg("companyIntegrationID") companyIntegrationID: string,
3447
3758
  @Arg("webhookURL", { nullable: true }) webhookURL: string,
@@ -3470,6 +3781,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3470
3781
  if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
3471
3782
  if (syncDirection) syncOptions.SyncDirection = syncDirection;
3472
3783
 
3784
+ // Capture the fire instant BEFORE launching the sync. The run-record lookup below
3785
+ // finds the run by recency (StartedAt >= firedAt) rather than by transient status, so
3786
+ // a fast run (0-record / empty connector / quick failure) that finishes before we poll
3787
+ // is still reported with its real RunID instead of an untrackable null. RunSync stamps
3788
+ // StartedAt with an app-side `new Date()`, so this clock is consistent with the row.
3789
+ const firedAt = new Date();
3790
+
3473
3791
  // Fire and forget — progress is tracked inside IntegrationEngine
3474
3792
  const syncPromise = IntegrationEngine.Instance.RunSync(
3475
3793
  companyIntegrationID,
@@ -3507,25 +3825,45 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3507
3825
  }
3508
3826
  });
3509
3827
 
3510
- // Small delay to let the run record get created
3511
- await new Promise(resolve => setTimeout(resolve, 200));
3512
-
3513
- const rv = new RunView();
3514
- const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
3515
- EntityName: 'MJ: Company Integration Runs',
3516
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND Status='In Progress'`,
3517
- OrderBy: '__mj_CreatedAt DESC',
3518
- MaxRows: 1,
3519
- ResultType: 'simple',
3520
- Fields: ['ID', 'Status', 'StartedAt']
3521
- }, user);
3828
+ // Resolve the run record by RECENCY, not by transient 'In Progress' status. The prior
3829
+ // implementation slept a fixed 200ms then filtered Status='In Progress' — when the run
3830
+ // finished in under 200ms (an empty connector, a 0-record sync, or a fast synchronous
3831
+ // failure) there was no 'In Progress' row to find, so it returned Success:true with a
3832
+ // null RunID: an optimistic, untrackable result that read as "started" when nothing
3833
+ // could be followed. Poll briefly for ANY run for this connector stamped at/after the
3834
+ // fire instant; that catches both the still-running and the already-finished cases.
3835
+ const firedAtFilter = firedAt.toISOString();
3836
+ let run: { ID: string; Status: string } | null = null;
3837
+ for (let attempt = 0; attempt < 15 && !run; attempt++) {
3838
+ await new Promise(resolve => setTimeout(resolve, 200));
3839
+ const rv = new RunView();
3840
+ const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
3841
+ EntityName: 'MJ: Company Integration Runs',
3842
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND StartedAt >= '${firedAtFilter}'`,
3843
+ OrderBy: 'StartedAt DESC',
3844
+ MaxRows: 1,
3845
+ ResultType: 'simple',
3846
+ Fields: ['ID', 'Status', 'StartedAt']
3847
+ }, user);
3848
+ if (runResult.Success && runResult.Results.length > 0) {
3849
+ run = runResult.Results[0];
3850
+ }
3851
+ }
3522
3852
 
3523
- const run = runResult.Success && runResult.Results.length > 0 ? runResult.Results[0] : null;
3853
+ // No run record after the poll window the sync genuinely did not start (it no-op'd
3854
+ // before creating a run). Report that honestly rather than claiming Success with a null
3855
+ // RunID — a caller/scheduler can act on a false, but a true+null silently strands it.
3856
+ if (!run) {
3857
+ return {
3858
+ Success: false,
3859
+ Message: 'Sync did not start — no run record was created (the connector may be gated or misconfigured)'
3860
+ };
3861
+ }
3524
3862
 
3525
3863
  return {
3526
3864
  Success: true,
3527
3865
  Message: 'Sync started',
3528
- RunID: run?.ID
3866
+ RunID: run.ID
3529
3867
  };
3530
3868
  } catch (e) {
3531
3869
  LogError(`IntegrationStartSync error: ${e}`);
@@ -3562,6 +3900,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3562
3900
  * Supports create, update, and delete operations.
3563
3901
  */
3564
3902
  @Mutation(() => WriteRecordOutput)
3903
+ @RequireSystemUser()
3565
3904
  async IntegrationWriteRecord(
3566
3905
  @Arg("companyIntegrationID") companyIntegrationID: string,
3567
3906
  @Arg("objectName") objectName: string,
@@ -3651,16 +3990,22 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3651
3990
  const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
3652
3991
  const rv = new RunView();
3653
3992
 
3654
- // Find IntegrationSync job type
3993
+ // §13 select the driver by job-kind: 'discovery' = schema-only refresh on a cron;
3994
+ // 'sync' (default) = the data-moving RunSync job.
3995
+ const jobKind = (input.JobKind ?? 'sync').toLowerCase();
3996
+ const driverClass = jobKind === 'discovery'
3997
+ ? 'IntegrationDiscoveryScheduledJobDriver'
3998
+ : 'IntegrationSyncScheduledJobDriver';
3999
+
3655
4000
  const jobTypeResult = await rv.RunView<MJScheduledJobTypeEntity>({
3656
4001
  EntityName: 'MJ: Scheduled Job Types',
3657
- ExtraFilter: `DriverClass='IntegrationSyncScheduledJobDriver'`,
4002
+ ExtraFilter: `DriverClass='${driverClass}'`,
3658
4003
  MaxRows: 1,
3659
4004
  ResultType: 'simple',
3660
4005
  Fields: ['ID']
3661
4006
  }, user);
3662
4007
  if (!jobTypeResult.Success || jobTypeResult.Results.length === 0) {
3663
- return { Success: false, Message: 'IntegrationSync scheduled job type not found' };
4008
+ return { Success: false, Message: `Scheduled job type not found for driver '${driverClass}' (kind='${jobKind}')` };
3664
4009
  }
3665
4010
  const jobTypeID = jobTypeResult.Results[0].ID;
3666
4011
 
@@ -3674,8 +4019,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3674
4019
  job.Status = 'Active';
3675
4020
  job.OwnerUserID = user.ID;
3676
4021
  const jobConfig: Record<string, unknown> = { CompanyIntegrationID: input.CompanyIntegrationID };
3677
- if (input.SyncDirection) jobConfig.SyncDirection = input.SyncDirection;
3678
- if (input.FullSync) jobConfig.FullSync = input.FullSync;
4022
+ if (jobKind === 'discovery') {
4023
+ // Discovery job: schema-only. SyncDirection/FullSync are meaningless here; carry the
4024
+ // deactivate toggle instead (driver defaults it to true when omitted).
4025
+ if (input.DeactivateAbsent !== undefined && input.DeactivateAbsent !== null) jobConfig.DeactivateAbsent = input.DeactivateAbsent;
4026
+ } else {
4027
+ if (input.SyncDirection) jobConfig.SyncDirection = input.SyncDirection;
4028
+ if (input.FullSync) jobConfig.FullSync = input.FullSync;
4029
+ }
3679
4030
  job.Configuration = JSON.stringify(jobConfig);
3680
4031
  job.NextRunAt = CronExpressionHelper.GetNextRunTime(input.CronExpression, input.Timezone || 'UTC');
3681
4032
 
@@ -4437,6 +4788,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4437
4788
  * Post-pipeline: create entity/field maps and start sync for each success.
4438
4789
  */
4439
4790
  @Mutation(() => ApplyAllBatchOutput)
4791
+ @RequireSystemUser()
4440
4792
  async IntegrationApplyAllBatch(
4441
4793
  @Arg("input") input: ApplyAllBatchInput,
4442
4794
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,
@@ -5024,13 +5376,19 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5024
5376
  return { Success: false, Message: 'Transaction failed — all deletes rolled back' };
5025
5377
  }
5026
5378
 
5027
- if (deleteData) {
5028
- LogError(`IntegrationDeleteConnection: deleteData=true requested but table deletion not yet implemented for ${companyIntegrationID}`);
5029
- }
5379
+ // deleteData=true asks us to also DROP the physical mirror tables. We deliberately do NOT:
5380
+ // RuntimeSchemaManager rejects DROP TABLE on the __mj schema by design (a destructive-op
5381
+ // safety guard), and there is no DROP generator. Dropping integration tables is real data
5382
+ // loss and must be an explicit, separately-designed operation — not a silent side effect of
5383
+ // removing a connection. So we delete the connection + all METADATA (maps/fields/schedules/
5384
+ // credential) and RETAIN the data tables, and we say so honestly rather than claim otherwise.
5385
+ const dataNote = deleteData
5386
+ ? ' The physical data tables were RETAINED (dropping integration tables is not performed automatically — it is a separate, explicit operation).'
5387
+ : '';
5030
5388
 
5031
5389
  return {
5032
5390
  Success: true,
5033
- Message: `Deleted connection and all associated records`,
5391
+ Message: `Deleted connection and all associated metadata records.${dataNote}`,
5034
5392
  EntityMapsDeleted: entityMapsDeleted,
5035
5393
  FieldMapsDeleted: fieldMapsDeleted,
5036
5394
  SchedulesDeleted: schedulesDeleted,
@@ -5050,6 +5408,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5050
5408
  * Compares the current connector introspection against existing MJ entities.
5051
5409
  */
5052
5410
  @Mutation(() => SchemaEvolutionOutput)
5411
+ @RequireSystemUser()
5053
5412
  async IntegrationSchemaEvolution(
5054
5413
  @Arg("companyIntegrationID") companyIntegrationID: string,
5055
5414
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,