@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.
Files changed (57) hide show
  1. package/dist/auth/newUsers.d.ts.map +1 -1
  2. package/dist/auth/newUsers.js +63 -70
  3. package/dist/auth/newUsers.js.map +1 -1
  4. package/dist/config.d.ts +151 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +15 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/generated/generated.d.ts +452 -6
  9. package/dist/generated/generated.d.ts.map +1 -1
  10. package/dist/generated/generated.js +2788 -303
  11. package/dist/generated/generated.js.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/resolvers/FeedbackResolver.d.ts +150 -0
  17. package/dist/resolvers/FeedbackResolver.d.ts.map +1 -0
  18. package/dist/resolvers/FeedbackResolver.js +876 -0
  19. package/dist/resolvers/FeedbackResolver.js.map +1 -0
  20. package/dist/resolvers/FileResolver.d.ts +27 -0
  21. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  22. package/dist/resolvers/FileResolver.js +32 -3
  23. package/dist/resolvers/FileResolver.js.map +1 -1
  24. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +100 -1
  25. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  26. package/dist/resolvers/IntegrationDiscoveryResolver.js +532 -41
  27. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  28. package/dist/resolvers/MCPResolver.d.ts +77 -0
  29. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  30. package/dist/resolvers/MCPResolver.js +300 -1
  31. package/dist/resolvers/MCPResolver.js.map +1 -1
  32. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  33. package/dist/resolvers/RunAIAgentResolver.js +87 -32
  34. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  35. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  36. package/dist/resolvers/SyncDataResolver.js +20 -12
  37. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  38. package/dist/resolvers/SyncRolesUsersResolver.d.ts +20 -9
  39. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  40. package/dist/resolvers/SyncRolesUsersResolver.js +153 -116
  41. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  42. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  43. package/dist/services/TaskOrchestrator.js +78 -79
  44. package/dist/services/TaskOrchestrator.js.map +1 -1
  45. package/package.json +68 -66
  46. package/src/auth/newUsers.ts +65 -74
  47. package/src/config.ts +19 -0
  48. package/src/generated/generated.ts +1753 -40
  49. package/src/index.ts +1 -0
  50. package/src/resolvers/FeedbackResolver.ts +940 -0
  51. package/src/resolvers/FileResolver.ts +33 -4
  52. package/src/resolvers/IntegrationDiscoveryResolver.ts +543 -43
  53. package/src/resolvers/MCPResolver.ts +297 -1
  54. package/src/resolvers/RunAIAgentResolver.ts +89 -32
  55. package/src/resolvers/SyncDataResolver.ts +24 -14
  56. package/src/resolvers/SyncRolesUsersResolver.ts +177 -141
  57. 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: 'Source object ID (IntegrationObject.ID)' }) SourceObjectID: string;
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
- SourceFieldName: f.Name,
1048
- TargetColumnName: f.Name.replace(/[^A-Za-z0-9_]/g, '_'),
1049
- TargetSqlType: mapper.MapSourceType(f.SourceType, platform, f),
1050
- IsNullable: !f.IsRequired,
1051
- MaxLength: f.MaxLength,
1052
- Precision: f.Precision,
1053
- Scale: f.Scale,
1054
- DefaultValue: f.DefaultValue,
1055
- Description: f.Description ?? objDescriptions?.fields.get(f.Name.toLowerCase()),
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
- // If IDs provided, resolve them to names from IntegrationObject records
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 objectIDs = input.SourceObjects.map(so => so.SourceObjectID);
1928
- const resolvedNames = await this.resolveSourceObjectNames(objectIDs, undefined, sourceSchema, companyIntegration.IntegrationID, user);
2294
+ const resolved = await this.resolveSourceObjectsToNames(input.SourceObjects, sourceSchema, user);
2295
+ const resolvedNames = resolved.names;
1929
2296
 
1930
- // Build SchemaPreviewObjectInput with Fields carried from SourceObjectInput
1931
- const fieldsByID = new Map(input.SourceObjects.map(so => [so.SourceObjectID, so.Fields]));
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 = fieldsByID.get(objectIDs[i]) ?? undefined;
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 (const so of input.SourceObjects) {
1956
- const resolvedName = resolvedNames[objectIDs.indexOf(so.SourceObjectID)];
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
- const syncRunID = input.StartSync !== false
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
- const introspect = connector.IntrospectSchema.bind(connector) as
2239
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
2240
- const sourceSchema = await introspect(companyIntegration, user);
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
- // Resolve object IDs to names with per-object Fields
3095
- const sourceSchema = await (connector.IntrospectSchema.bind(connector) as
3096
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
3097
- const objectIDs = connInput.SourceObjects.map(so => so.SourceObjectID);
3098
- const resolvedNames = await this.resolveSourceObjectNames(objectIDs, undefined, sourceSchema, companyIntegration.IntegrationID, user);
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
- const fieldsByID = new Map(connInput.SourceObjects.map(so => [so.SourceObjectID, so.Fields]));
3101
- const objects = resolvedNames.map((name, i) => {
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 = fieldsByID.get(objectIDs[i]) ?? undefined;
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 so of connInput.SourceObjects) {
3118
- const resolvedName = resolvedNames[objectIDs.indexOf(so.SourceObjectID)];
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
- const syncRunID = input.StartSync !== false
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;