@memberjunction/server 5.29.0 → 5.30.1
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/generated/generated.d.ts +126 -8
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +708 -61
- package/dist/generated/generated.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/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 +66 -66
- package/src/auth/newUsers.ts +65 -74
- package/src/generated/generated.ts +503 -50
- package/src/resolvers/IntegrationDiscoveryResolver.ts +543 -43
- package/src/resolvers/SyncDataResolver.ts +24 -14
- package/src/resolvers/SyncRolesUsersResolver.ts +177 -141
- package/src/services/TaskOrchestrator.ts +86 -93
|
@@ -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: '
|
|
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
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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
|
-
//
|
|
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
|
|
2535
|
-
const resolvedNames =
|
|
2536
|
-
// Build SchemaPreviewObjectInput with Fields
|
|
2537
|
-
|
|
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 =
|
|
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 (
|
|
2557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2778
|
-
|
|
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
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
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 =
|
|
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
|
|
3531
|
-
|
|
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
|
-
|
|
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")),
|