@memberjunction/server 5.29.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.
@@ -15,6 +15,7 @@ import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType } fro
15
15
  import { CompositeKey, LocalCacheManager, Metadata, RunView, LogError } from "@memberjunction/core";
16
16
  import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
17
17
  import { ConnectorFactory, IntegrationEngine, IntegrationSchemaSync } from "@memberjunction/integration-engine";
18
+ import { IntegrationEngineBase } from "@memberjunction/integration-engine-base";
18
19
  import { SchemaBuilder, TypeMapper, SchemaEvolution } from "@memberjunction/integration-schema-builder";
19
20
  import { RuntimeSchemaManager } from "@memberjunction/schema-engine";
20
21
  import { ResolverBase } from "../generic/ResolverBase.js";
@@ -95,9 +96,13 @@ ApplySchemaBatchItemInput = __decorate([
95
96
  let SourceObjectInput = class SourceObjectInput {
96
97
  };
97
98
  __decorate([
98
- Field({ description: 'Source object ID (IntegrationObject.ID)' }),
99
+ Field({ nullable: true, description: 'Existing IntegrationObject.ID. Either SourceObjectID or SourceObjectName must be provided; if both, ID wins.' }),
99
100
  __metadata("design:type", String)
100
101
  ], SourceObjectInput.prototype, "SourceObjectID", void 0);
102
+ __decorate([
103
+ 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.' }),
104
+ __metadata("design:type", String)
105
+ ], SourceObjectInput.prototype, "SourceObjectName", void 0);
101
106
  __decorate([
102
107
  Field(() => [String], { nullable: true, description: 'Optional field selection. Empty/null = all fields (including any new ones). Only specified fields get field maps.' }),
103
108
  __metadata("design:type", Array)
@@ -1495,6 +1500,65 @@ const VALID_ENTITY_MAP_STATUSES = ['Active', 'Inactive'];
1495
1500
  function isValidEntityMapStatus(value) {
1496
1501
  return VALID_ENTITY_MAP_STATUSES.includes(value);
1497
1502
  }
1503
+ // ─── List Source Objects (Full-Catalog Picker) ──────────────────────────────
1504
+ // Returns every object the source system exposes (e.g. all ~1,800 Salesforce
1505
+ // sobjects), merged with any existing IntegrationObject metadata so the UI
1506
+ // can show which objects are already registered versus newly discoverable.
1507
+ // Intentionally cheap: one global describe call, no per-object describes.
1508
+ let ListSourceObjectsItem = class ListSourceObjectsItem {
1509
+ };
1510
+ __decorate([
1511
+ Field(),
1512
+ __metadata("design:type", String)
1513
+ ], ListSourceObjectsItem.prototype, "Name", void 0);
1514
+ __decorate([
1515
+ Field(),
1516
+ __metadata("design:type", String)
1517
+ ], ListSourceObjectsItem.prototype, "Label", void 0);
1518
+ __decorate([
1519
+ Field({ nullable: true }),
1520
+ __metadata("design:type", String)
1521
+ ], ListSourceObjectsItem.prototype, "Description", void 0);
1522
+ __decorate([
1523
+ Field(),
1524
+ __metadata("design:type", Boolean)
1525
+ ], ListSourceObjectsItem.prototype, "SupportsIncrementalSync", void 0);
1526
+ __decorate([
1527
+ Field(),
1528
+ __metadata("design:type", Boolean)
1529
+ ], ListSourceObjectsItem.prototype, "SupportsWrite", void 0);
1530
+ __decorate([
1531
+ Field(),
1532
+ __metadata("design:type", Boolean)
1533
+ ], ListSourceObjectsItem.prototype, "AlreadyPersisted", void 0);
1534
+ __decorate([
1535
+ Field({ nullable: true }),
1536
+ __metadata("design:type", String)
1537
+ ], ListSourceObjectsItem.prototype, "IntegrationObjectID", void 0);
1538
+ __decorate([
1539
+ Field(),
1540
+ __metadata("design:type", Boolean)
1541
+ ], ListSourceObjectsItem.prototype, "IsCustom", void 0);
1542
+ ListSourceObjectsItem = __decorate([
1543
+ ObjectType()
1544
+ ], ListSourceObjectsItem);
1545
+ let ListSourceObjectsOutput = class ListSourceObjectsOutput {
1546
+ };
1547
+ __decorate([
1548
+ Field(),
1549
+ __metadata("design:type", Boolean)
1550
+ ], ListSourceObjectsOutput.prototype, "Success", void 0);
1551
+ __decorate([
1552
+ Field(),
1553
+ __metadata("design:type", String)
1554
+ ], ListSourceObjectsOutput.prototype, "Message", void 0);
1555
+ __decorate([
1556
+ Field(() => [ListSourceObjectsItem], { nullable: true }),
1557
+ __metadata("design:type", Array)
1558
+ ], ListSourceObjectsOutput.prototype, "Objects", void 0);
1559
+ ListSourceObjectsOutput = __decorate([
1560
+ ObjectType()
1561
+ ], ListSourceObjectsOutput);
1498
1562
  /**
1499
1563
  * GraphQL resolver for integration discovery operations.
1500
1564
  * Provides endpoints to test connections, discover objects, and discover fields
@@ -1563,6 +1627,94 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1563
1627
  return this.handleDiscoveryError(e);
1564
1628
  }
1565
1629
  }
1630
+ /**
1631
+ * Full-catalog picker endpoint: returns every object the source system
1632
+ * exposes (e.g. all ~1,800 Salesforce sobjects) merged with flags showing
1633
+ * which ones already have IntegrationObject rows in MJ. Cheap by design —
1634
+ * one global discovery call per source, no per-object describes. Per-object
1635
+ * describe runs later, at selection time, inside IntegrationApplyAllBatch.
1636
+ */
1637
+ async IntegrationListSourceObjects(companyIntegrationID, ctx) {
1638
+ try {
1639
+ const user = this.getAuthenticatedUser(ctx);
1640
+ const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
1641
+ // Use the engine cache for already-persisted IntegrationObject
1642
+ // rows — single in-memory read instead of a per-call DB roundtrip.
1643
+ await IntegrationEngine.Instance.Config(false, user);
1644
+ const existingObjects = IntegrationEngineBase.Instance
1645
+ .GetIntegrationObjectsByIntegrationID(companyIntegration.IntegrationID);
1646
+ const discoverObjects = connector.DiscoverObjects.bind(connector);
1647
+ const liveObjects = await discoverObjects(companyIntegration, user);
1648
+ const existingByName = new Map();
1649
+ for (const row of existingObjects) {
1650
+ existingByName.set(row.Name, { ID: row.ID, IsCustom: !!row.IsCustom });
1651
+ }
1652
+ // Live is SoT. When the probe succeeds, show only live objects
1653
+ // (the persisted IntegrationObject ID is overlaid by name when
1654
+ // there's a match). When the probe returns nothing (transient
1655
+ // SI failure, rate limit, expired session), fall back to the
1656
+ // engine cache so the user isn't stuck with an empty picker.
1657
+ const sourceObjects = liveObjects.length > 0
1658
+ ? liveObjects
1659
+ : existingObjects.map(row => ({
1660
+ Name: row.Name,
1661
+ Label: row.Name,
1662
+ Description: undefined,
1663
+ SupportsIncrementalSync: true,
1664
+ SupportsWrite: true,
1665
+ }));
1666
+ const merged = sourceObjects.map(o => {
1667
+ const existing = existingByName.get(o.Name);
1668
+ return {
1669
+ Name: o.Name,
1670
+ Label: o.Label,
1671
+ Description: o.Description,
1672
+ SupportsIncrementalSync: o.SupportsIncrementalSync,
1673
+ SupportsWrite: o.SupportsWrite,
1674
+ AlreadyPersisted: existing != null,
1675
+ IntegrationObjectID: existing?.ID,
1676
+ IsCustom: this.isCustomObjectName(o.Name, existing?.IsCustom),
1677
+ };
1678
+ });
1679
+ merged.sort((a, b) => a.Name.localeCompare(b.Name));
1680
+ return {
1681
+ Success: true,
1682
+ Message: `Listed ${merged.length} source objects (${liveObjects.length} from live probe, ${existingByName.size} already persisted)`,
1683
+ Objects: merged,
1684
+ };
1685
+ }
1686
+ catch (e) {
1687
+ LogError(`IntegrationListSourceObjects error: ${e}`);
1688
+ return { Success: false, Message: this.formatError(e) };
1689
+ }
1690
+ }
1691
+ async loadIntegrationObjectsByIntegrationID(integrationID, user) {
1692
+ const rv = new RunView();
1693
+ const result = await rv.RunView({
1694
+ EntityName: 'MJ: Integration Objects',
1695
+ ExtraFilter: `IntegrationID='${integrationID}'`,
1696
+ ResultType: 'entity_object',
1697
+ }, user);
1698
+ if (!result.Success) {
1699
+ LogError(`loadIntegrationObjectsByIntegrationID failed: ${result.ErrorMessage}`);
1700
+ return [];
1701
+ }
1702
+ return result.Results.map(r => ({
1703
+ ID: r.ID,
1704
+ Name: r.Name,
1705
+ IsCustom: r.IsCustom === true,
1706
+ }));
1707
+ }
1708
+ /**
1709
+ * Heuristic for flagging custom objects in the UI. Existing rows carry an
1710
+ * IsCustom column; newly-discovered ones don't, so fall back to the SF
1711
+ * `__c` suffix convention (harmless on systems where it doesn't apply).
1712
+ */
1713
+ isCustomObjectName(name, existingIsCustom) {
1714
+ if (existingIsCustom != null)
1715
+ return existingIsCustom;
1716
+ return name.endsWith('__c');
1717
+ }
1566
1718
  /**
1567
1719
  * Discovers fields on a specific external object.
1568
1720
  */
@@ -1758,6 +1910,14 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1758
1910
  // DB entities may not have Description populated yet on first run,
1759
1911
  // but the connector's GetIntegrationObjects() always has them.
1760
1912
  const connectorDescriptions = this.buildDescriptionLookup(connector);
1913
+ // Track all drop reasons so we can emit one summary line at the end
1914
+ // instead of forcing the caller to scan O(N) LogError lines to figure
1915
+ // out how many selections actually made it. The picker → ApplyAll →
1916
+ // RSU pipeline already had three layers of silent O(N) drops; this
1917
+ // makes them at least summarised.
1918
+ const droppedNotInSchema = [];
1919
+ const droppedNoFields = [];
1920
+ const droppedNoPrimaryKey = [];
1761
1921
  const results = [];
1762
1922
  for (const obj of objects) {
1763
1923
  const sourceObj = sourceSchema.Objects.find(o => o.ExternalName.toLowerCase() === obj.SourceObjectName.toLowerCase());
@@ -1765,6 +1925,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1765
1925
  // If the object wasn't discovered in IntrospectSchema (e.g. API error), skip it
1766
1926
  // rather than generating a broken table with no columns and a fallback PK.
1767
1927
  if (!sourceObj) {
1928
+ droppedNotInSchema.push(obj.SourceObjectName);
1768
1929
  LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — not found in source schema (IntrospectSchema may have failed for this object)`);
1769
1930
  continue;
1770
1931
  }
@@ -1773,29 +1934,41 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1773
1934
  ? new Set(obj.Fields.map(f => f.toLowerCase()))
1774
1935
  : null;
1775
1936
  const sourceFields = sourceObj.Fields.filter(f => !selectedFieldSet || selectedFieldSet.has(f.Name.toLowerCase()) || f.IsPrimaryKey);
1776
- const columns = sourceFields.map(f => ({
1777
- SourceFieldName: f.Name,
1778
- TargetColumnName: f.Name.replace(/[^A-Za-z0-9_]/g, '_'),
1779
- TargetSqlType: mapper.MapSourceType(f.SourceType, platform, f),
1780
- IsNullable: !f.IsRequired,
1781
- MaxLength: f.MaxLength,
1782
- Precision: f.Precision,
1783
- Scale: f.Scale,
1784
- DefaultValue: f.DefaultValue,
1785
- Description: f.Description ?? objDescriptions?.fields.get(f.Name.toLowerCase()),
1786
- }));
1937
+ const columns = sourceFields.map(f => {
1938
+ const targetSqlType = mapper.MapSourceType(f.SourceType, platform, f);
1939
+ return {
1940
+ SourceFieldName: f.Name,
1941
+ TargetColumnName: f.Name.replace(/[^A-Za-z0-9_]/g, '_'),
1942
+ TargetSqlType: targetSqlType,
1943
+ // Synced shadow tables must NOT enforce NOT NULL on non-PK
1944
+ // columns. The external system (SF, HubSpot, etc.) is the
1945
+ // source of truth for business data, not for MJ's schema
1946
+ // constraints and its describe output often declares
1947
+ // fields required when real records actually have nulls
1948
+ // (deprecated, calculated, or edge-case fields). Enforcing
1949
+ // NOT NULL here just aborts entire batches on one bad row.
1950
+ IsNullable: !f.IsPrimaryKey,
1951
+ MaxLength: f.MaxLength,
1952
+ Precision: f.Precision,
1953
+ Scale: f.Scale,
1954
+ DefaultValue: this.formatSqlDefault(f.DefaultValue, targetSqlType),
1955
+ Description: f.Description ?? objDescriptions?.fields.get(f.Name.toLowerCase()),
1956
+ };
1957
+ });
1787
1958
  const primaryKeyFields = sourceObj.Fields
1788
1959
  .filter(f => f.IsPrimaryKey)
1789
1960
  .map(f => f.Name.replace(/[^A-Za-z0-9_]/g, '_'));
1790
1961
  // If no columns were discovered, skip rather than generating a broken table
1791
1962
  // (DDL with UNIQUE ([ID]) on a non-existent column will always fail).
1792
1963
  if (columns.length === 0 && primaryKeyFields.length === 0) {
1964
+ droppedNoFields.push(obj.SourceObjectName);
1793
1965
  LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — 0 fields discovered (live API likely failed and no DB-cached fields available)`);
1794
1966
  continue;
1795
1967
  }
1796
1968
  // If columns exist but no PK was found, log diagnostic info and skip rather than
1797
1969
  // generating broken DDL with UNIQUE ([ID]) on a non-existent column.
1798
1970
  if (primaryKeyFields.length === 0 && columns.length > 0) {
1971
+ droppedNoPrimaryKey.push(obj.SourceObjectName);
1799
1972
  const fieldNames = sourceObj.Fields.map(f => `${f.Name}(pk=${f.IsPrimaryKey})`).join(', ');
1800
1973
  LogError(`[buildTargetConfigs] Skipping "${obj.SourceObjectName}" — ${columns.length} columns but NO primary key field found. Fields: [${fieldNames}]`);
1801
1974
  continue;
@@ -1811,6 +1984,25 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1811
1984
  SoftForeignKeys: []
1812
1985
  });
1813
1986
  }
1987
+ // Single-line summary of every drop that happened during this call.
1988
+ // Without this, callers see N individual LogError lines and have to
1989
+ // count them by hand to know how much got lost. With it, the gap
1990
+ // between "selections requested" and "tables generated" is a one-line
1991
+ // grep target (`buildTargetConfigs summary`) that names which objects
1992
+ // were lost and why.
1993
+ const totalRequested = objects.length;
1994
+ const totalAccepted = results.length;
1995
+ const totalDropped = totalRequested - totalAccepted;
1996
+ if (totalDropped > 0) {
1997
+ const fmt = (arr) => arr.length === 0
1998
+ ? '0'
1999
+ : `${arr.length} (${arr.slice(0, 5).join(', ')}${arr.length > 5 ? `, +${arr.length - 5} more` : ''})`;
2000
+ console.warn(`[buildTargetConfigs summary] requested=${totalRequested}, accepted=${totalAccepted}, dropped=${totalDropped} ` +
2001
+ `(notInSchema=${fmt(droppedNotInSchema)}, noFields=${fmt(droppedNoFields)}, noPK=${fmt(droppedNoPrimaryKey)})`);
2002
+ }
2003
+ else {
2004
+ console.log(`[buildTargetConfigs summary] requested=${totalRequested}, accepted=${totalAccepted} (all selections produced target configs)`);
2005
+ }
1814
2006
  return results;
1815
2007
  }
1816
2008
  /** Builds a lookup of object name → { objectDescription, fields: fieldName → description } from the connector's static metadata. */
@@ -1838,6 +2030,48 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1838
2030
  }
1839
2031
  return result;
1840
2032
  }
2033
+ /**
2034
+ * Format a raw default-value from source schema (SF describe, etc.) into a
2035
+ * SQL-literal string appropriate for the target column's SQL type.
2036
+ *
2037
+ * The DDLGenerator splats DefaultValue raw into `... DEFAULT ${value}`, so
2038
+ * the caller MUST pre-quote/pre-coerce. Previously this layer passed SF's
2039
+ * `String(defaultValue)` through unchanged, which produced invalid T-SQL
2040
+ * like `DEFAULT false` on BIT columns and `DEFAULT Diagonal` on strings.
2041
+ *
2042
+ * Rules:
2043
+ * - null/undefined/empty → undefined (no DEFAULT clause emitted)
2044
+ * - Known SQL expressions (GETDATE(), CURRENT_TIMESTAMP, NEWID(), NULL) → pass through
2045
+ * - Numeric-looking strings → pass through
2046
+ * - Booleans on BIT/BOOLEAN columns → '1' / '0'
2047
+ * - Everything else → quoted string literal with single-quote escaping
2048
+ */
2049
+ formatSqlDefault(raw, targetSqlType) {
2050
+ if (raw == null)
2051
+ return undefined;
2052
+ const trimmed = String(raw).trim();
2053
+ if (trimmed === '')
2054
+ return undefined;
2055
+ const upperType = targetSqlType.toUpperCase();
2056
+ const isBit = upperType.includes('BIT') || upperType.includes('BOOLEAN');
2057
+ // Preserve SQL keywords / well-known function calls
2058
+ const sqlFunctionRegex = /^(NULL|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\)|SYSDATETIME\(\)|NEWID\(\)|NEWSEQUENTIALID\(\))$/i;
2059
+ if (sqlFunctionRegex.test(trimmed))
2060
+ return trimmed.toUpperCase();
2061
+ // Booleans
2062
+ if (/^(true|false)$/i.test(trimmed)) {
2063
+ const isTrue = trimmed.toLowerCase() === 'true';
2064
+ if (isBit)
2065
+ return isTrue ? '1' : '0';
2066
+ // Non-bit column holding a boolean word — quote it as a string
2067
+ return isTrue ? "'true'" : "'false'";
2068
+ }
2069
+ // Numeric literal (int, decimal, scientific notation)
2070
+ if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(trimmed))
2071
+ return trimmed;
2072
+ // String literal — escape single quotes by doubling them
2073
+ return `'${trimmed.replace(/'/g, "''")}'`;
2074
+ }
1841
2075
  buildDescriptionLookup(connector) {
1842
2076
  const result = new Map();
1843
2077
  if (!connector)
@@ -1853,12 +2087,74 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1853
2087
  }
1854
2088
  return result;
1855
2089
  }
2090
+ /**
2091
+ * Decides whether an apply call should use the filtered-introspection flow.
2092
+ * Salesforce has ~1,800 sobjects and the global describe is prohibitively
2093
+ * expensive; other connectors have dozens and the legacy "describe all then
2094
+ * pick" behavior is fine. The flow also engages when the client opts in by
2095
+ * sending a SourceObjectName (which the SF full-catalog picker does).
2096
+ */
2097
+ shouldUseFilteredIntrospection(connector, sourceObjects) {
2098
+ const isSalesforce = connector.IntegrationName === 'Salesforce';
2099
+ const clientSentNames = sourceObjects.some(so => !!so.SourceObjectName);
2100
+ return isSalesforce && clientSentNames;
2101
+ }
2102
+ /**
2103
+ * Builds a selection plan from SourceObjectInput[] for the filtered flow.
2104
+ * Each entry resolves to { Name, Fields }, with Name coming from either:
2105
+ * - SourceObjectName directly (newly-picked from full-catalog picker), or
2106
+ * - A one-shot DB lookup of SourceObjectID → IntegrationObject.Name
2107
+ * Never fails on missing rows — such entries are silently dropped (the
2108
+ * caller raises on empty selection).
2109
+ */
2110
+ async resolveSelectionPlan(sourceObjects, user) {
2111
+ const idsToLookup = sourceObjects
2112
+ .filter(so => !so.SourceObjectName && so.SourceObjectID)
2113
+ .map(so => so.SourceObjectID);
2114
+ const idToName = new Map();
2115
+ if (idsToLookup.length > 0) {
2116
+ const rv = new RunView();
2117
+ const result = await rv.RunView({
2118
+ EntityName: 'MJ: Integration Objects',
2119
+ ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
2120
+ ResultType: 'simple',
2121
+ Fields: ['ID', 'Name'],
2122
+ }, user);
2123
+ if (result.Success) {
2124
+ for (const row of result.Results) {
2125
+ idToName.set(row.ID.toUpperCase(), row.Name);
2126
+ }
2127
+ }
2128
+ }
2129
+ const plan = [];
2130
+ for (const so of sourceObjects) {
2131
+ const name = so.SourceObjectName
2132
+ ?? (so.SourceObjectID ? idToName.get(so.SourceObjectID.toUpperCase()) : undefined);
2133
+ if (name)
2134
+ plan.push({ Name: name, Fields: so.Fields });
2135
+ }
2136
+ return plan;
2137
+ }
2138
+ /**
2139
+ * Aligns caller-supplied names to the source schema's ExternalName casing.
2140
+ * Keeps the original when no match is found so downstream steps can still
2141
+ * raise a targeted error rather than silently drop the object.
2142
+ */
2143
+ normalizeNamesAgainstSchema(names, sourceSchema) {
2144
+ const map = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
2145
+ return names.map(n => map.get(n.toLowerCase()) ?? n);
2146
+ }
1856
2147
  /**
1857
2148
  * Resolves source object IDs to exact names from the DB, and normalizes names
1858
2149
  * to match the source schema's ExternalName casing. Call once at each entry point.
1859
2150
  */
1860
2151
  async resolveSourceObjectNames(ids, names, sourceSchema, integrationID, user) {
1861
- // If IDs provided, resolve them to names from IntegrationObject records
2152
+ // PRESERVED for backward compat with older call sites; new code should
2153
+ // use resolveSourceObjectsToNames which handles per-item ID/Name fallback
2154
+ // without silently dropping items that have a name but no ID (or an ID
2155
+ // that doesn't match an IntegrationObject row yet — the picker can send
2156
+ // newly-discovered objects with no persisted row).
2157
+ void integrationID;
1862
2158
  if (ids && ids.length > 0) {
1863
2159
  const rv = new RunView();
1864
2160
  const result = await rv.RunView({
@@ -1871,13 +2167,84 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
1871
2167
  return result.Results.map(r => r.Name);
1872
2168
  }
1873
2169
  }
1874
- // Otherwise normalize provided names against source schema casing
1875
2170
  if (names && names.length > 0) {
1876
2171
  const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
1877
2172
  return names.map(n => nameMap.get(n.toLowerCase()) ?? n);
1878
2173
  }
1879
2174
  return [];
1880
2175
  }
2176
+ /**
2177
+ * Per-item ID/name resolver for picker selections.
2178
+ *
2179
+ * Each `SourceObjectInput` from the picker may carry SourceObjectID
2180
+ * (for objects with an existing IntegrationObject row), SourceObjectName
2181
+ * (for newly-discovered objects with no persisted row yet), or both.
2182
+ *
2183
+ * The legacy `resolveSourceObjectNames` only honored the IDs path:
2184
+ * `ids.map(...)` produced a SQL `WHERE ID IN (...)` and returned only
2185
+ * the matched rows — name-only selections and ID-misses were silently
2186
+ * dropped, with no surfaced log line. On real syncs this collapsed
2187
+ * 1156 picker selections to 420 IntegrationObjects to 181 generated
2188
+ * tables. Two silent O(N) data losses, invisible to users.
2189
+ *
2190
+ * This resolver:
2191
+ * - looks up names for selections that have an ID
2192
+ * - falls back to the SourceObjectName for selections without an ID
2193
+ * (or whose ID didn't match) — normalizing case against the source
2194
+ * schema when available
2195
+ * - LogErrors loudly when a selection truly can't be resolved (no ID
2196
+ * match AND no name) so the drop is visible in the run output
2197
+ * - returns names in the same order as the input, with the count of
2198
+ * dropped items so the caller can decide whether to abort or warn
2199
+ */
2200
+ async resolveSourceObjectsToNames(sourceObjects, sourceSchema, user) {
2201
+ // Look up names for any selections with an ID
2202
+ const idsToLookup = sourceObjects
2203
+ .map(so => so.SourceObjectID)
2204
+ .filter((id) => typeof id === 'string' && id.length > 0);
2205
+ const idToName = new Map();
2206
+ if (idsToLookup.length > 0) {
2207
+ const rv = new RunView();
2208
+ const result = await rv.RunView({
2209
+ EntityName: 'MJ: Integration Objects',
2210
+ ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
2211
+ ResultType: 'simple',
2212
+ Fields: ['ID', 'Name'],
2213
+ }, user);
2214
+ if (result.Success) {
2215
+ for (const r of result.Results)
2216
+ idToName.set(r.ID, r.Name);
2217
+ }
2218
+ }
2219
+ const schemaNameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
2220
+ const resolvedNames = [];
2221
+ const resolvedSourceObjects = [];
2222
+ const dropped = [];
2223
+ for (const so of sourceObjects) {
2224
+ let name;
2225
+ if (so.SourceObjectID && idToName.has(so.SourceObjectID)) {
2226
+ name = idToName.get(so.SourceObjectID);
2227
+ }
2228
+ else if (so.SourceObjectName) {
2229
+ // Normalize case against the schema when the connector reports it
2230
+ name = schemaNameMap.get(so.SourceObjectName.toLowerCase()) ?? so.SourceObjectName;
2231
+ }
2232
+ if (name) {
2233
+ resolvedNames.push(name);
2234
+ resolvedSourceObjects.push(so);
2235
+ }
2236
+ else {
2237
+ dropped.push(so);
2238
+ }
2239
+ }
2240
+ if (dropped.length > 0) {
2241
+ const sample = dropped.slice(0, 5).map(d => `{id=${d.SourceObjectID ?? '∅'}, name=${d.SourceObjectName ?? '∅'}}`).join(', ');
2242
+ LogError(`[resolveSourceObjectsToNames] Dropped ${dropped.length} of ${sourceObjects.length} selection(s) ` +
2243
+ `— neither SourceObjectID matched an IntegrationObject row nor was a SourceObjectName provided. ` +
2244
+ `Sample: ${sample}${dropped.length > 5 ? ` (+${dropped.length - 5} more)` : ''}.`);
2245
+ }
2246
+ return { names: resolvedNames, droppedCount: dropped.length, sourceObjects: resolvedSourceObjects };
2247
+ }
1881
2248
  /**
1882
2249
  * Resolves SourceObjectID/SourceObjectName on SchemaPreviewObjectInput array.
1883
2250
  * Mutates the objects in place — sets SourceObjectName from ID if provided.
@@ -2531,17 +2898,19 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2531
2898
  const msg = persistErr instanceof Error ? persistErr.message : String(persistErr);
2532
2899
  console.warn(`[IntegrationApplyAll] Schema persistence warning (non-fatal): ${msg}`);
2533
2900
  }
2534
- const objectIDs = input.SourceObjects.map(so => so.SourceObjectID);
2535
- const resolvedNames = await this.resolveSourceObjectNames(objectIDs, undefined, sourceSchema, companyIntegration.IntegrationID, user);
2536
- // Build SchemaPreviewObjectInput with Fields carried from SourceObjectInput
2537
- const fieldsByID = new Map(input.SourceObjects.map(so => [so.SourceObjectID, so.Fields]));
2901
+ const resolved = await this.resolveSourceObjectsToNames(input.SourceObjects, sourceSchema, user);
2902
+ const resolvedNames = resolved.names;
2903
+ // Build SchemaPreviewObjectInput with Fields from the matching
2904
+ // SourceObjectInput (resolved.sourceObjects is order-aligned with names).
2905
+ // Previously this stripped to IDs only, which silently dropped any
2906
+ // selection without an IntegrationObject row yet (newly discovered).
2538
2907
  const objects = resolvedNames.map((name, i) => {
2539
2908
  const obj = new SchemaPreviewObjectInput();
2540
2909
  obj.SourceObjectName = name;
2541
2910
  obj.SchemaName = schemaName;
2542
2911
  obj.TableName = name.replace(/[^A-Za-z0-9_]/g, '_');
2543
2912
  obj.EntityName = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
2544
- obj.Fields = fieldsByID.get(objectIDs[i]) ?? undefined;
2913
+ obj.Fields = resolved.sourceObjects[i].Fields ?? undefined;
2545
2914
  return obj;
2546
2915
  });
2547
2916
  // Step 3: Build schema and RSU pipeline input
@@ -2551,12 +2920,13 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2551
2920
  const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
2552
2921
  const pendingWorkDir = join(rsuWorkDir, '.rsu_pending');
2553
2922
  const pendingFilePath = join(pendingWorkDir, `${Date.now()}.json`);
2554
- // Build per-object field map for pending file (null = all fields)
2923
+ // Build per-object field map for pending file (null = all fields).
2924
+ // resolved.sourceObjects is order-aligned with resolvedNames after the
2925
+ // resolveSourceObjectsToNames refactor — pair them directly instead
2926
+ // of looking up by ID (which broke for name-only selections).
2555
2927
  const sourceObjectFields = {};
2556
- for (const so of input.SourceObjects) {
2557
- const resolvedName = resolvedNames[objectIDs.indexOf(so.SourceObjectID)];
2558
- if (resolvedName)
2559
- sourceObjectFields[resolvedName] = so.Fields ?? null;
2928
+ for (let i = 0; i < resolvedNames.length; i++) {
2929
+ sourceObjectFields[resolvedNames[i]] = resolved.sourceObjects[i].Fields ?? null;
2560
2930
  }
2561
2931
  const pendingPayload = {
2562
2932
  CompanyIntegrationID: input.CompanyIntegrationID,
@@ -2601,7 +2971,13 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2601
2971
  const entityMapsCreated = await this.createEntityAndFieldMaps(input.CompanyIntegrationID, objects, connector, companyIntegration, schemaName, user, input.DefaultSyncDirection ?? 'Pull');
2602
2972
  const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
2603
2973
  const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
2604
- const syncRunID = input.StartSync !== false
2974
+ // Skip sync when SyncScope='created' but 0 new maps were
2975
+ // created — otherwise empty EntityMapIDs falls through engine's
2976
+ // `length > 0` gate and runs a full integration sync against
2977
+ // every existing entity map (the 459-record-on-0-map-apply bug).
2978
+ const shouldStartSync = input.StartSync !== false &&
2979
+ (input.SyncScope === 'all' || createdMapIDs.length > 0);
2980
+ const syncRunID = shouldStartSync
2605
2981
  ? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
2606
2982
  : null;
2607
2983
  // Create schedule if requested
@@ -2772,10 +3148,23 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2772
3148
  * Build schema artifacts for a single connector's objects.
2773
3149
  * Shared by IntegrationApplySchema (single) and IntegrationApplySchemaBatch (batch).
2774
3150
  */
2775
- async buildSchemaForConnector(companyIntegrationID, objects, platform, user, skipGitCommit, skipRestart) {
3151
+ async buildSchemaForConnector(companyIntegrationID, objects, platform, user, skipGitCommit, skipRestart, prefetchedSourceSchema) {
2776
3152
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
2777
- const introspect = connector.IntrospectSchema.bind(connector);
2778
- const sourceSchema = await introspect(companyIntegration, user);
3153
+ // If the caller already ran IntrospectSchema (e.g. IntegrationApplyAllBatch),
3154
+ // reuse it. The legacy path was running introspect TWICE per apply — once
3155
+ // in the resolver and once here — which doubled probe time on connectors
3156
+ // like Sage Intacct AND silently dropped selections when the second pass
3157
+ // returned fewer objects than the first (rate limits, transient errors).
3158
+ // The picked items would then fail to match `filteredSchema` below and
3159
+ // get silently stripped before reaching buildTargetConfigs.
3160
+ let sourceSchema;
3161
+ if (prefetchedSourceSchema) {
3162
+ sourceSchema = prefetchedSourceSchema;
3163
+ }
3164
+ else {
3165
+ const introspect = connector.IntrospectSchema.bind(connector);
3166
+ sourceSchema = await introspect(companyIntegration, user);
3167
+ }
2779
3168
  // Normalize names to match source schema casing
2780
3169
  const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
2781
3170
  for (const obj of objects) {
@@ -3510,27 +3899,113 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
3510
3899
  const buildResults = await Promise.allSettled(input.Connectors.map(async (connInput) => {
3511
3900
  const { connector, companyIntegration } = await this.resolveConnector(connInput.CompanyIntegrationID, user);
3512
3901
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
3513
- // Resolve object IDs to names with per-object Fields
3514
- const sourceSchema = await connector.IntrospectSchema.bind(connector)(companyIntegration, user);
3515
- const objectIDs = connInput.SourceObjects.map(so => so.SourceObjectID);
3516
- const resolvedNames = await this.resolveSourceObjectNames(objectIDs, undefined, sourceSchema, companyIntegration.IntegrationID, user);
3517
- const fieldsByID = new Map(connInput.SourceObjects.map(so => [so.SourceObjectID, so.Fields]));
3518
- const objects = resolvedNames.map((name, i) => {
3902
+ console.log(`[IntegrationApplyAllBatch] connector=${companyIntegration.Integration} ` +
3903
+ `received ${connInput.SourceObjects.length} selections: ` +
3904
+ connInput.SourceObjects.map(so => `{id=${so.SourceObjectID ?? '∅'}, name=${so.SourceObjectName ?? '∅'}}`).slice(0, 30).join(', ') +
3905
+ (connInput.SourceObjects.length > 30 ? `, ... (+${connInput.SourceObjects.length - 30} more)` : ''));
3906
+ // Branch: Salesforce's full-catalog picker sends SourceObjectName
3907
+ // for freshly-discovered objects and uses the filtered describe
3908
+ // path to avoid a ~70s global describe on every apply. Other
3909
+ // connectors (HubSpot, YourMembership, etc.) retain the legacy
3910
+ // ID-only flow that describes and persists the entire schema.
3911
+ const useFilteredFlow = this.shouldUseFilteredIntrospection(connector, connInput.SourceObjects);
3912
+ let sourceSchema;
3913
+ let resolvedNames;
3914
+ const fieldsByName = new Map();
3915
+ if (useFilteredFlow) {
3916
+ // Salesforce path — describe only selected objects, persist only those
3917
+ const selectionPlan = await this.resolveSelectionPlan(connInput.SourceObjects, user);
3918
+ const selectionNames = selectionPlan.map(p => p.Name);
3919
+ if (selectionNames.length === 0) {
3920
+ throw new Error('No source objects selected — every SourceObject must have either SourceObjectID or SourceObjectName set');
3921
+ }
3922
+ sourceSchema = await connector.IntrospectSchema.bind(connector)(companyIntegration, user, { ObjectNames: selectionNames });
3923
+ try {
3924
+ const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
3925
+ IntegrationID: companyIntegration.IntegrationID,
3926
+ SourceSchema: sourceSchema,
3927
+ ContextUser: user,
3928
+ });
3929
+ console.log(`[IntegrationApplyAllBatch] Persisted describe for ${companyIntegration.Integration} (${selectionNames.length} selected): ` +
3930
+ `${persistResult.ObjectsCreated} new, ${persistResult.FieldsCreated} new fields, ` +
3931
+ `${persistResult.ObjectsUpdated} updated, ${persistResult.FieldsUpdated} updated fields`);
3932
+ }
3933
+ catch (persistErr) {
3934
+ LogError(`IntegrationApplyAllBatch: PersistDiscoveredSchema failed for ${companyIntegration.Integration}: ${persistErr}`);
3935
+ }
3936
+ resolvedNames = this.normalizeNamesAgainstSchema(selectionNames, sourceSchema);
3937
+ for (const p of selectionPlan) {
3938
+ fieldsByName.set(p.Name.toLowerCase(), p.Fields);
3939
+ }
3940
+ }
3941
+ else {
3942
+ // Legacy path (HubSpot, YourMembership, Sage Intacct, etc.)
3943
+ // — describe all, persist all, then resolve by either
3944
+ // SourceObjectID (legacy clients) OR SourceObjectName
3945
+ // (newly-discovered objects from connectors that probe
3946
+ // their full catalog at picker time, e.g. SI's 666
3947
+ // candidates). Without this fallback, freshly-probed
3948
+ // selections silently drop.
3949
+ sourceSchema = await connector.IntrospectSchema.bind(connector)(companyIntegration, user);
3950
+ try {
3951
+ const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
3952
+ IntegrationID: companyIntegration.IntegrationID,
3953
+ SourceSchema: sourceSchema,
3954
+ ContextUser: user,
3955
+ });
3956
+ console.log(`[IntegrationApplyAllBatch] Persisted discovered schema for ${companyIntegration.Integration}: ` +
3957
+ `${persistResult.ObjectsCreated} new objects, ${persistResult.FieldsCreated} new fields, ` +
3958
+ `${persistResult.ObjectsUpdated} updated objects, ${persistResult.FieldsUpdated} updated fields`);
3959
+ }
3960
+ catch (persistErr) {
3961
+ LogError(`IntegrationApplyAllBatch: PersistDiscoveredSchema failed for ${companyIntegration.Integration}: ${persistErr}`);
3962
+ }
3963
+ // Resolve names from BOTH ID lookups and direct names.
3964
+ // Direct names skip the IntegrationObject DB roundtrip
3965
+ // since the selection plan already has the API code.
3966
+ const idsOnly = connInput.SourceObjects.map(so => so.SourceObjectID).filter((x) => !!x);
3967
+ const directNames = connInput.SourceObjects.map(so => so.SourceObjectName).filter((x) => !!x);
3968
+ const namesFromIds = idsOnly.length > 0
3969
+ ? await this.resolveSourceObjectNames(idsOnly, undefined, sourceSchema, companyIntegration.IntegrationID, user)
3970
+ : [];
3971
+ const normalizedDirect = this.normalizeNamesAgainstSchema(directNames, sourceSchema);
3972
+ // Preserve original picker order while deduping
3973
+ const seen = new Set();
3974
+ resolvedNames = [];
3975
+ const orderedSources = [];
3976
+ let idCursor = 0;
3977
+ let nameCursor = 0;
3978
+ for (const so of connInput.SourceObjects) {
3979
+ const resolved = so.SourceObjectName
3980
+ ? normalizedDirect[nameCursor++]
3981
+ : (so.SourceObjectID ? namesFromIds[idCursor++] : undefined);
3982
+ if (!resolved)
3983
+ continue;
3984
+ const key = resolved.toLowerCase();
3985
+ if (seen.has(key))
3986
+ continue;
3987
+ seen.add(key);
3988
+ resolvedNames.push(resolved);
3989
+ orderedSources.push(so);
3990
+ }
3991
+ for (let i = 0; i < resolvedNames.length; i++) {
3992
+ fieldsByName.set(resolvedNames[i].toLowerCase(), orderedSources[i].Fields);
3993
+ }
3994
+ }
3995
+ const objects = resolvedNames.map(name => {
3519
3996
  const obj = new SchemaPreviewObjectInput();
3520
3997
  obj.SourceObjectName = name;
3521
3998
  obj.SchemaName = schemaName;
3522
3999
  obj.TableName = name.replace(/[^A-Za-z0-9_]/g, '_');
3523
4000
  obj.EntityName = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
3524
- obj.Fields = fieldsByID.get(objectIDs[i]) ?? undefined;
4001
+ obj.Fields = fieldsByName.get(name.toLowerCase()) ?? undefined;
3525
4002
  return obj;
3526
4003
  });
3527
- const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(connInput.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart);
4004
+ const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(connInput.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart, sourceSchema);
3528
4005
  // Build per-object field map for pending file
3529
4006
  const sourceObjectFields = {};
3530
- for (const so of connInput.SourceObjects) {
3531
- const resolvedName = resolvedNames[objectIDs.indexOf(so.SourceObjectID)];
3532
- if (resolvedName)
3533
- sourceObjectFields[resolvedName] = so.Fields ?? null;
4007
+ for (const name of resolvedNames) {
4008
+ sourceObjectFields[name] = fieldsByName.get(name.toLowerCase()) ?? null;
3534
4009
  }
3535
4010
  // Inject post-restart pending work payload
3536
4011
  const { join } = await import('node:path');
@@ -3631,7 +4106,15 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
3631
4106
  connResult.EntityMapsCreated = entityMapsCreated;
3632
4107
  const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
3633
4108
  const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
3634
- const syncRunID = input.StartSync !== false
4109
+ // Skip sync entirely when SyncScope='created' (default) but
4110
+ // no new maps were created. Otherwise the engine sees an
4111
+ // empty EntityMapIDs array, falls through its `length > 0`
4112
+ // gate, and runs a FULL integration sync — silently re-
4113
+ // pulling every existing map. That's why a 0-new-map apply
4114
+ // could trigger a 459-record sync against the 71 existing.
4115
+ const shouldStartSync = input.StartSync !== false &&
4116
+ (input.SyncScope === 'all' || createdMapIDs.length > 0);
4117
+ const syncRunID = shouldStartSync
3635
4118
  ? await this.startSyncAfterApply(build.connInput.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
3636
4119
  : null;
3637
4120
  if (syncRunID)
@@ -4067,6 +4550,14 @@ __decorate([
4067
4550
  __metadata("design:paramtypes", [String, Object]),
4068
4551
  __metadata("design:returntype", Promise)
4069
4552
  ], IntegrationDiscoveryResolver.prototype, "IntegrationDiscoverObjects", null);
4553
+ __decorate([
4554
+ Query(() => ListSourceObjectsOutput),
4555
+ __param(0, Arg("companyIntegrationID")),
4556
+ __param(1, Ctx()),
4557
+ __metadata("design:type", Function),
4558
+ __metadata("design:paramtypes", [String, Object]),
4559
+ __metadata("design:returntype", Promise)
4560
+ ], IntegrationDiscoveryResolver.prototype, "IntegrationListSourceObjects", null);
4070
4561
  __decorate([
4071
4562
  Query(() => DiscoverFieldsOutput),
4072
4563
  __param(0, Arg("companyIntegrationID")),