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.
Files changed (56) hide show
  1. package/CHANGELOG.md +44 -6
  2. package/INSTALL_FOR_AGENTS.md +3 -3
  3. package/README.md +16 -8
  4. package/dist/audit.js +7 -0
  5. package/dist/backfill.js +2 -16
  6. package/dist/cli/fix.d.ts +3 -0
  7. package/dist/cli/fix.js +70 -1
  8. package/dist/cli/help.d.ts +6 -0
  9. package/dist/cli/help.js +76 -2
  10. package/dist/cli/suggest.d.ts +2 -1
  11. package/dist/cli/suggest.js +49 -3
  12. package/dist/cli.js +40 -15
  13. package/dist/connector.js +96 -25
  14. package/dist/connectors/hubspot.d.ts +2 -1
  15. package/dist/connectors/hubspot.js +170 -4
  16. package/dist/connectors/salesforce.d.ts +2 -1
  17. package/dist/connectors/salesforce.js +135 -0
  18. package/dist/connectors/signalSources.js +2 -11
  19. package/dist/format.js +31 -5
  20. package/dist/freeEmailDomains.d.ts +1 -0
  21. package/dist/freeEmailDomains.js +14 -0
  22. package/dist/hierarchy.d.ts +29 -0
  23. package/dist/hierarchy.js +164 -0
  24. package/dist/index.d.ts +6 -2
  25. package/dist/index.js +5 -1
  26. package/dist/mcp.js +19 -15
  27. package/dist/relationships.d.ts +39 -0
  28. package/dist/relationships.js +101 -0
  29. package/dist/route.d.ts +46 -0
  30. package/dist/route.js +228 -0
  31. package/dist/rules.d.ts +1 -0
  32. package/dist/rules.js +172 -12
  33. package/dist/types.d.ts +30 -0
  34. package/docs/api.md +22 -4
  35. package/docs/architecture.md +5 -2
  36. package/package.json +1 -1
  37. package/skills/fullstackgtm/SKILL.md +2 -2
  38. package/src/audit.ts +7 -0
  39. package/src/backfill.ts +2 -17
  40. package/src/cli/fix.ts +68 -1
  41. package/src/cli/help.ts +83 -2
  42. package/src/cli/suggest.ts +58 -4
  43. package/src/cli.ts +38 -13
  44. package/src/connector.ts +94 -20
  45. package/src/connectors/hubspot.ts +164 -5
  46. package/src/connectors/salesforce.ts +136 -1
  47. package/src/connectors/signalSources.ts +2 -12
  48. package/src/format.ts +33 -5
  49. package/src/freeEmailDomains.ts +14 -0
  50. package/src/hierarchy.ts +185 -0
  51. package/src/index.ts +25 -0
  52. package/src/mcp.ts +22 -16
  53. package/src/relationships.ts +115 -0
  54. package/src/route.ts +278 -0
  55. package/src/rules.ts +192 -12
  56. 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 !== "--full" && !topic.startsWith("-")) {
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. Plain output is unchanged.
62
- if (args.includes("--json"))
63
- printCommandHelpJson(topic);
64
- else
65
- console.log(commandHelp(topic));
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(args.includes("--full") || topic === "--full" ? usage() : styledShortUsage());
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. Near-miss unknown flags (≤ 1 edit from a flag documented in
94
- // help.ts) now stop with the exact corrected command. Suggest-only: never
95
- // auto-execute the correction, so a typo can never change what a
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 = detectFlagTypo(args);
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
- console.error(`Did you mean: ${typo.suggestion}`);
106
- console.error(`Try: ${correctedCommand(command, args, typo)}`);
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 = results.length;
270
- const last = results[results.length - 1];
271
- if (last.status === "applied")
272
- progressCounts.applied += 1;
273
- else if (last.status === "failed")
274
- progressCounts.failed += 1;
275
- else if (last.status === "conflict")
276
- progressCounts.conflicts += 1;
277
- else
278
- progressCounts.skipped += 1;
279
- // Shared vocabulary: operation id + status only — a conflict/failure
280
- // detail can echo field values, which never leave the machine.
281
- options.progress?.opResult(last.operationId, last.status);
282
- options.progress?.items(results.length - resultsBefore, plan.operations.length);
283
- if (options.onOperation) {
284
- try {
285
- options.onOperation({
286
- completed: results.length - resultsBefore,
287
- total: plan.operations.length,
288
- ...progressCounts,
289
- });
290
- }
291
- catch {
292
- // progress is presentation-only
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 (const operation of plan.operations) {
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): Required<GtmConnector>;
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
- if (field === "closeDate" && typeof value === "string") {
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): Required<GtmConnector>;
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, "&amp;")
@@ -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 || FREE_MAIL.has(domain))
252
+ if (!domain || FREE_EMAIL_DOMAINS.has(domain))
262
253
  return "";
263
254
  return domain;
264
255
  }