fullstackgtm 0.40.0 → 0.42.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/dist/connector.js CHANGED
@@ -227,7 +227,41 @@ export async function applyPatchPlan(connector, plan, options) {
227
227
  }
228
228
  }
229
229
  }
230
+ // Bulk fast-path: route independent, approved create_record CONTACT ops
231
+ // through the connector's batch API (batched resolve-first read + batched
232
+ // writes) instead of one round-trip per record. Only ops that are safe to
233
+ // batch are eligible — every other op (grouped, needs an override, carries a
234
+ // company association, conflicted, or any non-create op) stays on the serial
235
+ // path below, so the safety contract is unchanged. Results are merged in by
236
+ // id; the serial loop short-circuits anything already resolved here.
237
+ const batched = new Map();
238
+ if (connector.applyCreateContactsBatch && !guardFailure) {
239
+ const eligible = plan.operations.filter((operation) => approved.has(operation.id) &&
240
+ operation.operation === "create_record" &&
241
+ operation.objectType === "contact" &&
242
+ options.valueOverrides?.[operation.id] === undefined &&
243
+ !requiresHumanInput(operation.afterValue) &&
244
+ !operation.groupId &&
245
+ !operation.afterValue?.associateCompanyName &&
246
+ !conflicts.has(operation.id) &&
247
+ !irreversibleStale.has(operation.id) &&
248
+ !(staleByFilter && staleByFilter.has(operation.objectId)));
249
+ // Only worth the batch machinery for more than a couple of creates.
250
+ if (eligible.length > 2) {
251
+ const batchResults = await connector.applyCreateContactsBatch(eligible);
252
+ for (const result of batchResults)
253
+ batched.set(result.operationId, result);
254
+ }
255
+ }
230
256
  for (const operation of plan.operations) {
257
+ const batchedResult = batched.get(operation.id);
258
+ if (batchedResult) {
259
+ results.push(batchedResult);
260
+ attempted += 1;
261
+ if (batchedResult.status === "applied")
262
+ applied += 1;
263
+ continue;
264
+ }
231
265
  if (!approved.has(operation.id)) {
232
266
  results.push({
233
267
  operationId: operation.id,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ATS job-board adapters for the `signals` layer — the free, no-auth example of
3
+ * "watch an account for hiring motion." Each public board JSON endpoint turns a
4
+ * company's board token into a list of open roles; the signals layer maps each
5
+ * role to a `job` signal whose `quote` is the role title plus a JD snippet that
6
+ * VERBATIM contains a configured keyword (so the downstream judge/drafter can
7
+ * gate a why-now on something a human can verify).
8
+ *
9
+ * Mirrors the `prospectSources.ts` raw-fetch idiom exactly: zero runtime deps
10
+ * (global fetch only, injectable for tests), a base-url override per provider,
11
+ * and a per-request `try { } catch { continue }` so one provider's outage never
12
+ * sinks a multi-source fetch. No SDK, no key.
13
+ *
14
+ * Governance: read-only. These adapters fetch public board JSON and return open
15
+ * roles. They write nothing — the signals layer (and only with `--save`) is the
16
+ * one that persists, and it never touches the CRM.
17
+ */
18
+ export type AtsBoardSource = "greenhouse" | "lever" | "ashby";
19
+ /** One open role, normalized across the three providers. */
20
+ export type AtsJob = {
21
+ /** Role title, verbatim from the board. */
22
+ title: string;
23
+ /**
24
+ * A short JD snippet. When `keywords` are supplied, this is a window around
25
+ * the first matching keyword (kept verbatim so the keyword survives into the
26
+ * evidence quote); otherwise it is the leading slice of the description.
27
+ */
28
+ jdSnippet: string;
29
+ /** Canonical posting URL (the signal's sourceUrl). */
30
+ url: string;
31
+ /**
32
+ * Stable per-role identity within a board (provider id when present, else the
33
+ * title). Used by the reposted-role heuristic to tell "same role, gone, back"
34
+ * from "two roles that happen to share a title".
35
+ */
36
+ firstSeenKey: string;
37
+ };
38
+ type FetchImpl = typeof fetch;
39
+ /**
40
+ * Fetch open roles for one board. Network failures (offline, non-2xx, malformed
41
+ * JSON) yield `[]` rather than throwing, matching the per-source resilience of
42
+ * the prospect sources — a single board's outage must not abort a watchlist run.
43
+ */
44
+ export declare function fetchAtsJobs(opts: {
45
+ source: AtsBoardSource;
46
+ boardToken: string;
47
+ accountDomain: string;
48
+ keywords?: string[];
49
+ fetchImpl?: FetchImpl;
50
+ apiBaseUrl?: string;
51
+ }): Promise<AtsJob[]>;
52
+ /**
53
+ * Pick a verbatim JD snippet. If a keyword matches the description (case-
54
+ * insensitive), return a window centered on the first match — kept verbatim so
55
+ * the keyword text survives into the signal quote and the evidence gate holds.
56
+ * With no keywords (or no match), fall back to the leading slice. Returns "" for
57
+ * an empty description so the caller can decide whether the role still qualifies.
58
+ */
59
+ export declare function snippetFor(description: string, keywords: string[]): string;
60
+ export {};
@@ -0,0 +1,179 @@
1
+ /**
2
+ * ATS job-board adapters for the `signals` layer — the free, no-auth example of
3
+ * "watch an account for hiring motion." Each public board JSON endpoint turns a
4
+ * company's board token into a list of open roles; the signals layer maps each
5
+ * role to a `job` signal whose `quote` is the role title plus a JD snippet that
6
+ * VERBATIM contains a configured keyword (so the downstream judge/drafter can
7
+ * gate a why-now on something a human can verify).
8
+ *
9
+ * Mirrors the `prospectSources.ts` raw-fetch idiom exactly: zero runtime deps
10
+ * (global fetch only, injectable for tests), a base-url override per provider,
11
+ * and a per-request `try { } catch { continue }` so one provider's outage never
12
+ * sinks a multi-source fetch. No SDK, no key.
13
+ *
14
+ * Governance: read-only. These adapters fetch public board JSON and return open
15
+ * roles. They write nothing — the signals layer (and only with `--save`) is the
16
+ * one that persists, and it never touches the CRM.
17
+ */
18
+ const DEFAULT_BASE_URLS = {
19
+ greenhouse: "https://boards-api.greenhouse.io",
20
+ lever: "https://api.lever.co",
21
+ ashby: "https://api.ashbyhq.com",
22
+ };
23
+ /** JD snippet length — long enough to carry a verbatim keyword window. */
24
+ const SNIPPET_LEN = 240;
25
+ /**
26
+ * Fetch open roles for one board. Network failures (offline, non-2xx, malformed
27
+ * JSON) yield `[]` rather than throwing, matching the per-source resilience of
28
+ * the prospect sources — a single board's outage must not abort a watchlist run.
29
+ */
30
+ export async function fetchAtsJobs(opts) {
31
+ const fetchImpl = opts.fetchImpl ?? fetch;
32
+ const base = (opts.apiBaseUrl ?? DEFAULT_BASE_URLS[opts.source]).replace(/\/$/, "");
33
+ const token = opts.boardToken.trim();
34
+ if (!token)
35
+ return [];
36
+ const keywords = opts.keywords ?? [];
37
+ try {
38
+ if (opts.source === "greenhouse") {
39
+ return await fetchGreenhouse(fetchImpl, base, token, keywords);
40
+ }
41
+ if (opts.source === "lever") {
42
+ return await fetchLever(fetchImpl, base, token, keywords);
43
+ }
44
+ return await fetchAshby(fetchImpl, base, token, keywords);
45
+ }
46
+ catch {
47
+ // Offline / 5xx / malformed payload: an empty board, never a crash.
48
+ return [];
49
+ }
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Greenhouse — GET /v1/boards/<token>/jobs?content=true
53
+ async function fetchGreenhouse(fetchImpl, base, token, keywords) {
54
+ const response = await fetchImpl(`${base}/v1/boards/${encodeURIComponent(token)}/jobs?content=true`, {
55
+ headers: { Accept: "application/json" },
56
+ });
57
+ if (!response.ok)
58
+ return [];
59
+ const data = (await response.json());
60
+ const out = [];
61
+ for (const job of data.jobs ?? []) {
62
+ const title = strOrEmpty(job.title);
63
+ if (!title)
64
+ continue;
65
+ const description = decodeHtmlish(strOrEmpty(job.content));
66
+ out.push({
67
+ title,
68
+ jdSnippet: snippetFor(description, keywords),
69
+ url: strOrEmpty(job.absolute_url),
70
+ firstSeenKey: job.id != null ? String(job.id) : title,
71
+ });
72
+ }
73
+ return out;
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Lever — GET /v0/postings/<token>?mode=json (returns a bare array)
77
+ async function fetchLever(fetchImpl, base, token, keywords) {
78
+ const response = await fetchImpl(`${base}/v0/postings/${encodeURIComponent(token)}?mode=json`, {
79
+ headers: { Accept: "application/json" },
80
+ });
81
+ if (!response.ok)
82
+ return [];
83
+ const data = (await response.json());
84
+ const out = [];
85
+ for (const posting of Array.isArray(data) ? data : []) {
86
+ const title = strOrEmpty(posting.text);
87
+ if (!title)
88
+ continue;
89
+ const description = strOrEmpty(posting.descriptionPlain) || decodeHtmlish(strOrEmpty(posting.description));
90
+ out.push({
91
+ title,
92
+ jdSnippet: snippetFor(description, keywords),
93
+ url: strOrEmpty(posting.hostedUrl),
94
+ firstSeenKey: strOrEmpty(posting.id) || title,
95
+ });
96
+ }
97
+ return out;
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Ashby — POST /posting-api/job-board/<token> with { jobBoardName }
101
+ async function fetchAshby(fetchImpl, base, token, keywords) {
102
+ const response = await fetchImpl(`${base}/posting-api/job-board/${encodeURIComponent(token)}`, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
105
+ body: JSON.stringify({ jobBoardName: token, includeCompensation: false }),
106
+ });
107
+ if (!response.ok)
108
+ return [];
109
+ const data = (await response.json());
110
+ const out = [];
111
+ for (const job of data.jobs ?? []) {
112
+ const title = strOrEmpty(job.title);
113
+ if (!title)
114
+ continue;
115
+ const description = strOrEmpty(job.descriptionPlain) || decodeHtmlish(strOrEmpty(job.descriptionHtml));
116
+ out.push({
117
+ title,
118
+ jdSnippet: snippetFor(description, keywords),
119
+ url: strOrEmpty(job.jobUrl) || strOrEmpty(job.applyUrl),
120
+ firstSeenKey: strOrEmpty(job.id) || title,
121
+ });
122
+ }
123
+ return out;
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Snippet selection: a verbatim window around the first matching keyword.
127
+ /**
128
+ * Pick a verbatim JD snippet. If a keyword matches the description (case-
129
+ * insensitive), return a window centered on the first match — kept verbatim so
130
+ * the keyword text survives into the signal quote and the evidence gate holds.
131
+ * With no keywords (or no match), fall back to the leading slice. Returns "" for
132
+ * an empty description so the caller can decide whether the role still qualifies.
133
+ */
134
+ export function snippetFor(description, keywords) {
135
+ const text = description.trim();
136
+ if (!text)
137
+ return "";
138
+ for (const keyword of keywords) {
139
+ const needle = keyword.trim();
140
+ if (!needle)
141
+ continue;
142
+ const idx = text.toLowerCase().indexOf(needle.toLowerCase());
143
+ if (idx === -1)
144
+ continue;
145
+ const pad = Math.max(0, Math.floor((SNIPPET_LEN - needle.length) / 2));
146
+ let start = Math.max(0, idx - pad);
147
+ let end = Math.min(text.length, idx + needle.length + pad);
148
+ // Snap to word boundaries so the window doesn't start/end mid-word, but
149
+ // never past the keyword itself (the verbatim span must stay intact).
150
+ while (start > 0 && /\S/.test(text[start - 1]) && start < idx)
151
+ start += 1;
152
+ while (end < text.length && /\S/.test(text[end]) && end > idx + needle.length)
153
+ end -= 1;
154
+ return text.slice(start, end).trim();
155
+ }
156
+ return text.slice(0, SNIPPET_LEN).trim();
157
+ }
158
+ /**
159
+ * Minimal HTML-ish cleanup for board `content`/`description` HTML: drop tags and
160
+ * decode the handful of entities Greenhouse/Ashby emit. Dependency-free; good
161
+ * enough to surface a readable, keyword-matchable snippet (the quote does not
162
+ * need to be byte-perfect HTML, only verbatim text the keyword appears in).
163
+ */
164
+ function decodeHtmlish(raw) {
165
+ return raw
166
+ .replace(/<\s*br\s*\/?\s*>/gi, " ")
167
+ .replace(/<\/?[^>]+>/g, " ")
168
+ .replace(/&amp;/g, "&")
169
+ .replace(/&lt;/g, "<")
170
+ .replace(/&gt;/g, ">")
171
+ .replace(/&quot;/g, '"')
172
+ .replace(/&#39;|&apos;/g, "'")
173
+ .replace(/&nbsp;/g, " ")
174
+ .replace(/\s+/g, " ")
175
+ .trim();
176
+ }
177
+ function strOrEmpty(value) {
178
+ return typeof value === "string" ? value.trim() : "";
179
+ }
@@ -555,6 +555,129 @@ export function createHubspotConnector(options) {
555
555
  providerData: { id: contactId, created: true },
556
556
  };
557
557
  }
558
+ /** Bulk resolve-first lookup: which match values already exist (value→id). */
559
+ async function searchContactsByValues(property, values) {
560
+ const found = new Map();
561
+ for (let i = 0; i < values.length; i += 100) {
562
+ const chunk = values.slice(i, i + 100);
563
+ const data = await request(`/crm/v3/objects/contacts/search`, {
564
+ method: "POST",
565
+ body: JSON.stringify({
566
+ filterGroups: [{ filters: [{ propertyName: property, operator: "IN", values: chunk }] }],
567
+ properties: [property],
568
+ limit: 100,
569
+ }),
570
+ });
571
+ for (const rec of (data?.results ?? [])) {
572
+ const v = rec?.properties?.[property];
573
+ if (typeof v === "string" && rec.id)
574
+ found.set(v.toLowerCase(), String(rec.id));
575
+ }
576
+ }
577
+ return found;
578
+ }
579
+ /**
580
+ * Batch create_record for contacts: bulk resolve-first (search IN), then
581
+ * bulk-create the genuine misses (batch/create, 100/call) — two API calls per
582
+ * ~100 leads instead of two per lead. On a batch-create rejection (e.g. an
583
+ * email collision HubSpot 409s the whole batch over), it falls back to
584
+ * per-record createRecord for that chunk, so one bad row never sinks the rest
585
+ * and resolve-first is preserved. Returns one result per input op.
586
+ */
587
+ async function applyCreateContactsBatch(operations) {
588
+ const byId = new Map();
589
+ // Group by the HubSpot search property (matchKey is usually uniform across a
590
+ // plan, but a mixed plan stays correct this way).
591
+ const groups = new Map();
592
+ for (const op of operations) {
593
+ const matchKey = op.afterValue?.matchKey || "email";
594
+ const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
595
+ let arr = groups.get(searchProperty);
596
+ if (!arr)
597
+ groups.set(searchProperty, (arr = []));
598
+ arr.push(op);
599
+ }
600
+ for (const [searchProperty, ops] of groups) {
601
+ const wanted = ops
602
+ .map((op) => String(op.afterValue.matchValue ?? "").trim())
603
+ .filter(Boolean);
604
+ const existing = await searchContactsByValues(searchProperty, [...new Set(wanted.map((v) => v.toLowerCase()))]);
605
+ const toCreate = [];
606
+ const seenInBatch = new Set();
607
+ for (const op of ops) {
608
+ const payload = op.afterValue;
609
+ const matchKey = payload.matchKey || "email";
610
+ const matchValue = String(payload.matchValue ?? "").trim();
611
+ if (!matchValue) {
612
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
613
+ continue;
614
+ }
615
+ const lower = matchValue.toLowerCase();
616
+ const dedupeKey = `${matchKey}:${lower}`;
617
+ const already = createdContactsByMatch.get(dedupeKey);
618
+ if (already) {
619
+ 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 } });
620
+ continue;
621
+ }
622
+ const existingId = existing.get(lower);
623
+ if (existingId) {
624
+ createdContactsByMatch.set(dedupeKey, existingId);
625
+ 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 } });
626
+ continue;
627
+ }
628
+ if (seenInBatch.has(lower)) {
629
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
630
+ continue;
631
+ }
632
+ seenInBatch.add(lower);
633
+ toCreate.push(op);
634
+ }
635
+ for (let i = 0; i < toCreate.length; i += 100) {
636
+ const chunk = toCreate.slice(i, i + 100);
637
+ const inputs = chunk.map((op) => {
638
+ const payload = op.afterValue;
639
+ const properties = { ...payload.properties };
640
+ if (payload.ownerId && !properties.hubspot_owner_id)
641
+ properties.hubspot_owner_id = String(payload.ownerId);
642
+ properties.hs_object_source_detail_2 = `fullstackgtm acquire (${op.id})`;
643
+ return { properties };
644
+ });
645
+ try {
646
+ const data = await request(`/crm/v3/objects/contacts/batch/create`, {
647
+ method: "POST",
648
+ body: JSON.stringify({ inputs }),
649
+ });
650
+ const created = (data?.results ?? []);
651
+ const idByValue = new Map();
652
+ for (const rec of created) {
653
+ const v = rec?.properties?.[searchProperty];
654
+ if (typeof v === "string" && rec.id)
655
+ idByValue.set(v.toLowerCase(), String(rec.id));
656
+ }
657
+ for (const op of chunk) {
658
+ const payload = op.afterValue;
659
+ const matchKey = payload.matchKey || "email";
660
+ const matchValue = String(payload.matchValue).trim();
661
+ const id = idByValue.get(matchValue.toLowerCase());
662
+ if (id) {
663
+ createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, id);
664
+ byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${id}).`, providerData: { id, created: true } });
665
+ }
666
+ else {
667
+ // Result didn't echo this value — resolve it per-record to be safe.
668
+ byId.set(op.id, await createRecord(op));
669
+ }
670
+ }
671
+ }
672
+ catch {
673
+ // Whole-chunk rejection (e.g. a collision). Isolate per record.
674
+ for (const op of chunk)
675
+ byId.set(op.id, await createRecord(op));
676
+ }
677
+ }
678
+ }
679
+ return operations.map((op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." });
680
+ }
558
681
  async function createTask(operation) {
559
682
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
560
683
  if (associationTypeId === undefined) {
@@ -801,6 +924,7 @@ export function createHubspotConnector(options) {
801
924
  fetchSnapshot,
802
925
  fetchChanges,
803
926
  applyOperation,
927
+ applyCreateContactsBatch,
804
928
  readField,
805
929
  };
806
930
  }
@@ -56,11 +56,6 @@ export declare function fetchPipe0CrustdataProspects(opts: {
56
56
  apiBaseUrl?: string;
57
57
  fetchImpl?: FetchImpl;
58
58
  }): Promise<Prospect[]>;
59
- /**
60
- * Resolve real work emails for people via pipe0's waterfall. Input rows need a
61
- * full name + a company domain (or name). Returns the prospects with `email`
62
- * filled where the waterfall found one; rows with no hit are returned unchanged.
63
- */
64
59
  export declare function pipe0ResolveWorkEmails(opts: {
65
60
  apiKey: string;
66
61
  prospects: Prospect[];
@@ -70,6 +65,11 @@ export declare function pipe0ResolveWorkEmails(opts: {
70
65
  * batch rate-limit (a throttled chunk returns work_email status "failed"
71
66
  * for every row, so one big batch is all-or-nothing). Default 3. */
72
67
  chunkSize?: number;
68
+ /** Chunks resolved in parallel, bounded to respect pipe0's rate limit. Default
69
+ * 3 — paired with pipe0Post's backoff this cuts wall-clock from O(n) serial
70
+ * calls without throttling away coverage (higher defaults tanked the hit-rate
71
+ * at scale). */
72
+ concurrency?: number;
73
73
  }): Promise<Prospect[]>;
74
74
  /** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
75
75
  export declare function hostFromUrl(url: string | undefined): string | undefined;
@@ -87,6 +87,8 @@ export declare function pipe0ResolveCompanyDomains(opts: {
87
87
  apiBaseUrl?: string;
88
88
  fetchImpl?: FetchImpl;
89
89
  chunkSize?: number;
90
+ /** Chunks resolved in parallel (bounded). Default 3. */
91
+ concurrency?: number;
90
92
  }): Promise<Prospect[]>;
91
93
  /**
92
94
  * Stable identity keys for a prospect (prefixed by kind). A match on ANY key
@@ -98,44 +98,83 @@ function normalizeLinkedin(value) {
98
98
  * full name + a company domain (or name). Returns the prospects with `email`
99
99
  * filled where the waterfall found one; rows with no hit are returned unchanged.
100
100
  */
101
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
102
+ /** Run `count` async tasks with at most `limit` in flight. Order-independent. */
103
+ async function runWithConcurrency(count, limit, worker) {
104
+ const out = new Array(count);
105
+ let next = 0;
106
+ const runners = Array.from({ length: Math.max(1, Math.min(limit, count || 1)) }, async () => {
107
+ for (let i = next++; i < count; i = next++)
108
+ out[i] = await worker(i);
109
+ });
110
+ await Promise.all(runners);
111
+ return out;
112
+ }
113
+ /**
114
+ * POST a pipe0 sync run with exponential backoff on transient failure
115
+ * (throttle/5xx/network). pipe0's waterfall throttles under burst — a 429'd
116
+ * chunk returns all-failed — so concurrency must be paired with real backoff,
117
+ * not a single retry, or coverage collapses at scale (a parallel run without
118
+ * this dropped the work-email hit-rate from ~79% to ~17%). Retries 500ms →
119
+ * 1s → 2s → 4s (capped) so a throttled chunk lands on a later attempt instead
120
+ * of being lost.
121
+ */
122
+ async function pipe0Post(fetchImpl, base, apiKey, payload, maxAttempts = 5) {
123
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
124
+ try {
125
+ const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
126
+ method: "POST",
127
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
128
+ body: JSON.stringify(payload),
129
+ });
130
+ if (response.ok)
131
+ return (await response.json());
132
+ // non-ok (429/5xx) — fall through to backoff + retry
133
+ }
134
+ catch {
135
+ // network error — fall through to backoff + retry
136
+ }
137
+ if (attempt < maxAttempts - 1)
138
+ await sleep(Math.min(8000, 500 * 2 ** attempt));
139
+ }
140
+ return undefined; // still failing after backoff — caller keeps the other chunks
141
+ }
101
142
  export async function pipe0ResolveWorkEmails(opts) {
102
143
  const fetchImpl = opts.fetchImpl ?? fetch;
103
144
  const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
104
145
  const chunkSize = Math.max(1, opts.chunkSize ?? 3);
146
+ const concurrency = Math.max(1, opts.concurrency ?? 3);
105
147
  // Only rows with the waterfall's required inputs (name + company_domain|name).
106
148
  const resolvable = opts.prospects.filter((p) => p.fullName && (p.companyDomain || p.companyName));
107
149
  if (resolvable.length === 0)
108
150
  return opts.prospects;
109
- const emailByKey = new Map();
110
- for (let i = 0; i < resolvable.length; i += chunkSize) {
111
- const chunk = resolvable.slice(i, i + chunkSize);
112
- const input = chunk.map((p) => ({
151
+ const chunks = [];
152
+ for (let i = 0; i < resolvable.length; i += chunkSize)
153
+ chunks.push(resolvable.slice(i, i + chunkSize));
154
+ const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
155
+ const input = chunks[ci].map((p) => ({
113
156
  name: p.fullName,
114
157
  ...(p.companyDomain ? { company_domain: p.companyDomain } : { company_name: p.companyName }),
115
158
  }));
116
- let body;
117
- try {
118
- const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
119
- method: "POST",
120
- headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
121
- body: JSON.stringify({ pipes: [{ pipe_id: "person:workemail:waterfall@1" }], input }),
122
- });
123
- if (!response.ok)
124
- continue; // throttled/5xx chunk — skip, keep the rest
125
- body = (await response.json());
126
- }
127
- catch {
128
- continue; // network hiccup on one chunk must not sink the whole run
129
- }
130
- for (const recordId of body.order ?? []) {
159
+ const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
160
+ pipes: [{ pipe_id: "person:workemail:waterfall@1" }],
161
+ input,
162
+ });
163
+ const entries = [];
164
+ for (const recordId of body?.order ?? []) {
131
165
  const fields = body.records?.[recordId]?.fields ?? {};
132
166
  const email = fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined;
133
167
  if (email) {
134
168
  const domain = fieldValue(fields.company_domain) ?? fieldValue(fields.company_name);
135
- emailByKey.set(personKey(fieldValue(fields.name), domain), email);
169
+ entries.push([personKey(fieldValue(fields.name), domain), email]);
136
170
  }
137
171
  }
138
- }
172
+ return entries;
173
+ });
174
+ const emailByKey = new Map();
175
+ for (const entries of perChunk)
176
+ for (const [k, v] of entries)
177
+ emailByKey.set(k, v);
139
178
  return opts.prospects.map((p) => {
140
179
  const email = emailByKey.get(personKey(p.fullName, p.companyDomain ?? p.companyName));
141
180
  return email ? { ...p, email } : p;
@@ -169,6 +208,7 @@ export async function pipe0ResolveCompanyDomains(opts) {
169
208
  const fetchImpl = opts.fetchImpl ?? fetch;
170
209
  const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
171
210
  const chunkSize = Math.max(1, opts.chunkSize ?? 5);
211
+ const concurrency = Math.max(1, opts.concurrency ?? 3);
172
212
  const uniqueNames = [
173
213
  ...new Set(opts.prospects
174
214
  .filter((p) => !p.companyDomain && p.companyName)
@@ -177,33 +217,28 @@ export async function pipe0ResolveCompanyDomains(opts) {
177
217
  ];
178
218
  if (uniqueNames.length === 0)
179
219
  return opts.prospects;
180
- const domainByName = new Map();
181
- for (let i = 0; i < uniqueNames.length; i += chunkSize) {
182
- const chunk = uniqueNames.slice(i, i + chunkSize);
183
- try {
184
- const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
185
- method: "POST",
186
- headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
187
- body: JSON.stringify({
188
- pipes: [{ pipe_id: "company:identity@1" }],
189
- input: chunk.map((company_name) => ({ company_name })),
190
- }),
191
- });
192
- if (!response.ok)
193
- continue;
194
- const body = (await response.json());
195
- for (const recordId of body.order ?? []) {
196
- const fields = body.records?.[recordId]?.fields ?? {};
197
- const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
198
- const domain = hostFromUrl(fieldValue(fields.company_website_url));
199
- if (name && domain)
200
- domainByName.set(name.trim().toLowerCase(), domain);
201
- }
202
- }
203
- catch {
204
- continue;
220
+ const chunks = [];
221
+ for (let i = 0; i < uniqueNames.length; i += chunkSize)
222
+ chunks.push(uniqueNames.slice(i, i + chunkSize));
223
+ const perChunk = await runWithConcurrency(chunks.length, concurrency, async (ci) => {
224
+ const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
225
+ pipes: [{ pipe_id: "company:identity@1" }],
226
+ input: chunks[ci].map((company_name) => ({ company_name })),
227
+ });
228
+ const entries = [];
229
+ for (const recordId of body?.order ?? []) {
230
+ const fields = body.records?.[recordId]?.fields ?? {};
231
+ const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
232
+ const domain = hostFromUrl(fieldValue(fields.company_website_url));
233
+ if (name && domain)
234
+ entries.push([name.trim().toLowerCase(), domain]);
205
235
  }
206
- }
236
+ return entries;
237
+ });
238
+ const domainByName = new Map();
239
+ for (const entries of perChunk)
240
+ for (const [k, v] of entries)
241
+ domainByName.set(k, v);
207
242
  return opts.prospects.map((p) => {
208
243
  if (p.companyDomain || !p.companyName)
209
244
  return p;