fullstackgtm 0.43.0 → 0.45.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 (130) hide show
  1. package/CHANGELOG.md +378 -1
  2. package/README.md +37 -14
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +104 -4556
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/outboxChannel.d.ts +64 -0
  48. package/dist/connectors/outboxChannel.js +170 -0
  49. package/dist/connectors/prospectSources.d.ts +22 -0
  50. package/dist/connectors/prospectSources.js +43 -0
  51. package/dist/connectors/salesforce.d.ts +3 -1
  52. package/dist/connectors/salesforce.js +12 -5
  53. package/dist/connectors/signalSources.d.ts +105 -0
  54. package/dist/connectors/signalSources.js +264 -0
  55. package/dist/connectors/theirstack.d.ts +84 -0
  56. package/dist/connectors/theirstack.js +125 -0
  57. package/dist/icp.d.ts +52 -4
  58. package/dist/icp.js +112 -35
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +1 -0
  61. package/dist/init.js +3 -0
  62. package/dist/judge.d.ts +2 -0
  63. package/dist/judge.js +6 -0
  64. package/dist/judgeEval.d.ts +7 -0
  65. package/dist/judgeEval.js +8 -1
  66. package/dist/marketClassify.d.ts +2 -0
  67. package/dist/marketClassify.js +7 -1
  68. package/dist/mcp-bin.js +2 -2
  69. package/dist/mcp.js +259 -166
  70. package/dist/runReport.d.ts +26 -0
  71. package/dist/runReport.js +15 -0
  72. package/dist/schedule.d.ts +80 -2
  73. package/dist/schedule.js +272 -5
  74. package/dist/signals.d.ts +54 -0
  75. package/dist/signals.js +64 -0
  76. package/dist/spoolFiles.d.ts +44 -0
  77. package/dist/spoolFiles.js +114 -0
  78. package/dist/tam.d.ts +225 -0
  79. package/dist/tam.js +470 -0
  80. package/dist/types.d.ts +11 -0
  81. package/docs/api.md +91 -11
  82. package/docs/outbox-format.md +92 -0
  83. package/docs/recipes.md +37 -0
  84. package/docs/roadmap-to-1.0.md +31 -1
  85. package/docs/signal-spool-format.md +175 -0
  86. package/docs/tam.md +195 -0
  87. package/llms.txt +83 -11
  88. package/package.json +1 -1
  89. package/skills/fullstackgtm/SKILL.md +4 -3
  90. package/src/audit.ts +27 -1
  91. package/src/cli/audit.ts +447 -0
  92. package/src/cli/auth.ts +549 -0
  93. package/src/cli/call.ts +398 -0
  94. package/src/cli/capabilities.ts +215 -0
  95. package/src/cli/draft.ts +101 -0
  96. package/src/cli/enrich.ts +885 -0
  97. package/src/cli/fix.ts +372 -0
  98. package/src/cli/help.ts +664 -0
  99. package/src/cli/icp.ts +265 -0
  100. package/src/cli/init.ts +65 -0
  101. package/src/cli/market.ts +423 -0
  102. package/src/cli/plans.ts +523 -0
  103. package/src/cli/schedule.ts +526 -0
  104. package/src/cli/shared.ts +375 -0
  105. package/src/cli/signals.ts +434 -0
  106. package/src/cli/suggest.ts +151 -0
  107. package/src/cli/tam.ts +435 -0
  108. package/src/cli/ui.ts +426 -0
  109. package/src/cli.ts +103 -5170
  110. package/src/connector.ts +46 -0
  111. package/src/connectors/hubspot.ts +14 -2
  112. package/src/connectors/outboxChannel.ts +202 -0
  113. package/src/connectors/prospectSources.ts +57 -0
  114. package/src/connectors/salesforce.ts +18 -2
  115. package/src/connectors/signalSources.ts +314 -0
  116. package/src/connectors/theirstack.ts +170 -0
  117. package/src/icp.ts +120 -34
  118. package/src/index.ts +32 -0
  119. package/src/init.ts +3 -0
  120. package/src/judge.ts +7 -0
  121. package/src/judgeEval.ts +8 -1
  122. package/src/marketClassify.ts +8 -1
  123. package/src/mcp-bin.ts +2 -2
  124. package/src/mcp.ts +130 -57
  125. package/src/runReport.ts +39 -0
  126. package/src/schedule.ts +330 -7
  127. package/src/signals.ts +90 -0
  128. package/src/spoolFiles.ts +116 -0
  129. package/src/tam.ts +654 -0
  130. package/src/types.ts +12 -0
package/src/connector.ts CHANGED
@@ -105,6 +105,22 @@ export type ApplyPatchPlanOptions = {
105
105
  * edited mid-apply is conflicted out instead of overwritten. Default 25.
106
106
  */
107
107
  recheckEvery?: number;
108
+ /**
109
+ * Per-operation progress as the run executes (presentation only — a
110
+ * throwing callback never affects the run). `completed` counts every
111
+ * resolved operation including skips and conflicts; `total` is the plan's
112
+ * full operation count.
113
+ */
114
+ onOperation?: (progress: ApplyProgress) => void;
115
+ };
116
+
117
+ export type ApplyProgress = {
118
+ completed: number;
119
+ total: number;
120
+ applied: number;
121
+ failed: number;
122
+ conflicts: number;
123
+ skipped: number;
108
124
  };
109
125
 
110
126
  const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
@@ -313,7 +329,36 @@ export async function applyPatchPlan(
313
329
  }
314
330
  }
315
331
 
332
+ // Progress ticker state: every loop iteration below pushes at most one
333
+ // result; `lastNotified` guards the rare early-throw path so an iteration
334
+ // that pushed nothing reports nothing.
335
+ const progressCounts = { applied: 0, failed: 0, conflicts: 0, skipped: 0 };
336
+ const resultsBefore = results.length;
337
+ let lastNotified = results.length;
338
+ const notifyProgress = () => {
339
+ if (!options.onOperation || results.length === lastNotified) return;
340
+ lastNotified = results.length;
341
+ const last = results[results.length - 1];
342
+ if (last.status === "applied") progressCounts.applied += 1;
343
+ else if (last.status === "failed") progressCounts.failed += 1;
344
+ else if (last.status === "conflict") progressCounts.conflicts += 1;
345
+ else progressCounts.skipped += 1;
346
+ try {
347
+ options.onOperation({
348
+ completed: results.length - resultsBefore,
349
+ total: plan.operations.length,
350
+ ...progressCounts,
351
+ });
352
+ } catch {
353
+ // progress is presentation-only
354
+ }
355
+ };
356
+
316
357
  for (const operation of plan.operations) {
358
+ // Report the previous iteration's result (guarded: no-op if it pushed
359
+ // nothing). One-iteration lag keeps this a two-line hook instead of a
360
+ // notify after all ten push sites; the loop-exit call below flushes the last.
361
+ notifyProgress();
317
362
  const batchedResult = batched.get(operation.id);
318
363
  if (batchedResult) {
319
364
  results.push(batchedResult);
@@ -403,6 +448,7 @@ export async function applyPatchPlan(
403
448
  });
404
449
  }
405
450
  }
451
+ notifyProgress();
406
452
 
407
453
  return {
408
454
  planId: plan.id,
@@ -18,6 +18,7 @@ import type {
18
18
  GtmObjectType,
19
19
  PatchOperation,
20
20
  PatchOperationResult,
21
+ SnapshotProgress,
21
22
  } from "../types.ts";
22
23
 
23
24
  const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
@@ -30,6 +31,8 @@ export type HubspotConnectorOptions = {
30
31
  apiBaseUrl?: string;
31
32
  /** Injectable fetch for testing. */
32
33
  fetchImpl?: typeof fetch;
34
+ /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
35
+ onProgress?: (progress: SnapshotProgress) => void;
33
36
  };
34
37
 
35
38
  const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
@@ -123,7 +126,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
123
126
  return map;
124
127
  }
125
128
 
126
- async function list(path: string): Promise<any[]> {
129
+ async function list(path: string, onPage?: (fetched: number) => void): Promise<any[]> {
127
130
  const results: any[] = [];
128
131
  let after: string | undefined;
129
132
  const seen = new Set<string>();
@@ -137,6 +140,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
137
140
  const separator = path.includes("?") ? "&" : "?";
138
141
  const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
139
142
  results.push(...(data.results ?? []));
143
+ try {
144
+ onPage?.(results.length);
145
+ } catch {
146
+ // progress is presentation-only; never let it fail a pull
147
+ }
140
148
  after = data.paging?.next?.after;
141
149
  } while (after);
142
150
  return results;
@@ -149,7 +157,9 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
149
157
  withAssociations: boolean,
150
158
  ) => Promise<any[]>,
151
159
  ): Promise<CanonicalGtmSnapshot> {
152
- const owners = await list("/crm/v3/owners?limit=100");
160
+ const owners = await list("/crm/v3/owners?limit=100", (fetched) =>
161
+ options.onProgress?.({ objectType: "user", fetched }),
162
+ );
153
163
  const users: CanonicalUser[] = owners
154
164
  .filter((owner) => owner.id)
155
165
  .map((owner) => ({
@@ -310,9 +320,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
310
320
  }
311
321
 
312
322
  async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
323
+ const canonicalType = { companies: "account", contacts: "contact", deals: "deal" } as const;
313
324
  return assembleSnapshot((objectType, properties, withAssociations) =>
314
325
  list(
315
326
  `/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`,
327
+ (fetched) => options.onProgress?.({ objectType: canonicalType[objectType], fetched }),
316
328
  ),
317
329
  );
318
330
  }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Outbox channel connector — the governed SEND-side terminus of the outbound
3
+ * loop (signal → judge → draft → approve → apply → outbox), and the mirror image
4
+ * of the webhook spool. It is a `GtmConnector` whose `applyOperation` RENDERS an
5
+ * approved drafted opener to a local outbox artifact instead of writing a CRM —
6
+ * so it reuses the entire governed apply machinery (approval set, integrity/HMAC
7
+ * verification, idempotency, run recording) with no change to the apply engine.
8
+ *
9
+ * THE INVARIANT IT KEEPS: the CLI **transmits nothing**. Rendering an approved
10
+ * opener to `<home>/signals/outbox/<channel>.jsonl` is a local file write, never
11
+ * an SMTP/API connection. A downstream sender (the hosted product, or the
12
+ * operator's own MTA) drains the outbox and does the actual transmission. This
13
+ * is the send-side half of the open-core boundary: the governed artifact + its
14
+ * format are open; always-on transmission infrastructure is hosted/opt-in.
15
+ *
16
+ * Governance: it only renders ops that came from `draft` (a `create_task` op
17
+ * whose policy is `draft:<channel>`); any other op is `skipped` (it is not a
18
+ * general CRM writer). Idempotent on the operation id — re-applying an approved
19
+ * plan never duplicates an outbox row. Read paths (`fetchSnapshot`) intentionally
20
+ * throw: a channel has no snapshot, and `applyPatchPlan` never calls it for a
21
+ * draft plan (no guards/filter/irreversible ops → no snapshot needed).
22
+ */
23
+
24
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { ensureSecureHomeDir, writeSecureFile } from "../credentials.ts";
27
+ import { signalsOutboxDir } from "../signals.ts";
28
+ import type { CanonicalGtmSnapshot, GtmConnector, PatchOperation, PatchOperationResult } from "../types.ts";
29
+
30
+ /** One approved, ready-to-send touch — a governed send INTENT, not a sent message. */
31
+ export type OutboxEntry = {
32
+ /** Idempotency key = the source operation id (stable per draft op). */
33
+ id: string;
34
+ /** email | linkedin | task — from the draft op's `draft:<channel>` policy. */
35
+ channel: string;
36
+ /** The CRM target the opener is addressed to (a sender resolves id → address). */
37
+ objectType: string;
38
+ objectId: string;
39
+ /** The APPROVED opener, verbatim as it was signed in the plan op. */
40
+ body: string;
41
+ /** The draft op's human-readable reason (carries the account + trigger). */
42
+ reason?: string;
43
+ /** Evidence ids the opener was grounded in (the verbatim signal quote). */
44
+ evidenceIds: string[];
45
+ /** ISO 8601 — when the CLI rendered this to the outbox (NOT a send time). */
46
+ renderedAt: string;
47
+ };
48
+
49
+ /** Channel ids this build can render to. */
50
+ export const CHANNELS = ["outbox"] as const;
51
+
52
+ export type OutboxChannelOptions = {
53
+ /** Outbox directory; defaults to the profile-scoped `signalsOutboxDir()`. */
54
+ outboxDir?: string;
55
+ /** Injectable clock for deterministic tests. */
56
+ now?: () => Date;
57
+ };
58
+
59
+ const DRAFT_POLICY_PREFIX = "draft:";
60
+
61
+ /**
62
+ * Build the outbox channel connector. With no `outboxDir`, writes to the
63
+ * profile-scoped conventional outbox and locks the home down (0700/0600) like
64
+ * the signal store; with an explicit `outboxDir` (tests), it writes there
65
+ * without touching the real home.
66
+ */
67
+ export function createOutboxChannelConnector(options: OutboxChannelOptions = {}): GtmConnector {
68
+ const usingDefaultHome = options.outboxDir === undefined;
69
+ const dir = options.outboxDir ?? signalsOutboxDir();
70
+ const now = options.now ?? (() => new Date());
71
+
72
+ function fileFor(channel: string): string {
73
+ if (!/^[\w.-]+$/.test(channel)) throw new Error(`Invalid outbox channel name: ${channel}`);
74
+ return join(dir, `${channel}.jsonl`);
75
+ }
76
+
77
+ /** Existing entry ids in a channel file (for idempotent re-apply). */
78
+ function existingIds(channel: string): Set<string> {
79
+ const ids = new Set<string>();
80
+ let raw: string;
81
+ try {
82
+ raw = readFileSync(fileFor(channel), "utf8");
83
+ } catch {
84
+ return ids; // no file yet
85
+ }
86
+ for (const line of raw.split("\n")) {
87
+ const t = line.trim();
88
+ if (!t) continue;
89
+ try {
90
+ const id = (JSON.parse(t) as { id?: unknown }).id;
91
+ if (typeof id === "string") ids.add(id);
92
+ } catch {
93
+ // Skip a corrupt line rather than fail the whole apply.
94
+ }
95
+ }
96
+ return ids;
97
+ }
98
+
99
+ function append(channel: string, entry: OutboxEntry): void {
100
+ if (usingDefaultHome) ensureSecureHomeDir();
101
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
102
+ const path = fileFor(channel);
103
+ // Owner-only like the signal store: outbox carries opener text + CRM ids.
104
+ if (!existsSync(path)) writeSecureFile(path, "");
105
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
106
+ }
107
+
108
+ async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
109
+ const policy = String(operation.sourceRuleOrPolicy ?? "");
110
+ if (operation.operation !== "create_task" || !policy.startsWith(DRAFT_POLICY_PREFIX)) {
111
+ return {
112
+ operationId: operation.id,
113
+ status: "skipped",
114
+ detail:
115
+ "The outbox channel only renders drafted openers (create_task ops from `draft`). " +
116
+ "Apply other operations through a CRM connector (--provider hubspot|salesforce).",
117
+ };
118
+ }
119
+ const channel = policy.slice(DRAFT_POLICY_PREFIX.length) || "task";
120
+
121
+ // Idempotent: a re-applied approved plan must not duplicate the row.
122
+ if (existingIds(channel).has(operation.id)) {
123
+ return {
124
+ operationId: operation.id,
125
+ status: "applied",
126
+ detail: `Already in outbox (${channel}.jsonl); idempotent — not duplicated. Nothing transmitted.`,
127
+ };
128
+ }
129
+
130
+ const entry: OutboxEntry = {
131
+ id: operation.id,
132
+ channel,
133
+ objectType: operation.objectType,
134
+ objectId: operation.objectId,
135
+ body: typeof operation.afterValue === "string" ? operation.afterValue : String(operation.afterValue ?? ""),
136
+ ...(operation.reason ? { reason: operation.reason } : {}),
137
+ evidenceIds: operation.evidenceIds ?? [],
138
+ renderedAt: now().toISOString(),
139
+ };
140
+ append(channel, entry);
141
+ return {
142
+ operationId: operation.id,
143
+ status: "applied",
144
+ detail: `Rendered to outbox (${channel}.jsonl) for a downstream sender. Nothing was transmitted.`,
145
+ };
146
+ }
147
+
148
+ async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
149
+ // A channel has no readable state. apply never calls this for a draft plan
150
+ // (no guards/filter/irreversible ops); make the misuse explicit if it does.
151
+ throw new Error(
152
+ "The outbox channel has no snapshot to read — it is a send-side render target. " +
153
+ "Use it only to `apply` an approved draft plan (`apply --plan-id <id> --channel outbox`).",
154
+ );
155
+ }
156
+
157
+ return {
158
+ provider: "outbox",
159
+ fetchSnapshot,
160
+ applyOperation,
161
+ };
162
+ }
163
+
164
+ /** Resolve a channel connector by id (mirrors the source-connector registry). */
165
+ export function createChannelConnector(id: string, options: OutboxChannelOptions = {}): GtmConnector {
166
+ if (id === "outbox") return createOutboxChannelConnector(options);
167
+ throw new Error(`Unknown channel: ${id} (one of: ${CHANNELS.join(", ")}).`);
168
+ }
169
+
170
+ /**
171
+ * Read every outbox entry across all channel files in `dir` (default: the
172
+ * conventional outbox), newest-appended last. The reader a downstream sender (or
173
+ * a future `signals outbox` command) uses to drain the queue.
174
+ */
175
+ export function listOutbox(dir?: string): OutboxEntry[] {
176
+ const outboxDir = dir ?? signalsOutboxDir();
177
+ let names: string[];
178
+ try {
179
+ names = readdirSync(outboxDir).filter((name) => name.endsWith(".jsonl")).sort();
180
+ } catch {
181
+ return [];
182
+ }
183
+ const out: OutboxEntry[] = [];
184
+ for (const name of names) {
185
+ let raw: string;
186
+ try {
187
+ raw = readFileSync(join(outboxDir, name), "utf8");
188
+ } catch {
189
+ continue;
190
+ }
191
+ for (const line of raw.split("\n")) {
192
+ const t = line.trim();
193
+ if (!t) continue;
194
+ try {
195
+ out.push(JSON.parse(t) as OutboxEntry);
196
+ } catch {
197
+ // Skip corrupt lines.
198
+ }
199
+ }
200
+ }
201
+ return out;
202
+ }
@@ -166,6 +166,63 @@ function strField(field: { value?: unknown } | undefined): string | undefined {
166
166
  return typeof v === "string" && v.trim() ? v : undefined;
167
167
  }
168
168
 
169
+ // ---------------------------------------------------------------------------
170
+ // TAM universe count — "how many ACCOUNTS match this ICP firmographic?"
171
+ //
172
+ // Verified empirically (2026-06-25) against the live providers, because the
173
+ // obvious endpoints lie about totals:
174
+ // - Explorium /v1/prospects `total_results` == the PAGE size (1→1, 10→10), not
175
+ // the universe — useless for sizing. So is /v1/businesses for tiny pages? No:
176
+ // - Explorium /v1/businesses `total_results` IS a real COUNT of matching
177
+ // companies (US+10000-emp → 9,754; Liechtenstein+10000 → 11), capped at
178
+ // EXPLORIUM_BUSINESS_COUNT_CAP. At/above the cap it saturates at exactly that
179
+ // number, so the caller must treat a capped reading as a FLOOR, not a count.
180
+ // - pipe0/Crustdata people search returns only a pagination cursor, NO total —
181
+ // it cannot size a universe at all (callers use it for discovery, not counting).
182
+ // So the TAM count source is Explorium /v1/businesses (a company/account count).
183
+
184
+ export const EXPLORIUM_BUSINESS_COUNT_CAP = 60_000;
185
+
186
+ export type BusinessCountProbe = {
187
+ /** matching companies (the account universe); == cap when saturated. */
188
+ total: number;
189
+ /** true when total hit EXPLORIUM_BUSINESS_COUNT_CAP — treat total as a lower bound. */
190
+ capped: boolean;
191
+ };
192
+
193
+ /**
194
+ * Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
195
+ * /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
196
+ * ceiling) or null if the response carries no `total_results`. Use
197
+ * `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
198
+ * differ from /v1/prospects).
199
+ */
200
+ export async function probeExploriumBusinessCount(opts: {
201
+ apiKey: string;
202
+ filters: Record<string, { values?: string[] }>;
203
+ apiBaseUrl?: string;
204
+ fetchImpl?: FetchImpl;
205
+ }): Promise<BusinessCountProbe | null> {
206
+ const fetchImpl = opts.fetchImpl ?? fetch;
207
+ const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
208
+ const response = await fetchImpl(`${base}/v1/businesses`, {
209
+ method: "POST",
210
+ headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
211
+ // page_size 1: we want the envelope's total_results, not the rows. CRITICAL:
212
+ // do NOT send `size` — Explorium caps total_results to `size` when present
213
+ // (verified: size:1 → total_results:1; omitted → the real count, e.g. 19,058).
214
+ body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
215
+ });
216
+ if (!response.ok) {
217
+ throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
218
+ }
219
+ const body = (await response.json()) as { total_results?: unknown };
220
+ const total = body.total_results;
221
+ if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return null;
222
+ const rounded = Math.round(total);
223
+ return { total: rounded, capped: rounded >= EXPLORIUM_BUSINESS_COUNT_CAP };
224
+ }
225
+
169
226
  function normalizeLinkedin(value: string | undefined): string | undefined {
170
227
  if (!value) return undefined;
171
228
  const v = value.trim().replace(/^https?:\/\//i, "").replace(/\/$/, "");
@@ -17,6 +17,7 @@ import type {
17
17
  GtmObjectType,
18
18
  PatchOperation,
19
19
  PatchOperationResult,
20
+ SnapshotProgress,
20
21
  } from "../types.ts";
21
22
 
22
23
  const DEFAULT_API_VERSION = "v59.0";
@@ -35,6 +36,8 @@ export type SalesforceConnectorOptions = {
35
36
  apiVersion?: string;
36
37
  /** Injectable fetch for testing. */
37
38
  fetchImpl?: typeof fetch;
39
+ /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
40
+ onProgress?: (progress: SnapshotProgress) => void;
38
41
  };
39
42
 
40
43
  const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
@@ -161,7 +164,7 @@ export function createSalesforceConnector(
161
164
  return text ? JSON.parse(text) : null;
162
165
  }
163
166
 
164
- async function query(soql: string): Promise<any[]> {
167
+ async function query(soql: string, onPage?: (fetched: number) => void): Promise<any[]> {
165
168
  const records: any[] = [];
166
169
  let next: string | undefined = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
167
170
  const seen = new Set<string>();
@@ -171,6 +174,11 @@ export function createSalesforceConnector(
171
174
  seen.add(next);
172
175
  const data = await request(next);
173
176
  records.push(...(data?.records ?? []));
177
+ try {
178
+ onPage?.(records.length);
179
+ } catch {
180
+ // progress is presentation-only; never let it fail a pull
181
+ }
174
182
  next = data?.nextRecordsUrl ?? undefined;
175
183
  }
176
184
  return records;
@@ -194,7 +202,12 @@ export function createSalesforceConnector(
194
202
  }
195
203
 
196
204
  async function assembleSnapshot(whereClause: string): Promise<CanonicalGtmSnapshot> {
197
- const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`);
205
+ const progressFor = (objectType: SnapshotProgress["objectType"]) => (fetched: number) =>
206
+ options.onProgress?.({ objectType, fetched });
207
+ const sfUsers = await query(
208
+ `SELECT ${selectFields("owners")} FROM User${whereClause}`,
209
+ progressFor("user"),
210
+ );
198
211
  const users: CanonicalUser[] = sfUsers.map((user) => {
199
212
  const id = String(readMapped(user, "owners", "id", "Id"));
200
213
  const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
@@ -212,6 +225,7 @@ export function createSalesforceConnector(
212
225
 
213
226
  const sfAccounts = await query(
214
227
  `SELECT ${selectFields("accounts")} FROM Account${whereClause}`,
228
+ progressFor("account"),
215
229
  );
216
230
  const accounts: CanonicalAccount[] = sfAccounts.map((account) => {
217
231
  const id = String(readMapped(account, "accounts", "id", "Id"));
@@ -236,6 +250,7 @@ export function createSalesforceConnector(
236
250
 
237
251
  const sfContacts = await query(
238
252
  `SELECT ${selectFields("contacts")} FROM Contact${whereClause}`,
253
+ progressFor("contact"),
239
254
  );
240
255
  const contacts: CanonicalContact[] = sfContacts.map((contact) => {
241
256
  const id = String(readMapped(contact, "contacts", "id", "Id"));
@@ -257,6 +272,7 @@ export function createSalesforceConnector(
257
272
 
258
273
  const sfOpportunities = await query(
259
274
  `SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`,
275
+ progressFor("deal"),
260
276
  );
261
277
  const deals: CanonicalDeal[] = sfOpportunities.map((opportunity) => {
262
278
  const id = String(readMapped(opportunity, "deals", "id", "Id"));