@memberjunction/server 5.41.0 → 5.43.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/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +5 -1
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +15 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +50 -11
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +5 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +4 -7
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -2
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +612 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +6934 -3626
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -17
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +108 -0
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
- package/dist/integration/CustomColumnPromoter.js +508 -0
- package/dist/integration/CustomColumnPromoter.js.map +1 -0
- package/dist/logging/StartupLogger.d.ts +57 -1
- package/dist/logging/StartupLogger.d.ts.map +1 -1
- package/dist/logging/StartupLogger.js +115 -6
- package/dist/logging/StartupLogger.js.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +46 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +192 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +629 -58
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +130 -0
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeBridgeResolver.js +580 -0
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +78 -5
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +227 -15
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +0 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +8 -0
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +5 -3
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/package.json +83 -78
- package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +8 -1
- package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
- package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
- package/src/agentSessions/SessionJanitor.ts +15 -1
- package/src/agentSessions/SessionManager.ts +59 -10
- package/src/agentSessions/index.ts +5 -0
- package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
- package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
- package/src/auth/initializeProviders.ts +4 -7
- package/src/config.ts +13 -2
- package/src/generated/generated.ts +2502 -198
- package/src/index.ts +38 -17
- package/src/integration/CustomColumnPromoter.ts +597 -0
- package/src/logging/StartupLogger.ts +130 -6
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +164 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +490 -53
- package/src/resolvers/RealtimeBridgeResolver.ts +493 -0
- package/src/resolvers/RemoteBrowserActionResolver.ts +232 -17
- package/src/resolvers/RunAIAgentResolver.ts +1 -9
- package/src/resolvers/RunTemplateResolver.ts +8 -0
- package/src/rest/setupRESTEndpoints.ts +5 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType } from "type-graphql";
|
|
2
|
-
import { CompositeKey, DatabaseProviderBase, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
|
|
1
|
+
import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType, Int, Float } from "type-graphql";
|
|
2
|
+
import { CompositeKey, DatabaseProviderBase, EntityInfo, 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";
|
|
5
5
|
import {
|
|
@@ -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
|
|
1557
|
-
//
|
|
1558
|
-
// source of truth for business data,
|
|
1559
|
-
// constraints — and its describe output
|
|
1560
|
-
// fields required when real records actually
|
|
1561
|
-
// (deprecated, calculated, or edge-case fields).
|
|
1562
|
-
//
|
|
1563
|
-
|
|
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
|
-
//
|
|
1585
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -3178,9 +3451,29 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3178
3451
|
): Promise<ApplyAllEntityMapCreated[]> {
|
|
3179
3452
|
const results: ApplyAllEntityMapCreated[] = [];
|
|
3180
3453
|
|
|
3454
|
+
// PERF (large-catalog ApplyAll, e.g. Salesforce 1695 objects): precompute O(1) lookups ONCE before
|
|
3455
|
+
// the per-object loop. The per-object path previously did an O(N) `provider.Entities.find` + a
|
|
3456
|
+
// BypassCache RunView for the existing entity map PER object → O(N²) scans + N DB round-trips, so a
|
|
3457
|
+
// 1695-object connector burned its whole budget in ApplyAll and FullSync never started. One entity
|
|
3458
|
+
// index + one batched entity-map query make both O(1) per object.
|
|
3459
|
+
const entityByKey = new Map(
|
|
3460
|
+
provider.Entities.map(e => [`${e.SchemaName.toLowerCase()}|${e.BaseTable.toLowerCase()}`, e] as const)
|
|
3461
|
+
);
|
|
3462
|
+
const existingMapByExternalName = new Map<string, MJCompanyIntegrationEntityMapEntity>();
|
|
3463
|
+
const allMaps = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
3464
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
3465
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
3466
|
+
ResultType: 'entity_object',
|
|
3467
|
+
BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
|
|
3468
|
+
}, user);
|
|
3469
|
+
if (allMaps.Success) {
|
|
3470
|
+
for (const m of allMaps.Results) existingMapByExternalName.set((m.ExternalObjectName ?? '').toLowerCase(), m);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3181
3473
|
for (const obj of objects) {
|
|
3182
3474
|
const entityMapResult = await this.createSingleEntityMap(
|
|
3183
|
-
companyIntegrationID, obj, connector, companyIntegration, schemaName, user, provider, defaultSyncDirection
|
|
3475
|
+
companyIntegrationID, obj, connector, companyIntegration, schemaName, user, provider, defaultSyncDirection,
|
|
3476
|
+
entityByKey, existingMapByExternalName
|
|
3184
3477
|
);
|
|
3185
3478
|
if (entityMapResult) {
|
|
3186
3479
|
results.push(entityMapResult);
|
|
@@ -3198,10 +3491,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3198
3491
|
schemaName: string,
|
|
3199
3492
|
user: UserInfo,
|
|
3200
3493
|
md: IMetadataProvider,
|
|
3201
|
-
defaultSyncDirection: string = 'Pull'
|
|
3494
|
+
defaultSyncDirection: string = 'Pull',
|
|
3495
|
+
entityByKey?: Map<string, EntityInfo>,
|
|
3496
|
+
existingMapByExternalName?: Map<string, MJCompanyIntegrationEntityMapEntity>
|
|
3202
3497
|
): Promise<ApplyAllEntityMapCreated | null> {
|
|
3203
|
-
// Find the entity by schema + table name
|
|
3204
|
-
|
|
3498
|
+
// Find the entity by schema + table name. PERF: O(1) precomputed index when provided
|
|
3499
|
+
// (large-catalog ApplyAll); falls back to the O(N) scan when called without it.
|
|
3500
|
+
const entityInfo = entityByKey?.get(`${schemaName.toLowerCase()}|${obj.TableName.toLowerCase()}`)
|
|
3501
|
+
?? md.Entities.find(
|
|
3205
3502
|
e => e.SchemaName.toLowerCase() === schemaName.toLowerCase()
|
|
3206
3503
|
&& e.BaseTable.toLowerCase() === obj.TableName.toLowerCase()
|
|
3207
3504
|
);
|
|
@@ -3215,19 +3512,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3215
3512
|
// multiplies the maps in lockstep (N applies → N duplicate maps per object), which
|
|
3216
3513
|
// silently corrupts the record-map 1:1 completeness gate and makes the forward sync
|
|
3217
3514
|
// process each object N times. Reuse the existing (connection, external object) map instead.
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3515
|
+
// PERF: use the precomputed existing-map index when provided (one batched query for the whole
|
|
3516
|
+
// ApplyAll instead of one BypassCache RunView per object); falls back to a per-object query.
|
|
3517
|
+
let existing: MJCompanyIntegrationEntityMapEntity | undefined;
|
|
3518
|
+
if (existingMapByExternalName) {
|
|
3519
|
+
existing = existingMapByExternalName.get(obj.SourceObjectName.toLowerCase());
|
|
3520
|
+
} else {
|
|
3521
|
+
const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
|
|
3522
|
+
const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
3523
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
3524
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
|
|
3525
|
+
OrderBy: '__mj_CreatedAt ASC',
|
|
3526
|
+
MaxRows: 1,
|
|
3527
|
+
ResultType: 'entity_object',
|
|
3528
|
+
BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
|
|
3529
|
+
}, user);
|
|
3530
|
+
if (existingMapResult.Success && existingMapResult.Results.length > 0) existing = existingMapResult.Results[0];
|
|
3531
|
+
}
|
|
3227
3532
|
|
|
3228
3533
|
let em: MJCompanyIntegrationEntityMapEntity;
|
|
3229
|
-
if (
|
|
3230
|
-
em =
|
|
3534
|
+
if (existing) {
|
|
3535
|
+
em = existing; // reuse — keeps the map stable across re-applies
|
|
3231
3536
|
} else {
|
|
3232
3537
|
em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
|
|
3233
3538
|
em.NewRecord();
|
|
@@ -3351,6 +3656,82 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3351
3656
|
* Build schema artifacts for a single connector's objects.
|
|
3352
3657
|
* Shared by IntegrationApplySchema (single) and IntegrationApplySchemaBatch (batch).
|
|
3353
3658
|
*/
|
|
3659
|
+
/**
|
|
3660
|
+
* §B — enforce OPTIONAL table/column caps at the create-tables (RSU) gate. These are OPERATOR
|
|
3661
|
+
* (deployment) guardrails read from ENV — `MJ_INTEGRATION_MAX_TABLES` / `MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE`
|
|
3662
|
+
* (absent or ≤0 = unbounded, the common case). They are DELIBERATELY env-only, NOT per-connection
|
|
3663
|
+
* `Configuration`/GraphQL: a guardrail a user can raise via the same API they apply with is toothless.
|
|
3664
|
+
* THROWS a clear error when the selection exceeds a cap so NOTHING partial is created — the caller surfaces
|
|
3665
|
+
* it and the user narrows the selection (the cap itself is an operator concern). NEVER truncates. Discovery
|
|
3666
|
+
* still surfaces every object/field; only materialization is capped. Per-table column count = the selected
|
|
3667
|
+
* field subset, or the object's full discovered field count when all fields are selected.
|
|
3668
|
+
*/
|
|
3669
|
+
private async enforceSchemaLimits(
|
|
3670
|
+
objects: SchemaPreviewObjectInput[],
|
|
3671
|
+
filteredSchema: SourceSchemaInfo,
|
|
3672
|
+
companyIntegration: MJCompanyIntegrationEntity,
|
|
3673
|
+
user: UserInfo,
|
|
3674
|
+
md: IMetadataProvider,
|
|
3675
|
+
): Promise<string[]> {
|
|
3676
|
+
const warnings: string[] = [];
|
|
3677
|
+
const fullCountByName = new Map(filteredSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.Fields.length]));
|
|
3678
|
+
// Use the MAX of the input object's Fields and the filtered-schema count — the batch path can pass
|
|
3679
|
+
// objects with empty Fields (the real columns come from filteredSchema, which is what buildSchema
|
|
3680
|
+
// materializes). `?? ` would wrongly read an empty [] as 0 (0 is not nullish); Math.max is robust to
|
|
3681
|
+
// either source being empty and reflects the count that will actually become columns.
|
|
3682
|
+
const colCount = (o: SchemaPreviewObjectInput): number =>
|
|
3683
|
+
Math.max(fullCountByName.get(o.SourceObjectName.toLowerCase()) ?? 0, o.Fields?.length ?? 0);
|
|
3684
|
+
|
|
3685
|
+
// ── Per-table COLUMN limit: DISABLE-the-offender, NOT reject-the-apply. The limit is clamped at
|
|
3686
|
+
// startup to ≤ IntegrationEngine.MAX_COLUMNS_CEILING (SQL Server's 1024 minus framework column
|
|
3687
|
+
// headroom). An object exceeding it is set Status='Disabled' (reversible — it re-enables on a later
|
|
3688
|
+
// discovery if it has shrunk back under the limit) and dropped from THIS apply, so the materializable
|
|
3689
|
+
// objects still sync instead of the whole apply failing CREATE TABLE. One CLI warning per disabled object.
|
|
3690
|
+
const limit = IntegrationEngine.Instance.MaxColumnsPerTable;
|
|
3691
|
+
const overLimit = objects.filter(o => colCount(o) > limit);
|
|
3692
|
+
if (overLimit.length > 0) {
|
|
3693
|
+
const ioIDByName = new Map(
|
|
3694
|
+
IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID)
|
|
3695
|
+
.map(io => [io.Name.toLowerCase(), io.ID] as [string, string])
|
|
3696
|
+
);
|
|
3697
|
+
for (const o of overLimit) {
|
|
3698
|
+
warnings.push(`Disabled "${o.SourceObjectName}" (${colCount(o)} columns > limit ${limit}) — too wide to materialize as one table; reduce its columns or it stays disabled.`);
|
|
3699
|
+
const ioID = ioIDByName.get(o.SourceObjectName.toLowerCase());
|
|
3700
|
+
if (ioID) {
|
|
3701
|
+
const io = await md.GetEntityObject<MJIntegrationObjectEntity>('MJ: Integration Objects', user);
|
|
3702
|
+
if (await io.Load(ioID)) {
|
|
3703
|
+
io.Status = 'Disabled';
|
|
3704
|
+
if (!await io.Save()) {
|
|
3705
|
+
LogError(`enforceSchemaLimits: failed to disable over-wide IO '${o.SourceObjectName}': ${io.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
// Drop the disabled objects from BOTH the shared objects array (used downstream by
|
|
3711
|
+
// buildTargetConfigs + createEntityAndFieldMaps) and filteredSchema, in place.
|
|
3712
|
+
const disabled = new Set(overLimit.map(o => o.SourceObjectName.toLowerCase()));
|
|
3713
|
+
for (let i = objects.length - 1; i >= 0; i--) {
|
|
3714
|
+
if (disabled.has(objects[i].SourceObjectName.toLowerCase())) objects.splice(i, 1);
|
|
3715
|
+
}
|
|
3716
|
+
filteredSchema.Objects = filteredSchema.Objects.filter(o => !disabled.has(o.ExternalName.toLowerCase()));
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
// MaxTables remains an operator/env REJECT guardrail (unchanged), evaluated on the surviving objects.
|
|
3720
|
+
// Columns are handled above via disable, so the pure decision is asked about tables only here.
|
|
3721
|
+
const maxTables = ((): number | null => {
|
|
3722
|
+
const v = parseInt(process.env.MJ_INTEGRATION_MAX_TABLES ?? '', 10);
|
|
3723
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
3724
|
+
})();
|
|
3725
|
+
const violations = decideSchemaLimitViolations({
|
|
3726
|
+
TableCount: objects.length,
|
|
3727
|
+
ColumnCountByTable: [],
|
|
3728
|
+
MaxTables: maxTables,
|
|
3729
|
+
MaxColumnsPerTable: null,
|
|
3730
|
+
});
|
|
3731
|
+
if (violations.length > 0) throw new Error(violations.join(' '));
|
|
3732
|
+
return warnings;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3354
3735
|
private async buildSchemaForConnector(
|
|
3355
3736
|
companyIntegrationID: string,
|
|
3356
3737
|
objects: SchemaPreviewObjectInput[],
|
|
@@ -3409,6 +3790,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3409
3790
|
Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
|
|
3410
3791
|
};
|
|
3411
3792
|
|
|
3793
|
+
// §B — reject an over-limit table/column SELECTION before ANY table is materialized. This is the
|
|
3794
|
+
// single shared gate for ApplyAll / ApplyAllBatch / ApplySchemaBatch (all route through here).
|
|
3795
|
+
// Caps are operator/env guardrails (MJ_INTEGRATION_MAX_TABLES / _MAX_COLUMNS_PER_TABLE).
|
|
3796
|
+
const limitWarnings = await this.enforceSchemaLimits(objects, filteredSchema, companyIntegration, user, provider);
|
|
3797
|
+
|
|
3412
3798
|
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform, connector);
|
|
3413
3799
|
|
|
3414
3800
|
const input: SchemaBuilderInput = {
|
|
@@ -3426,6 +3812,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3426
3812
|
|
|
3427
3813
|
const builder = new SchemaBuilder();
|
|
3428
3814
|
const schemaOutput = builder.BuildSchema(input);
|
|
3815
|
+
// Surface the auto-disabled over-wide objects (if any) in the apply's Warnings → CLI output.
|
|
3816
|
+
if (limitWarnings.length > 0) schemaOutput.Warnings.unshift(...limitWarnings);
|
|
3429
3817
|
|
|
3430
3818
|
if (schemaOutput.Errors.length > 0) {
|
|
3431
3819
|
throw new Error(`Schema generation failed: ${schemaOutput.Errors.join('; ')}`);
|
|
@@ -3442,6 +3830,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3442
3830
|
* Sends a webhook to the registered callback when complete.
|
|
3443
3831
|
*/
|
|
3444
3832
|
@Mutation(() => StartSyncOutput)
|
|
3833
|
+
@RequireSystemUser()
|
|
3445
3834
|
async IntegrationStartSync(
|
|
3446
3835
|
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
3447
3836
|
@Arg("webhookURL", { nullable: true }) webhookURL: string,
|
|
@@ -3470,6 +3859,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3470
3859
|
if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
|
|
3471
3860
|
if (syncDirection) syncOptions.SyncDirection = syncDirection;
|
|
3472
3861
|
|
|
3862
|
+
// Capture the fire instant BEFORE launching the sync. The run-record lookup below
|
|
3863
|
+
// finds the run by recency (StartedAt >= firedAt) rather than by transient status, so
|
|
3864
|
+
// a fast run (0-record / empty connector / quick failure) that finishes before we poll
|
|
3865
|
+
// is still reported with its real RunID instead of an untrackable null. RunSync stamps
|
|
3866
|
+
// StartedAt with an app-side `new Date()`, so this clock is consistent with the row.
|
|
3867
|
+
const firedAt = new Date();
|
|
3868
|
+
|
|
3473
3869
|
// Fire and forget — progress is tracked inside IntegrationEngine
|
|
3474
3870
|
const syncPromise = IntegrationEngine.Instance.RunSync(
|
|
3475
3871
|
companyIntegrationID,
|
|
@@ -3507,25 +3903,45 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3507
3903
|
}
|
|
3508
3904
|
});
|
|
3509
3905
|
|
|
3510
|
-
//
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3906
|
+
// Resolve the run record by RECENCY, not by transient 'In Progress' status. The prior
|
|
3907
|
+
// implementation slept a fixed 200ms then filtered Status='In Progress' — when the run
|
|
3908
|
+
// finished in under 200ms (an empty connector, a 0-record sync, or a fast synchronous
|
|
3909
|
+
// failure) there was no 'In Progress' row to find, so it returned Success:true with a
|
|
3910
|
+
// null RunID: an optimistic, untrackable result that read as "started" when nothing
|
|
3911
|
+
// could be followed. Poll briefly for ANY run for this connector stamped at/after the
|
|
3912
|
+
// fire instant; that catches both the still-running and the already-finished cases.
|
|
3913
|
+
const firedAtFilter = firedAt.toISOString();
|
|
3914
|
+
let run: { ID: string; Status: string } | null = null;
|
|
3915
|
+
for (let attempt = 0; attempt < 15 && !run; attempt++) {
|
|
3916
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
3917
|
+
const rv = new RunView();
|
|
3918
|
+
const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
|
|
3919
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
3920
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND StartedAt >= '${firedAtFilter}'`,
|
|
3921
|
+
OrderBy: 'StartedAt DESC',
|
|
3922
|
+
MaxRows: 1,
|
|
3923
|
+
ResultType: 'simple',
|
|
3924
|
+
Fields: ['ID', 'Status', 'StartedAt']
|
|
3925
|
+
}, user);
|
|
3926
|
+
if (runResult.Success && runResult.Results.length > 0) {
|
|
3927
|
+
run = runResult.Results[0];
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3522
3930
|
|
|
3523
|
-
|
|
3931
|
+
// No run record after the poll window ⇒ the sync genuinely did not start (it no-op'd
|
|
3932
|
+
// before creating a run). Report that honestly rather than claiming Success with a null
|
|
3933
|
+
// RunID — a caller/scheduler can act on a false, but a true+null silently strands it.
|
|
3934
|
+
if (!run) {
|
|
3935
|
+
return {
|
|
3936
|
+
Success: false,
|
|
3937
|
+
Message: 'Sync did not start — no run record was created (the connector may be gated or misconfigured)'
|
|
3938
|
+
};
|
|
3939
|
+
}
|
|
3524
3940
|
|
|
3525
3941
|
return {
|
|
3526
3942
|
Success: true,
|
|
3527
3943
|
Message: 'Sync started',
|
|
3528
|
-
RunID: run
|
|
3944
|
+
RunID: run.ID
|
|
3529
3945
|
};
|
|
3530
3946
|
} catch (e) {
|
|
3531
3947
|
LogError(`IntegrationStartSync error: ${e}`);
|
|
@@ -3562,6 +3978,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3562
3978
|
* Supports create, update, and delete operations.
|
|
3563
3979
|
*/
|
|
3564
3980
|
@Mutation(() => WriteRecordOutput)
|
|
3981
|
+
@RequireSystemUser()
|
|
3565
3982
|
async IntegrationWriteRecord(
|
|
3566
3983
|
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
3567
3984
|
@Arg("objectName") objectName: string,
|
|
@@ -3651,16 +4068,22 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3651
4068
|
const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
|
|
3652
4069
|
const rv = new RunView();
|
|
3653
4070
|
|
|
3654
|
-
//
|
|
4071
|
+
// §13 — select the driver by job-kind: 'discovery' = schema-only refresh on a cron;
|
|
4072
|
+
// 'sync' (default) = the data-moving RunSync job.
|
|
4073
|
+
const jobKind = (input.JobKind ?? 'sync').toLowerCase();
|
|
4074
|
+
const driverClass = jobKind === 'discovery'
|
|
4075
|
+
? 'IntegrationDiscoveryScheduledJobDriver'
|
|
4076
|
+
: 'IntegrationSyncScheduledJobDriver';
|
|
4077
|
+
|
|
3655
4078
|
const jobTypeResult = await rv.RunView<MJScheduledJobTypeEntity>({
|
|
3656
4079
|
EntityName: 'MJ: Scheduled Job Types',
|
|
3657
|
-
ExtraFilter: `DriverClass='
|
|
4080
|
+
ExtraFilter: `DriverClass='${driverClass}'`,
|
|
3658
4081
|
MaxRows: 1,
|
|
3659
4082
|
ResultType: 'simple',
|
|
3660
4083
|
Fields: ['ID']
|
|
3661
4084
|
}, user);
|
|
3662
4085
|
if (!jobTypeResult.Success || jobTypeResult.Results.length === 0) {
|
|
3663
|
-
return { Success: false, Message:
|
|
4086
|
+
return { Success: false, Message: `Scheduled job type not found for driver '${driverClass}' (kind='${jobKind}')` };
|
|
3664
4087
|
}
|
|
3665
4088
|
const jobTypeID = jobTypeResult.Results[0].ID;
|
|
3666
4089
|
|
|
@@ -3674,8 +4097,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3674
4097
|
job.Status = 'Active';
|
|
3675
4098
|
job.OwnerUserID = user.ID;
|
|
3676
4099
|
const jobConfig: Record<string, unknown> = { CompanyIntegrationID: input.CompanyIntegrationID };
|
|
3677
|
-
if (
|
|
3678
|
-
|
|
4100
|
+
if (jobKind === 'discovery') {
|
|
4101
|
+
// Discovery job: schema-only. SyncDirection/FullSync are meaningless here; carry the
|
|
4102
|
+
// deactivate toggle instead (driver defaults it to true when omitted).
|
|
4103
|
+
if (input.DeactivateAbsent !== undefined && input.DeactivateAbsent !== null) jobConfig.DeactivateAbsent = input.DeactivateAbsent;
|
|
4104
|
+
} else {
|
|
4105
|
+
if (input.SyncDirection) jobConfig.SyncDirection = input.SyncDirection;
|
|
4106
|
+
if (input.FullSync) jobConfig.FullSync = input.FullSync;
|
|
4107
|
+
}
|
|
3679
4108
|
job.Configuration = JSON.stringify(jobConfig);
|
|
3680
4109
|
job.NextRunAt = CronExpressionHelper.GetNextRunTime(input.CronExpression, input.Timezone || 'UTC');
|
|
3681
4110
|
|
|
@@ -4437,6 +4866,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4437
4866
|
* Post-pipeline: create entity/field maps and start sync for each success.
|
|
4438
4867
|
*/
|
|
4439
4868
|
@Mutation(() => ApplyAllBatchOutput)
|
|
4869
|
+
@RequireSystemUser()
|
|
4440
4870
|
async IntegrationApplyAllBatch(
|
|
4441
4871
|
@Arg("input") input: ApplyAllBatchInput,
|
|
4442
4872
|
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
@@ -5024,13 +5454,19 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5024
5454
|
return { Success: false, Message: 'Transaction failed — all deletes rolled back' };
|
|
5025
5455
|
}
|
|
5026
5456
|
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5457
|
+
// deleteData=true asks us to also DROP the physical mirror tables. We deliberately do NOT:
|
|
5458
|
+
// RuntimeSchemaManager rejects DROP TABLE on the __mj schema by design (a destructive-op
|
|
5459
|
+
// safety guard), and there is no DROP generator. Dropping integration tables is real data
|
|
5460
|
+
// loss and must be an explicit, separately-designed operation — not a silent side effect of
|
|
5461
|
+
// removing a connection. So we delete the connection + all METADATA (maps/fields/schedules/
|
|
5462
|
+
// credential) and RETAIN the data tables, and we say so honestly rather than claim otherwise.
|
|
5463
|
+
const dataNote = deleteData
|
|
5464
|
+
? ' The physical data tables were RETAINED (dropping integration tables is not performed automatically — it is a separate, explicit operation).'
|
|
5465
|
+
: '';
|
|
5030
5466
|
|
|
5031
5467
|
return {
|
|
5032
5468
|
Success: true,
|
|
5033
|
-
Message: `Deleted connection and all associated records`,
|
|
5469
|
+
Message: `Deleted connection and all associated metadata records.${dataNote}`,
|
|
5034
5470
|
EntityMapsDeleted: entityMapsDeleted,
|
|
5035
5471
|
FieldMapsDeleted: fieldMapsDeleted,
|
|
5036
5472
|
SchedulesDeleted: schedulesDeleted,
|
|
@@ -5050,6 +5486,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5050
5486
|
* Compares the current connector introspection against existing MJ entities.
|
|
5051
5487
|
*/
|
|
5052
5488
|
@Mutation(() => SchemaEvolutionOutput)
|
|
5489
|
+
@RequireSystemUser()
|
|
5053
5490
|
async IntegrationSchemaEvolution(
|
|
5054
5491
|
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
5055
5492
|
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|