fullstackgtm 0.41.0 → 0.43.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/src/calls.ts CHANGED
@@ -369,6 +369,15 @@ export type CallDealSuggestion = {
369
369
  dealName?: string;
370
370
  accountId?: string;
371
371
  accountName?: string;
372
+ /**
373
+ * HOW the deal's account was matched — `account_domain` (the company-wide,
374
+ * reliable signal) vs `contact_email` (a specific person, possibly stale) vs
375
+ * `both`. An agent needs this to weight the match; the bare confidence hides it.
376
+ */
377
+ resolvedVia?: "account_domain" | "contact_email" | "both";
378
+ /** The attendee contacts that matched (the email path), so the contact↔account
379
+ * hop is visible, not just the resolved account. */
380
+ matchedContacts?: { id: string; email?: string; accountId?: string }[];
372
381
  confidence: "high" | "low" | "none";
373
382
  reason: string;
374
383
  };
@@ -393,17 +402,27 @@ export function suggestCallDeal(
393
402
  }
394
403
 
395
404
  const accountIds = new Set<string>();
405
+ let viaDomain = false;
406
+ let viaEmail = false;
407
+ const matchedContacts: { id: string; email?: string; accountId?: string }[] = [];
396
408
  for (const account of snapshot.accounts) {
397
409
  const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
398
- if (domain && domains.has(domain)) accountIds.add(account.id);
410
+ if (domain && domains.has(domain)) {
411
+ accountIds.add(account.id);
412
+ viaDomain = true;
413
+ }
399
414
  }
400
415
  for (const contact of snapshot.contacts) {
401
416
  const email = contact.email?.trim().toLowerCase();
402
417
  const at = email ? email.indexOf("@") : -1;
403
418
  if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
404
419
  accountIds.add(contact.accountId);
420
+ viaEmail = true;
421
+ matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
405
422
  }
406
423
  }
424
+ const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
425
+ const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) } as const;
407
426
  if (accountIds.size === 0) {
408
427
  return {
409
428
  dealId: null,
@@ -432,8 +451,9 @@ export function suggestCallDeal(
432
451
  dealName: top.name,
433
452
  accountId: top.accountId,
434
453
  accountName: account?.name,
454
+ ...via,
435
455
  confidence: "high",
436
- reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
456
+ reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
437
457
  };
438
458
  }
439
459
  return {
@@ -441,8 +461,9 @@ export function suggestCallDeal(
441
461
  dealName: top.name,
442
462
  accountId: top.accountId,
443
463
  accountName: account?.name,
464
+ ...via,
444
465
  confidence: "low",
445
- reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
466
+ reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
446
467
  };
447
468
  }
448
469
 
package/src/cli.ts CHANGED
@@ -125,6 +125,11 @@ import {
125
125
  remaining,
126
126
  type AcquireRemaining,
127
127
  } from "./acquireMeter.ts";
128
+ import {
129
+ scaffoldWorkspace,
130
+ type InitProvider,
131
+ type InitSource,
132
+ } from "./init.ts";
128
133
  import {
129
134
  crmContactKeys,
130
135
  fetchExploriumProspects,
@@ -246,6 +251,10 @@ Usage:
246
251
  the process list and shell history. Pipe them on stdin or enter them at the
247
252
  interactive prompt:
248
253
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
254
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
255
+ cold start: scaffold icp.json + enrich.config.json + a
256
+ PLAYBOOK wired for this workspace (the CLI ships primitives,
257
+ your agent is the orchestrator — see docs/recipes.md)
249
258
  fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
250
259
  fullstackgtm audit [source options] [audit options] [--save]
251
260
  fullstackgtm report [source options] [audit options] [report options]
@@ -466,6 +475,17 @@ type HelpEntry = {
466
475
  };
467
476
 
468
477
  const HELP: Record<string, HelpEntry> = {
478
+ // Get started
479
+ init: {
480
+ summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
481
+ phase: "Setup",
482
+ synopsis: [
483
+ "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
484
+ ],
485
+ detail:
486
+ "Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
487
+ seeAlso: ["icp", "enrich", "signals"],
488
+ },
469
489
  // Setup & health
470
490
  login: {
471
491
  summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
@@ -726,12 +746,13 @@ const HELP: Record<string, HelpEntry> = {
726
746
 
727
747
  // Verbs that print their own richer multi-subcommand help; runCli routes their
728
748
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
729
- const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
749
+ const BESPOKE_HELP = ["init", "call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
730
750
 
731
751
  // Lifecycle-grouped front door. One line per verb, organized by the
732
752
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
733
753
  function shortUsage() {
734
754
  const groups: Array<[string, string[]]> = [
755
+ ["Get started", ["init"]],
735
756
  ["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
736
757
  ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
737
758
  ["Prevent — gate writes", ["resolve"]],
@@ -2659,7 +2680,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2659
2680
  throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
2660
2681
  }
2661
2682
  const touchId = option(rest, "--touch") ?? undefined;
2662
- const outcome = makeOutcome({ accountDomain: accountArg, touchId, result: result as SignalOutcomeResult });
2683
+ const contactId = option(rest, "--contact") ?? undefined;
2684
+ const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result as SignalOutcomeResult });
2663
2685
  await createFileSignalStore().appendOutcome(outcome);
2664
2686
  console.error(
2665
2687
  `Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
@@ -2840,6 +2862,63 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
2840
2862
  }
2841
2863
  }
2842
2864
 
2865
+ /**
2866
+ * `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
2867
+ * enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
2868
+ * wired with the chosen source/provider that points at the recipes. Pure
2869
+ * file-writer: no network, never overwrites without --force.
2870
+ */
2871
+ function initCommand(args: string[]) {
2872
+ if (args.includes("--help") || args.includes("-h")) {
2873
+ console.log(`Usage:
2874
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
2875
+
2876
+ Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
2877
+ icp.json starter ICP (edit, or rebuild with \`icp interview\`)
2878
+ enrich.config.json acquire preset for --source + a placeholder assign policy
2879
+ PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
2880
+
2881
+ The CLI ships governed primitives; your coding agent is the orchestrator. See
2882
+ docs/recipes.md for the full play set. Existing files are kept unless --force.`);
2883
+ return;
2884
+ }
2885
+ const source = (option(args, "--source") ?? "pipe0") as InitSource;
2886
+ if (!["pipe0", "explorium", "linkedin"].includes(source)) {
2887
+ throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
2888
+ }
2889
+ const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
2890
+ if (!["hubspot", "salesforce"].includes(provider)) {
2891
+ throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
2892
+ }
2893
+ const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
2894
+ const force = args.includes("--force");
2895
+ const current = activeProfile();
2896
+ const profile = current === DEFAULT_PROFILE ? undefined : current;
2897
+
2898
+ const files = scaffoldWorkspace({ source, provider, profile });
2899
+ const wrote: string[] = [];
2900
+ const kept: string[] = [];
2901
+ for (const file of files) {
2902
+ const path = resolve(outDir, file.path);
2903
+ if (existsSync(path) && !force) {
2904
+ kept.push(file.path);
2905
+ continue;
2906
+ }
2907
+ writeFileSync(path, file.content);
2908
+ wrote.push(file.path);
2909
+ }
2910
+
2911
+ console.log(
2912
+ `Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`,
2913
+ );
2914
+ if (wrote.length) console.log(` wrote: ${wrote.join(", ")}`);
2915
+ if (kept.length) console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
2916
+ console.log(
2917
+ "\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
2918
+ "(full recipe set: docs/recipes.md).",
2919
+ );
2920
+ }
2921
+
2843
2922
  /**
2844
2923
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
2845
2924
  * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
@@ -2929,12 +3008,20 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2929
3008
  const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
2930
3009
  if (unjudged.length === 0) throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
2931
3010
 
2932
- // 2) Optional inputs: ICP (may be undefined), snapshot (only --with-history),
2933
- // outcomes + config from the signal store / DEFAULT_SIGNALS_CONFIG.
3011
+ // 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
3012
+ // The snapshot is loaded whenever a source is given (--provider/--input/
3013
+ // --demo/--sample) — it resolves each decision's CRM target (accountId +
3014
+ // best contact) so `draft` can write against a real record. --with-history
3015
+ // additionally enables the memory + fit scoring inputs.
2934
3016
  const icp = loadIcp(rest);
2935
3017
  const config: SignalsConfig = DEFAULT_SIGNALS_CONFIG;
2936
3018
  const outcomes = await signalStore.listOutcomes();
2937
- const snapshot = withHistory ? await readSnapshot(rest) : undefined;
3019
+ const hasSnapshotSource =
3020
+ Boolean(option(rest, "--provider")) ||
3021
+ Boolean(option(rest, "--input")) ||
3022
+ rest.includes("--demo") ||
3023
+ rest.includes("--sample");
3024
+ const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
2938
3025
 
2939
3026
  // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
2940
3027
  // otherwise — never PROMPT here; judge must run key-free).
@@ -5206,6 +5293,10 @@ export async function runCli(argv: string[]) {
5206
5293
  await enrichCommand(args);
5207
5294
  return;
5208
5295
  }
5296
+ if (command === "init") {
5297
+ initCommand(args);
5298
+ return;
5299
+ }
5209
5300
  if (command === "icp") {
5210
5301
  await icpCommand(args);
5211
5302
  return;
package/src/connector.ts CHANGED
@@ -2,6 +2,7 @@ import { dedupeKey } from "./dedupe.ts";
2
2
  import { requiresHumanInput } from "./rules.ts";
3
3
  import type {
4
4
  CanonicalGtmSnapshot,
5
+ CreateRecordPayload,
5
6
  GtmConnector,
6
7
  PatchOperation,
7
8
  PatchOperationResult,
@@ -283,7 +284,43 @@ export async function applyPatchPlan(
283
284
  }
284
285
  }
285
286
 
287
+ // Bulk fast-path: route independent, approved create_record CONTACT ops
288
+ // through the connector's batch API (batched resolve-first read + batched
289
+ // writes) instead of one round-trip per record. Only ops that are safe to
290
+ // batch are eligible — every other op (grouped, needs an override, carries a
291
+ // company association, conflicted, or any non-create op) stays on the serial
292
+ // path below, so the safety contract is unchanged. Results are merged in by
293
+ // id; the serial loop short-circuits anything already resolved here.
294
+ const batched = new Map<string, PatchOperationResult>();
295
+ if (connector.applyCreateContactsBatch && !guardFailure) {
296
+ const eligible = plan.operations.filter(
297
+ (operation) =>
298
+ approved.has(operation.id) &&
299
+ operation.operation === "create_record" &&
300
+ operation.objectType === "contact" &&
301
+ options.valueOverrides?.[operation.id] === undefined &&
302
+ !requiresHumanInput(operation.afterValue) &&
303
+ !operation.groupId &&
304
+ !(operation.afterValue as CreateRecordPayload | undefined)?.associateCompanyName &&
305
+ !conflicts.has(operation.id) &&
306
+ !irreversibleStale.has(operation.id) &&
307
+ !(staleByFilter && staleByFilter.has(operation.objectId)),
308
+ );
309
+ // Only worth the batch machinery for more than a couple of creates.
310
+ if (eligible.length > 2) {
311
+ const batchResults = await connector.applyCreateContactsBatch(eligible);
312
+ for (const result of batchResults) batched.set(result.operationId, result);
313
+ }
314
+ }
315
+
286
316
  for (const operation of plan.operations) {
317
+ const batchedResult = batched.get(operation.id);
318
+ if (batchedResult) {
319
+ results.push(batchedResult);
320
+ attempted += 1;
321
+ if (batchedResult.status === "applied") applied += 1;
322
+ continue;
323
+ }
287
324
  if (!approved.has(operation.id)) {
288
325
  results.push({
289
326
  operationId: operation.id,
@@ -506,18 +506,34 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
506
506
  };
507
507
  }
508
508
 
509
- /** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
510
- async function searchCompaniesByName(name: string): Promise<string[]> {
509
+ /** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
510
+ async function searchCompaniesBy(property: string, value: string): Promise<string[]> {
511
511
  const data = await request(`/crm/v3/objects/companies/search`, {
512
512
  method: "POST",
513
513
  body: JSON.stringify({
514
- filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
515
- properties: ["name"],
514
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
515
+ properties: [property],
516
516
  limit: 3,
517
517
  }),
518
518
  });
519
519
  return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
520
520
  }
521
+ const searchCompaniesByName = (name: string) => searchCompaniesBy("name", name);
522
+
523
+ /** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
524
+ async function fillCompanyDomainIfEmpty(companyId: string, domain: string): Promise<void> {
525
+ try {
526
+ const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
527
+ const current = data?.properties?.domain;
528
+ if (typeof current === "string" && current.trim()) return; // already has one — leave it
529
+ await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
530
+ method: "PATCH",
531
+ body: JSON.stringify({ properties: { domain } }),
532
+ });
533
+ } catch {
534
+ // Best-effort: a failed domain fill must not sink the contact create.
535
+ }
536
+ }
521
537
 
522
538
  /** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
523
539
  async function searchContactsBy(property: string, value: string): Promise<string[]> {
@@ -533,36 +549,51 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
533
549
  }
534
550
 
535
551
  /**
536
- * Resolve a company by exact name, creating it on a confirmed miss. Returns
537
- * the company id, or null on ambiguity (≥2 existing) so the caller skips the
538
- * association rather than guessing. Same-run safe via createdCompaniesByName.
552
+ * Resolve a company to a real account record, creating it on a confirmed miss.
553
+ * Matches by DOMAIN first (the accurate key) and falls back to exact name;
554
+ * creates with the domain stamped, and fills the domain on a name-matched
555
+ * account that lacks one — so the account is signal-watchable. Returns null on
556
+ * ambiguity (≥2 matches) so the caller skips the association rather than
557
+ * guessing. Same-run safe via createdCompaniesByName.
539
558
  */
540
- async function resolveOrCreateCompanyByName(name: string, opId: string): Promise<string | null> {
541
- const nameKey = name.toLowerCase();
542
- const already = createdCompaniesByName.get(nameKey);
559
+ async function resolveOrCreateCompany(name: string, domain: string | undefined, opId: string): Promise<string | null> {
560
+ const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
561
+ const already = createdCompaniesByName.get(cacheKey);
543
562
  if (already) return already;
544
- const matches = await searchCompaniesByName(name);
545
- if (matches.length > 1) return null;
546
- if (matches.length === 1) {
547
- createdCompaniesByName.set(nameKey, matches[0]);
548
- return matches[0];
563
+
564
+ // 1. Domain-first match (accurate; an account matched here already has it).
565
+ if (domain) {
566
+ const byDomain = await searchCompaniesBy("domain", domain);
567
+ if (byDomain.length > 1) return null;
568
+ if (byDomain.length === 1) {
569
+ createdCompaniesByName.set(cacheKey, byDomain[0]);
570
+ return byDomain[0];
571
+ }
572
+ }
573
+ // 2. Exact-name match; stamp the domain if the account has none.
574
+ const byName = await searchCompaniesByName(name);
575
+ if (byName.length > 1) return null;
576
+ if (byName.length === 1) {
577
+ if (domain) await fillCompanyDomainIfEmpty(byName[0], domain);
578
+ createdCompaniesByName.set(cacheKey, byName[0]);
579
+ return byName[0];
549
580
  }
581
+ // 3. Create with name + domain (provenance is best-effort).
582
+ const props: Record<string, string> = { name, ...(domain ? { domain } : {}) };
550
583
  let created;
551
584
  try {
552
585
  created = await request(`/crm/v3/objects/companies`, {
553
586
  method: "POST",
554
- body: JSON.stringify({
555
- properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
556
- }),
587
+ body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
557
588
  });
558
589
  } catch {
559
590
  created = await request(`/crm/v3/objects/companies`, {
560
591
  method: "POST",
561
- body: JSON.stringify({ properties: { name } }),
592
+ body: JSON.stringify({ properties: props }),
562
593
  });
563
594
  }
564
595
  const id = String(created.id);
565
- createdCompaniesByName.set(nameKey, id);
596
+ createdCompaniesByName.set(cacheKey, id);
566
597
  return id;
567
598
  }
568
599
 
@@ -588,7 +619,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
588
619
  }
589
620
 
590
621
  if (operation.objectType === "account") {
591
- const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
622
+ const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
592
623
  if (id === null) {
593
624
  return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
594
625
  }
@@ -637,13 +668,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
637
668
  let companyNote = "";
638
669
  if (payload.associateCompanyName) {
639
670
  try {
640
- const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
671
+ const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
641
672
  if (companyId) {
642
673
  await request(
643
674
  `/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
644
675
  { method: "PUT" },
645
676
  );
646
- companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
677
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
678
+ companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
647
679
  } else {
648
680
  companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
649
681
  }
@@ -660,6 +692,130 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
660
692
  };
661
693
  }
662
694
 
695
+ /** Bulk resolve-first lookup: which match values already exist (value→id). */
696
+ async function searchContactsByValues(property: string, values: string[]): Promise<Map<string, string>> {
697
+ const found = new Map<string, string>();
698
+ for (let i = 0; i < values.length; i += 100) {
699
+ const chunk = values.slice(i, i + 100);
700
+ const data = await request(`/crm/v3/objects/contacts/search`, {
701
+ method: "POST",
702
+ body: JSON.stringify({
703
+ filterGroups: [{ filters: [{ propertyName: property, operator: "IN", values: chunk }] }],
704
+ properties: [property],
705
+ limit: 100,
706
+ }),
707
+ });
708
+ for (const rec of (data?.results ?? []) as Array<{ id?: string; properties?: Record<string, string> }>) {
709
+ const v = rec?.properties?.[property];
710
+ if (typeof v === "string" && rec.id) found.set(v.toLowerCase(), String(rec.id));
711
+ }
712
+ }
713
+ return found;
714
+ }
715
+
716
+ /**
717
+ * Batch create_record for contacts: bulk resolve-first (search IN), then
718
+ * bulk-create the genuine misses (batch/create, 100/call) — two API calls per
719
+ * ~100 leads instead of two per lead. On a batch-create rejection (e.g. an
720
+ * email collision HubSpot 409s the whole batch over), it falls back to
721
+ * per-record createRecord for that chunk, so one bad row never sinks the rest
722
+ * and resolve-first is preserved. Returns one result per input op.
723
+ */
724
+ async function applyCreateContactsBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
725
+ const byId = new Map<string, PatchOperationResult>();
726
+ // Group by the HubSpot search property (matchKey is usually uniform across a
727
+ // plan, but a mixed plan stays correct this way).
728
+ const groups = new Map<string, PatchOperation[]>();
729
+ for (const op of operations) {
730
+ const matchKey = (op.afterValue as CreateRecordPayload | undefined)?.matchKey || "email";
731
+ const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
732
+ let arr = groups.get(searchProperty);
733
+ if (!arr) groups.set(searchProperty, (arr = []));
734
+ arr.push(op);
735
+ }
736
+
737
+ for (const [searchProperty, ops] of groups) {
738
+ const wanted = ops
739
+ .map((op) => String((op.afterValue as CreateRecordPayload).matchValue ?? "").trim())
740
+ .filter(Boolean);
741
+ const existing = await searchContactsByValues(searchProperty, [...new Set(wanted.map((v) => v.toLowerCase()))]);
742
+
743
+ const toCreate: PatchOperation[] = [];
744
+ const seenInBatch = new Set<string>();
745
+ for (const op of ops) {
746
+ const payload = op.afterValue as CreateRecordPayload;
747
+ const matchKey = payload.matchKey || "email";
748
+ const matchValue = String(payload.matchValue ?? "").trim();
749
+ if (!matchValue) {
750
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
751
+ continue;
752
+ }
753
+ const lower = matchValue.toLowerCase();
754
+ const dedupeKey = `${matchKey}:${lower}`;
755
+ const already = createdContactsByMatch.get(dedupeKey);
756
+ if (already) {
757
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: already, existing: true } });
758
+ continue;
759
+ }
760
+ const existingId = existing.get(lower);
761
+ if (existingId) {
762
+ createdContactsByMatch.set(dedupeKey, existingId);
763
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existingId}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } });
764
+ continue;
765
+ }
766
+ if (seenInBatch.has(lower)) {
767
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
768
+ continue;
769
+ }
770
+ seenInBatch.add(lower);
771
+ toCreate.push(op);
772
+ }
773
+
774
+ for (let i = 0; i < toCreate.length; i += 100) {
775
+ const chunk = toCreate.slice(i, i + 100);
776
+ const inputs = chunk.map((op) => {
777
+ const payload = op.afterValue as CreateRecordPayload;
778
+ const properties: Record<string, string> = { ...payload.properties };
779
+ if (payload.ownerId && !properties.hubspot_owner_id) properties.hubspot_owner_id = String(payload.ownerId);
780
+ properties.hs_object_source_detail_2 = `fullstackgtm acquire (${op.id})`;
781
+ return { properties };
782
+ });
783
+ try {
784
+ const data = await request(`/crm/v3/objects/contacts/batch/create`, {
785
+ method: "POST",
786
+ body: JSON.stringify({ inputs }),
787
+ });
788
+ const created = (data?.results ?? []) as Array<{ id?: string; properties?: Record<string, string> }>;
789
+ const idByValue = new Map<string, string>();
790
+ for (const rec of created) {
791
+ const v = rec?.properties?.[searchProperty];
792
+ if (typeof v === "string" && rec.id) idByValue.set(v.toLowerCase(), String(rec.id));
793
+ }
794
+ for (const op of chunk) {
795
+ const payload = op.afterValue as CreateRecordPayload;
796
+ const matchKey = payload.matchKey || "email";
797
+ const matchValue = String(payload.matchValue).trim();
798
+ const id = idByValue.get(matchValue.toLowerCase());
799
+ if (id) {
800
+ createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, id);
801
+ byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${id}).`, providerData: { id, created: true } });
802
+ } else {
803
+ // Result didn't echo this value — resolve it per-record to be safe.
804
+ byId.set(op.id, await createRecord(op));
805
+ }
806
+ }
807
+ } catch {
808
+ // Whole-chunk rejection (e.g. a collision). Isolate per record.
809
+ for (const op of chunk) byId.set(op.id, await createRecord(op));
810
+ }
811
+ }
812
+ }
813
+
814
+ return operations.map(
815
+ (op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." },
816
+ );
817
+ }
818
+
663
819
  async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
664
820
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
665
821
  if (associationTypeId === undefined) {
@@ -926,6 +1082,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
926
1082
  fetchSnapshot,
927
1083
  fetchChanges,
928
1084
  applyOperation,
1085
+ applyCreateContactsBatch,
929
1086
  readField,
930
1087
  };
931
1088
  }