@memberjunction/server 5.28.0 → 5.30.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/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +63 -70
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/config.d.ts +151 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +452 -6
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2788 -303
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/FeedbackResolver.d.ts +150 -0
- package/dist/resolvers/FeedbackResolver.d.ts.map +1 -0
- package/dist/resolvers/FeedbackResolver.js +876 -0
- package/dist/resolvers/FeedbackResolver.js.map +1 -0
- package/dist/resolvers/FileResolver.d.ts +27 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +32 -3
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +100 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +532 -41
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/MCPResolver.d.ts +77 -0
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +300 -1
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +87 -32
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +20 -12
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts +20 -9
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +153 -116
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/services/TaskOrchestrator.d.ts.map +1 -1
- package/dist/services/TaskOrchestrator.js +78 -79
- package/dist/services/TaskOrchestrator.js.map +1 -1
- package/package.json +68 -66
- package/src/auth/newUsers.ts +65 -74
- package/src/config.ts +19 -0
- package/src/generated/generated.ts +1753 -40
- package/src/index.ts +1 -0
- package/src/resolvers/FeedbackResolver.ts +940 -0
- package/src/resolvers/FileResolver.ts +33 -4
- package/src/resolvers/IntegrationDiscoveryResolver.ts +543 -43
- package/src/resolvers/MCPResolver.ts +297 -1
- package/src/resolvers/RunAIAgentResolver.ts +89 -32
- package/src/resolvers/SyncDataResolver.ts +24 -14
- package/src/resolvers/SyncRolesUsersResolver.ts +177 -141
- package/src/services/TaskOrchestrator.ts +86 -93
|
@@ -4,6 +4,7 @@ import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
|
|
|
4
4
|
import {
|
|
5
5
|
MJCompanyIntegrationEntity,
|
|
6
6
|
MJIntegrationEntity,
|
|
7
|
+
MJIntegrationObjectEntity,
|
|
7
8
|
MJCredentialEntity,
|
|
8
9
|
MJCompanyIntegrationEntityMapEntity,
|
|
9
10
|
MJCompanyIntegrationFieldMapEntity,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
SourceSchemaInfo,
|
|
28
29
|
IntegrationSchemaSync
|
|
29
30
|
} from "@memberjunction/integration-engine";
|
|
31
|
+
import { IntegrationEngineBase } from "@memberjunction/integration-engine-base";
|
|
30
32
|
import {
|
|
31
33
|
SchemaBuilder,
|
|
32
34
|
TypeMapper,
|
|
@@ -75,7 +77,8 @@ class ApplySchemaBatchItemInput {
|
|
|
75
77
|
|
|
76
78
|
@InputType()
|
|
77
79
|
class SourceObjectInput {
|
|
78
|
-
@Field({ description: '
|
|
80
|
+
@Field({ nullable: true, description: 'Existing IntegrationObject.ID. Either SourceObjectID or SourceObjectName must be provided; if both, ID wins.' }) SourceObjectID?: string;
|
|
81
|
+
@Field({ nullable: true, description: 'External object name (e.g. "Account"). Use when the object has no IntegrationObject row yet — the server will create one via describe+persist.' }) SourceObjectName?: string;
|
|
79
82
|
@Field(() => [String], { nullable: true, description: 'Optional field selection. Empty/null = all fields (including any new ones). Only specified fields get field maps.' }) Fields?: string[];
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -692,6 +695,34 @@ function isValidEntityMapStatus(value: string): value is EntityMapStatus {
|
|
|
692
695
|
return (VALID_ENTITY_MAP_STATUSES as readonly string[]).includes(value);
|
|
693
696
|
}
|
|
694
697
|
|
|
698
|
+
// ─── List Source Objects (Full-Catalog Picker) ──────────────────────────────
|
|
699
|
+
// Returns every object the source system exposes (e.g. all ~1,800 Salesforce
|
|
700
|
+
// sobjects), merged with any existing IntegrationObject metadata so the UI
|
|
701
|
+
// can show which objects are already registered versus newly discoverable.
|
|
702
|
+
// Intentionally cheap: one global describe call, no per-object describes.
|
|
703
|
+
|
|
704
|
+
@ObjectType()
|
|
705
|
+
class ListSourceObjectsItem {
|
|
706
|
+
@Field() Name: string;
|
|
707
|
+
@Field() Label: string;
|
|
708
|
+
@Field({ nullable: true }) Description?: string;
|
|
709
|
+
@Field() SupportsIncrementalSync: boolean;
|
|
710
|
+
@Field() SupportsWrite: boolean;
|
|
711
|
+
/** True when an IntegrationObject row already exists for this object. */
|
|
712
|
+
@Field() AlreadyPersisted: boolean;
|
|
713
|
+
/** IntegrationObject.ID — populated only when AlreadyPersisted is true. */
|
|
714
|
+
@Field({ nullable: true }) IntegrationObjectID?: string;
|
|
715
|
+
/** True when the source system flags this as user/custom (e.g. SF __c names). */
|
|
716
|
+
@Field() IsCustom: boolean;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
@ObjectType()
|
|
720
|
+
class ListSourceObjectsOutput {
|
|
721
|
+
@Field() Success: boolean;
|
|
722
|
+
@Field() Message: string;
|
|
723
|
+
@Field(() => [ListSourceObjectsItem], { nullable: true }) Objects?: ListSourceObjectsItem[];
|
|
724
|
+
}
|
|
725
|
+
|
|
695
726
|
/**
|
|
696
727
|
* GraphQL resolver for integration discovery operations.
|
|
697
728
|
* Provides endpoints to test connections, discover objects, and discover fields
|
|
@@ -773,6 +804,109 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
773
804
|
}
|
|
774
805
|
}
|
|
775
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Full-catalog picker endpoint: returns every object the source system
|
|
809
|
+
* exposes (e.g. all ~1,800 Salesforce sobjects) merged with flags showing
|
|
810
|
+
* which ones already have IntegrationObject rows in MJ. Cheap by design —
|
|
811
|
+
* one global discovery call per source, no per-object describes. Per-object
|
|
812
|
+
* describe runs later, at selection time, inside IntegrationApplyAllBatch.
|
|
813
|
+
*/
|
|
814
|
+
@Query(() => ListSourceObjectsOutput)
|
|
815
|
+
async IntegrationListSourceObjects(
|
|
816
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
817
|
+
@Ctx() ctx: AppContext
|
|
818
|
+
): Promise<ListSourceObjectsOutput> {
|
|
819
|
+
try {
|
|
820
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
821
|
+
const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
|
|
822
|
+
|
|
823
|
+
// Use the engine cache for already-persisted IntegrationObject
|
|
824
|
+
// rows — single in-memory read instead of a per-call DB roundtrip.
|
|
825
|
+
await IntegrationEngine.Instance.Config(false, user);
|
|
826
|
+
const existingObjects = IntegrationEngineBase.Instance
|
|
827
|
+
.GetIntegrationObjectsByIntegrationID(companyIntegration.IntegrationID);
|
|
828
|
+
|
|
829
|
+
const discoverObjects = connector.DiscoverObjects.bind(connector) as
|
|
830
|
+
(ci: unknown, u: unknown) => Promise<ExternalObjectSchema[]>;
|
|
831
|
+
const liveObjects = await discoverObjects(companyIntegration, user);
|
|
832
|
+
|
|
833
|
+
const existingByName = new Map<string, { ID: string; IsCustom: boolean }>();
|
|
834
|
+
for (const row of existingObjects) {
|
|
835
|
+
existingByName.set(row.Name, { ID: row.ID, IsCustom: !!row.IsCustom });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Live is SoT. When the probe succeeds, show only live objects
|
|
839
|
+
// (the persisted IntegrationObject ID is overlaid by name when
|
|
840
|
+
// there's a match). When the probe returns nothing (transient
|
|
841
|
+
// SI failure, rate limit, expired session), fall back to the
|
|
842
|
+
// engine cache so the user isn't stuck with an empty picker.
|
|
843
|
+
const sourceObjects = liveObjects.length > 0
|
|
844
|
+
? liveObjects
|
|
845
|
+
: existingObjects.map(row => ({
|
|
846
|
+
Name: row.Name,
|
|
847
|
+
Label: row.Name,
|
|
848
|
+
Description: undefined,
|
|
849
|
+
SupportsIncrementalSync: true,
|
|
850
|
+
SupportsWrite: true,
|
|
851
|
+
}) as ExternalObjectSchema);
|
|
852
|
+
|
|
853
|
+
const merged: ListSourceObjectsItem[] = sourceObjects.map(o => {
|
|
854
|
+
const existing = existingByName.get(o.Name);
|
|
855
|
+
return {
|
|
856
|
+
Name: o.Name,
|
|
857
|
+
Label: o.Label,
|
|
858
|
+
Description: o.Description,
|
|
859
|
+
SupportsIncrementalSync: o.SupportsIncrementalSync,
|
|
860
|
+
SupportsWrite: o.SupportsWrite,
|
|
861
|
+
AlreadyPersisted: existing != null,
|
|
862
|
+
IntegrationObjectID: existing?.ID,
|
|
863
|
+
IsCustom: this.isCustomObjectName(o.Name, existing?.IsCustom),
|
|
864
|
+
};
|
|
865
|
+
});
|
|
866
|
+
merged.sort((a, b) => a.Name.localeCompare(b.Name));
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
Success: true,
|
|
870
|
+
Message: `Listed ${merged.length} source objects (${liveObjects.length} from live probe, ${existingByName.size} already persisted)`,
|
|
871
|
+
Objects: merged,
|
|
872
|
+
};
|
|
873
|
+
} catch (e) {
|
|
874
|
+
LogError(`IntegrationListSourceObjects error: ${e}`);
|
|
875
|
+
return { Success: false, Message: this.formatError(e) };
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private async loadIntegrationObjectsByIntegrationID(
|
|
880
|
+
integrationID: string,
|
|
881
|
+
user: UserInfo
|
|
882
|
+
): Promise<Array<{ ID: string; Name: string; IsCustom: boolean }>> {
|
|
883
|
+
const rv = new RunView();
|
|
884
|
+
const result = await rv.RunView<MJIntegrationObjectEntity>({
|
|
885
|
+
EntityName: 'MJ: Integration Objects',
|
|
886
|
+
ExtraFilter: `IntegrationID='${integrationID}'`,
|
|
887
|
+
ResultType: 'entity_object',
|
|
888
|
+
}, user);
|
|
889
|
+
if (!result.Success) {
|
|
890
|
+
LogError(`loadIntegrationObjectsByIntegrationID failed: ${result.ErrorMessage}`);
|
|
891
|
+
return [];
|
|
892
|
+
}
|
|
893
|
+
return result.Results.map(r => ({
|
|
894
|
+
ID: r.ID,
|
|
895
|
+
Name: r.Name,
|
|
896
|
+
IsCustom: r.IsCustom === true,
|
|
897
|
+
}));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Heuristic for flagging custom objects in the UI. Existing rows carry an
|
|
902
|
+
* IsCustom column; newly-discovered ones don't, so fall back to the SF
|
|
903
|
+
* `__c` suffix convention (harmless on systems where it doesn't apply).
|
|
904
|
+
*/
|
|
905
|
+
private isCustomObjectName(name: string, existingIsCustom: boolean | undefined): boolean {
|
|
906
|
+
if (existingIsCustom != null) return existingIsCustom;
|
|
907
|
+
return name.endsWith('__c');
|
|
908
|
+
}
|
|
909
|
+
|
|
776
910
|
/**
|
|
777
911
|
* Discovers fields on a specific external object.
|
|
778
912
|
*/
|
|
@@ -1023,6 +1157,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1023
1157
|
// but the connector's GetIntegrationObjects() always has them.
|
|
1024
1158
|
const connectorDescriptions = this.buildDescriptionLookup(connector);
|
|
1025
1159
|
|
|
1160
|
+
// Track all drop reasons so we can emit one summary line at the end
|
|
1161
|
+
// instead of forcing the caller to scan O(N) LogError lines to figure
|
|
1162
|
+
// out how many selections actually made it. The picker → ApplyAll →
|
|
1163
|
+
// RSU pipeline already had three layers of silent O(N) drops; this
|
|
1164
|
+
// makes them at least summarised.
|
|
1165
|
+
const droppedNotInSchema: string[] = [];
|
|
1166
|
+
const droppedNoFields: string[] = [];
|
|
1167
|
+
const droppedNoPrimaryKey: string[] = [];
|
|
1168
|
+
|
|
1026
1169
|
const results: TargetTableConfig[] = [];
|
|
1027
1170
|
for (const obj of objects) {
|
|
1028
1171
|
const sourceObj = sourceSchema.Objects.find(o => o.ExternalName.toLowerCase() === obj.SourceObjectName.toLowerCase());
|
|
@@ -1031,6 +1174,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1031
1174
|
// If the object wasn't discovered in IntrospectSchema (e.g. API error), skip it
|
|
1032
1175
|
// rather than generating a broken table with no columns and a fallback PK.
|
|
1033
1176
|
if (!sourceObj) {
|
|
1177
|
+
droppedNotInSchema.push(obj.SourceObjectName);
|
|
1034
1178
|
LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — not found in source schema (IntrospectSchema may have failed for this object)`);
|
|
1035
1179
|
continue;
|
|
1036
1180
|
}
|
|
@@ -1043,17 +1187,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1043
1187
|
!selectedFieldSet || selectedFieldSet.has(f.Name.toLowerCase()) || f.IsPrimaryKey
|
|
1044
1188
|
);
|
|
1045
1189
|
|
|
1046
|
-
const columns: TargetColumnConfig[] = sourceFields.map(f =>
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1190
|
+
const columns: TargetColumnConfig[] = sourceFields.map(f => {
|
|
1191
|
+
const targetSqlType = mapper.MapSourceType(f.SourceType, platform, f);
|
|
1192
|
+
return {
|
|
1193
|
+
SourceFieldName: f.Name,
|
|
1194
|
+
TargetColumnName: f.Name.replace(/[^A-Za-z0-9_]/g, '_'),
|
|
1195
|
+
TargetSqlType: targetSqlType,
|
|
1196
|
+
// Synced shadow tables must NOT enforce NOT NULL on non-PK
|
|
1197
|
+
// columns. The external system (SF, HubSpot, etc.) is the
|
|
1198
|
+
// source of truth for business data, not for MJ's schema
|
|
1199
|
+
// constraints — and its describe output often declares
|
|
1200
|
+
// fields required when real records actually have nulls
|
|
1201
|
+
// (deprecated, calculated, or edge-case fields). Enforcing
|
|
1202
|
+
// NOT NULL here just aborts entire batches on one bad row.
|
|
1203
|
+
IsNullable: !f.IsPrimaryKey,
|
|
1204
|
+
MaxLength: f.MaxLength,
|
|
1205
|
+
Precision: f.Precision,
|
|
1206
|
+
Scale: f.Scale,
|
|
1207
|
+
DefaultValue: this.formatSqlDefault(f.DefaultValue, targetSqlType),
|
|
1208
|
+
Description: f.Description ?? objDescriptions?.fields.get(f.Name.toLowerCase()),
|
|
1209
|
+
};
|
|
1210
|
+
});
|
|
1057
1211
|
|
|
1058
1212
|
const primaryKeyFields = sourceObj.Fields
|
|
1059
1213
|
.filter(f => f.IsPrimaryKey)
|
|
@@ -1062,6 +1216,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1062
1216
|
// If no columns were discovered, skip rather than generating a broken table
|
|
1063
1217
|
// (DDL with UNIQUE ([ID]) on a non-existent column will always fail).
|
|
1064
1218
|
if (columns.length === 0 && primaryKeyFields.length === 0) {
|
|
1219
|
+
droppedNoFields.push(obj.SourceObjectName);
|
|
1065
1220
|
LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — 0 fields discovered (live API likely failed and no DB-cached fields available)`);
|
|
1066
1221
|
continue;
|
|
1067
1222
|
}
|
|
@@ -1069,6 +1224,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1069
1224
|
// If columns exist but no PK was found, log diagnostic info and skip rather than
|
|
1070
1225
|
// generating broken DDL with UNIQUE ([ID]) on a non-existent column.
|
|
1071
1226
|
if (primaryKeyFields.length === 0 && columns.length > 0) {
|
|
1227
|
+
droppedNoPrimaryKey.push(obj.SourceObjectName);
|
|
1072
1228
|
const fieldNames = sourceObj.Fields.map(f => `${f.Name}(pk=${f.IsPrimaryKey})`).join(', ');
|
|
1073
1229
|
LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — ${columns.length} columns but NO primary key field found. Fields: [${fieldNames}]`);
|
|
1074
1230
|
continue;
|
|
@@ -1085,6 +1241,28 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1085
1241
|
SoftForeignKeys: []
|
|
1086
1242
|
});
|
|
1087
1243
|
}
|
|
1244
|
+
|
|
1245
|
+
// Single-line summary of every drop that happened during this call.
|
|
1246
|
+
// Without this, callers see N individual LogError lines and have to
|
|
1247
|
+
// count them by hand to know how much got lost. With it, the gap
|
|
1248
|
+
// between "selections requested" and "tables generated" is a one-line
|
|
1249
|
+
// grep target (`buildTargetConfigs summary`) that names which objects
|
|
1250
|
+
// were lost and why.
|
|
1251
|
+
const totalRequested = objects.length;
|
|
1252
|
+
const totalAccepted = results.length;
|
|
1253
|
+
const totalDropped = totalRequested - totalAccepted;
|
|
1254
|
+
if (totalDropped > 0) {
|
|
1255
|
+
const fmt = (arr: string[]): string =>
|
|
1256
|
+
arr.length === 0
|
|
1257
|
+
? '0'
|
|
1258
|
+
: `${arr.length} (${arr.slice(0, 5).join(', ')}${arr.length > 5 ? `, +${arr.length - 5} more` : ''})`;
|
|
1259
|
+
console.warn(
|
|
1260
|
+
`[buildTargetConfigs summary] requested=${totalRequested}, accepted=${totalAccepted}, dropped=${totalDropped} ` +
|
|
1261
|
+
`(notInSchema=${fmt(droppedNotInSchema)}, noFields=${fmt(droppedNoFields)}, noPK=${fmt(droppedNoPrimaryKey)})`
|
|
1262
|
+
);
|
|
1263
|
+
} else {
|
|
1264
|
+
console.log(`[buildTargetConfigs summary] requested=${totalRequested}, accepted=${totalAccepted} (all selections produced target configs)`);
|
|
1265
|
+
}
|
|
1088
1266
|
return results;
|
|
1089
1267
|
}
|
|
1090
1268
|
|
|
@@ -1116,6 +1294,49 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1116
1294
|
return result;
|
|
1117
1295
|
}
|
|
1118
1296
|
|
|
1297
|
+
/**
|
|
1298
|
+
* Format a raw default-value from source schema (SF describe, etc.) into a
|
|
1299
|
+
* SQL-literal string appropriate for the target column's SQL type.
|
|
1300
|
+
*
|
|
1301
|
+
* The DDLGenerator splats DefaultValue raw into `... DEFAULT ${value}`, so
|
|
1302
|
+
* the caller MUST pre-quote/pre-coerce. Previously this layer passed SF's
|
|
1303
|
+
* `String(defaultValue)` through unchanged, which produced invalid T-SQL
|
|
1304
|
+
* like `DEFAULT false` on BIT columns and `DEFAULT Diagonal` on strings.
|
|
1305
|
+
*
|
|
1306
|
+
* Rules:
|
|
1307
|
+
* - null/undefined/empty → undefined (no DEFAULT clause emitted)
|
|
1308
|
+
* - Known SQL expressions (GETDATE(), CURRENT_TIMESTAMP, NEWID(), NULL) → pass through
|
|
1309
|
+
* - Numeric-looking strings → pass through
|
|
1310
|
+
* - Booleans on BIT/BOOLEAN columns → '1' / '0'
|
|
1311
|
+
* - Everything else → quoted string literal with single-quote escaping
|
|
1312
|
+
*/
|
|
1313
|
+
private formatSqlDefault(raw: string | null | undefined, targetSqlType: string): string | undefined {
|
|
1314
|
+
if (raw == null) return undefined;
|
|
1315
|
+
const trimmed = String(raw).trim();
|
|
1316
|
+
if (trimmed === '') return undefined;
|
|
1317
|
+
|
|
1318
|
+
const upperType = targetSqlType.toUpperCase();
|
|
1319
|
+
const isBit = upperType.includes('BIT') || upperType.includes('BOOLEAN');
|
|
1320
|
+
|
|
1321
|
+
// Preserve SQL keywords / well-known function calls
|
|
1322
|
+
const sqlFunctionRegex = /^(NULL|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\)|SYSDATETIME\(\)|NEWID\(\)|NEWSEQUENTIALID\(\))$/i;
|
|
1323
|
+
if (sqlFunctionRegex.test(trimmed)) return trimmed.toUpperCase();
|
|
1324
|
+
|
|
1325
|
+
// Booleans
|
|
1326
|
+
if (/^(true|false)$/i.test(trimmed)) {
|
|
1327
|
+
const isTrue = trimmed.toLowerCase() === 'true';
|
|
1328
|
+
if (isBit) return isTrue ? '1' : '0';
|
|
1329
|
+
// Non-bit column holding a boolean word — quote it as a string
|
|
1330
|
+
return isTrue ? "'true'" : "'false'";
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Numeric literal (int, decimal, scientific notation)
|
|
1334
|
+
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(trimmed)) return trimmed;
|
|
1335
|
+
|
|
1336
|
+
// String literal — escape single quotes by doubling them
|
|
1337
|
+
return `'${trimmed.replace(/'/g, "''")}'`;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1119
1340
|
private buildDescriptionLookup(connector?: BaseIntegrationConnector): Map<string, { objectDescription?: string; fields: Map<string, string> }> {
|
|
1120
1341
|
const result = new Map<string, { objectDescription?: string; fields: Map<string, string> }>();
|
|
1121
1342
|
if (!connector) return result;
|
|
@@ -1131,6 +1352,73 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1131
1352
|
return result;
|
|
1132
1353
|
}
|
|
1133
1354
|
|
|
1355
|
+
/**
|
|
1356
|
+
* Decides whether an apply call should use the filtered-introspection flow.
|
|
1357
|
+
* Salesforce has ~1,800 sobjects and the global describe is prohibitively
|
|
1358
|
+
* expensive; other connectors have dozens and the legacy "describe all then
|
|
1359
|
+
* pick" behavior is fine. The flow also engages when the client opts in by
|
|
1360
|
+
* sending a SourceObjectName (which the SF full-catalog picker does).
|
|
1361
|
+
*/
|
|
1362
|
+
private shouldUseFilteredIntrospection(
|
|
1363
|
+
connector: BaseIntegrationConnector,
|
|
1364
|
+
sourceObjects: SourceObjectInput[]
|
|
1365
|
+
): boolean {
|
|
1366
|
+
const isSalesforce = connector.IntegrationName === 'Salesforce';
|
|
1367
|
+
const clientSentNames = sourceObjects.some(so => !!so.SourceObjectName);
|
|
1368
|
+
return isSalesforce && clientSentNames;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Builds a selection plan from SourceObjectInput[] for the filtered flow.
|
|
1373
|
+
* Each entry resolves to { Name, Fields }, with Name coming from either:
|
|
1374
|
+
* - SourceObjectName directly (newly-picked from full-catalog picker), or
|
|
1375
|
+
* - A one-shot DB lookup of SourceObjectID → IntegrationObject.Name
|
|
1376
|
+
* Never fails on missing rows — such entries are silently dropped (the
|
|
1377
|
+
* caller raises on empty selection).
|
|
1378
|
+
*/
|
|
1379
|
+
private async resolveSelectionPlan(
|
|
1380
|
+
sourceObjects: SourceObjectInput[],
|
|
1381
|
+
user: UserInfo
|
|
1382
|
+
): Promise<Array<{ Name: string; Fields?: string[] }>> {
|
|
1383
|
+
const idsToLookup = sourceObjects
|
|
1384
|
+
.filter(so => !so.SourceObjectName && so.SourceObjectID)
|
|
1385
|
+
.map(so => so.SourceObjectID!);
|
|
1386
|
+
|
|
1387
|
+
const idToName = new Map<string, string>();
|
|
1388
|
+
if (idsToLookup.length > 0) {
|
|
1389
|
+
const rv = new RunView();
|
|
1390
|
+
const result = await rv.RunView<{ ID: string; Name: string }>({
|
|
1391
|
+
EntityName: 'MJ: Integration Objects',
|
|
1392
|
+
ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
|
|
1393
|
+
ResultType: 'simple',
|
|
1394
|
+
Fields: ['ID', 'Name'],
|
|
1395
|
+
}, user);
|
|
1396
|
+
if (result.Success) {
|
|
1397
|
+
for (const row of result.Results) {
|
|
1398
|
+
idToName.set(row.ID.toUpperCase(), row.Name);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const plan: Array<{ Name: string; Fields?: string[] }> = [];
|
|
1404
|
+
for (const so of sourceObjects) {
|
|
1405
|
+
const name = so.SourceObjectName
|
|
1406
|
+
?? (so.SourceObjectID ? idToName.get(so.SourceObjectID.toUpperCase()) : undefined);
|
|
1407
|
+
if (name) plan.push({ Name: name, Fields: so.Fields });
|
|
1408
|
+
}
|
|
1409
|
+
return plan;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Aligns caller-supplied names to the source schema's ExternalName casing.
|
|
1414
|
+
* Keeps the original when no match is found so downstream steps can still
|
|
1415
|
+
* raise a targeted error rather than silently drop the object.
|
|
1416
|
+
*/
|
|
1417
|
+
private normalizeNamesAgainstSchema(names: string[], sourceSchema: SourceSchemaInfo): string[] {
|
|
1418
|
+
const map = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
1419
|
+
return names.map(n => map.get(n.toLowerCase()) ?? n);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1134
1422
|
/**
|
|
1135
1423
|
* Resolves source object IDs to exact names from the DB, and normalizes names
|
|
1136
1424
|
* to match the source schema's ExternalName casing. Call once at each entry point.
|
|
@@ -1142,7 +1430,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1142
1430
|
integrationID: string,
|
|
1143
1431
|
user: UserInfo
|
|
1144
1432
|
): Promise<string[]> {
|
|
1145
|
-
//
|
|
1433
|
+
// PRESERVED for backward compat with older call sites; new code should
|
|
1434
|
+
// use resolveSourceObjectsToNames which handles per-item ID/Name fallback
|
|
1435
|
+
// without silently dropping items that have a name but no ID (or an ID
|
|
1436
|
+
// that doesn't match an IntegrationObject row yet — the picker can send
|
|
1437
|
+
// newly-discovered objects with no persisted row).
|
|
1438
|
+
void integrationID;
|
|
1146
1439
|
if (ids && ids.length > 0) {
|
|
1147
1440
|
const rv = new RunView();
|
|
1148
1441
|
const result = await rv.RunView<{ ID: string; Name: string }>({
|
|
@@ -1155,16 +1448,90 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1155
1448
|
return result.Results.map(r => r.Name);
|
|
1156
1449
|
}
|
|
1157
1450
|
}
|
|
1158
|
-
|
|
1159
|
-
// Otherwise normalize provided names against source schema casing
|
|
1160
1451
|
if (names && names.length > 0) {
|
|
1161
1452
|
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
1162
1453
|
return names.map(n => nameMap.get(n.toLowerCase()) ?? n);
|
|
1163
1454
|
}
|
|
1164
|
-
|
|
1165
1455
|
return [];
|
|
1166
1456
|
}
|
|
1167
1457
|
|
|
1458
|
+
/**
|
|
1459
|
+
* Per-item ID/name resolver for picker selections.
|
|
1460
|
+
*
|
|
1461
|
+
* Each `SourceObjectInput` from the picker may carry SourceObjectID
|
|
1462
|
+
* (for objects with an existing IntegrationObject row), SourceObjectName
|
|
1463
|
+
* (for newly-discovered objects with no persisted row yet), or both.
|
|
1464
|
+
*
|
|
1465
|
+
* The legacy `resolveSourceObjectNames` only honored the IDs path:
|
|
1466
|
+
* `ids.map(...)` produced a SQL `WHERE ID IN (...)` and returned only
|
|
1467
|
+
* the matched rows — name-only selections and ID-misses were silently
|
|
1468
|
+
* dropped, with no surfaced log line. On real syncs this collapsed
|
|
1469
|
+
* 1156 picker selections to 420 IntegrationObjects to 181 generated
|
|
1470
|
+
* tables. Two silent O(N) data losses, invisible to users.
|
|
1471
|
+
*
|
|
1472
|
+
* This resolver:
|
|
1473
|
+
* - looks up names for selections that have an ID
|
|
1474
|
+
* - falls back to the SourceObjectName for selections without an ID
|
|
1475
|
+
* (or whose ID didn't match) — normalizing case against the source
|
|
1476
|
+
* schema when available
|
|
1477
|
+
* - LogErrors loudly when a selection truly can't be resolved (no ID
|
|
1478
|
+
* match AND no name) so the drop is visible in the run output
|
|
1479
|
+
* - returns names in the same order as the input, with the count of
|
|
1480
|
+
* dropped items so the caller can decide whether to abort or warn
|
|
1481
|
+
*/
|
|
1482
|
+
private async resolveSourceObjectsToNames(
|
|
1483
|
+
sourceObjects: SourceObjectInput[],
|
|
1484
|
+
sourceSchema: SourceSchemaInfo,
|
|
1485
|
+
user: UserInfo
|
|
1486
|
+
): Promise<{ names: string[]; droppedCount: number; sourceObjects: SourceObjectInput[] }> {
|
|
1487
|
+
// Look up names for any selections with an ID
|
|
1488
|
+
const idsToLookup = sourceObjects
|
|
1489
|
+
.map(so => so.SourceObjectID)
|
|
1490
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
1491
|
+
const idToName = new Map<string, string>();
|
|
1492
|
+
if (idsToLookup.length > 0) {
|
|
1493
|
+
const rv = new RunView();
|
|
1494
|
+
const result = await rv.RunView<{ ID: string; Name: string }>({
|
|
1495
|
+
EntityName: 'MJ: Integration Objects',
|
|
1496
|
+
ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
|
|
1497
|
+
ResultType: 'simple',
|
|
1498
|
+
Fields: ['ID', 'Name'],
|
|
1499
|
+
}, user);
|
|
1500
|
+
if (result.Success) {
|
|
1501
|
+
for (const r of result.Results) idToName.set(r.ID, r.Name);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const schemaNameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
1506
|
+
const resolvedNames: string[] = [];
|
|
1507
|
+
const resolvedSourceObjects: SourceObjectInput[] = [];
|
|
1508
|
+
const dropped: SourceObjectInput[] = [];
|
|
1509
|
+
for (const so of sourceObjects) {
|
|
1510
|
+
let name: string | undefined;
|
|
1511
|
+
if (so.SourceObjectID && idToName.has(so.SourceObjectID)) {
|
|
1512
|
+
name = idToName.get(so.SourceObjectID);
|
|
1513
|
+
} else if (so.SourceObjectName) {
|
|
1514
|
+
// Normalize case against the schema when the connector reports it
|
|
1515
|
+
name = schemaNameMap.get(so.SourceObjectName.toLowerCase()) ?? so.SourceObjectName;
|
|
1516
|
+
}
|
|
1517
|
+
if (name) {
|
|
1518
|
+
resolvedNames.push(name);
|
|
1519
|
+
resolvedSourceObjects.push(so);
|
|
1520
|
+
} else {
|
|
1521
|
+
dropped.push(so);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (dropped.length > 0) {
|
|
1525
|
+
const sample = dropped.slice(0, 5).map(d => `{id=${d.SourceObjectID ?? '∅'}, name=${d.SourceObjectName ?? '∅'}}`).join(', ');
|
|
1526
|
+
LogError(
|
|
1527
|
+
`[resolveSourceObjectsToNames] Dropped ${dropped.length} of ${sourceObjects.length} selection(s) ` +
|
|
1528
|
+
`— neither SourceObjectID matched an IntegrationObject row nor was a SourceObjectName provided. ` +
|
|
1529
|
+
`Sample: ${sample}${dropped.length > 5 ? ` (+${dropped.length - 5} more)` : ''}.`
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
return { names: resolvedNames, droppedCount: dropped.length, sourceObjects: resolvedSourceObjects };
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1168
1535
|
/**
|
|
1169
1536
|
* Resolves SourceObjectID/SourceObjectName on SchemaPreviewObjectInput array.
|
|
1170
1537
|
* Mutates the objects in place — sets SourceObjectName from ID if provided.
|
|
@@ -1924,18 +2291,20 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1924
2291
|
console.warn(`[IntegrationApplyAll] Schema persistence warning (non-fatal): ${msg}`);
|
|
1925
2292
|
}
|
|
1926
2293
|
|
|
1927
|
-
const
|
|
1928
|
-
const resolvedNames =
|
|
2294
|
+
const resolved = await this.resolveSourceObjectsToNames(input.SourceObjects, sourceSchema, user);
|
|
2295
|
+
const resolvedNames = resolved.names;
|
|
1929
2296
|
|
|
1930
|
-
// Build SchemaPreviewObjectInput with Fields
|
|
1931
|
-
|
|
2297
|
+
// Build SchemaPreviewObjectInput with Fields from the matching
|
|
2298
|
+
// SourceObjectInput (resolved.sourceObjects is order-aligned with names).
|
|
2299
|
+
// Previously this stripped to IDs only, which silently dropped any
|
|
2300
|
+
// selection without an IntegrationObject row yet (newly discovered).
|
|
1932
2301
|
const objects = resolvedNames.map((name, i) => {
|
|
1933
2302
|
const obj = new SchemaPreviewObjectInput();
|
|
1934
2303
|
obj.SourceObjectName = name;
|
|
1935
2304
|
obj.SchemaName = schemaName;
|
|
1936
2305
|
obj.TableName = name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
1937
2306
|
obj.EntityName = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
|
|
1938
|
-
obj.Fields =
|
|
2307
|
+
obj.Fields = resolved.sourceObjects[i].Fields ?? undefined;
|
|
1939
2308
|
return obj;
|
|
1940
2309
|
});
|
|
1941
2310
|
|
|
@@ -1950,11 +2319,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1950
2319
|
const pendingWorkDir = join(rsuWorkDir, '.rsu_pending');
|
|
1951
2320
|
const pendingFilePath = join(pendingWorkDir, `${Date.now()}.json`);
|
|
1952
2321
|
|
|
1953
|
-
// Build per-object field map for pending file (null = all fields)
|
|
2322
|
+
// Build per-object field map for pending file (null = all fields).
|
|
2323
|
+
// resolved.sourceObjects is order-aligned with resolvedNames after the
|
|
2324
|
+
// resolveSourceObjectsToNames refactor — pair them directly instead
|
|
2325
|
+
// of looking up by ID (which broke for name-only selections).
|
|
1954
2326
|
const sourceObjectFields: Record<string, string[] | null> = {};
|
|
1955
|
-
for (
|
|
1956
|
-
|
|
1957
|
-
if (resolvedName) sourceObjectFields[resolvedName] = so.Fields ?? null;
|
|
2327
|
+
for (let i = 0; i < resolvedNames.length; i++) {
|
|
2328
|
+
sourceObjectFields[resolvedNames[i]] = resolved.sourceObjects[i].Fields ?? null;
|
|
1958
2329
|
}
|
|
1959
2330
|
|
|
1960
2331
|
const pendingPayload = {
|
|
@@ -2004,7 +2375,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
2004
2375
|
);
|
|
2005
2376
|
const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
|
|
2006
2377
|
const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
|
|
2007
|
-
|
|
2378
|
+
// Skip sync when SyncScope='created' but 0 new maps were
|
|
2379
|
+
// created — otherwise empty EntityMapIDs falls through engine's
|
|
2380
|
+
// `length > 0` gate and runs a full integration sync against
|
|
2381
|
+
// every existing entity map (the 459-record-on-0-map-apply bug).
|
|
2382
|
+
const shouldStartSync = input.StartSync !== false &&
|
|
2383
|
+
(input.SyncScope === 'all' || createdMapIDs.length > 0);
|
|
2384
|
+
const syncRunID = shouldStartSync
|
|
2008
2385
|
? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
|
|
2009
2386
|
: null;
|
|
2010
2387
|
|
|
@@ -2231,13 +2608,26 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
2231
2608
|
platform: 'sqlserver' | 'postgresql',
|
|
2232
2609
|
user: UserInfo,
|
|
2233
2610
|
skipGitCommit: boolean,
|
|
2234
|
-
skipRestart: boolean
|
|
2611
|
+
skipRestart: boolean,
|
|
2612
|
+
prefetchedSourceSchema?: SourceSchemaInfo
|
|
2235
2613
|
): Promise<{ schemaOutput: SchemaBuilderOutput; rsuInput: RSUPipelineInput }> {
|
|
2236
2614
|
const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
|
|
2237
2615
|
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2616
|
+
// If the caller already ran IntrospectSchema (e.g. IntegrationApplyAllBatch),
|
|
2617
|
+
// reuse it. The legacy path was running introspect TWICE per apply — once
|
|
2618
|
+
// in the resolver and once here — which doubled probe time on connectors
|
|
2619
|
+
// like Sage Intacct AND silently dropped selections when the second pass
|
|
2620
|
+
// returned fewer objects than the first (rate limits, transient errors).
|
|
2621
|
+
// The picked items would then fail to match `filteredSchema` below and
|
|
2622
|
+
// get silently stripped before reaching buildTargetConfigs.
|
|
2623
|
+
let sourceSchema: SourceSchemaInfo;
|
|
2624
|
+
if (prefetchedSourceSchema) {
|
|
2625
|
+
sourceSchema = prefetchedSourceSchema;
|
|
2626
|
+
} else {
|
|
2627
|
+
const introspect = connector.IntrospectSchema.bind(connector) as
|
|
2628
|
+
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
|
|
2629
|
+
sourceSchema = await introspect(companyIntegration, user);
|
|
2630
|
+
}
|
|
2241
2631
|
|
|
2242
2632
|
// Normalize names to match source schema casing
|
|
2243
2633
|
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
@@ -3090,33 +3480,134 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3090
3480
|
input.Connectors.map(async (connInput) => {
|
|
3091
3481
|
const { connector, companyIntegration } = await this.resolveConnector(connInput.CompanyIntegrationID, user);
|
|
3092
3482
|
const schemaName = this.deriveSchemaName(companyIntegration.Integration);
|
|
3483
|
+
console.log(
|
|
3484
|
+
`[IntegrationApplyAllBatch] connector=${companyIntegration.Integration} ` +
|
|
3485
|
+
`received ${connInput.SourceObjects.length} selections: ` +
|
|
3486
|
+
connInput.SourceObjects.map(so =>
|
|
3487
|
+
`{id=${so.SourceObjectID ?? '∅'}, name=${so.SourceObjectName ?? '∅'}}`
|
|
3488
|
+
).slice(0, 30).join(', ') +
|
|
3489
|
+
(connInput.SourceObjects.length > 30 ? `, ... (+${connInput.SourceObjects.length - 30} more)` : '')
|
|
3490
|
+
);
|
|
3491
|
+
|
|
3492
|
+
// Branch: Salesforce's full-catalog picker sends SourceObjectName
|
|
3493
|
+
// for freshly-discovered objects and uses the filtered describe
|
|
3494
|
+
// path to avoid a ~70s global describe on every apply. Other
|
|
3495
|
+
// connectors (HubSpot, YourMembership, etc.) retain the legacy
|
|
3496
|
+
// ID-only flow that describes and persists the entire schema.
|
|
3497
|
+
const useFilteredFlow = this.shouldUseFilteredIntrospection(connector, connInput.SourceObjects);
|
|
3498
|
+
|
|
3499
|
+
let sourceSchema: SourceSchemaInfo;
|
|
3500
|
+
let resolvedNames: string[];
|
|
3501
|
+
const fieldsByName = new Map<string, string[] | null | undefined>();
|
|
3502
|
+
|
|
3503
|
+
if (useFilteredFlow) {
|
|
3504
|
+
// Salesforce path — describe only selected objects, persist only those
|
|
3505
|
+
const selectionPlan = await this.resolveSelectionPlan(connInput.SourceObjects, user);
|
|
3506
|
+
const selectionNames = selectionPlan.map(p => p.Name);
|
|
3507
|
+
if (selectionNames.length === 0) {
|
|
3508
|
+
throw new Error('No source objects selected — every SourceObject must have either SourceObjectID or SourceObjectName set');
|
|
3509
|
+
}
|
|
3093
3510
|
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3511
|
+
sourceSchema = await (connector.IntrospectSchema.bind(connector) as
|
|
3512
|
+
(ci: unknown, u: unknown, opts: { ObjectNames?: string[] }) => Promise<SourceSchemaInfo>)(
|
|
3513
|
+
companyIntegration, user, { ObjectNames: selectionNames }
|
|
3514
|
+
);
|
|
3515
|
+
|
|
3516
|
+
try {
|
|
3517
|
+
const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
|
|
3518
|
+
IntegrationID: companyIntegration.IntegrationID,
|
|
3519
|
+
SourceSchema: sourceSchema,
|
|
3520
|
+
ContextUser: user,
|
|
3521
|
+
});
|
|
3522
|
+
console.log(
|
|
3523
|
+
`[IntegrationApplyAllBatch] Persisted describe for ${companyIntegration.Integration} (${selectionNames.length} selected): ` +
|
|
3524
|
+
`${persistResult.ObjectsCreated} new, ${persistResult.FieldsCreated} new fields, ` +
|
|
3525
|
+
`${persistResult.ObjectsUpdated} updated, ${persistResult.FieldsUpdated} updated fields`
|
|
3526
|
+
);
|
|
3527
|
+
} catch (persistErr) {
|
|
3528
|
+
LogError(`IntegrationApplyAllBatch: PersistDiscoveredSchema failed for ${companyIntegration.Integration}: ${persistErr}`);
|
|
3529
|
+
}
|
|
3099
3530
|
|
|
3100
|
-
|
|
3101
|
-
|
|
3531
|
+
resolvedNames = this.normalizeNamesAgainstSchema(selectionNames, sourceSchema);
|
|
3532
|
+
for (const p of selectionPlan) {
|
|
3533
|
+
fieldsByName.set(p.Name.toLowerCase(), p.Fields);
|
|
3534
|
+
}
|
|
3535
|
+
} else {
|
|
3536
|
+
// Legacy path (HubSpot, YourMembership, Sage Intacct, etc.)
|
|
3537
|
+
// — describe all, persist all, then resolve by either
|
|
3538
|
+
// SourceObjectID (legacy clients) OR SourceObjectName
|
|
3539
|
+
// (newly-discovered objects from connectors that probe
|
|
3540
|
+
// their full catalog at picker time, e.g. SI's 666
|
|
3541
|
+
// candidates). Without this fallback, freshly-probed
|
|
3542
|
+
// selections silently drop.
|
|
3543
|
+
sourceSchema = await (connector.IntrospectSchema.bind(connector) as
|
|
3544
|
+
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
|
|
3545
|
+
|
|
3546
|
+
try {
|
|
3547
|
+
const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
|
|
3548
|
+
IntegrationID: companyIntegration.IntegrationID,
|
|
3549
|
+
SourceSchema: sourceSchema,
|
|
3550
|
+
ContextUser: user,
|
|
3551
|
+
});
|
|
3552
|
+
console.log(
|
|
3553
|
+
`[IntegrationApplyAllBatch] Persisted discovered schema for ${companyIntegration.Integration}: ` +
|
|
3554
|
+
`${persistResult.ObjectsCreated} new objects, ${persistResult.FieldsCreated} new fields, ` +
|
|
3555
|
+
`${persistResult.ObjectsUpdated} updated objects, ${persistResult.FieldsUpdated} updated fields`
|
|
3556
|
+
);
|
|
3557
|
+
} catch (persistErr) {
|
|
3558
|
+
LogError(`IntegrationApplyAllBatch: PersistDiscoveredSchema failed for ${companyIntegration.Integration}: ${persistErr}`);
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
// Resolve names from BOTH ID lookups and direct names.
|
|
3562
|
+
// Direct names skip the IntegrationObject DB roundtrip
|
|
3563
|
+
// since the selection plan already has the API code.
|
|
3564
|
+
const idsOnly = connInput.SourceObjects.map(so => so.SourceObjectID).filter((x): x is string => !!x);
|
|
3565
|
+
const directNames = connInput.SourceObjects.map(so => so.SourceObjectName).filter((x): x is string => !!x);
|
|
3566
|
+
const namesFromIds = idsOnly.length > 0
|
|
3567
|
+
? await this.resolveSourceObjectNames(idsOnly, undefined, sourceSchema, companyIntegration.IntegrationID, user)
|
|
3568
|
+
: [];
|
|
3569
|
+
const normalizedDirect = this.normalizeNamesAgainstSchema(directNames, sourceSchema);
|
|
3570
|
+
|
|
3571
|
+
// Preserve original picker order while deduping
|
|
3572
|
+
const seen = new Set<string>();
|
|
3573
|
+
resolvedNames = [];
|
|
3574
|
+
const orderedSources: SourceObjectInput[] = [];
|
|
3575
|
+
let idCursor = 0;
|
|
3576
|
+
let nameCursor = 0;
|
|
3577
|
+
for (const so of connInput.SourceObjects) {
|
|
3578
|
+
const resolved = so.SourceObjectName
|
|
3579
|
+
? normalizedDirect[nameCursor++]
|
|
3580
|
+
: (so.SourceObjectID ? namesFromIds[idCursor++] : undefined);
|
|
3581
|
+
if (!resolved) continue;
|
|
3582
|
+
const key = resolved.toLowerCase();
|
|
3583
|
+
if (seen.has(key)) continue;
|
|
3584
|
+
seen.add(key);
|
|
3585
|
+
resolvedNames.push(resolved);
|
|
3586
|
+
orderedSources.push(so);
|
|
3587
|
+
}
|
|
3588
|
+
for (let i = 0; i < resolvedNames.length; i++) {
|
|
3589
|
+
fieldsByName.set(resolvedNames[i].toLowerCase(), orderedSources[i].Fields);
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
const objects = resolvedNames.map(name => {
|
|
3102
3594
|
const obj = new SchemaPreviewObjectInput();
|
|
3103
3595
|
obj.SourceObjectName = name;
|
|
3104
3596
|
obj.SchemaName = schemaName;
|
|
3105
3597
|
obj.TableName = name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
3106
3598
|
obj.EntityName = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
|
|
3107
|
-
obj.Fields =
|
|
3599
|
+
obj.Fields = fieldsByName.get(name.toLowerCase()) ?? undefined;
|
|
3108
3600
|
return obj;
|
|
3109
3601
|
});
|
|
3110
3602
|
|
|
3111
3603
|
const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(
|
|
3112
|
-
connInput.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart
|
|
3604
|
+
connInput.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart, sourceSchema
|
|
3113
3605
|
);
|
|
3114
3606
|
|
|
3115
3607
|
// Build per-object field map for pending file
|
|
3116
3608
|
const sourceObjectFields: Record<string, string[] | null> = {};
|
|
3117
|
-
for (const
|
|
3118
|
-
|
|
3119
|
-
if (resolvedName) sourceObjectFields[resolvedName] = so.Fields ?? null;
|
|
3609
|
+
for (const name of resolvedNames) {
|
|
3610
|
+
sourceObjectFields[name] = fieldsByName.get(name.toLowerCase()) ?? null;
|
|
3120
3611
|
}
|
|
3121
3612
|
|
|
3122
3613
|
// Inject post-restart pending work payload
|
|
@@ -3238,7 +3729,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3238
3729
|
|
|
3239
3730
|
const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
|
|
3240
3731
|
const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
|
|
3241
|
-
|
|
3732
|
+
|
|
3733
|
+
// Skip sync entirely when SyncScope='created' (default) but
|
|
3734
|
+
// no new maps were created. Otherwise the engine sees an
|
|
3735
|
+
// empty EntityMapIDs array, falls through its `length > 0`
|
|
3736
|
+
// gate, and runs a FULL integration sync — silently re-
|
|
3737
|
+
// pulling every existing map. That's why a 0-new-map apply
|
|
3738
|
+
// could trigger a 459-record sync against the 71 existing.
|
|
3739
|
+
const shouldStartSync = input.StartSync !== false &&
|
|
3740
|
+
(input.SyncScope === 'all' || createdMapIDs.length > 0);
|
|
3741
|
+
const syncRunID = shouldStartSync
|
|
3242
3742
|
? await this.startSyncAfterApply(build.connInput.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
|
|
3243
3743
|
: null;
|
|
3244
3744
|
if (syncRunID) connResult.SyncRunID = syncRunID;
|