fullstackgtm 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -6
- package/INSTALL_FOR_AGENTS.md +3 -3
- package/README.md +16 -8
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +70 -1
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +76 -2
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli.js +40 -15
- package/dist/connector.js +96 -25
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/hubspot.js +170 -4
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/salesforce.js +135 -0
- package/dist/connectors/signalSources.js +2 -11
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/mcp.js +19 -15
- package/dist/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/types.d.ts +30 -0
- package/docs/api.md +22 -4
- package/docs/architecture.md +5 -2
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -2
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/fix.ts +68 -1
- package/src/cli/help.ts +83 -2
- package/src/cli/suggest.ts +58 -4
- package/src/cli.ts +38 -13
- package/src/connector.ts +94 -20
- package/src/connectors/hubspot.ts +164 -5
- package/src/connectors/salesforce.ts +136 -1
- package/src/connectors/signalSources.ts +2 -12
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +25 -0
- package/src/mcp.ts +22 -16
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/types.ts +32 -0
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { activeProfile, listProfiles, setActiveProfile } from "./credentials.js"
|
|
|
2
2
|
import { capabilitiesCommand, printCommandHelpJson, robotDocsCommand, unknownCommandEnvelope } from "./cli/capabilities.js";
|
|
3
3
|
import { BESPOKE_HELP, commandHelp, HELP, shortUsage, stylizeShortUsage, usage } from "./cli/help.js";
|
|
4
4
|
import { readPackageInfo } from "./cli/shared.js";
|
|
5
|
-
import { correctedCommand, detectFlagTypo, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.js";
|
|
5
|
+
import { correctedCommand, detectFlagTypo, detectUnknownFlag, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.js";
|
|
6
6
|
import { colorEnabled, paint } from "./cli/ui.js";
|
|
7
7
|
// Verb modules load lazily inside their dispatch branch below. The dispatcher
|
|
8
8
|
// used to import all of them eagerly, so `--version` compiled the full
|
|
@@ -56,16 +56,22 @@ export async function runCli(argv) {
|
|
|
56
56
|
}
|
|
57
57
|
if (command === "help") {
|
|
58
58
|
const [topic] = args;
|
|
59
|
-
if (topic && topic
|
|
59
|
+
if (topic && !topic.startsWith("-") && args.includes("--json")) {
|
|
60
60
|
// `help <cmd> --json` → machine-readable help derived from the same
|
|
61
|
-
// HELP entry the plain text renders from.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
// HELP entry the plain text renders from. JSON takes precedence over
|
|
62
|
+
// --full so automated callers get a stable shape.
|
|
63
|
+
printCommandHelpJson(topic);
|
|
64
|
+
}
|
|
65
|
+
else if (args.includes("--full")) {
|
|
66
|
+
// --full is the global escape hatch to the complete reference, even
|
|
67
|
+
// when a topic is given (help <cmd> --full), unless --json is requested.
|
|
68
|
+
console.log(usage());
|
|
69
|
+
}
|
|
70
|
+
else if (topic && !topic.startsWith("-")) {
|
|
71
|
+
console.log(commandHelp(topic));
|
|
66
72
|
}
|
|
67
73
|
else {
|
|
68
|
-
console.log(
|
|
74
|
+
console.log(styledShortUsage());
|
|
69
75
|
}
|
|
70
76
|
return;
|
|
71
77
|
}
|
|
@@ -90,20 +96,27 @@ export async function runCli(argv) {
|
|
|
90
96
|
}
|
|
91
97
|
// Flag typos used to be silently dropped by the per-verb parsers —
|
|
92
98
|
// `audit --demo --jsn` exited 0 and printed markdown where the agent asked
|
|
93
|
-
// for JSON.
|
|
94
|
-
// help.ts
|
|
95
|
-
// auto-execute the correction, so a typo can never
|
|
96
|
-
// write-shaped invocation stages.
|
|
99
|
+
// for JSON. Every flag-shaped token is now checked against the per-command
|
|
100
|
+
// registry in help.ts, so unknown flags fail closed instead of being ignored.
|
|
101
|
+
// Suggest-only: never auto-execute the correction, so a typo can never
|
|
102
|
+
// silently change what a write-shaped invocation stages.
|
|
97
103
|
if (command in HELP) {
|
|
98
|
-
const typo =
|
|
104
|
+
const typo = detectUnknownFlag(command, args);
|
|
99
105
|
if (typo) {
|
|
100
106
|
if (args.includes("--json")) {
|
|
101
107
|
console.log(JSON.stringify(unknownFlagEnvelope(command, args, typo), null, 2));
|
|
102
108
|
}
|
|
103
109
|
else {
|
|
110
|
+
console.error(`Unknown flag for ${command}: ${typo.given}`);
|
|
111
|
+
// Keep the old near-miss shape too; existing agents key off it.
|
|
104
112
|
console.error(`Unknown flag: ${typo.given}`);
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
if (typo.suggestion) {
|
|
114
|
+
console.error(`Did you mean ${typo.suggestion}?`);
|
|
115
|
+
console.error(`Did you mean: ${typo.suggestion}`);
|
|
116
|
+
if (typo.replacement.length > 0) {
|
|
117
|
+
console.error(`Try: ${correctedCommand(command, args, typo)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
107
120
|
}
|
|
108
121
|
process.exitCode = 1;
|
|
109
122
|
return;
|
|
@@ -161,6 +174,18 @@ export async function runCli(argv) {
|
|
|
161
174
|
await (await import("./cli/fix.js")).resolveCommand(args);
|
|
162
175
|
return;
|
|
163
176
|
}
|
|
177
|
+
if (command === "route") {
|
|
178
|
+
await (await import("./cli/fix.js")).routeCommand(args);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (command === "hierarchy") {
|
|
182
|
+
await (await import("./cli/fix.js")).hierarchyCommand(args);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (command === "relationships") {
|
|
186
|
+
await (await import("./cli/fix.js")).relationshipsCommand(args);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
164
189
|
if (command === "bulk-update") {
|
|
165
190
|
await (await import("./cli/fix.js")).bulkUpdateCommand(args);
|
|
166
191
|
return;
|
package/dist/connector.js
CHANGED
|
@@ -71,6 +71,13 @@ function normalizeForComparison(value) {
|
|
|
71
71
|
return null;
|
|
72
72
|
return String(value);
|
|
73
73
|
}
|
|
74
|
+
function isApplyBatchCandidate(operation) {
|
|
75
|
+
return (((operation.operation === "set_field" || operation.operation === "clear_field") && Boolean(operation.field)) ||
|
|
76
|
+
operation.operation === "archive_record");
|
|
77
|
+
}
|
|
78
|
+
function sameApplyBatchKind(a, b) {
|
|
79
|
+
return a.operation === b.operation && a.objectType === b.objectType;
|
|
80
|
+
}
|
|
74
81
|
/**
|
|
75
82
|
* Apply an approved subset of a patch plan through a connector.
|
|
76
83
|
*
|
|
@@ -194,6 +201,11 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
194
201
|
for (const operation of plan.operations) {
|
|
195
202
|
if (!approved.has(operation.id) || conflicts.has(operation.id))
|
|
196
203
|
continue;
|
|
204
|
+
// Generic batch connectors (Salesforce) do their own batched CAS read for
|
|
205
|
+
// eligible field writes, so don't pre-read those one-by-one here.
|
|
206
|
+
if (connector.applyBatch && isApplyBatchCandidate(operation) && !operation.groupId && !operation.preconditions?.length) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
197
209
|
let conflict = null;
|
|
198
210
|
if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
|
|
199
211
|
const current = await connector.readField(operation.objectType, operation.objectId, operation.field);
|
|
@@ -266,35 +278,38 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
266
278
|
const notifyProgress = () => {
|
|
267
279
|
if ((!options.onOperation && !options.progress) || results.length === lastNotified)
|
|
268
280
|
return;
|
|
269
|
-
lastNotified
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
281
|
+
while (lastNotified < results.length) {
|
|
282
|
+
const last = results[lastNotified];
|
|
283
|
+
lastNotified += 1;
|
|
284
|
+
if (last.status === "applied")
|
|
285
|
+
progressCounts.applied += 1;
|
|
286
|
+
else if (last.status === "failed")
|
|
287
|
+
progressCounts.failed += 1;
|
|
288
|
+
else if (last.status === "conflict")
|
|
289
|
+
progressCounts.conflicts += 1;
|
|
290
|
+
else
|
|
291
|
+
progressCounts.skipped += 1;
|
|
292
|
+
// Shared vocabulary: operation id + status only — a conflict/failure
|
|
293
|
+
// detail can echo field values, which never leave the machine.
|
|
294
|
+
options.progress?.opResult(last.operationId, last.status);
|
|
295
|
+
options.progress?.items(results.length - resultsBefore, plan.operations.length);
|
|
296
|
+
if (options.onOperation) {
|
|
297
|
+
try {
|
|
298
|
+
options.onOperation({
|
|
299
|
+
completed: lastNotified - resultsBefore,
|
|
300
|
+
total: plan.operations.length,
|
|
301
|
+
...progressCounts,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// progress is presentation-only
|
|
306
|
+
}
|
|
293
307
|
}
|
|
294
308
|
}
|
|
295
309
|
};
|
|
296
310
|
emitStage("operations");
|
|
297
|
-
for (
|
|
311
|
+
for (let opIndex = 0; opIndex < plan.operations.length; opIndex++) {
|
|
312
|
+
const operation = plan.operations[opIndex];
|
|
298
313
|
// Report the previous iteration's result (guarded: no-op if it pushed
|
|
299
314
|
// nothing). One-iteration lag keeps this a two-line hook instead of a
|
|
300
315
|
// notify after all ten push sites; the loop-exit call below flushes the last.
|
|
@@ -362,6 +377,62 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
362
377
|
});
|
|
363
378
|
continue;
|
|
364
379
|
}
|
|
380
|
+
if (connector.applyBatch && isApplyBatchCandidate(operation) && (checkConflicts || operation.operation === "archive_record") && !operation.groupId && !operation.preconditions?.length) {
|
|
381
|
+
const chunk = [];
|
|
382
|
+
const applyBatchLimit = Math.max(1, connector.applyBatchLimit ?? 200);
|
|
383
|
+
for (let scan = opIndex; scan < plan.operations.length && chunk.length < applyBatchLimit; scan++) {
|
|
384
|
+
const candidate = plan.operations[scan];
|
|
385
|
+
if (!sameApplyBatchKind(operation, candidate) || !isApplyBatchCandidate(candidate))
|
|
386
|
+
break;
|
|
387
|
+
const candidateOverride = options.valueOverrides?.[candidate.id];
|
|
388
|
+
if (!approved.has(candidate.id) ||
|
|
389
|
+
(requiresHumanInput(candidate.afterValue) && candidateOverride === undefined) ||
|
|
390
|
+
conflicts.has(candidate.id) ||
|
|
391
|
+
Boolean(candidate.groupId) ||
|
|
392
|
+
Boolean(candidate.preconditions?.length) ||
|
|
393
|
+
(staleByFilter && staleByFilter.has(candidate.objectId)) ||
|
|
394
|
+
irreversibleStale.has(candidate.id)) {
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
chunk.push(candidateOverride === undefined ? candidate : { ...candidate, afterValue: candidateOverride });
|
|
398
|
+
}
|
|
399
|
+
if (chunk.length > 1) {
|
|
400
|
+
try {
|
|
401
|
+
const batchResults = await connector.applyBatch(chunk);
|
|
402
|
+
const byOperationId = new Map(batchResults.map((result) => [result.operationId, result]));
|
|
403
|
+
for (const batchOperation of chunk) {
|
|
404
|
+
const result = byOperationId.get(batchOperation.id) ?? {
|
|
405
|
+
operationId: batchOperation.id,
|
|
406
|
+
status: "failed",
|
|
407
|
+
detail: "Batch connector did not return a result for this operation.",
|
|
408
|
+
};
|
|
409
|
+
results.push(result);
|
|
410
|
+
if (result.status === "applied" || result.status === "failed")
|
|
411
|
+
attempted += 1;
|
|
412
|
+
if (result.status === "applied")
|
|
413
|
+
applied += 1;
|
|
414
|
+
}
|
|
415
|
+
const appliedInBatch = batchResults.filter((result) => result.status === "applied").length;
|
|
416
|
+
appliedSinceRecheck += appliedInBatch;
|
|
417
|
+
if (appliedInBatch > 0 && (applied === appliedInBatch || appliedSinceRecheck >= recheckEvery)) {
|
|
418
|
+
appliedSinceRecheck = 0;
|
|
419
|
+
await refreshSnapshotChecks();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
for (const batchOperation of chunk) {
|
|
424
|
+
results.push({
|
|
425
|
+
operationId: batchOperation.id,
|
|
426
|
+
status: "failed",
|
|
427
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
428
|
+
});
|
|
429
|
+
attempted += 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
opIndex += chunk.length - 1;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
365
436
|
const resolved = override === undefined ? operation : { ...operation, afterValue: override };
|
|
366
437
|
attempted += 1;
|
|
367
438
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FieldMappings } from "../mappings.ts";
|
|
2
2
|
import type { GtmConnector, SnapshotProgress } from "../types.ts";
|
|
3
3
|
import { type ProgressEmitter } from "../progress.ts";
|
|
4
|
+
export type HubspotWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
|
|
4
5
|
export type HubspotConnectorOptions = {
|
|
5
6
|
/** Returns a HubSpot access token (private app token or OAuth access token). */
|
|
6
7
|
getAccessToken: () => string | Promise<string>;
|
|
@@ -26,4 +27,4 @@ export type HubspotConnectorOptions = {
|
|
|
26
27
|
* amountless deals — so audit rules can surface the gaps instead of hiding
|
|
27
28
|
* them.
|
|
28
29
|
*/
|
|
29
|
-
export declare function createHubspotConnector(options: HubspotConnectorOptions):
|
|
30
|
+
export declare function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector;
|
|
@@ -1057,6 +1057,173 @@ export function createHubspotConnector(options) {
|
|
|
1057
1057
|
detail: `Archived ${objectPath}/${operation.objectId}.`,
|
|
1058
1058
|
};
|
|
1059
1059
|
}
|
|
1060
|
+
function fieldMapping(operation) {
|
|
1061
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
1062
|
+
const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
|
|
1063
|
+
if (!objectPath || !mappingType || !operation.field)
|
|
1064
|
+
return null;
|
|
1065
|
+
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
1066
|
+
return { objectPath, property: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field) };
|
|
1067
|
+
}
|
|
1068
|
+
function normalizeHubspotReadValue(field, value) {
|
|
1069
|
+
if (field === "closeDate" && typeof value === "string")
|
|
1070
|
+
return value.split("T")[0];
|
|
1071
|
+
return value ?? null;
|
|
1072
|
+
}
|
|
1073
|
+
function normalizeForComparison(value) {
|
|
1074
|
+
if (value === undefined || value === null || value === "")
|
|
1075
|
+
return null;
|
|
1076
|
+
return String(value);
|
|
1077
|
+
}
|
|
1078
|
+
function batchErrorIds(error) {
|
|
1079
|
+
const context = error?.context ?? {};
|
|
1080
|
+
const ids = context.ids ?? context.id ?? error?.id ?? error?.objectId;
|
|
1081
|
+
if (Array.isArray(ids))
|
|
1082
|
+
return ids.map(String);
|
|
1083
|
+
if (ids !== undefined && ids !== null)
|
|
1084
|
+
return [String(ids)];
|
|
1085
|
+
return [];
|
|
1086
|
+
}
|
|
1087
|
+
function batchErrorDetail(error) {
|
|
1088
|
+
const category = error?.category ? `${String(error.category)}: ` : "";
|
|
1089
|
+
return `${category}${String(error?.message ?? "HubSpot batch row failed.")}`;
|
|
1090
|
+
}
|
|
1091
|
+
async function applySetFieldBatch(operations) {
|
|
1092
|
+
const out = new Map();
|
|
1093
|
+
const mapped = operations.map((operation) => ({ operation, mapping: fieldMapping(operation) }));
|
|
1094
|
+
for (const item of mapped) {
|
|
1095
|
+
if (!item.mapping) {
|
|
1096
|
+
out.set(item.operation.id, {
|
|
1097
|
+
operationId: item.operation.id,
|
|
1098
|
+
status: "skipped",
|
|
1099
|
+
detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
const clean = mapped.filter((item) => Boolean(item.mapping));
|
|
1104
|
+
if (clean.length === 0)
|
|
1105
|
+
return operations.map((operation) => out.get(operation.id));
|
|
1106
|
+
const objectPath = clean[0].mapping.objectPath;
|
|
1107
|
+
const properties = [...new Set(clean.map((item) => item.mapping.property))];
|
|
1108
|
+
const liveById = new Map();
|
|
1109
|
+
const ids = [...new Set(clean.map((item) => item.operation.objectId))];
|
|
1110
|
+
for (let i = 0; i < ids.length; i += 100) {
|
|
1111
|
+
const chunkIds = ids.slice(i, i + 100);
|
|
1112
|
+
const data = await request(`/crm/v3/objects/${objectPath}/batch/read`, {
|
|
1113
|
+
method: "POST",
|
|
1114
|
+
body: JSON.stringify({ properties, inputs: chunkIds.map((id) => ({ id })) }),
|
|
1115
|
+
});
|
|
1116
|
+
for (const row of data?.results ?? []) {
|
|
1117
|
+
if (row?.id)
|
|
1118
|
+
liveById.set(String(row.id), row.properties ?? {});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
const toWrite = [];
|
|
1122
|
+
for (const { operation, mapping } of clean) {
|
|
1123
|
+
const props = liveById.get(operation.objectId);
|
|
1124
|
+
const current = normalizeHubspotReadValue(operation.field, props?.[mapping.property] ?? null);
|
|
1125
|
+
const expected = normalizeForComparison(operation.beforeValue);
|
|
1126
|
+
const found = normalizeForComparison(current);
|
|
1127
|
+
if (!props || expected !== found) {
|
|
1128
|
+
out.set(operation.id, {
|
|
1129
|
+
operationId: operation.id,
|
|
1130
|
+
status: "conflict",
|
|
1131
|
+
detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
|
|
1132
|
+
providerData: { currentValue: current ?? null },
|
|
1133
|
+
});
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
toWrite.push({ operation, property: mapping.property });
|
|
1137
|
+
}
|
|
1138
|
+
for (let i = 0; i < toWrite.length; i += 100) {
|
|
1139
|
+
const chunk = toWrite.slice(i, i + 100);
|
|
1140
|
+
let data;
|
|
1141
|
+
try {
|
|
1142
|
+
data = await request(`/crm/v3/objects/${objectPath}/batch/update`, {
|
|
1143
|
+
method: "POST",
|
|
1144
|
+
body: JSON.stringify({
|
|
1145
|
+
inputs: chunk.map(({ operation, property }) => ({
|
|
1146
|
+
id: operation.objectId,
|
|
1147
|
+
properties: { [property]: operation.operation === "clear_field" ? "" : String(operation.afterValue ?? "") },
|
|
1148
|
+
})),
|
|
1149
|
+
}),
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
catch (error) {
|
|
1153
|
+
for (const { operation } of chunk)
|
|
1154
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
const opByObjectId = new Map(chunk.map(({ operation }) => [operation.objectId, operation]));
|
|
1158
|
+
for (const error of data?.errors ?? []) {
|
|
1159
|
+
for (const id of batchErrorIds(error)) {
|
|
1160
|
+
const operation = opByObjectId.get(id);
|
|
1161
|
+
if (operation)
|
|
1162
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
for (const result of data?.results ?? []) {
|
|
1166
|
+
const operation = opByObjectId.get(String(result?.id));
|
|
1167
|
+
if (operation && !out.has(operation.id))
|
|
1168
|
+
out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Set ${chunk.find((item) => item.operation.id === operation.id)?.property ?? "field"} on ${objectPath}/${operation.objectId}.`, providerData: { id: result?.id } });
|
|
1169
|
+
}
|
|
1170
|
+
for (const { operation, property } of chunk) {
|
|
1171
|
+
if (!out.has(operation.id))
|
|
1172
|
+
out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Set ${property} on ${objectPath}/${operation.objectId}.` });
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
|
|
1176
|
+
}
|
|
1177
|
+
async function applyArchiveBatch(operations) {
|
|
1178
|
+
const objectPath = OBJECT_PATHS[operations[0]?.objectType];
|
|
1179
|
+
if (!objectPath)
|
|
1180
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
|
|
1181
|
+
const out = new Map();
|
|
1182
|
+
for (let i = 0; i < operations.length; i += 100) {
|
|
1183
|
+
const chunk = operations.slice(i, i + 100);
|
|
1184
|
+
let data;
|
|
1185
|
+
try {
|
|
1186
|
+
data = await request(`/crm/v3/objects/${objectPath}/batch/archive`, {
|
|
1187
|
+
method: "POST",
|
|
1188
|
+
body: JSON.stringify({ inputs: chunk.map((operation) => ({ id: operation.objectId })) }),
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
catch (error) {
|
|
1192
|
+
for (const operation of chunk)
|
|
1193
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
const opByObjectId = new Map(chunk.map((operation) => [operation.objectId, operation]));
|
|
1197
|
+
for (const error of data?.errors ?? []) {
|
|
1198
|
+
for (const id of batchErrorIds(error)) {
|
|
1199
|
+
const operation = opByObjectId.get(id);
|
|
1200
|
+
if (operation)
|
|
1201
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
for (const result of data?.results ?? []) {
|
|
1205
|
+
const operation = opByObjectId.get(String(result?.id));
|
|
1206
|
+
if (operation && !out.has(operation.id))
|
|
1207
|
+
out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
|
|
1208
|
+
}
|
|
1209
|
+
for (const operation of chunk) {
|
|
1210
|
+
if (!out.has(operation.id))
|
|
1211
|
+
out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch archive did not process this operation." });
|
|
1215
|
+
}
|
|
1216
|
+
async function applyBatch(operations) {
|
|
1217
|
+
if (operations.length === 0)
|
|
1218
|
+
return [];
|
|
1219
|
+
if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
|
|
1220
|
+
return applySetFieldBatch(operations);
|
|
1221
|
+
}
|
|
1222
|
+
if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
|
|
1223
|
+
return applyArchiveBatch(operations);
|
|
1224
|
+
}
|
|
1225
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
|
|
1226
|
+
}
|
|
1060
1227
|
/**
|
|
1061
1228
|
* Merge a duplicate group into the approved survivor via HubSpot's v3
|
|
1062
1229
|
* merge API (supported for contacts, companies, deals, and tickets).
|
|
@@ -1182,10 +1349,7 @@ export function createHubspotConnector(options) {
|
|
|
1182
1349
|
// single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
|
|
1183
1350
|
// made compare-and-set see a spurious drift and refuse every date-field write.
|
|
1184
1351
|
// Mirror the snapshot's normalization so the comparison is apples-to-apples.
|
|
1185
|
-
|
|
1186
|
-
return value.split("T")[0];
|
|
1187
|
-
}
|
|
1188
|
-
return value;
|
|
1352
|
+
return normalizeHubspotReadValue(field, value);
|
|
1189
1353
|
}
|
|
1190
1354
|
return {
|
|
1191
1355
|
provider: "hubspot",
|
|
@@ -1193,6 +1357,8 @@ export function createHubspotConnector(options) {
|
|
|
1193
1357
|
fetchChanges,
|
|
1194
1358
|
applyOperation,
|
|
1195
1359
|
applyCreateContactsBatch,
|
|
1360
|
+
applyBatch,
|
|
1361
|
+
applyBatchLimit: 100,
|
|
1196
1362
|
readField,
|
|
1197
1363
|
};
|
|
1198
1364
|
}
|
|
@@ -6,6 +6,7 @@ export type SalesforceConnection = {
|
|
|
6
6
|
/** e.g. https://yourorg.my.salesforce.com */
|
|
7
7
|
instanceUrl: string;
|
|
8
8
|
};
|
|
9
|
+
export type SalesforceWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
|
|
9
10
|
export type SalesforceConnectorOptions = {
|
|
10
11
|
/** Returns an access token plus the instance URL it belongs to. */
|
|
11
12
|
getConnection: () => SalesforceConnection | Promise<SalesforceConnection>;
|
|
@@ -32,4 +33,4 @@ export type SalesforceConnectorOptions = {
|
|
|
32
33
|
* surface the gaps). Probabilities are normalized to 0..1 to match the
|
|
33
34
|
* canonical model.
|
|
34
35
|
*/
|
|
35
|
-
export declare function createSalesforceConnector(options: SalesforceConnectorOptions):
|
|
36
|
+
export declare function createSalesforceConnector(options: SalesforceConnectorOptions): SalesforceWritableConnector;
|
|
@@ -376,6 +376,139 @@ export function createSalesforceConnector(options) {
|
|
|
376
376
|
detail: `Deleted ${sobjectType}/${operation.objectId}.`,
|
|
377
377
|
};
|
|
378
378
|
}
|
|
379
|
+
function normalizeSalesforceValue(value) {
|
|
380
|
+
if (value === undefined || value === null || value === "")
|
|
381
|
+
return null;
|
|
382
|
+
return String(value).split("T")[0];
|
|
383
|
+
}
|
|
384
|
+
function fieldMappingFor(operation) {
|
|
385
|
+
const sobjectType = SOBJECT_TYPES[operation.objectType];
|
|
386
|
+
const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
|
|
387
|
+
if (!sobjectType || !mappingType || !operation.field)
|
|
388
|
+
return null;
|
|
389
|
+
const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
390
|
+
return {
|
|
391
|
+
sobjectType,
|
|
392
|
+
field: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function compositeErrorDetail(result) {
|
|
396
|
+
const errors = Array.isArray(result?.errors) ? result.errors : [];
|
|
397
|
+
const message = errors
|
|
398
|
+
.map((error) => [error?.statusCode, error?.message].filter(Boolean).join(": "))
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join("; ");
|
|
401
|
+
return message ? `Salesforce rejected this record: ${message.slice(0, 300)}.` : "Salesforce rejected this record.";
|
|
402
|
+
}
|
|
403
|
+
async function applySetFieldBatch(operations) {
|
|
404
|
+
const out = new Map();
|
|
405
|
+
const mapped = operations.map((operation) => ({ operation, mapping: fieldMappingFor(operation) }));
|
|
406
|
+
for (const item of mapped) {
|
|
407
|
+
if (!item.mapping) {
|
|
408
|
+
out.set(item.operation.id, {
|
|
409
|
+
operationId: item.operation.id,
|
|
410
|
+
status: "skipped",
|
|
411
|
+
detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const clean = mapped.filter((item) => Boolean(item.mapping));
|
|
416
|
+
if (clean.length === 0)
|
|
417
|
+
return operations.map((operation) => out.get(operation.id));
|
|
418
|
+
const sobjectType = clean[0].mapping.sobjectType;
|
|
419
|
+
const fields = [...new Set(clean.map((item) => item.mapping.field))];
|
|
420
|
+
const liveById = new Map();
|
|
421
|
+
const ids = [...new Set(clean.map((item) => item.operation.objectId))];
|
|
422
|
+
for (let i = 0; i < ids.length; i += 200) {
|
|
423
|
+
const idList = ids.slice(i, i + 200).map((id) => `'${id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",");
|
|
424
|
+
const rows = await query(`SELECT Id, ${fields.join(", ")} FROM ${sobjectType} WHERE Id IN (${idList})`);
|
|
425
|
+
for (const row of rows)
|
|
426
|
+
liveById.set(String(row.Id), row);
|
|
427
|
+
}
|
|
428
|
+
const toWrite = [];
|
|
429
|
+
for (const { operation, mapping } of clean) {
|
|
430
|
+
const row = liveById.get(operation.objectId);
|
|
431
|
+
const current = row?.[mapping.field] ?? null;
|
|
432
|
+
const expected = normalizeSalesforceValue(operation.beforeValue);
|
|
433
|
+
const found = normalizeSalesforceValue(current);
|
|
434
|
+
if (!row || expected !== found) {
|
|
435
|
+
out.set(operation.id, {
|
|
436
|
+
operationId: operation.id,
|
|
437
|
+
status: "conflict",
|
|
438
|
+
detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
|
|
439
|
+
providerData: { currentValue: current ?? null },
|
|
440
|
+
});
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
toWrite.push({ operation, field: mapping.field });
|
|
444
|
+
}
|
|
445
|
+
for (let i = 0; i < toWrite.length; i += 200) {
|
|
446
|
+
const chunk = toWrite.slice(i, i + 200);
|
|
447
|
+
const records = chunk.map(({ operation, field }) => ({
|
|
448
|
+
attributes: { type: sobjectType },
|
|
449
|
+
Id: operation.objectId,
|
|
450
|
+
[field]: operation.operation === "clear_field" ? null : String(operation.afterValue ?? ""),
|
|
451
|
+
}));
|
|
452
|
+
let response;
|
|
453
|
+
try {
|
|
454
|
+
response = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
|
|
455
|
+
method: "PATCH",
|
|
456
|
+
body: JSON.stringify({ allOrNone: false, records }),
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
for (const { operation } of chunk)
|
|
461
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
465
|
+
const { operation, field } = chunk[j];
|
|
466
|
+
const result = Array.isArray(response) ? response[j] : undefined;
|
|
467
|
+
out.set(operation.id, result?.success
|
|
468
|
+
? { operationId: operation.id, status: "applied", detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`, providerData: { sobjectType, field } }
|
|
469
|
+
: { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
|
|
473
|
+
}
|
|
474
|
+
async function applyArchiveBatch(operations) {
|
|
475
|
+
const sobjectType = SOBJECT_TYPES[operations[0]?.objectType];
|
|
476
|
+
if (!sobjectType)
|
|
477
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
|
|
478
|
+
const out = new Map();
|
|
479
|
+
for (let i = 0; i < operations.length; i += 200) {
|
|
480
|
+
const chunk = operations.slice(i, i + 200);
|
|
481
|
+
const ids = chunk.map((operation) => encodeURIComponent(operation.objectId)).join(",");
|
|
482
|
+
let response;
|
|
483
|
+
try {
|
|
484
|
+
response = (await request(`/services/data/${apiVersion}/composite/sobjects?ids=${ids}&allOrNone=false`, { method: "DELETE" }));
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
for (const operation of chunk)
|
|
488
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
492
|
+
const operation = chunk[j];
|
|
493
|
+
const result = Array.isArray(response) ? response[j] : undefined;
|
|
494
|
+
out.set(operation.id, result?.success
|
|
495
|
+
? { operationId: operation.id, status: "applied", detail: `Deleted ${sobjectType}/${operation.objectId}.` }
|
|
496
|
+
: { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch delete did not process this operation." });
|
|
500
|
+
}
|
|
501
|
+
async function applyBatch(operations) {
|
|
502
|
+
if (operations.length === 0)
|
|
503
|
+
return [];
|
|
504
|
+
if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
|
|
505
|
+
return applySetFieldBatch(operations);
|
|
506
|
+
}
|
|
507
|
+
if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
|
|
508
|
+
return applyArchiveBatch(operations);
|
|
509
|
+
}
|
|
510
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
|
|
511
|
+
}
|
|
379
512
|
function escapeXml(value) {
|
|
380
513
|
return value
|
|
381
514
|
.replace(/&/g, "&")
|
|
@@ -738,6 +871,8 @@ export function createSalesforceConnector(options) {
|
|
|
738
871
|
fetchChanges,
|
|
739
872
|
applyOperation,
|
|
740
873
|
applyCreateContactsBatch,
|
|
874
|
+
applyBatch,
|
|
875
|
+
applyBatchLimit: 200,
|
|
741
876
|
readField,
|
|
742
877
|
};
|
|
743
878
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { readFileSync, statSync } from "node:fs";
|
|
22
22
|
import { resolve } from "node:path";
|
|
23
|
+
import { FREE_EMAIL_DOMAINS } from "../freeEmailDomains.js";
|
|
23
24
|
import { SIGNAL_BUCKETS } from "../signals.js";
|
|
24
25
|
import { parseSpoolText, spoolFilesIn } from "../spoolFiles.js";
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
@@ -243,22 +244,12 @@ function fieldValue(values, name) {
|
|
|
243
244
|
}
|
|
244
245
|
return "";
|
|
245
246
|
}
|
|
246
|
-
const FREE_MAIL = new Set([
|
|
247
|
-
"gmail.com",
|
|
248
|
-
"yahoo.com",
|
|
249
|
-
"hotmail.com",
|
|
250
|
-
"outlook.com",
|
|
251
|
-
"icloud.com",
|
|
252
|
-
"aol.com",
|
|
253
|
-
"proton.me",
|
|
254
|
-
"protonmail.com",
|
|
255
|
-
]);
|
|
256
247
|
/** Company domain from an email, or "" for free-mail / no email. */
|
|
257
248
|
function corporateDomain(email) {
|
|
258
249
|
if (!email.includes("@"))
|
|
259
250
|
return "";
|
|
260
251
|
const domain = email.split("@").at(-1).trim().toLowerCase();
|
|
261
|
-
if (!domain ||
|
|
252
|
+
if (!domain || FREE_EMAIL_DOMAINS.has(domain))
|
|
262
253
|
return "";
|
|
263
254
|
return domain;
|
|
264
255
|
}
|