fullstackgtm 0.22.0 → 0.23.1

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.
@@ -0,0 +1,250 @@
1
+ import type { CanonicalGtmSnapshot } from "./types.ts";
2
+ import type { EnrichObjectType, EnrichSourceRecord, EnrichWorkItem } from "./enrich.ts";
3
+
4
+ /**
5
+ * Apollo source client for the enrich layer: raw fetch against Apollo's REST
6
+ * enrichment endpoints (people match, organization enrich), bring-your-own
7
+ * key (`login apollo`), no SDK.
8
+ *
9
+ * The one genuinely new HTTP behavior lives here and ONLY here: enrichment
10
+ * APIs rate-limit aggressively, so requests retry on 429 with capped
11
+ * exponential backoff (honoring Retry-After when present). The shared
12
+ * connector contract is deliberately untouched.
13
+ */
14
+
15
+ const DEFAULT_API_BASE_URL = "https://api.apollo.io";
16
+ const DEFAULT_MAX_RETRIES = 3;
17
+ const BASE_BACKOFF_MS = 500;
18
+ const MAX_BACKOFF_MS = 8_000;
19
+
20
+ export type ApolloClientOptions = {
21
+ getApiKey: () => string | Promise<string>;
22
+ apiBaseUrl?: string;
23
+ /** Injectable fetch for testing — tests must never hit the real Apollo API. */
24
+ fetchImpl?: typeof fetch;
25
+ /** Max retries after a 429 (default 3). */
26
+ maxRetries?: number;
27
+ /** Injectable sleep for testing backoff without waiting. */
28
+ sleep?: (ms: number) => Promise<void>;
29
+ };
30
+
31
+ export type ApolloClient = {
32
+ /** GET /api/v1/organizations/enrich?domain=… → response body, or null on no data. */
33
+ enrichOrganization(domain: string): Promise<Record<string, unknown> | null>;
34
+ /** POST /api/v1/people/match { email } → response body, or null on no data. */
35
+ matchPerson(email: string): Promise<Record<string, unknown> | null>;
36
+ };
37
+
38
+ const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
39
+
40
+ function retryDelayMs(response: Response, attempt: number): number {
41
+ const retryAfter = response.headers.get("Retry-After");
42
+ if (retryAfter) {
43
+ const seconds = Number(retryAfter);
44
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, MAX_BACKOFF_MS);
45
+ const at = Date.parse(retryAfter);
46
+ if (Number.isFinite(at)) return Math.min(Math.max(at - Date.now(), 0), MAX_BACKOFF_MS);
47
+ }
48
+ return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
49
+ }
50
+
51
+ export function createApolloClient(options: ApolloClientOptions): ApolloClient {
52
+ const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
53
+ const fetchImpl = options.fetchImpl ?? fetch;
54
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
55
+ const sleep = options.sleep ?? defaultSleep;
56
+
57
+ async function request(path: string, init: RequestInit = {}): Promise<Record<string, unknown> | null> {
58
+ const apiKey = await options.getApiKey();
59
+ for (let attempt = 0; ; attempt += 1) {
60
+ let response: Response;
61
+ try {
62
+ response = await fetchImpl(`${baseUrl}${path}`, {
63
+ ...init,
64
+ headers: {
65
+ "X-Api-Key": apiKey,
66
+ "Content-Type": "application/json",
67
+ Accept: "application/json",
68
+ ...(init.headers ?? {}),
69
+ },
70
+ });
71
+ } catch (error) {
72
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
73
+ throw new Error(`Cannot reach Apollo at ${baseUrl}${cause}. Check network access.`);
74
+ }
75
+ if (response.status === 429 && attempt < maxRetries) {
76
+ await sleep(retryDelayMs(response, attempt));
77
+ continue;
78
+ }
79
+ if (response.status === 404) return null;
80
+ if (!response.ok) {
81
+ const body = await response.text();
82
+ const exhausted = response.status === 429 ? ` (rate limited; ${maxRetries} retries exhausted)` : "";
83
+ throw new Error(`Apollo API error ${response.status}${exhausted}: ${body}`);
84
+ }
85
+ const text = await response.text();
86
+ return text ? (JSON.parse(text) as Record<string, unknown>) : null;
87
+ }
88
+ }
89
+
90
+ return {
91
+ async enrichOrganization(domain) {
92
+ return request(`/api/v1/organizations/enrich?domain=${encodeURIComponent(domain)}`, {
93
+ method: "GET",
94
+ });
95
+ },
96
+ async matchPerson(email) {
97
+ return request("/api/v1/people/match", {
98
+ method: "POST",
99
+ body: JSON.stringify({ email }),
100
+ });
101
+ },
102
+ };
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Pull adapter: snapshot-driven keys in, normalized source records out.
107
+
108
+ export type ApolloPullKey = {
109
+ objectType: EnrichObjectType;
110
+ /** "domain" for companies, "email" for contacts. */
111
+ key: "domain" | "email";
112
+ value: string;
113
+ };
114
+
115
+ export type ApolloPullResult = {
116
+ records: EnrichSourceRecord[];
117
+ /** Pull keys Apollo returned no data for. */
118
+ misses: ApolloPullKey[];
119
+ };
120
+
121
+ export type ApolloPullProgress = {
122
+ /** The pull key just processed — the run-store cursor value. */
123
+ lastKeyValue: string;
124
+ record?: EnrichSourceRecord;
125
+ miss?: ApolloPullKey;
126
+ };
127
+
128
+ export type ApolloPullOptions = {
129
+ /** Resume checkpoint: pull keys at or before this value are skipped. */
130
+ resumeAfter?: string | null;
131
+ /** Checkpoint callback after each pull key is processed. */
132
+ onProgress?: (progress: ApolloPullProgress) => void | Promise<void>;
133
+ };
134
+
135
+ function stringAt(payload: Record<string, unknown> | null | undefined, path: string[]): string | undefined {
136
+ let current: unknown = payload;
137
+ for (const segment of path) {
138
+ if (!current || typeof current !== "object") return undefined;
139
+ current = (current as Record<string, unknown>)[segment];
140
+ }
141
+ return typeof current === "string" && current ? current : undefined;
142
+ }
143
+
144
+ /**
145
+ * Pull keys for an append run: records of the requested types where at least
146
+ * one configured field is blank and the pull key (companies: domain,
147
+ * contacts: email) is present. Deduplicated — two CRM companies sharing a
148
+ * domain produce ONE Apollo call, and the matcher then reports the collision.
149
+ */
150
+ export function apolloPullKeysForAppend(
151
+ snapshot: CanonicalGtmSnapshot,
152
+ objectTypes: EnrichObjectType[],
153
+ needsEnrichment: (objectType: EnrichObjectType, record: Record<string, unknown>) => boolean,
154
+ ): ApolloPullKey[] {
155
+ const keys: ApolloPullKey[] = [];
156
+ const seen = new Set<string>();
157
+ for (const objectType of objectTypes) {
158
+ const records = objectType === "company" ? snapshot.accounts : snapshot.contacts;
159
+ const keyName = objectType === "company" ? ("domain" as const) : ("email" as const);
160
+ for (const record of records) {
161
+ const value = ((record as unknown as Record<string, unknown>)[keyName] as string | undefined)?.trim();
162
+ if (!value) continue;
163
+ if (!needsEnrichment(objectType, record as unknown as Record<string, unknown>)) continue;
164
+ const dedupe = `${objectType}|${value.toLowerCase()}`;
165
+ if (seen.has(dedupe)) continue;
166
+ seen.add(dedupe);
167
+ keys.push({ objectType, key: keyName, value });
168
+ }
169
+ }
170
+ return keys;
171
+ }
172
+
173
+ /** Pull keys for a refresh run: the stale work set mapped back to pull keys. */
174
+ export function apolloPullKeysForRefresh(
175
+ snapshot: CanonicalGtmSnapshot,
176
+ workSet: EnrichWorkItem[],
177
+ ): ApolloPullKey[] {
178
+ const keys: ApolloPullKey[] = [];
179
+ const seen = new Set<string>();
180
+ for (const item of workSet) {
181
+ const records = item.objectType === "company" ? snapshot.accounts : snapshot.contacts;
182
+ const record = records.find((entry) => entry.id === item.objectId);
183
+ if (!record) continue;
184
+ const keyName = item.objectType === "company" ? ("domain" as const) : ("email" as const);
185
+ const value = ((record as unknown as Record<string, unknown>)[keyName] as string | undefined)?.trim();
186
+ if (!value) continue;
187
+ const dedupe = `${item.objectType}|${value.toLowerCase()}`;
188
+ if (seen.has(dedupe)) continue;
189
+ seen.add(dedupe);
190
+ keys.push({ objectType: item.objectType, key: keyName, value });
191
+ }
192
+ return keys;
193
+ }
194
+
195
+ /**
196
+ * Execute the pull: one Apollo call per key, normalized into source records.
197
+ * Sequential on purpose (enrichment endpoints rate-limit aggressively); the
198
+ * 429 backoff lives in the client. `onProgress` checkpoints the run-store
199
+ * cursor after every key so an interrupted pull resumes instead of re-paying
200
+ * for completed lookups.
201
+ */
202
+ export async function pullApolloRecords(
203
+ client: ApolloClient,
204
+ keys: ApolloPullKey[],
205
+ options: ApolloPullOptions = {},
206
+ ): Promise<ApolloPullResult> {
207
+ const records: EnrichSourceRecord[] = [];
208
+ const misses: ApolloPullKey[] = [];
209
+ // Resume: skip keys up to and including the checkpoint. An unknown
210
+ // checkpoint (snapshot changed since the interrupted run) restarts cleanly.
211
+ const resumeIndex = options.resumeAfter
212
+ ? keys.findIndex((key) => key.value === options.resumeAfter)
213
+ : -1;
214
+ for (const key of keys.slice(resumeIndex + 1)) {
215
+ let record: EnrichSourceRecord | undefined;
216
+ if (key.objectType === "company") {
217
+ const payload = await client.enrichOrganization(key.value);
218
+ const organization = payload?.organization as Record<string, unknown> | undefined;
219
+ if (organization) {
220
+ record = {
221
+ id: `apollo:org_${stringAt(payload, ["organization", "id"]) ?? key.value}`,
222
+ objectType: "company",
223
+ keys: {
224
+ domain: stringAt(payload, ["organization", "primary_domain"]) ?? key.value,
225
+ name: stringAt(payload, ["organization", "name"]),
226
+ },
227
+ payload: payload as Record<string, unknown>,
228
+ };
229
+ }
230
+ } else {
231
+ const payload = await client.matchPerson(key.value);
232
+ const person = payload?.person as Record<string, unknown> | undefined;
233
+ if (person) {
234
+ record = {
235
+ id: `apollo:person_${stringAt(payload, ["person", "id"]) ?? key.value}`,
236
+ objectType: "contact",
237
+ keys: {
238
+ email: stringAt(payload, ["person", "email"]) ?? key.value,
239
+ name: stringAt(payload, ["person", "name"]),
240
+ },
241
+ payload: payload as Record<string, unknown>,
242
+ };
243
+ }
244
+ }
245
+ if (record) records.push(record);
246
+ else misses.push(key);
247
+ await options.onProgress?.({ lastKeyValue: key.value, record, miss: record ? undefined : key });
248
+ }
249
+ return { records, misses };
250
+ }
package/src/index.ts CHANGED
@@ -52,6 +52,51 @@ export {
52
52
  type StoredCredential,
53
53
  } from "./credentials.ts";
54
54
  export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
55
+ export {
56
+ buildEnrichPlan,
57
+ createFileEnrichRunStore,
58
+ DEFAULT_STALE_DAYS,
59
+ ENRICH_CONFIG_FILE_NAME,
60
+ enrichRunId,
61
+ inferIngestObjectType,
62
+ ingestKeyValue,
63
+ latestStamps,
64
+ loadEnrichConfig,
65
+ matchSourceRecord,
66
+ parseCsv,
67
+ parseEnrichConfig,
68
+ resolveCrmField,
69
+ selectStaleWork,
70
+ sourceValueAt,
71
+ stagedSourceRecords,
72
+ staleDaysFor,
73
+ type BuildEnrichPlanOptions,
74
+ type EnrichAmbiguity,
75
+ type EnrichConfig,
76
+ type EnrichCounts,
77
+ type EnrichFieldConfig,
78
+ type EnrichMatchConfig,
79
+ type EnrichMode,
80
+ type EnrichObjectType,
81
+ type EnrichPlanResult,
82
+ type EnrichRun,
83
+ type EnrichRunStore,
84
+ type EnrichSourceConfig,
85
+ type EnrichSourceRecord,
86
+ type EnrichStamp,
87
+ type EnrichWorkItem,
88
+ type MatchOutcome,
89
+ } from "./enrich.ts";
90
+ export {
91
+ apolloPullKeysForAppend,
92
+ apolloPullKeysForRefresh,
93
+ createApolloClient,
94
+ pullApolloRecords,
95
+ type ApolloClient,
96
+ type ApolloClientOptions,
97
+ type ApolloPullKey,
98
+ type ApolloPullResult,
99
+ } from "./enrichApollo.ts";
55
100
  export {
56
101
  diffFindings,
57
102
  diffSnapshots,
@@ -459,6 +459,13 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
459
459
  );
460
460
  };
461
461
 
462
+ const vendorHeads = config.vendors
463
+ .map(
464
+ (vendor) =>
465
+ `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`,
466
+ )
467
+ .join("");
468
+
462
469
  // Claims grouped by front state, each group a collapsed <details> whose
463
470
  // summary carries the stats a skimmer needs; the full matrix is one click
464
471
  // away, not a wall the reader must climb to reach the takeaway.
@@ -476,7 +483,7 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
476
483
  : 0;
477
484
  const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
478
485
  return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
479
- <table><thead><tr><th></th>${"${vendorHeads}"}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
486
+ <table><thead><tr><th></th>${vendorHeads}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
480
487
  </details>`;
481
488
  }).join("");
482
489
 
@@ -546,13 +553,6 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
546
553
  })
547
554
  .join("");
548
555
 
549
- const vendorHeads = config.vendors
550
- .map(
551
- (vendor) =>
552
- `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`,
553
- )
554
- .join("");
555
-
556
556
  return `<!doctype html>
557
557
  <html lang="en"><head><meta charset="utf-8">
558
558
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -640,7 +640,7 @@ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; fon
640
640
  </header>
641
641
  ${axisHtml.strategicMap}
642
642
  <section>
643
- <h2>Claims, front by front</h2>
643
+ <h2>Market Claims</h2>
644
644
  <div class="key">
645
645
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
646
646
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>