fullstackgtm 0.45.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +104 -21
  25. package/dist/connectors/hubspot.d.ts +8 -1
  26. package/dist/connectors/hubspot.js +406 -13
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +8 -1
  30. package/dist/connectors/salesforce.js +163 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +33 -1
  56. package/docs/api.md +4 -2
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +110 -16
  76. package/src/connectors/hubspot.ts +423 -14
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +168 -3
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +35 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
@@ -20,6 +20,7 @@ import type {
20
20
  PatchOperationResult,
21
21
  SnapshotProgress,
22
22
  } from "../types.ts";
23
+ import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
23
24
 
24
25
  const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
25
26
 
@@ -33,6 +34,12 @@ export type HubspotConnectorOptions = {
33
34
  fetchImpl?: typeof fetch;
34
35
  /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
35
36
  onProgress?: (progress: SnapshotProgress) => void;
37
+ /**
38
+ * Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
39
+ * `stage` per object type and an `items` heartbeat per page. Additive
40
+ * alongside the legacy `onProgress` callback; both are presentation-only.
41
+ */
42
+ progress?: ProgressEmitter;
36
43
  };
37
44
 
38
45
  const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
@@ -47,6 +54,15 @@ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType,
47
54
  deal: "deals",
48
55
  };
49
56
 
57
+ // SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
58
+ // the CLI checklist and the app StageTimeline read the same stage labels.
59
+ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHOT_PULL_STAGES)[number]> = {
60
+ user: "owners",
61
+ account: "accounts",
62
+ contact: "contacts",
63
+ deal: "deals",
64
+ };
65
+
50
66
  /**
51
67
  * Reference connector for HubSpot.
52
68
  *
@@ -55,7 +71,7 @@ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType,
55
71
  * amountless deals — so audit rules can surface the gaps instead of hiding
56
72
  * them.
57
73
  */
58
- export function createHubspotConnector(options: HubspotConnectorOptions): Required<GtmConnector> {
74
+ export function createHubspotConnector(options: HubspotConnectorOptions): GtmConnector {
59
75
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
60
76
  const fetchImpl = options.fetchImpl ?? fetch;
61
77
  const mappings = options.fieldMappings;
@@ -67,6 +83,32 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
67
83
  // match value (email): HubSpot search is eventually consistent, so a contact
68
84
  // created earlier in this apply run is invisible to a later search.
69
85
  const createdContactsByMatch = new Map<string, string>();
86
+ // Same-run dedup for `create_record` deal creates, keyed by
87
+ // matchKey:matchValue (e.g. stripe_invoice_id:in_123) — same
88
+ // eventual-consistency rationale as the contact cache.
89
+ const createdDealsByMatch = new Map<string, string>();
90
+ // One /crm/v3/pipelines/deals read per connector lifetime (one apply run):
91
+ // pipeline/stage metadata doesn't change mid-run, and deal creates resolve
92
+ // the closed-won stage from it per operation.
93
+ let dealPipelinesCache: Promise<any[]> | undefined;
94
+ // Custom deal dedupe properties ensured (or confirmed missing) this run.
95
+ const ensuredDealProperties = new Map<string, boolean>();
96
+
97
+ // Per-page snapshot-pull progress: the legacy onProgress callback plus the
98
+ // shared emitter (stage on the first page of each object type, items per
99
+ // page). Both are presentation-only — callers already swallow errors.
100
+ let lastPullStage: string | undefined;
101
+ const pullProgress = (objectType: SnapshotProgress["objectType"], fetched: number) => {
102
+ options.onProgress?.({ objectType, fetched });
103
+ const emitter = options.progress;
104
+ if (!emitter) return;
105
+ const stage = PULL_STAGE_BY_TYPE[objectType];
106
+ if (stage !== lastPullStage) {
107
+ lastPullStage = stage;
108
+ emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
109
+ }
110
+ emitter.items(fetched);
111
+ };
70
112
 
71
113
  async function request(path: string, init: RequestInit = {}): Promise<any> {
72
114
  const token = await options.getAccessToken();
@@ -158,7 +200,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
158
200
  ) => Promise<any[]>,
159
201
  ): Promise<CanonicalGtmSnapshot> {
160
202
  const owners = await list("/crm/v3/owners?limit=100", (fetched) =>
161
- options.onProgress?.({ objectType: "user", fetched }),
203
+ pullProgress("user", fetched),
162
204
  );
163
205
  const users: CanonicalUser[] = owners
164
206
  .filter((owner) => owner.id)
@@ -306,6 +348,9 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
306
348
  };
307
349
  });
308
350
 
351
+ // Deliver any throttled trailing items heartbeat before the pull returns.
352
+ options.progress?.flush();
353
+
309
354
  return {
310
355
  generatedAt: new Date().toISOString(),
311
356
  provider: "hubspot",
@@ -324,7 +369,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
324
369
  return assembleSnapshot((objectType, properties, withAssociations) =>
325
370
  list(
326
371
  `/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`,
327
- (fetched) => options.onProgress?.({ objectType: canonicalType[objectType], fetched }),
372
+ (fetched) => pullProgress(canonicalType[objectType], fetched),
328
373
  ),
329
374
  );
330
375
  }
@@ -609,12 +654,215 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
609
654
  return id;
610
655
  }
611
656
 
657
+ /** Exact-value deal lookup (by any searchable property) for resolve-first creates. */
658
+ async function searchDealsBy(property: string, value: string): Promise<string[]> {
659
+ const data = await request(`/crm/v3/objects/deals/search`, {
660
+ method: "POST",
661
+ body: JSON.stringify({
662
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
663
+ properties: [property],
664
+ limit: 3,
665
+ }),
666
+ });
667
+ return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
668
+ }
669
+
670
+ /**
671
+ * Make sure the deal dedupe property (e.g. "stripe_invoice_id") exists so
672
+ * both the resolve-first search and the stamped value are real. Best-effort
673
+ * create on a confirmed miss; returns false when the property can neither
674
+ * be read nor created — the caller must SKIP then (creating a deal without
675
+ * its dedupe key would make every replay a duplicate).
676
+ */
677
+ async function ensureDealProperty(name: string): Promise<boolean> {
678
+ const cached = ensuredDealProperties.get(name);
679
+ if (cached !== undefined) return cached;
680
+ let ok: boolean;
681
+ try {
682
+ await request(`/crm/v3/properties/deals/${encodeURIComponent(name)}`);
683
+ ok = true;
684
+ } catch {
685
+ // Missing (404) or unreadable — try to create it; a failure here means
686
+ // we cannot guarantee the dedupe key, so report false.
687
+ try {
688
+ await request(`/crm/v3/properties/deals`, {
689
+ method: "POST",
690
+ body: JSON.stringify({
691
+ name,
692
+ label: name,
693
+ type: "string",
694
+ fieldType: "text",
695
+ groupName: "dealinformation",
696
+ hasUniqueValue: false,
697
+ }),
698
+ });
699
+ ok = true;
700
+ } catch {
701
+ ok = false;
702
+ }
703
+ }
704
+ ensuredDealProperties.set(name, ok);
705
+ return ok;
706
+ }
707
+
612
708
  /**
613
- * Create a NET-NEW record (a sourced lead). Resolve-first: re-checks the
614
- * dedupe key against the live CRM (the plan-time snapshot can be stale) and
615
- * creates ONLY on a confirmed miss a record a concurrent writer already
616
- * added is returned as `skipped`, never duplicated. Contacts can be linked
617
- * to a resolved-or-created company in the same step.
709
+ * Resolve the target pipeline and its closed-won stage from the portal's
710
+ * own pipeline metadata (one read per run). The pipeline is picked by id or
711
+ * case-insensitive label (`hint`), else the default (lowest displayOrder);
712
+ * the closed-won stage is the one whose metadata says isClosed with
713
+ * probability 1 — NEVER a substring guess against stage names. Returns null
714
+ * when either is unresolvable so the caller skips instead of guessing.
715
+ */
716
+ async function resolveClosedWonStage(
717
+ hint: string | undefined,
718
+ ): Promise<{ pipelineId: string; stageId: string } | null> {
719
+ if (!dealPipelinesCache) {
720
+ dealPipelinesCache = request("/crm/v3/pipelines/deals").then(
721
+ (data) => (data?.results ?? []) as any[],
722
+ );
723
+ // A failed read must not poison the cache for the rest of the run.
724
+ dealPipelinesCache = dealPipelinesCache.catch(() => []);
725
+ }
726
+ const pipelines = await dealPipelinesCache;
727
+ if (pipelines.length === 0) return null;
728
+ let pipeline: any | undefined;
729
+ if (hint) {
730
+ const wanted = hint.trim().toLowerCase();
731
+ pipeline = pipelines.find(
732
+ (candidate) =>
733
+ String(candidate?.id ?? "").toLowerCase() === wanted ||
734
+ String(candidate?.label ?? "").trim().toLowerCase() === wanted,
735
+ );
736
+ if (!pipeline) return null; // an explicit hint that matches nothing is an error, not "use default"
737
+ } else {
738
+ pipeline = [...pipelines].sort(
739
+ (a, b) => Number(a?.displayOrder ?? 0) - Number(b?.displayOrder ?? 0),
740
+ )[0];
741
+ }
742
+ if (!pipeline?.id) return null;
743
+ const stage = (pipeline.stages ?? []).find((candidate: any) => {
744
+ const meta = candidate?.metadata ?? {};
745
+ return (
746
+ candidate?.id &&
747
+ String(meta.isClosed).toLowerCase() === "true" &&
748
+ Number(meta.probability) === 1
749
+ );
750
+ });
751
+ if (!stage) return null;
752
+ return { pipelineId: String(pipeline.id), stageId: String(stage.id) };
753
+ }
754
+
755
+ /**
756
+ * Deal branch of create_record (backfill: one closed-won deal per paid
757
+ * invoice). Resolve-first on the custom dedupe property (ensured to exist
758
+ * first), stage resolved from pipeline metadata, company association
759
+ * best-effort — same contract as the contact branch.
760
+ */
761
+ async function createDealRecord(
762
+ operation: PatchOperation,
763
+ payload: CreateRecordPayload,
764
+ matchValue: string,
765
+ ): Promise<PatchOperationResult> {
766
+ const matchKey = String(payload.matchKey ?? "").trim();
767
+ if (!matchKey) {
768
+ return { operationId: operation.id, status: "skipped", detail: "create_record for deals needs a matchKey (the dedupe property, e.g. stripe_invoice_id)." };
769
+ }
770
+ const dedupeKey = `${matchKey}:${matchValue.toLowerCase()}`;
771
+ const alreadyCreated = createdDealsByMatch.get(dedupeKey);
772
+ if (alreadyCreated) {
773
+ return { operationId: operation.id, status: "skipped", detail: `Deal ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: alreadyCreated, existing: true } };
774
+ }
775
+
776
+ // The dedupe property is usually CUSTOM — ensure it exists before the
777
+ // search; searching a property HubSpot doesn't have 400s, and creating a
778
+ // deal without the key stamped would defeat every future resolve-first.
779
+ const propertyOk = await ensureDealProperty(matchKey);
780
+ if (!propertyOk) {
781
+ return { operationId: operation.id, status: "skipped", detail: `Deal dedupe property "${matchKey}" does not exist and could not be created; refusing to create a deal without its dedupe key.` };
782
+ }
783
+
784
+ const existing = await searchDealsBy(matchKey, matchValue);
785
+ if (existing.length > 0) {
786
+ createdDealsByMatch.set(dedupeKey, existing[0]);
787
+ return { operationId: operation.id, status: "skipped", detail: `Deal ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
788
+ }
789
+
790
+ // Stage: only the provider-neutral "closed_won" sentinel is understood,
791
+ // and it must resolve to a real stage id from pipeline metadata.
792
+ if (payload.dealStage !== "closed_won") {
793
+ return { operationId: operation.id, status: "skipped", detail: 'create_record for deals needs dealStage "closed_won" (the connector resolves the real stage id from pipeline metadata).' };
794
+ }
795
+ const resolved = await resolveClosedWonStage(payload.dealPipeline);
796
+ if (!resolved) {
797
+ return {
798
+ operationId: operation.id,
799
+ status: "skipped",
800
+ detail: payload.dealPipeline
801
+ ? `Could not resolve pipeline "${payload.dealPipeline}" (by id or label) to a closed-won stage; not guessing a stage.`
802
+ : "Could not resolve the default deal pipeline's closed-won stage (isClosed + probability 1) from pipeline metadata; not guessing a stage.",
803
+ };
804
+ }
805
+
806
+ const properties: Record<string, string> = {
807
+ ...payload.properties,
808
+ [matchKey]: matchValue,
809
+ pipeline: resolved.pipelineId,
810
+ dealstage: resolved.stageId,
811
+ };
812
+ if (payload.ownerId && !properties.hubspot_owner_id) {
813
+ properties.hubspot_owner_id = String(payload.ownerId);
814
+ }
815
+ let created;
816
+ try {
817
+ created = await request(`/crm/v3/objects/deals`, {
818
+ method: "POST",
819
+ body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm backfill (${operation.id})` } }),
820
+ });
821
+ } catch {
822
+ // Some portals reject writes to source-detail properties — the provenance
823
+ // stamp is best-effort, the create is not.
824
+ created = await request(`/crm/v3/objects/deals`, {
825
+ method: "POST",
826
+ body: JSON.stringify({ properties }),
827
+ });
828
+ }
829
+ const dealId = String(created.id);
830
+ createdDealsByMatch.set(dedupeKey, dealId);
831
+
832
+ let companyNote = "";
833
+ if (payload.associateCompanyName) {
834
+ try {
835
+ const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
836
+ if (companyId) {
837
+ await request(
838
+ `/crm/v4/objects/deals/${encodeURIComponent(dealId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
839
+ { method: "PUT" },
840
+ );
841
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
842
+ companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
843
+ } else {
844
+ companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
845
+ }
846
+ } catch (error) {
847
+ // Association is best-effort — the deal create already succeeded.
848
+ companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
849
+ }
850
+ }
851
+ return {
852
+ operationId: operation.id,
853
+ status: "applied",
854
+ detail: `Created deal ${matchKey}=${matchValue} (${dealId}) in stage ${resolved.stageId}.${companyNote}`,
855
+ providerData: { id: dealId, created: true, pipelineId: resolved.pipelineId, stageId: resolved.stageId },
856
+ };
857
+ }
858
+
859
+ /**
860
+ * Create a NET-NEW record (a sourced lead, or a backfilled deal).
861
+ * Resolve-first: re-checks the dedupe key against the live CRM (the
862
+ * plan-time snapshot can be stale) and creates ONLY on a confirmed miss — a
863
+ * record a concurrent writer already added is returned as `skipped`, never
864
+ * duplicated. Contacts and deals can be linked to a resolved-or-created
865
+ * company in the same step.
618
866
  */
619
867
  async function createRecord(operation: PatchOperation): Promise<PatchOperationResult> {
620
868
  const payload = operation.afterValue as CreateRecordPayload | undefined;
@@ -622,14 +870,18 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
622
870
  return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
623
871
  }
624
872
  const objectPath = OBJECT_PATHS[operation.objectType];
625
- if (operation.objectType !== "contact" && operation.objectType !== "account") {
626
- return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
873
+ if (operation.objectType !== "contact" && operation.objectType !== "account" && operation.objectType !== "deal") {
874
+ return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts, accounts, and deals." };
627
875
  }
628
876
  const matchValue = String(payload.matchValue ?? "").trim();
629
877
  if (!matchValue) {
630
878
  return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
631
879
  }
632
880
 
881
+ if (operation.objectType === "deal") {
882
+ return createDealRecord(operation, payload, matchValue);
883
+ }
884
+
633
885
  if (operation.objectType === "account") {
634
886
  const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
635
887
  if (id === null) {
@@ -943,6 +1195,164 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
943
1195
  };
944
1196
  }
945
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
+
946
1356
  /**
947
1357
  * Merge a duplicate group into the approved survivor via HubSpot's v3
948
1358
  * merge API (supported for contacts, companies, deals, and tickets).
@@ -1083,10 +1493,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1083
1493
  // single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
1084
1494
  // made compare-and-set see a spurious drift and refuse every date-field write.
1085
1495
  // Mirror the snapshot's normalization so the comparison is apples-to-apples.
1086
- if (field === "closeDate" && typeof value === "string") {
1087
- return value.split("T")[0];
1088
- }
1089
- return value;
1496
+ return normalizeHubspotReadValue(field, value);
1090
1497
  }
1091
1498
 
1092
1499
  return {
@@ -1095,6 +1502,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1095
1502
  fetchChanges,
1096
1503
  applyOperation,
1097
1504
  applyCreateContactsBatch,
1505
+ applyBatch,
1506
+ applyBatchLimit: 100,
1098
1507
  readField,
1099
1508
  };
1100
1509
  }
@@ -1,4 +1,3 @@
1
- import { createServer } from "node:http";
2
1
  import { randomBytes } from "node:crypto";
3
2
 
4
3
  /**
@@ -153,6 +152,11 @@ export async function runHubspotLoopbackLogin(
153
152
  const log = options.log ?? ((message: string) => console.error(message));
154
153
  const redirectUri = `http://localhost:${port}/callback`;
155
154
  const state = randomBytes(16).toString("hex");
155
+ // Deferred: `node:http` transitively compiles node's bundled undici on
156
+ // import (~10ms+, 12% of a demo-audit run's CPU profile). This module is in
157
+ // the eager graph of every CLI invocation via help.ts/auth.ts, but the
158
+ // server is only needed during the interactive OAuth loopback login.
159
+ const { createServer } = await import("node:http");
156
160
 
157
161
  const code = await new Promise<string>((resolve, reject) => {
158
162
  const server = createServer((request, response) => {
@@ -212,37 +212,37 @@ export function normalizeHeyReachLead(raw: any): LinkedInProspect {
212
212
 
213
213
  export const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[] = [
214
214
  {
215
- firstName: "Dana",
216
- lastName: "Okafor",
217
- fullName: "Dana Okafor",
215
+ firstName: "Example",
216
+ lastName: "ProspectAlpha",
217
+ fullName: "Example Prospect Alpha",
218
218
  headline: "VP RevOps",
219
219
  jobTitle: "VP Revenue Operations",
220
220
  company: "Northwind Logistics",
221
- profileUrl: "https://www.linkedin.com/in/dana-okafor",
221
+ profileUrl: "https://example.com/linkedin/example-prospect-alpha",
222
222
  location: "Chicago, IL",
223
- email: "dana@northwind.example",
223
+ email: "prospect.alpha@example.com",
224
224
  emailStatus: "verified",
225
225
  },
226
226
  {
227
- firstName: "Priya",
228
- lastName: "Raman",
229
- fullName: "Priya Raman",
227
+ firstName: "Example",
228
+ lastName: "ProspectBeta",
229
+ fullName: "Example Prospect Beta",
230
230
  headline: "Head of Sales Ops",
231
231
  jobTitle: "Head of Sales Operations",
232
232
  company: "Cobalt Health",
233
- profileUrl: "https://www.linkedin.com/in/priya-raman",
233
+ profileUrl: "https://example.com/linkedin/example-prospect-beta",
234
234
  location: "Austin, TX",
235
235
  },
236
236
  {
237
- firstName: "Marco",
238
- lastName: "Bianchi",
239
- fullName: "Marco Bianchi",
237
+ firstName: "Example",
238
+ lastName: "ProspectGamma",
239
+ fullName: "Example Prospect Gamma",
240
240
  headline: "RevOps Manager",
241
241
  jobTitle: "Revenue Operations Manager",
242
242
  company: "Ferro Systems",
243
- profileUrl: "https://www.linkedin.com/in/marco-bianchi",
243
+ profileUrl: "https://example.com/linkedin/example-prospect-gamma",
244
244
  location: "Remote",
245
- email: "marco@ferro.example",
245
+ email: "prospect.gamma@example.com",
246
246
  emailStatus: "guessed",
247
247
  },
248
248
  ];