fullstackgtm 0.46.0 → 0.47.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 CHANGED
@@ -7,6 +7,12 @@ The path to 1.0 is planned in the roadmap doc in the repository.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.47.0] — 2026-07-06
11
+
12
+ ### Added
13
+
14
+ - **Batched CRM apply for updates and deletes.** `applyPatchPlan` can now dispatch consecutive homogeneous `set_field` / `clear_field` / `archive_record` runs through an optional connector `applyBatch` capability with connector-declared `applyBatchLimit`. Salesforce implements this with batched CAS SOQL reads and Composite sObject Collections update/delete (`allOrNone:false`, 200/call); HubSpot implements batched CAS reads plus `/batch/update` and `/batch/archive` (100/call). Both preserve per-operation `applied | skipped | conflict | failed` results and connectors without the capability stay on sequential apply.
15
+
10
16
  ## [0.46.0] — 2026-07-06
11
17
 
12
18
  ### Fixed — launch hardening
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 {
@@ -26,4 +26,4 @@ export type HubspotConnectorOptions = {
26
26
  * amountless deals — so audit rules can surface the gaps instead of hiding
27
27
  * them.
28
28
  */
29
- export declare function createHubspotConnector(options: HubspotConnectorOptions): Required<GtmConnector>;
29
+ export declare function createHubspotConnector(options: HubspotConnectorOptions): GtmConnector;
@@ -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
  }
@@ -32,4 +32,4 @@ export type SalesforceConnectorOptions = {
32
32
  * surface the gaps). Probabilities are normalized to 0..1 to match the
33
33
  * canonical model.
34
34
  */
35
- export declare function createSalesforceConnector(options: SalesforceConnectorOptions): Required<GtmConnector>;
35
+ export declare function createSalesforceConnector(options: SalesforceConnectorOptions): GtmConnector;
@@ -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
  }
package/dist/types.d.ts CHANGED
@@ -435,6 +435,21 @@ export type GtmConnector = {
435
435
  * result per input op. Optional: connectors without it use `applyOperation`.
436
436
  */
437
437
  applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
438
+ /**
439
+ * Optional bulk apply path for homogeneous operation runs selected by
440
+ * `applyPatchPlan` (currently consecutive `set_field` ops on one object type
441
+ * and consecutive `archive_record` ops on one object type). Implementations
442
+ * MUST return exactly one result per input operation, preserve per-operation
443
+ * CAS/conflict semantics for field writes, and must not write operations that
444
+ * would have conflicted under the serial `readField` + `applyOperation` path.
445
+ * Connectors without it use `applyOperation` per operation.
446
+ */
447
+ applyBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
448
+ /**
449
+ * Maximum number of operations `applyPatchPlan` should pass to one
450
+ * `applyBatch` call. Defaults to 200 when unspecified.
451
+ */
452
+ applyBatchLimit?: number;
438
453
  /**
439
454
  * Read the live value of one canonical field, used for compare-and-set:
440
455
  * apply orchestration refuses to write over values that drifted since the
package/docs/api.md CHANGED
@@ -37,9 +37,10 @@ release.
37
37
 
38
38
  ## Connectors
39
39
 
40
- - `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, applyCreateContactsBatch?, readField?, fetchChanges? }`.
40
+ - `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, applyCreateContactsBatch?, applyBatch?, applyBatchLimit?, readField?, fetchChanges? }`.
41
41
  - Connectors never silently drop unresolvable records; audits surface them.
42
42
  - `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
43
+ - `applyBatch?(operations)` — optional bulk apply path for consecutive homogeneous `set_field`, `clear_field`, or `archive_record` runs selected by `applyPatchPlan`. It must return one result per input operation; field-write implementations preserve CAS by batch-reading live target values first and writing only clean records. Connectors without it stay on the sequential `applyOperation` path. `applyBatchLimit?` declares max operations per `applyBatch` call (default 200; Salesforce 200, HubSpot 100). Salesforce uses SOQL `IN` CAS reads plus Composite sObject Collections update/delete (`allOrNone:false`); HubSpot uses batch/read + batch/update/archive.
43
44
  - `applyCreateContactsBatch?(operations)` — optional bulk fast-path for
44
45
  independent `create_record` contact ops. `applyPatchPlan` routes safe-to-batch
45
46
  creates here (batched resolve-first + batched create — ~N/100 calls instead of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
package/src/connector.ts CHANGED
@@ -138,6 +138,17 @@ function normalizeForComparison(value: unknown): string | null {
138
138
  return String(value);
139
139
  }
140
140
 
141
+ function isApplyBatchCandidate(operation: PatchOperation): boolean {
142
+ return (
143
+ ((operation.operation === "set_field" || operation.operation === "clear_field") && Boolean(operation.field)) ||
144
+ operation.operation === "archive_record"
145
+ );
146
+ }
147
+
148
+ function sameApplyBatchKind(a: PatchOperation, b: PatchOperation): boolean {
149
+ return a.operation === b.operation && a.objectType === b.objectType;
150
+ }
151
+
141
152
  /**
142
153
  * Apply an approved subset of a patch plan through a connector.
143
154
  *
@@ -267,6 +278,11 @@ export async function applyPatchPlan(
267
278
  if (checkConflicts && connector.readField) {
268
279
  for (const operation of plan.operations) {
269
280
  if (!approved.has(operation.id) || conflicts.has(operation.id)) continue;
281
+ // Generic batch connectors (Salesforce) do their own batched CAS read for
282
+ // eligible field writes, so don't pre-read those one-by-one here.
283
+ if (connector.applyBatch && isApplyBatchCandidate(operation) && !operation.groupId && !operation.preconditions?.length) {
284
+ continue;
285
+ }
270
286
  let conflict: PatchOperationResult | null = null;
271
287
  if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
272
288
  const current = await connector.readField(
@@ -349,31 +365,34 @@ export async function applyPatchPlan(
349
365
  let lastNotified = results.length;
350
366
  const notifyProgress = () => {
351
367
  if ((!options.onOperation && !options.progress) || results.length === lastNotified) return;
352
- lastNotified = results.length;
353
- const last = results[results.length - 1];
354
- if (last.status === "applied") progressCounts.applied += 1;
355
- else if (last.status === "failed") progressCounts.failed += 1;
356
- else if (last.status === "conflict") progressCounts.conflicts += 1;
357
- else progressCounts.skipped += 1;
358
- // Shared vocabulary: operation id + status only — a conflict/failure
359
- // detail can echo field values, which never leave the machine.
360
- options.progress?.opResult(last.operationId, last.status);
361
- options.progress?.items(results.length - resultsBefore, plan.operations.length);
362
- if (options.onOperation) {
363
- try {
364
- options.onOperation({
365
- completed: results.length - resultsBefore,
366
- total: plan.operations.length,
367
- ...progressCounts,
368
- });
369
- } catch {
370
- // progress is presentation-only
368
+ while (lastNotified < results.length) {
369
+ const last = results[lastNotified];
370
+ lastNotified += 1;
371
+ if (last.status === "applied") progressCounts.applied += 1;
372
+ else if (last.status === "failed") progressCounts.failed += 1;
373
+ else if (last.status === "conflict") progressCounts.conflicts += 1;
374
+ else progressCounts.skipped += 1;
375
+ // Shared vocabulary: operation id + status only a conflict/failure
376
+ // detail can echo field values, which never leave the machine.
377
+ options.progress?.opResult(last.operationId, last.status);
378
+ options.progress?.items(results.length - resultsBefore, plan.operations.length);
379
+ if (options.onOperation) {
380
+ try {
381
+ options.onOperation({
382
+ completed: lastNotified - resultsBefore,
383
+ total: plan.operations.length,
384
+ ...progressCounts,
385
+ });
386
+ } catch {
387
+ // progress is presentation-only
388
+ }
371
389
  }
372
390
  }
373
391
  };
374
392
 
375
393
  emitStage("operations");
376
- for (const operation of plan.operations) {
394
+ for (let opIndex = 0; opIndex < plan.operations.length; opIndex++) {
395
+ const operation = plan.operations[opIndex];
377
396
  // Report the previous iteration's result (guarded: no-op if it pushed
378
397
  // nothing). One-iteration lag keeps this a two-line hook instead of a
379
398
  // notify after all ten push sites; the loop-exit call below flushes the last.
@@ -441,6 +460,61 @@ export async function applyPatchPlan(
441
460
  continue;
442
461
  }
443
462
 
463
+ if (connector.applyBatch && isApplyBatchCandidate(operation) && (checkConflicts || operation.operation === "archive_record") && !operation.groupId && !operation.preconditions?.length) {
464
+ const chunk: PatchOperation[] = [];
465
+ const applyBatchLimit = Math.max(1, connector.applyBatchLimit ?? 200);
466
+ for (let scan = opIndex; scan < plan.operations.length && chunk.length < applyBatchLimit; scan++) {
467
+ const candidate = plan.operations[scan];
468
+ if (!sameApplyBatchKind(operation, candidate) || !isApplyBatchCandidate(candidate)) break;
469
+ const candidateOverride = options.valueOverrides?.[candidate.id];
470
+ if (
471
+ !approved.has(candidate.id) ||
472
+ (requiresHumanInput(candidate.afterValue) && candidateOverride === undefined) ||
473
+ conflicts.has(candidate.id) ||
474
+ Boolean(candidate.groupId) ||
475
+ Boolean(candidate.preconditions?.length) ||
476
+ (staleByFilter && staleByFilter.has(candidate.objectId)) ||
477
+ irreversibleStale.has(candidate.id)
478
+ ) {
479
+ break;
480
+ }
481
+ chunk.push(candidateOverride === undefined ? candidate : { ...candidate, afterValue: candidateOverride });
482
+ }
483
+ if (chunk.length > 1) {
484
+ try {
485
+ const batchResults = await connector.applyBatch(chunk);
486
+ const byOperationId = new Map(batchResults.map((result) => [result.operationId, result]));
487
+ for (const batchOperation of chunk) {
488
+ const result = byOperationId.get(batchOperation.id) ?? {
489
+ operationId: batchOperation.id,
490
+ status: "failed" as const,
491
+ detail: "Batch connector did not return a result for this operation.",
492
+ };
493
+ results.push(result);
494
+ if (result.status === "applied" || result.status === "failed") attempted += 1;
495
+ if (result.status === "applied") applied += 1;
496
+ }
497
+ const appliedInBatch = batchResults.filter((result) => result.status === "applied").length;
498
+ appliedSinceRecheck += appliedInBatch;
499
+ if (appliedInBatch > 0 && (applied === appliedInBatch || appliedSinceRecheck >= recheckEvery)) {
500
+ appliedSinceRecheck = 0;
501
+ await refreshSnapshotChecks();
502
+ }
503
+ } catch (error) {
504
+ for (const batchOperation of chunk) {
505
+ results.push({
506
+ operationId: batchOperation.id,
507
+ status: "failed",
508
+ detail: error instanceof Error ? error.message : String(error),
509
+ });
510
+ attempted += 1;
511
+ }
512
+ }
513
+ opIndex += chunk.length - 1;
514
+ continue;
515
+ }
516
+ }
517
+
444
518
  const resolved: PatchOperation =
445
519
  override === undefined ? operation : { ...operation, afterValue: override };
446
520
 
@@ -71,7 +71,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
71
71
  * amountless deals — so audit rules can surface the gaps instead of hiding
72
72
  * them.
73
73
  */
74
- export function createHubspotConnector(options: HubspotConnectorOptions): Required<GtmConnector> {
74
+ export function createHubspotConnector(options: HubspotConnectorOptions): GtmConnector {
75
75
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
76
76
  const fetchImpl = options.fetchImpl ?? fetch;
77
77
  const mappings = options.fieldMappings;
@@ -1195,6 +1195,164 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1195
1195
  };
1196
1196
  }
1197
1197
 
1198
+ function fieldMapping(operation: PatchOperation): { objectPath: string; property: string } | null {
1199
+ const objectPath = OBJECT_PATHS[operation.objectType];
1200
+ const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
1201
+ if (!objectPath || !mappingType || !operation.field) return null;
1202
+ const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
1203
+ return { objectPath, property: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field) };
1204
+ }
1205
+
1206
+ function normalizeHubspotReadValue(field: string | undefined, value: unknown): unknown {
1207
+ if (field === "closeDate" && typeof value === "string") return value.split("T")[0];
1208
+ return value ?? null;
1209
+ }
1210
+
1211
+ function normalizeForComparison(value: unknown): string | null {
1212
+ if (value === undefined || value === null || value === "") return null;
1213
+ return String(value);
1214
+ }
1215
+
1216
+ function batchErrorIds(error: any): string[] {
1217
+ const context = error?.context ?? {};
1218
+ const ids = context.ids ?? context.id ?? error?.id ?? error?.objectId;
1219
+ if (Array.isArray(ids)) return ids.map(String);
1220
+ if (ids !== undefined && ids !== null) return [String(ids)];
1221
+ return [];
1222
+ }
1223
+
1224
+ function batchErrorDetail(error: any): string {
1225
+ const category = error?.category ? `${String(error.category)}: ` : "";
1226
+ return `${category}${String(error?.message ?? "HubSpot batch row failed.")}`;
1227
+ }
1228
+
1229
+ async function applySetFieldBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
1230
+ const out = new Map<string, PatchOperationResult>();
1231
+ const mapped = operations.map((operation) => ({ operation, mapping: fieldMapping(operation) }));
1232
+ for (const item of mapped) {
1233
+ if (!item.mapping) {
1234
+ out.set(item.operation.id, {
1235
+ operationId: item.operation.id,
1236
+ status: "skipped",
1237
+ detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
1238
+ });
1239
+ }
1240
+ }
1241
+ const clean = mapped.filter((item): item is { operation: PatchOperation; mapping: { objectPath: string; property: string } } => Boolean(item.mapping));
1242
+ if (clean.length === 0) return operations.map((operation) => out.get(operation.id)!);
1243
+ const objectPath = clean[0].mapping.objectPath;
1244
+ const properties = [...new Set(clean.map((item) => item.mapping.property))];
1245
+ const liveById = new Map<string, Record<string, unknown>>();
1246
+ const ids = [...new Set(clean.map((item) => item.operation.objectId))];
1247
+ for (let i = 0; i < ids.length; i += 100) {
1248
+ const chunkIds = ids.slice(i, i + 100);
1249
+ const data = await request(`/crm/v3/objects/${objectPath}/batch/read`, {
1250
+ method: "POST",
1251
+ body: JSON.stringify({ properties, inputs: chunkIds.map((id) => ({ id })) }),
1252
+ });
1253
+ for (const row of data?.results ?? []) {
1254
+ if (row?.id) liveById.set(String(row.id), row.properties ?? {});
1255
+ }
1256
+ }
1257
+
1258
+ const toWrite: Array<{ operation: PatchOperation; property: string }> = [];
1259
+ for (const { operation, mapping } of clean) {
1260
+ const props = liveById.get(operation.objectId);
1261
+ const current = normalizeHubspotReadValue(operation.field, props?.[mapping.property] ?? null);
1262
+ const expected = normalizeForComparison(operation.beforeValue);
1263
+ const found = normalizeForComparison(current);
1264
+ if (!props || expected !== found) {
1265
+ out.set(operation.id, {
1266
+ operationId: operation.id,
1267
+ status: "conflict",
1268
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
1269
+ providerData: { currentValue: current ?? null },
1270
+ });
1271
+ continue;
1272
+ }
1273
+ toWrite.push({ operation, property: mapping.property });
1274
+ }
1275
+
1276
+ for (let i = 0; i < toWrite.length; i += 100) {
1277
+ const chunk = toWrite.slice(i, i + 100);
1278
+ let data: any;
1279
+ try {
1280
+ data = await request(`/crm/v3/objects/${objectPath}/batch/update`, {
1281
+ method: "POST",
1282
+ body: JSON.stringify({
1283
+ inputs: chunk.map(({ operation, property }) => ({
1284
+ id: operation.objectId,
1285
+ properties: { [property]: operation.operation === "clear_field" ? "" : String(operation.afterValue ?? "") },
1286
+ })),
1287
+ }),
1288
+ });
1289
+ } catch (error) {
1290
+ for (const { operation } of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
1291
+ continue;
1292
+ }
1293
+ const opByObjectId = new Map(chunk.map(({ operation }) => [operation.objectId, operation]));
1294
+ for (const error of data?.errors ?? []) {
1295
+ for (const id of batchErrorIds(error)) {
1296
+ const operation = opByObjectId.get(id);
1297
+ if (operation) out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
1298
+ }
1299
+ }
1300
+ for (const result of data?.results ?? []) {
1301
+ const operation = opByObjectId.get(String(result?.id));
1302
+ if (operation && !out.has(operation.id)) 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 } });
1303
+ }
1304
+ for (const { operation, property } of chunk) {
1305
+ if (!out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Set ${property} on ${objectPath}/${operation.objectId}.` });
1306
+ }
1307
+ }
1308
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
1309
+ }
1310
+
1311
+ async function applyArchiveBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
1312
+ const objectPath = OBJECT_PATHS[operations[0]?.objectType];
1313
+ if (!objectPath) return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
1314
+ const out = new Map<string, PatchOperationResult>();
1315
+ for (let i = 0; i < operations.length; i += 100) {
1316
+ const chunk = operations.slice(i, i + 100);
1317
+ let data: any;
1318
+ try {
1319
+ data = await request(`/crm/v3/objects/${objectPath}/batch/archive`, {
1320
+ method: "POST",
1321
+ body: JSON.stringify({ inputs: chunk.map((operation) => ({ id: operation.objectId })) }),
1322
+ });
1323
+ } catch (error) {
1324
+ for (const operation of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
1325
+ continue;
1326
+ }
1327
+ const opByObjectId = new Map(chunk.map((operation) => [operation.objectId, operation]));
1328
+ for (const error of data?.errors ?? []) {
1329
+ for (const id of batchErrorIds(error)) {
1330
+ const operation = opByObjectId.get(id);
1331
+ if (operation) out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
1332
+ }
1333
+ }
1334
+ for (const result of data?.results ?? []) {
1335
+ const operation = opByObjectId.get(String(result?.id));
1336
+ if (operation && !out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
1337
+ }
1338
+ for (const operation of chunk) {
1339
+ if (!out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
1340
+ }
1341
+ }
1342
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch archive did not process this operation." });
1343
+ }
1344
+
1345
+ async function applyBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
1346
+ if (operations.length === 0) return [];
1347
+ if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
1348
+ return applySetFieldBatch(operations);
1349
+ }
1350
+ if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
1351
+ return applyArchiveBatch(operations);
1352
+ }
1353
+ return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
1354
+ }
1355
+
1198
1356
  /**
1199
1357
  * Merge a duplicate group into the approved survivor via HubSpot's v3
1200
1358
  * merge API (supported for contacts, companies, deals, and tickets).
@@ -1335,10 +1493,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1335
1493
  // single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
1336
1494
  // made compare-and-set see a spurious drift and refuse every date-field write.
1337
1495
  // Mirror the snapshot's normalization so the comparison is apples-to-apples.
1338
- if (field === "closeDate" && typeof value === "string") {
1339
- return value.split("T")[0];
1340
- }
1341
- return value;
1496
+ return normalizeHubspotReadValue(field, value);
1342
1497
  }
1343
1498
 
1344
1499
  return {
@@ -1347,6 +1502,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1347
1502
  fetchChanges,
1348
1503
  applyOperation,
1349
1504
  applyCreateContactsBatch,
1505
+ applyBatch,
1506
+ applyBatchLimit: 100,
1350
1507
  readField,
1351
1508
  };
1352
1509
  }
@@ -86,7 +86,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
86
86
  */
87
87
  export function createSalesforceConnector(
88
88
  options: SalesforceConnectorOptions,
89
- ): Required<GtmConnector> {
89
+ ): GtmConnector {
90
90
  const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
91
91
  const fetchImpl = options.fetchImpl ?? fetch;
92
92
  const mappings = options.fieldMappings;
@@ -489,6 +489,137 @@ export function createSalesforceConnector(
489
489
  };
490
490
  }
491
491
 
492
+ function normalizeSalesforceValue(value: unknown): string | null {
493
+ if (value === undefined || value === null || value === "") return null;
494
+ return String(value).split("T")[0];
495
+ }
496
+
497
+ function fieldMappingFor(operation: PatchOperation): { sobjectType: string; field: string } | null {
498
+ const sobjectType = SOBJECT_TYPES[operation.objectType];
499
+ const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
500
+ if (!sobjectType || !mappingType || !operation.field) return null;
501
+ const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
502
+ return {
503
+ sobjectType,
504
+ field: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field),
505
+ };
506
+ }
507
+
508
+ function compositeErrorDetail(result: any): string {
509
+ const errors = Array.isArray(result?.errors) ? result.errors : [];
510
+ const message = errors
511
+ .map((error: any) => [error?.statusCode, error?.message].filter(Boolean).join(": "))
512
+ .filter(Boolean)
513
+ .join("; ");
514
+ return message ? `Salesforce rejected this record: ${message.slice(0, 300)}.` : "Salesforce rejected this record.";
515
+ }
516
+
517
+ async function applySetFieldBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
518
+ const out = new Map<string, PatchOperationResult>();
519
+ const mapped = operations.map((operation) => ({ operation, mapping: fieldMappingFor(operation) }));
520
+ for (const item of mapped) {
521
+ if (!item.mapping) {
522
+ out.set(item.operation.id, {
523
+ operationId: item.operation.id,
524
+ status: "skipped",
525
+ detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
526
+ });
527
+ }
528
+ }
529
+ const clean = mapped.filter((item): item is { operation: PatchOperation; mapping: { sobjectType: string; field: string } } => Boolean(item.mapping));
530
+ if (clean.length === 0) return operations.map((operation) => out.get(operation.id)!);
531
+ const sobjectType = clean[0].mapping.sobjectType;
532
+ const fields = [...new Set(clean.map((item) => item.mapping.field))];
533
+ const liveById = new Map<string, Record<string, unknown>>();
534
+ const ids = [...new Set(clean.map((item) => item.operation.objectId))];
535
+ for (let i = 0; i < ids.length; i += 200) {
536
+ const idList = ids.slice(i, i + 200).map((id) => `'${id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",");
537
+ const rows = await query(`SELECT Id, ${fields.join(", ")} FROM ${sobjectType} WHERE Id IN (${idList})`);
538
+ for (const row of rows) liveById.set(String(row.Id), row);
539
+ }
540
+
541
+ const toWrite: Array<{ operation: PatchOperation; field: string }> = [];
542
+ for (const { operation, mapping } of clean) {
543
+ const row = liveById.get(operation.objectId);
544
+ const current = row?.[mapping.field] ?? null;
545
+ const expected = normalizeSalesforceValue(operation.beforeValue);
546
+ const found = normalizeSalesforceValue(current);
547
+ if (!row || expected !== found) {
548
+ out.set(operation.id, {
549
+ operationId: operation.id,
550
+ status: "conflict",
551
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
552
+ providerData: { currentValue: current ?? null },
553
+ });
554
+ continue;
555
+ }
556
+ toWrite.push({ operation, field: mapping.field });
557
+ }
558
+
559
+ for (let i = 0; i < toWrite.length; i += 200) {
560
+ const chunk = toWrite.slice(i, i + 200);
561
+ const records = chunk.map(({ operation, field }) => ({
562
+ attributes: { type: sobjectType },
563
+ Id: operation.objectId,
564
+ [field]: operation.operation === "clear_field" ? null : String(operation.afterValue ?? ""),
565
+ }));
566
+ let response: any[];
567
+ try {
568
+ response = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
569
+ method: "PATCH",
570
+ body: JSON.stringify({ allOrNone: false, records }),
571
+ })) as any[];
572
+ } catch (error) {
573
+ for (const { operation } of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
574
+ continue;
575
+ }
576
+ for (let j = 0; j < chunk.length; j++) {
577
+ const { operation, field } = chunk[j];
578
+ const result = Array.isArray(response) ? response[j] : undefined;
579
+ out.set(operation.id, result?.success
580
+ ? { operationId: operation.id, status: "applied", detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`, providerData: { sobjectType, field } }
581
+ : { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
582
+ }
583
+ }
584
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
585
+ }
586
+
587
+ async function applyArchiveBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
588
+ const sobjectType = SOBJECT_TYPES[operations[0]?.objectType];
589
+ if (!sobjectType) return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
590
+ const out = new Map<string, PatchOperationResult>();
591
+ for (let i = 0; i < operations.length; i += 200) {
592
+ const chunk = operations.slice(i, i + 200);
593
+ const ids = chunk.map((operation) => encodeURIComponent(operation.objectId)).join(",");
594
+ let response: any[];
595
+ try {
596
+ response = (await request(`/services/data/${apiVersion}/composite/sobjects?ids=${ids}&allOrNone=false`, { method: "DELETE" })) as any[];
597
+ } catch (error) {
598
+ for (const operation of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
599
+ continue;
600
+ }
601
+ for (let j = 0; j < chunk.length; j++) {
602
+ const operation = chunk[j];
603
+ const result = Array.isArray(response) ? response[j] : undefined;
604
+ out.set(operation.id, result?.success
605
+ ? { operationId: operation.id, status: "applied", detail: `Deleted ${sobjectType}/${operation.objectId}.` }
606
+ : { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
607
+ }
608
+ }
609
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch delete did not process this operation." });
610
+ }
611
+
612
+ async function applyBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
613
+ if (operations.length === 0) return [];
614
+ if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
615
+ return applySetFieldBatch(operations);
616
+ }
617
+ if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
618
+ return applyArchiveBatch(operations);
619
+ }
620
+ return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
621
+ }
622
+
492
623
  function escapeXml(value: string): string {
493
624
  return value
494
625
  .replace(/&/g, "&amp;")
@@ -869,6 +1000,8 @@ export function createSalesforceConnector(
869
1000
  fetchChanges,
870
1001
  applyOperation,
871
1002
  applyCreateContactsBatch,
1003
+ applyBatch,
1004
+ applyBatchLimit: 200,
872
1005
  readField,
873
1006
  };
874
1007
  }
package/src/types.ts CHANGED
@@ -523,6 +523,21 @@ export type GtmConnector = {
523
523
  * result per input op. Optional: connectors without it use `applyOperation`.
524
524
  */
525
525
  applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
526
+ /**
527
+ * Optional bulk apply path for homogeneous operation runs selected by
528
+ * `applyPatchPlan` (currently consecutive `set_field` ops on one object type
529
+ * and consecutive `archive_record` ops on one object type). Implementations
530
+ * MUST return exactly one result per input operation, preserve per-operation
531
+ * CAS/conflict semantics for field writes, and must not write operations that
532
+ * would have conflicted under the serial `readField` + `applyOperation` path.
533
+ * Connectors without it use `applyOperation` per operation.
534
+ */
535
+ applyBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
536
+ /**
537
+ * Maximum number of operations `applyPatchPlan` should pass to one
538
+ * `applyBatch` call. Defaults to 200 when unspecified.
539
+ */
540
+ applyBatchLimit?: number;
526
541
  /**
527
542
  * Read the live value of one canonical field, used for compare-and-set:
528
543
  * apply orchestration refuses to write over values that drifted since the